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
152 changed files with 1917 additions and 6108 deletions
+1 -20
View File
@@ -1,5 +1,5 @@
NEXTAUTH_SECRET=very_sensitive_secret NEXTAUTH_SECRET=very_sensitive_secret
NEXTAUTH_URL=http://localhost:3000/api/v1/auth NEXTAUTH_URL=http://localhost:3000
# Manual installation database settings # Manual installation database settings
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
@@ -20,9 +20,6 @@ MAX_LINKS_PER_USER=
ARCHIVE_TAKE_COUNT= ARCHIVE_TAKE_COUNT=
BROWSER_TIMEOUT= BROWSER_TIMEOUT=
IGNORE_UNAUTHORIZED_CA= IGNORE_UNAUTHORIZED_CA=
IGNORE_HTTPS_ERRORS=
IGNORE_URL_SIZE_LIMIT=
ADMINISTRATOR=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=
@@ -37,15 +34,6 @@ NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM= EMAIL_FROM=
EMAIL_SERVER= EMAIL_SERVER=
# Proxy settings
PROXY=
PROXY_USERNAME=
PROXY_PASSWORD=
PROXY_BYPASS=
# PDF archive settings
PDF_MARGIN_TOP=
PDF_MARGIN_BOTTOM=
# #
# SSO Providers # SSO Providers
@@ -77,13 +65,6 @@ AUTH0_ISSUER=
AUTH0_CLIENT_SECRET= AUTH0_CLIENT_SECRET=
AUTH0_CLIENT_ID= AUTH0_CLIENT_ID=
# Authelia
NEXT_PUBLIC_AUTHELIA_ENABLED=""
AUTHELIA_CLIENT_ID=""
AUTHELIA_CLIENT_SECRET=""
AUTHELIA_WELLKNOWN_URL=""
# Authentik # Authentik
NEXT_PUBLIC_AUTHENTIK_ENABLED= NEXT_PUBLIC_AUTHENTIK_ENABLED=
AUTHENTIK_CUSTOM_NAME= AUTHENTIK_CUSTOM_NAME=
-1
View File
@@ -47,4 +47,3 @@ prisma/dev.db
# docker # docker
pgdata pgdata
certificates
-45
View File
@@ -1,45 +0,0 @@
# Architecture
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
When you start Linkwarden, there are mainly two components that run:
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
## Main Tech Stack
- [NextJS](https://github.com/vercel/next.js)
- [TypeScript](https://github.com/microsoft/TypeScript)
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
- [DaisyUI](https://github.com/saadeghi/daisyui)
- [Prisma](https://github.com/prisma/prisma)
- [Playwright](https://github.com/microsoft/playwright)
- [Zustand](https://github.com/pmndrs/zustand)
## Folder Structure
Here's a summary of the main files and folders in the project:
```
linkwarden
├── components # React components
├── hooks # React reusable hooks
├── layouts # Layouts for pages
├── lib
│   ├── api # Server-side functions (controllers, etc.)
│   ├── client # Client-side functions
│   └── shared # Shared functions between client and server
├── pages # Pages and API routes
├── prisma # Prisma schema and migrations
├── scripts
│   ├── migration # Scripts for breaking changes
│   └── worker.ts # Background worker for archiving links
├── store # Zustand stores
├── styles # Styles
└── types # TypeScript types
```
## Versioning
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./ COPY ./package.json ./yarn.lock ./playwright.config.ts ./
# Increase timeout to pass github actions arm64 build # Increase timeout to pass github actions arm64 build
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000 RUN yarn install --network-timeout 10000000
RUN npx playwright install-deps && \ RUN npx playwright install-deps && \
apt-get clean && \ apt-get clean && \
View File
+2 -8
View File
@@ -17,9 +17,7 @@
## Intro & motivation ## Intro & motivation
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.** **Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.** The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly. Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
@@ -59,7 +57,7 @@ We've forked the old version from the current repository into [this repo](https:
- 📸 Auto capture a screenshot, PDF, and readable view of each webpage. - 📸 Auto capture a screenshot, PDF, and readable view of each webpage.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional) - 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- 📂 Organize links by collection, sub-collection, name, description and multiple tags. - 📂 Organize links by collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection. - 👥 Collaborate on gathering links in a collection.
- 🎛️ Customize the permissions of each member. - 🎛️ Customize the permissions of each member.
- 🌐 Share your collected links and preserved formats with the world. - 🌐 Share your collected links and preserved formats with the world.
@@ -70,10 +68,6 @@ We've forked the old version from the current repository into [this repo](https:
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension) - 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import and export your bookmarks. - ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only) - 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍎 iOS Shortcut to save links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- ✨ And so many more features! - ✨ And so many more features!
## Like what we're doing? Give us a Star ⭐ ## Like what we're doing? Give us a Star ⭐
+2 -2
View File
@@ -12,11 +12,11 @@ export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
<div className="w-fit font-semibold"> <div className="w-fit font-semibold">
🎉 See what&apos;s new in{" "} 🎉 See what&apos;s new in{" "}
<Link <Link
href="https://blog.linkwarden.app/releases/v2.5" href="https://blog.linkwarden.app/releases/v2.4"
target="_blank" target="_blank"
className="underline hover:opacity-50 duration-100" className="underline hover:opacity-50 duration-100"
> >
Linkwarden v2.5 Linkwarden v2.4
</Link> </Link>
! 🥳 ! 🥳
</div> </div>
+6 -25
View File
@@ -8,38 +8,19 @@ type Props = {
onMount?: (rect: DOMRect) => void; onMount?: (rect: DOMRect) => void;
}; };
function getZIndex(element: HTMLElement): number {
let zIndex = 0;
while (element) {
const zIndexStyle = window
.getComputedStyle(element)
.getPropertyValue("z-index");
const numericZIndex = Number(zIndexStyle);
if (zIndexStyle !== "auto" && !isNaN(numericZIndex)) {
zIndex = numericZIndex;
break;
}
element = element.parentElement as HTMLElement;
}
return zIndex;
}
function useOutsideAlerter( function useOutsideAlerter(
ref: RefObject<HTMLElement>, ref: RefObject<HTMLElement>,
onClickOutside: Function onClickOutside: Function
) { ) {
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: Event) {
const clickedElement = event.target as HTMLElement; if (
if (ref.current && !ref.current.contains(clickedElement)) { ref.current &&
const refZIndex = getZIndex(ref.current); !ref.current.contains(event.target as HTMLInputElement)
const clickedZIndex = getZIndex(clickedElement); ) {
if (clickedZIndex <= refZIndex) { onClickOutside(event);
onClickOutside(event);
}
} }
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
+1 -3
View File
@@ -9,7 +9,6 @@ import useAccountStore from "@/store/account";
import EditCollectionModal from "./ModalContent/EditCollectionModal"; import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal"; import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal"; import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
@@ -71,7 +70,6 @@ export default function CollectionCard({ collection, className }: Props) {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral" className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<i className="bi-three-dots text-xl" title="More"></i> <i className="bi-three-dots text-xl" title="More"></i>
@@ -172,7 +170,7 @@ export default function CollectionCard({ collection, className }: Props) {
<div className="font-bold text-sm flex justify-end gap-1 items-center"> <div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? ( {collection.isPublic ? (
<i <i
className="bi-globe2 drop-shadow text-neutral" className="bi-globe-americas drop-shadow text-neutral"
title="This collection is being shared publicly." title="This collection is being shared publicly."
></i> ></i>
) : undefined} ) : undefined}
-365
View File
@@ -1,365 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import Tree, {
mutateTree,
moveItemOnTree,
RenderItemParams,
TreeItem,
TreeData,
ItemId,
TreeSourcePosition,
TreeDestinationPosition,
} from "@atlaskit/tree";
import useCollectionStore from "@/store/collections";
import { Collection } from "@prisma/client";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router";
import useAccountStore from "@/store/account";
import toast from "react-hot-toast";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
}
const CollectionListing = () => {
const { collections, updateCollection } = useCollectionStore();
const { account, updateAccount } = useAccountStore();
const router = useRouter();
const currentPath = router.asPath;
const initialTree = useMemo(() => {
if (collections.length > 0) {
return buildTreeFromCollections(
collections,
router,
account.collectionOrder
);
}
return undefined;
}, [collections, router]);
const [tree, setTree] = useState(initialTree);
useEffect(() => {
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (account.username) {
if (
(!account.collectionOrder || account.collectionOrder.length === 0) &&
collections.length > 0
)
updateAccount({
...account,
collectionOrder: collections
.filter(
(e) =>
e.parentId === null ||
!collections.find((i) => i.id === e.parentId)
) // Filter out collections with non-null parentId
.map((e) => e.id as number), // Use "as number" to assert that e.id is a number
});
else {
const newCollectionOrder: number[] = [
...(account.collectionOrder || []),
];
// Start with collections that are in both account.collectionOrder and collections
const existingCollectionIds = collections.map((c) => c.id as number);
const filteredCollectionOrder = account.collectionOrder.filter((id) =>
existingCollectionIds.includes(id)
);
// Add new collections that are not in account.collectionOrder and meet the specific conditions
collections.forEach((collection) => {
if (
!filteredCollectionOrder.includes(collection.id as number) &&
(!collection.parentId || collection.ownerId === account.id)
) {
filteredCollectionOrder.push(collection.id as number);
}
});
// check if the newCollectionOrder is the same as the old one
if (
JSON.stringify(newCollectionOrder) !==
JSON.stringify(account.collectionOrder)
) {
updateAccount({
...account,
collectionOrder: newCollectionOrder,
});
}
}
}
}, [collections]);
const onExpand = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
mutateTree(currentTree!, movedCollectionId, { isExpanded: true })
);
};
const onCollapse = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
mutateTree(currentTree as TreeData, movedCollectionId, {
isExpanded: false,
})
);
};
const onDragEnd = async (
source: TreeSourcePosition,
destination: TreeDestinationPosition | undefined
) => {
if (!destination || !tree) {
return;
}
if (
source.index === destination.index &&
source.parentId === destination.parentId
) {
return;
}
const movedCollectionId = Number(
tree.items[source.parentId].children[source.index]
);
const movedCollection = collections.find((c) => c.id === movedCollectionId);
const destinationCollection = collections.find(
(c) => c.id === Number(destination.parentId)
);
if (
(movedCollection?.ownerId !== account.id &&
destination.parentId !== source.parentId) ||
(destinationCollection?.ownerId !== account.id &&
destination.parentId !== "root")
) {
return toast.error(
"You can't make change to a collection you don't own."
);
}
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
const updatedCollectionOrder = [...account.collectionOrder];
if (source.parentId !== destination.parentId) {
await updateCollection({
...movedCollection,
parentId:
destination.parentId && destination.parentId !== "root"
? Number(destination.parentId)
: destination.parentId === "root"
? "root"
: null,
} as any);
}
if (
destination.index !== undefined &&
destination.parentId === source.parentId &&
source.parentId === "root"
) {
updatedCollectionOrder.includes(movedCollectionId) &&
updatedCollectionOrder.splice(source.index, 1);
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateAccount({
...account,
collectionOrder: updatedCollectionOrder,
});
} else if (
destination.index !== undefined &&
destination.parentId === "root"
) {
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateAccount({
...account,
collectionOrder: updatedCollectionOrder,
});
} else if (
source.parentId === "root" &&
destination.parentId &&
destination.parentId !== "root"
) {
updatedCollectionOrder.splice(source.index, 1);
await updateAccount({
...account,
collectionOrder: updatedCollectionOrder,
});
}
};
if (!tree) {
return <></>;
} else
return (
<Tree
tree={tree}
renderItem={(itemProps) => renderItem({ ...itemProps }, currentPath)}
onExpand={onExpand}
onCollapse={onCollapse}
onDragEnd={onDragEnd}
isDragEnabled
isNestingEnabled
/>
);
};
export default CollectionListing;
const renderItem = (
{ item, onExpand, onCollapse, provided }: RenderItemParams,
currentPath: string
) => {
const collection = item.data;
return (
<div ref={provided.innerRef} {...provided.draggableProps} className="mb-1">
<div
className={`${
currentPath === `/collections/${collection.id}`
? "bg-primary/20 is-active"
: "hover:bg-neutral/20"
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
>
{Icon(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link
href={`/collections/${collection.id}`}
className="w-full"
{...provided.dragHandleProps}
>
<div
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
style={{ color: collection.color }}
></i>
<p className="truncate w-full">{collection.name}</p>
{collection.isPublic ? (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
) : undefined}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
</div>
</Link>
</div>
</div>
);
};
const Icon = (
item: ExtendedTreeItem,
onExpand: (id: ItemId) => void,
onCollapse: (id: ItemId) => void
) => {
if (item.children && item.children.length > 0) {
return item.isExpanded ? (
<button onClick={() => onCollapse(item.id)}>
<div className="bi-caret-down-fill opacity-50 hover:opacity-100 duration-200"></div>
</button>
) : (
<button onClick={() => onExpand(item.id)}>
<div className="bi-caret-right-fill opacity-40 hover:opacity-100 duration-200"></div>
</button>
);
}
// return <span>&bull;</span>;
return <div></div>;
};
const buildTreeFromCollections = (
collections: CollectionIncludingMembersAndLinkCount[],
router: ReturnType<typeof useRouter>,
order?: number[]
): TreeData => {
if (order) {
collections.sort((a: any, b: any) => {
return order.indexOf(a.id) - order.indexOf(b.id);
});
}
const items: { [key: string]: ExtendedTreeItem } = collections.reduce(
(acc: any, collection) => {
acc[collection.id as number] = {
id: collection.id,
children: [],
hasChildren: false,
isExpanded: false,
data: {
id: collection.id,
parentId: collection.parentId,
name: collection.name,
description: collection.description,
color: collection.color,
isPublic: collection.isPublic,
ownerId: collection.ownerId,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
_count: {
links: collection._count?.links,
},
},
};
return acc;
},
{}
);
const activeCollectionId = Number(router.asPath.split("/collections/")[1]);
if (activeCollectionId) {
for (const item in items) {
const collection = items[item];
if (Number(item) === activeCollectionId && collection.data.parentId) {
// get all the parents of the active collection recursively until root and set isExpanded to true
let parentId = collection.data.parentId || null;
while (parentId && items[parentId]) {
items[parentId].isExpanded = true;
parentId = items[parentId].data.parentId;
}
}
}
}
collections.forEach((collection) => {
const parentId = collection.parentId;
if (parentId && items[parentId] && collection.id) {
items[parentId].children.push(collection.id);
items[parentId].hasChildren = true;
}
});
const rootId = "root";
items[rootId] = {
id: rootId,
children: (collections
.filter(
(c) =>
c.parentId === null || !collections.find((i) => i.id === c.parentId)
)
.map((c) => c.id) || "") as unknown as string[],
hasChildren: true,
isExpanded: true,
data: { name: "Root" } as Collection,
};
return { rootId, items };
};
+22 -26
View File
@@ -1,4 +1,3 @@
import { dropdownTriggerer } from "@/lib/client/utils";
import React from "react"; import React from "react";
type Props = { type Props = {
@@ -21,12 +20,11 @@ export default function FilterSearchDropdown({
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-square btn-ghost" className="btn btn-sm btn-square btn-ghost"
> >
<i className="bi-funnel text-neutral text-2xl"></i> <i className="bi-funnel text-neutral text-2xl"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-56 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@@ -84,6 +82,27 @@ export default function FilterSearchDropdown({
<span className="label-text">Description</span> <span className="label-text">Description</span>
</label> </label>
</li> </li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<span className="label-text">Full Content</span>
</label>
</li>
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@@ -105,29 +124,6 @@ export default function FilterSearchDropdown({
<span className="label-text">Tags</span> <span className="label-text">Tags</span>
</label> </label>
</li> </li>
<li>
<label
className="label cursor-pointer flex justify-between"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<span className="label-text">Full Content</span>
<div className="ml-auto badge badge-sm badge-neutral">Slower</div>
</label>
</li>
</ul> </ul>
</div> </div>
); );
+15 -88
View File
@@ -4,26 +4,18 @@ import { useEffect, useState } from "react";
import { styles } from "./styles"; import { styles } from "./styles";
import { Options } from "./types"; import { Options } from "./types";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
import Select from "react-select";
type Props = { type Props = {
onChange: any; onChange: any;
showDefaultValue?: boolean; defaultValue:
defaultValue?:
| { | {
label: string; label: string;
value?: number; value?: number;
} }
| undefined; | undefined;
creatable?: boolean;
}; };
export default function CollectionSelection({ export default function CollectionSelection({ onChange, defaultValue }: Props) {
onChange,
defaultValue,
showDefaultValue = true,
creatable = true,
}: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const router = useRouter(); const router = useRouter();
@@ -44,87 +36,22 @@ export default function CollectionSelection({
useEffect(() => { useEffect(() => {
const formatedCollections = collections.map((e) => { const formatedCollections = collections.map((e) => {
return { return { value: e.id, label: e.name, ownerId: e.ownerId };
value: e.id,
label: e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
}); });
setOptions(formatedCollections); setOptions(formatedCollections);
}, [collections]); }, [collections]);
const getParentNames = (parentId: number): string[] => { return (
const parentNames = []; <CreatableSelect
const parent = collections.find((e) => e.id === parentId); isClearable={false}
className="react-select-container"
if (parent) { classNamePrefix="react-select"
parentNames.push(parent.name); onChange={onChange}
if (parent.parentId) { options={options}
parentNames.push(...getParentNames(parent.parentId)); styles={styles}
} defaultValue={defaultValue}
} // menuPosition="fixed"
/>
// Have the top level parent at beginning );
return parentNames.reverse();
};
const customOption = ({ data, innerProps }: any) => {
return (
<div
{...innerProps}
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content cursor-pointer"
>
<div className="flex w-full justify-between items-center">
<span>{data.label}</span>
<span className="text-sm text-neutral">{data.count?.links}</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
{getParentNames(data?.parentId).length > 0 ? (
<>
{getParentNames(data.parentId).join(" > ")} {">"} {data.label}
</>
) : (
data.label
)}
</div>
</div>
);
};
if (creatable) {
return (
<CreatableSelect
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
} else {
return (
<Select
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
}
} }
+1 -24
View File
@@ -1,39 +1,16 @@
import LinkCard from "@/components/LinkViews/LinkCard"; import LinkCard from "@/components/LinkViews/LinkCard";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { link } from "fs";
import { GridLoader } from "react-spinners";
export default function CardView({ export default function CardView({
links, links,
editMode,
isLoading,
}: { }: {
links: LinkIncludingShortenedCollectionAndTags[]; links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) { }) {
return ( return (
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{links.map((e, i) => { {links.map((e, i) => {
return ( return <LinkCard key={i} link={e} count={i} />;
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})} })}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div> </div>
); );
} }
+2 -24
View File
@@ -1,38 +1,16 @@
import LinkList from "@/components/LinkViews/LinkList"; import LinkList from "@/components/LinkViews/LinkList";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
export default function ListView({ export default function ListView({
links, links,
editMode,
isLoading,
}: { }: {
links: LinkIncludingShortenedCollectionAndTags[]; links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) { }) {
return ( return (
<div className="flex gap-1 flex-col"> <div className="flex flex-col">
{links.map((e, i) => { {links.map((e, i) => {
return ( return <LinkList key={i} link={e} count={i} />;
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})} })}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div> </div>
); );
} }
+35 -64
View File
@@ -14,49 +14,24 @@ import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity"; import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link"; import Link from "next/link";
import LinkIcon from "./LinkComponents/LinkIcon"; import LinkIcon from "./LinkComponents/LinkIcon";
import LinkGroupedIconURL from "./LinkComponents/LinkGroupedIconURL";
import useOnScreen from "@/hooks/useOnScreen"; import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; count: number;
className?: string; className?: string;
flipDropdown?: boolean;
editMode?: boolean;
}; };
export default function LinkCard({ link, flipDropdown, editMode }: Props) { export default function LinkGrid({ link, count, className }: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore(); const { links, getLink } = useLinkStore();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL; let shortendURL;
try { try {
if (link.url) { shortendURL = new URL(link.url || "").host.toLowerCase();
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
@@ -78,7 +53,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref); const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
useEffect(() => { useEffect(() => {
let interval: any; let interval: any;
@@ -102,35 +76,15 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const [showInfo, setShowInfo] = useState(false); const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border-primary bg-base-300"
: "border-neutral-content";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
return ( return (
<div <div
ref={ref} ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`} className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative"
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(
"You don't have permission to edit or delete this item."
)
: undefined
}
> >
<div <Link
href={link.url || ""}
target="_blank"
className="rounded-2xl cursor-pointer" className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
}
> >
<div className="relative rounded-t-2xl h-40 overflow-hidden"> <div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? ( {previewAvailable(link) ? (
@@ -152,7 +106,15 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
) : ( ) : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div> <div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)} )}
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> <div
style={
{
// background:
// "radial-gradient(circle, rgba(255, 255, 255, 0.5), transparent)",
}
}
className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"
>
<LinkIcon link={link} /> <LinkIcon link={link} />
</div> </div>
</div> </div>
@@ -164,22 +126,32 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
{unescapeString(link.name || link.description) || link.url} {unescapeString(link.name || link.description) || link.url}
</p> </p>
<LinkTypeBadge link={link} /> <Link
href={link.url || ""}
target="_blank"
title={link.url || ""}
className="w-fit"
>
<div className="flex gap-1 item-center select-none text-neutral mt-1">
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</div>
</Link>
</div> </div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> <hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex justify-between text-xs text-neutral px-3 pb-1"> <div className="flex justify-between text-xs text-neutral px-3 pb-1">
<div className="cursor-pointer w-fit"> <div className="cursor-pointer w-fit">
{collection && ( {collection ? (
<LinkCollection link={link} collection={collection} /> <LinkCollection link={link} collection={collection} />
)} ) : undefined}
</div> </div>
<LinkDate link={link} /> <LinkDate link={link} />
</div> </div>
</div> </Link>
{showInfo && ( {showInfo ? (
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto"> <div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
<div <div
onClick={() => setShowInfo(!showInfo)} onClick={() => setShowInfo(!showInfo)}
@@ -199,7 +171,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
</span> </span>
)} )}
</p> </p>
{link.tags[0] && ( {link.tags[0] ? (
<> <>
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p> <p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
@@ -222,9 +194,9 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
</div> </div>
</div> </div>
</> </>
)} ) : undefined}
</div> </div>
)} ) : undefined}
<LinkActions <LinkActions
link={link} link={link}
@@ -232,7 +204,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
position="top-[10.75rem] right-3" position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)} toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo} linkInfo={showInfo}
flipDropdown={flipDropdown}
/> />
</div> </div>
); );
@@ -10,7 +10,6 @@ import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsMod
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@@ -18,7 +17,6 @@ type Props = {
position?: string; position?: string;
toggleShowInfo?: () => void; toggleShowInfo?: () => void;
linkInfo?: boolean; linkInfo?: boolean;
flipDropdown?: boolean;
}; };
export default function LinkActions({ export default function LinkActions({
@@ -26,7 +24,6 @@ export default function LinkActions({
toggleShowInfo, toggleShowInfo,
position, position,
linkInfo, linkInfo,
flipDropdown,
}: Props) { }: Props) {
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
@@ -67,33 +64,34 @@ export default function LinkActions({
return ( return (
<> <>
<div <div
className={`dropdown dropdown-left dropdown-end absolute ${ className={`dropdown dropdown-left absolute ${
position || "top-3 right-3" position || "top-3 right-3"
} z-20`} } z-20`}
> >
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral" className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<i title="More" className="bi-three-dots text-xl" /> <i title="More" className="bi-three-dots text-xl" />
</div> </div>
<ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 translate-y-10"> <ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
<li> {permissions === true ? (
<div <li>
role="button" <div
tabIndex={0} role="button"
onClick={() => { tabIndex={0}
(document?.activeElement as HTMLElement)?.blur(); onClick={() => {
pinLink(); (document?.activeElement as HTMLElement)?.blur();
}} pinLink();
> }}
{link?.pinnedBy && link.pinnedBy[0] >
? "Unpin" {link?.pinnedBy && link.pinnedBy[0]
: "Pin to Dashboard"} ? "Unpin"
</div> : "Pin to Dashboard"}
</li> </div>
</li>
) : undefined}
{linkInfo !== undefined && toggleShowInfo ? ( {linkInfo !== undefined && toggleShowInfo ? (
<li> <li>
<div <div
@@ -122,20 +120,18 @@ export default function LinkActions({
</div> </div>
</li> </li>
) : undefined} ) : undefined}
{link.type === "url" && ( <li>
<li> <div
<div role="button"
role="button" tabIndex={0}
tabIndex={0} onClick={() => {
onClick={() => { (document?.activeElement as HTMLElement)?.blur();
(document?.activeElement as HTMLElement)?.blur(); setPreservedFormatsModal(true);
setPreservedFormatsModal(true); }}
}} >
> Preserved Formats
Preserved Formats </div>
</div> </li>
</li>
)}
{permissions === true || permissions?.canDelete ? ( {permissions === true || permissions?.canDelete ? (
<li> <li>
<div <div
@@ -2,7 +2,6 @@ import {
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react"; import React from "react";
@@ -16,12 +15,12 @@ export default function LinkCollection({
const router = useRouter(); const router = useRouter();
return ( return (
<Link <div
href={`/collections/${link.collection.id}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.preventDefault();
router.push(`/collections/${link.collection.id}`);
}} }}
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none" className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
title={collection?.name} title={collection?.name}
> >
<i <i
@@ -29,6 +28,6 @@ export default function LinkCollection({
style={{ color: collection?.color }} style={{ color: collection?.color }}
></i> ></i>
<p className="truncate capitalize">{collection?.name}</p> <p className="truncate capitalize">{collection?.name}</p>
</Link> </div>
); );
} }
@@ -6,13 +6,14 @@ export default function LinkDate({
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
}) { }) {
const formattedDate = new Date( const formattedDate = new Date(link.createdAt as string).toLocaleString(
(link.importDate || link.createdAt) as string "en-US",
).toLocaleString("en-US", { {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}); }
);
return ( return (
<div className="flex items-center gap-1 text-neutral"> <div className="flex items-center gap-1 text-neutral">
@@ -6,11 +6,9 @@ import React from "react";
export default function LinkIcon({ export default function LinkIcon({
link, link,
width, width,
className,
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
width?: string; width?: string;
className?: string;
}) { }) {
const url = const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined; isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
@@ -18,55 +16,33 @@ export default function LinkIcon({
const iconClasses: string = const iconClasses: string =
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" + "bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" +
" " + " " +
(width || "w-12") + (width || "w-12");
" " +
(className || "");
const [showFavicon, setShowFavicon] = React.useState<boolean>(true); const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
return ( return (
<> <>
{link.type === "url" && url ? ( {link.url && url && showFavicon ? (
showFavicon ? ( <Image
<Image src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`} width={64}
width={64} height={64}
height={64} alt=""
alt="" className={iconClasses}
className={iconClasses} draggable="false"
draggable="false" onError={() => {
onError={() => { setShowFavicon(false);
setShowFavicon(false); }}
}} />
/> ) : showFavicon === false ? (
) : ( <div className={iconClasses}>
<LinkPlaceholderIcon iconClasses={iconClasses} icon="bi-link-45deg" /> <i className="bi-link-45deg text-4xl text-black"></i>
) </div>
) : link.type === "pdf" ? ( ) : link.type === "pdf" ? (
<LinkPlaceholderIcon <i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
iconClasses={iconClasses}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? ( ) : link.type === "image" ? (
<LinkPlaceholderIcon <i className={`bi-file-earmark-image ${iconClasses}`}></i>
iconClasses={iconClasses}
icon="bi-file-earmark-image"
/>
) : undefined} ) : undefined}
</> </>
); );
} }
const LinkPlaceholderIcon = ({
iconClasses,
icon,
}: {
iconClasses: string;
icon: string;
}) => {
return (
<div className={`text-4xl text-black aspect-square ${iconClasses}`}>
<i className={`${icon} m-auto`}></i>
</div>
);
};
@@ -1,38 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import React from "react";
export default function LinkTypeBadge({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
let shortendURL;
if (link.type === "url" && link.url) {
try {
shortendURL = new URL(link.url).host.toLowerCase();
} catch (error) {
console.log(error);
}
}
return link.url && shortendURL ? (
<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="badge badge-primary badge-sm my-1 select-none">
{link.type}
</div>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ type Props = {
className?: string; className?: string;
}; };
export default function LinkGrid({ link }: Props) { export default function LinkGrid({ link, count, className }: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { links } = useLinkStore(); const { links } = useLinkStore();
+80 -88
View File
@@ -11,51 +11,25 @@ import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon"; import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import Link from "next/link"; import Link from "next/link";
import { isPWA } from "@/lib/client/utils";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; count: number;
className?: string; className?: string;
flipDropdown?: boolean;
editMode?: boolean;
}; };
export default function LinkCardCompact({ export default function LinkCardCompact({ link, count, className }: Props) {
link,
flipDropdown,
editMode,
}: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
useEffect(() => { const { links } = useLinkStore();
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = ( let shortendURL;
link: LinkIncludingShortenedCollectionAndTags
) => {
const linkIndex = selectedLinks.findIndex(
(selectedLink) => selectedLink.id === link.id
);
if (linkIndex !== -1) { try {
const updatedLinks = [...selectedLinks]; shortendURL = new URL(link.url || "").host.toLowerCase();
updatedLinks.splice(linkIndex, 1); } catch (error) {
setSelectedLinks(updatedLinks); console.log(error);
} else { }
setSelectedLinks([...selectedLinks, link]);
}
};
const [collection, setCollection] = const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>( useState<CollectionIncludingMembersAndLinkCount>(
@@ -72,89 +46,107 @@ export default function LinkCardCompact({
); );
}, [collections, links]); }, [collections, links]);
const permissions = usePermissions(collection?.id as number);
const [showInfo, setShowInfo] = useState(false); const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border border-primary bg-base-300"
: "border-transparent";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
return ( return (
<> <>
<div <div
className={`${selectedStyle} border relative items-center flex ${ className={`border-neutral-content relative ${
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3" !showInfo ? "hover:bg-base-300" : ""
} duration-200 rounded-lg`} } duration-200 rounded-lg`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(
"You don't have permission to edit or delete this item."
)
: undefined
}
> >
{/* {showCheckbox && <Link
editMode && href={link.url || ""}
(permissions === true || target="_blank"
permissions?.canCreate || className="flex items-center cursor-pointer py-3 px-3"
permissions?.canDelete) && (
<input
type="checkbox"
className="checkbox checkbox-primary my-auto mr-2"
checked={selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)}
onChange={() => handleCheckboxClick(link)}
/>
)} */}
<div
className="flex items-center cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
}
> >
<div className="shrink-0"> <div className="shrink-0">
<LinkIcon <LinkIcon link={link} width="sm:w-12 w-8" />
link={link}
width="sm:w-12 w-8"
className="mt-1 sm:mt-0"
/>
</div> </div>
<div className="w-[calc(100%-56px)] ml-2"> <div className="w-[calc(100%-56px)] ml-2">
<p className="line-clamp-1 mr-8 text-primary select-none"> <p className="line-clamp-1 mr-8 text-primary">
{unescapeString(link.name || link.description) || link.url} {unescapeString(link.name || link.description) || link.url}
</p> </p>
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral"> <div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
<div className="flex items-center gap-x-3 w-fit text-neutral flex-wrap"> <div className="flex items-center gap-2">
{collection ? ( {collection ? (
<LinkCollection link={link} collection={collection} /> <>
<LinkCollection link={link} collection={collection} />
&middot;
</>
) : undefined} ) : undefined}
<LinkTypeBadge link={link} /> {link.url ? (
<LinkDate link={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">
{link.type}
</div>
)}
</div> </div>
<span className="hidden sm:block">&middot;</span>
<LinkDate link={link} />
</div> </div>
</div> </div>
</div> </Link>
<LinkActions <LinkActions
link={link} link={link}
collection={collection} collection={collection}
position="top-3 right-3" position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)} // toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo} // linkInfo={showInfo}
/> />
{showInfo ? (
<div>
<div className="pb-3 mt-1 px-3">
<p className="text-neutral text-lg font-semibold">Description</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<p>
{link.description ? (
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
No description provided.
</span>
)}
</p>
{link.tags[0] ? (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
Tags
</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</Link>
))}
</div>
</div>
</>
) : undefined}
</div>
</div>
) : undefined}
</div> </div>
<div className="divider my-0 last:hidden h-[1px]"></div> <div className="divider my-0 last:hidden h-[1px]"></div>
</> </>
); );
-96
View File
@@ -1,96 +0,0 @@
import { dropdownTriggerer, isIphone } from "@/lib/client/utils";
import React from "react";
import { useState } from "react";
import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal";
import UploadFileModal from "./ModalContent/UploadFileModal";
import MobileNavigationButton from "./MobileNavigationButton";
type Props = {};
export default function MobileNavigation({}: Props) {
const [newLinkModal, setNewLinkModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
return (
<>
<div
className={`fixed bottom-0 left-0 right-0 z-30 duration-200 sm:hidden`}
>
<div
className={`w-full flex bg-base-100 ${
isIphone() ? "pb-5" : ""
} border-solid border-t-neutral-content border-t`}
>
<MobileNavigationButton href={`/dashboard`} icon={"bi-house"} />
<MobileNavigationButton
href={`/links/pinned`}
icon={"bi-pin-angle"}
/>
<div className="dropdown dropdown-top -mt-4">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className={`flex items-center btn btn-accent dark:border-violet-400 text-white btn-circle w-20 h-20 px-2 relative`}
>
<span>
<i className="bi-plus text-5xl pointer-events-none"></i>
</span>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mb-1 -ml-12">
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewLinkModal(true);
}}
tabIndex={0}
role="button"
>
New Link
</div>
</li>
{/* <li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
Upload File
</div>
</li> */}
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
>
New Collection
</div>
</li>
</ul>
</div>
<MobileNavigationButton href={`/links`} icon={"bi-link-45deg"} />
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div>
</div>
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined}
{uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined}
</>
);
}
-45
View File
@@ -1,45 +0,0 @@
import { isPWA } from "@/lib/client/utils";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
export default function MobileNavigationButton({
href,
icon,
}: {
href: string;
icon: string;
}) {
const router = useRouter();
const [active, setActive] = useState(false);
useEffect(() => {
setActive(href === router.asPath);
}, [router]);
return (
<Link
href={href}
className="w-full active:scale-[80%] duration-200 select-none"
draggable="false"
style={{ WebkitTouchCallout: "none" }}
onContextMenu={(e) => {
if (isPWA()) {
e.preventDefault();
e.stopPropagation();
return false;
} else return null;
}}
>
<div
className={`py-2 cursor-pointer gap-2 w-full rounded-full capitalize flex items-center justify-center`}
>
<i
className={`${icon} text-primary text-3xl drop-shadow duration-200 rounded-full w-14 h-14 text-center pt-[0.65rem] ${
active || false ? "bg-primary/20" : ""
}`}
></i>
</div>
</Link>
);
}
+23 -52
View File
@@ -1,6 +1,5 @@
import React, { MouseEventHandler, ReactNode, useEffect } from "react"; import React, { MouseEventHandler, ReactNode, useEffect } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler"; import ClickAwayHandler from "@/components/ClickAwayHandler";
import { Drawer } from "vaul";
type Props = { type Props = {
toggleModal: Function; toggleModal: Function;
@@ -9,59 +8,31 @@ type Props = {
}; };
export default function Modal({ toggleModal, className, children }: Props) { export default function Modal({ toggleModal, className, children }: Props) {
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
useEffect(() => { useEffect(() => {
if (window.innerWidth >= 640) { document.body.style.overflow = "hidden";
document.body.style.overflow = "hidden"; return () => {
document.body.style.position = "relative"; document.body.style.overflow = "auto";
return () => { };
document.body.style.overflow = "auto"; });
document.body.style.position = "";
};
}
}, []);
if (window.innerWidth < 640) { return (
return ( <div className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-40">
<Drawer.Root <ClickAwayHandler
open={drawerIsOpen} onClickOutside={toggleModal}
onClose={() => setTimeout(() => toggleModal(), 100)} className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
className || ""
}`}
> >
<Drawer.Portal> <div className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible">
<Drawer.Overlay className="fixed inset-0 bg-black/40" /> <div
<ClickAwayHandler onClickOutside={() => setDrawerIsOpen(false)}> onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
<Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30"> className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
<div className="p-4 pb-32 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"> >
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5" /> <i className="bi-x text-neutral text-2xl"></i>
{children}
</div>
</Drawer.Content>
</ClickAwayHandler>
</Drawer.Portal>
</Drawer.Root>
);
} else {
return (
<div className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-40">
<ClickAwayHandler
onClickOutside={toggleModal}
className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
className || ""
}`}
>
<div className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible">
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
{children}
</div> </div>
</ClickAwayHandler> {children}
</div> </div>
); </ClickAwayHandler>
} </div>
);
} }
@@ -1,75 +0,0 @@
import React from "react";
import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
type Props = {
onClose: Function;
};
export default function BulkDeleteLinksModal({ onClose }: Props) {
const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore();
const deleteLink = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
toast.dismiss(load);
if (response.ok) {
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}`
);
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
Delete {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
{selectedLinks.length > 1 ? (
<p>Are you sure you want to delete {selectedLinks.length} links?</p>
) : (
<p>Are you sure you want to delete this link?</p>
)}
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>Warning:</b> This action is irreversible!
</span>
</div>
<p>
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
&apos;Delete&apos; to bypass this confirmation in the future.
</p>
<button
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
onClick={deleteLink}
>
<i className="bi-trash text-xl" />
Delete
</button>
</div>
</Modal>
);
}
@@ -1,102 +0,0 @@
import React, { useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
type Props = {
onClose: Function;
};
export default function BulkEditLinksModal({ onClose }: Props) {
const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [removePreviousTags, setRemovePreviousTags] = useState(false);
const [updatedValues, setUpdatedValues] = useState<
Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId">
>({ tags: [] });
const setCollection = (e: any) => {
const collectionId = e?.value || null;
console.log(updatedValues);
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
};
const setTags = (e: any) => {
const tags = e.map((tag: any) => ({ name: tag.label }));
setUpdatedValues((prevValues) => ({ ...prevValues, tags }));
};
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading("Updating...");
const response = await updateLinks(
selectedLinks,
removePreviousTags,
updatedValues
);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
Edit {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Move to Collection</p>
<CollectionSelection
showDefaultValue={false}
onChange={setCollection}
creatable={false}
/>
</div>
<div>
<p className="mb-2">Add Tags</p>
<TagSelection onChange={setTags} />
</div>
</div>
<div className="sm:ml-auto w-1/2 p-3">
<label className="flex items-center gap-2 ">
<input
type="checkbox"
className="checkbox checkbox-primary"
checked={removePreviousTags}
onChange={(e) => setRemovePreviousTags(e.target.checked)}
/>
Remove previous tags
</label>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Save Changes
</button>
</div>
</Modal>
);
}
@@ -15,6 +15,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
useState<LinkIncludingShortenedCollectionAndTags>(activeLink); useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const { removeLink } = useLinkStore(); const { removeLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter(); const router = useRouter();
@@ -1,51 +0,0 @@
import toast from "react-hot-toast";
import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
type Props = {
onClose: Function;
userId: number;
};
export default function DeleteUserModal({ onClose, userId }: Props) {
const { removeUser } = useUserStore();
const deleteUser = async () => {
const load = toast.loading("Deleting...");
const response = await removeUser(userId);
toast.dismiss(load);
response.ok && toast.success(`User Deleted.`);
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Delete User</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>Are you sure you want to remove this user?</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>
<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={deleteUser}
>
<i className="bi-trash text-xl" />
Delete, I know what I&apos;m doing
</button>
</div>
</Modal>
);
}
@@ -110,7 +110,7 @@ export default function EditCollectionModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto" className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit} onClick={submit}
> >
Save Changes Save
</button> </button>
</div> </div>
</Modal> </Modal>
@@ -9,7 +9,6 @@ import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto"; import ProfilePhoto from "../ProfilePhoto";
import addMemberToCollection from "@/lib/client/addMemberToCollection"; import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Modal from "../Modal"; import Modal from "../Modal";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -234,8 +233,11 @@ export default function EditCollectionSharingModal({
: undefined; : undefined;
return ( return (
<React.Fragment key={i}> <>
<div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"> <div
key={i}
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
>
<div <div
className={"flex items-center justify-between w-full"} className={"flex items-center justify-between w-full"}
> >
@@ -262,7 +264,6 @@ export default function EditCollectionSharingModal({
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-primary font-normal" className="btn btn-sm btn-primary font-normal"
> >
{roleLabel} {roleLabel}
@@ -430,7 +431,7 @@ export default function EditCollectionSharingModal({
</div> </div>
</div> </div>
<div className="divider my-0 last:hidden h-[3px]"></div> <div className="divider my-0 last:hidden h-[3px]"></div>
</React.Fragment> </>
); );
})} })}
</div> </div>
@@ -442,7 +443,7 @@ export default function EditCollectionSharingModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3" className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3"
onClick={submit} onClick={submit}
> >
Save Changes Save
</button> </button>
)} )}
</div> </div>
+1 -2
View File
@@ -124,7 +124,6 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
label: "Unorganized", label: "Unorganized",
} }
} }
creatable={false}
/> />
) : null} ) : null}
</div> </div>
@@ -158,7 +157,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
className="btn btn-accent dark:border-violet-400 text-white" className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit} onClick={submit}
> >
Save Changes Save
</button> </button>
</div> </div>
</Modal> </Modal>
+4 -22
View File
@@ -5,26 +5,19 @@ import toast from "react-hot-toast";
import { HexColorPicker } from "react-colorful"; import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client"; import { Collection } from "@prisma/client";
import Modal from "../Modal"; import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useAccountStore from "@/store/account";
import { useSession } from "next-auth/react";
type Props = { type Props = {
onClose: Function; onClose: Function;
parent?: CollectionIncludingMembersAndLinkCount;
}; };
export default function NewCollectionModal({ onClose, parent }: Props) { export default function NewCollectionModal({ onClose }: Props) {
const initial = { const initial = {
parentId: parent?.id,
name: "", name: "",
description: "", description: "",
color: "#0ea5e9", color: "#0ea5e9",
} as Partial<Collection>; };
const [collection, setCollection] = useState<Partial<Collection>>(initial); const [collection, setCollection] = useState<Partial<Collection>>(initial);
const { setAccount } = useAccountStore();
const { data } = useSession();
useEffect(() => { useEffect(() => {
setCollection(initial); setCollection(initial);
@@ -46,11 +39,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
if (response.ok) { if (response.ok) {
toast.success("Created!"); toast.success("Created!");
if (response.data) { onClose();
// If the collection was created successfully, we need to get the new collection order
setAccount(data?.user.id as number);
onClose();
}
} else toast.error(response.data as string); } else toast.error(response.data as string);
setSubmitLoader(false); setSubmitLoader(false);
@@ -58,14 +47,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
{parent?.id ? ( <p className="text-xl font-thin">Create a New Collection</p>
<>
<p className="text-xl font-thin">New Sub-Collection</p>
<p className="capitalize text-sm">For {parent.name}</p>
</>
) : (
<p className="text-xl font-thin">Create a New Collection</p>
)}
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
+2 -1
View File
@@ -109,6 +109,7 @@ export default function NewLinkModal({ onClose }: Props) {
toast.success(`Created!`); toast.success(`Created!`);
onClose(); onClose();
} else toast.error(response.data as string); } else toast.error(response.data as string);
setSubmitLoader(false); setSubmitLoader(false);
return response; return response;
@@ -178,7 +179,7 @@ export default function NewLinkModal({ onClose }: Props) {
setLink({ ...link, description: e.target.value }) setLink({ ...link, description: e.target.value })
} }
placeholder="Will be auto generated if nothing is provided." placeholder="Will be auto generated if nothing is provided."
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100" className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/> />
</div> </div>
</div> </div>
-227
View File
@@ -1,227 +0,0 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { TokenExpiry } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import useTokenStore from "@/store/tokens";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = {
onClose: Function;
};
export default function NewTokenModal({ onClose }: Props) {
const [newToken, setNewToken] = useState("");
const { addToken } = useTokenStore();
const initial = {
name: "",
expires: 0 as TokenExpiry,
};
const [token, setToken] = useState(initial as any);
const [submitLoader, setSubmitLoader] = useState(false);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading("Creating...");
const { ok, data } = await addToken(token);
toast.dismiss(load);
if (ok) {
toast.success(`Created!`);
setNewToken((data as any).secretKey);
} else toast.error(data as string);
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
{newToken ? (
<div className="flex flex-col justify-center space-y-4">
<p className="text-xl font-thin">Access Token Created</p>
<p>
Your new token has been created. Please copy it and store it
somewhere safe. You will not be able to see it again.
</p>
<TextInput
spellCheck={false}
value={newToken}
onChange={() => {}}
className="w-full"
/>
<button
onClick={() => {
navigator.clipboard.writeText(newToken);
toast.success("Copied to clipboard!");
}}
className="btn btn-primary w-fit mx-auto"
>
Copy to Clipboard
</button>
</div>
) : (
<>
<p className="text-xl font-thin">Create an Access Token</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex sm:flex-row flex-col gap-2 items-center">
<div className="w-full">
<p className="mb-2">Name</p>
<TextInput
value={token.name}
onChange={(e) => setToken({ ...token, name: e.target.value })}
placeholder="e.g. For the iOS shortcut"
className="bg-base-200"
/>
</div>
<div className="w-full sm:w-fit">
<p className="mb-2">Expires in</p>
<div className="dropdown dropdown-bottom dropdown-end w-full">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-outline w-full sm:w-36 flex items-center btn-sm h-10"
>
{token.expires === TokenExpiry.sevenDays && "7 Days"}
{token.expires === TokenExpiry.oneMonth && "30 Days"}
{token.expires === TokenExpiry.twoMonths && "60 Days"}
{token.expires === TokenExpiry.threeMonths && "90 Days"}
{token.expires === TokenExpiry.never && "No Expiration"}
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-full sm:w-52 mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.sevenDays}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.sevenDays,
});
}}
/>
<span className="label-text">7 Days</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.oneMonth}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({ ...token, expires: TokenExpiry.oneMonth });
}}
/>
<span className="label-text">30 Days</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.twoMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.twoMonths,
});
}}
/>
<span className="label-text">60 Days</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.threeMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.threeMonths,
});
}}
/>
<span className="label-text">90 Days</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.never}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({ ...token, expires: TokenExpiry.never });
}}
/>
<span className="label-text">No Expiration</span>
</label>
</li>
</ul>
</div>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Create Access Token
</button>
</div>
</>
)}
</Modal>
);
}
-133
View File
@@ -1,133 +0,0 @@
import toast from "react-hot-toast";
import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
import TextInput from "../TextInput";
import { FormEvent, useState } from "react";
type Props = {
onClose: Function;
};
type FormData = {
name: string;
username?: string;
email?: string;
password: string;
};
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
export default function NewUserModal({ onClose }: Props) {
const { addUser } = useUserStore();
const [form, setForm] = useState<FormData>({
name: "",
username: "",
email: emailEnabled ? "" : undefined,
password: "",
});
const [submitLoader, setSubmitLoader] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!submitLoader) {
const checkFields = () => {
if (emailEnabled) {
return form.name !== "" && form.email !== "" && form.password !== "";
} else {
return (
form.name !== "" && form.username !== "" && form.password !== ""
);
}
};
if (checkFields()) {
if (form.password.length < 8)
return toast.error("Passwords must be at least 8 characters.");
setSubmitLoader(true);
const load = toast.loading("Creating Account...");
const response = await addUser(form);
toast.dismiss(load);
setSubmitLoader(false);
if (response.ok) {
toast.success("User Created!");
onClose();
} else {
toast.error(response.data as string);
}
} else {
toast.error("Please fill out all the fields.");
}
}
}
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Create New User</p>
<div className="divider mb-3 mt-1"></div>
<form onSubmit={submit}>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Display Name</p>
<TextInput
placeholder="Johnny"
className="bg-base-200"
onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.name}
/>
</div>
{emailEnabled ? (
<div>
<p className="mb-2">Username</p>
<TextInput
placeholder="john"
className="bg-base-200"
onChange={(e) => setForm({ ...form, username: e.target.value })}
value={form.username}
/>
</div>
) : undefined}
<div>
<p className="mb-2">Email</p>
<TextInput
placeholder="johnny@example.com"
className="bg-base-200"
onChange={(e) => setForm({ ...form, email: e.target.value })}
value={form.email}
/>
</div>
<div>
<p className="mb-2">Password</p>
<TextInput
placeholder="••••••••••••••"
className="bg-base-200"
onChange={(e) => setForm({ ...form, password: e.target.value })}
value={form.password}
/>
</div>
</div>
<div className="flex justify-between items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
type="submit"
>
Create User
</button>
</div>
</form>
</Modal>
);
}
@@ -69,13 +69,11 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
const isReady = () => { const isReady = () => {
return ( return (
collectionOwner.archiveAsScreenshot ===
(link && link.pdf && link.pdf !== "pending") &&
collectionOwner.archiveAsPDF ===
(link && link.pdf && link.pdf !== "pending") &&
link && link &&
(collectionOwner.archiveAsScreenshot === true
? link.pdf && link.pdf !== "pending"
: true) &&
(collectionOwner.archiveAsPDF === true
? link.pdf && link.pdf !== "pending"
: true) &&
link.readable && link.readable &&
link.readable !== "pending" link.readable !== "pending"
); );
@@ -217,7 +215,10 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
<i className="bi-box-arrow-up-right" /> <i className="bi-box-arrow-up-right" />
</Link> </Link>
{link?.collection.ownerId === session.data?.user.id ? ( {link?.collection.ownerId === session.data?.user.id ? (
<div className={`btn btn-outline`} onClick={() => updateArchive()}> <div
className={`btn w-1/2 btn-outline`}
onClick={() => updateArchive()}
>
<div> <div>
<p>Refresh Preserved Formats</p> <p>Refresh Preserved Formats</p>
<p className="text-xs"> <p className="text-xs">
@@ -1,62 +0,0 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
type Props = {
onClose: Function;
activeToken: AccessToken;
};
export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const [token, setToken] = useState<AccessToken>(activeToken);
const { revokeToken } = useTokenStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
useEffect(() => {
setToken(activeToken);
}, []);
const deleteLink = async () => {
console.log(token);
const load = toast.loading("Deleting...");
const response = await revokeToken(token.id as number);
toast.dismiss(load);
response.ok && toast.success(`Token Revoked.`);
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Revoke Token</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>
Are you sure you want to revoke this Access Token? Any apps or
services using this token will no longer be able to access Linkwarden
using it.
</p>
<button
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
onClick={deleteLink}
>
<i className="bi-trash text-xl" />
Revoke
</button>
</div>
</Modal>
);
}
+46 -12
View File
@@ -43,7 +43,7 @@ export default function UploadFileModal({ onClose }: Props) {
const [file, setFile] = useState<File>(); const [file, setFile] = useState<File>();
const { uploadFile } = useLinkStore(); const { addLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false);
@@ -100,22 +100,56 @@ export default function UploadFileModal({ onClose }: Props) {
const submit = async () => { const submit = async () => {
if (!submitLoader && file) { if (!submitLoader && file) {
setSubmitLoader(true); let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
const load = toast.loading("Creating..."); if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
}
const response = await uploadFile(link, file); if (fileType !== null && linkType !== null) {
setSubmitLoader(true);
toast.dismiss(load); let response;
if (response.ok) { const load = toast.loading("Creating...");
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false); response = await addLink({
...link,
type: linkType,
name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
});
return response; toast.dismiss(load);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${
(response.data as LinkIncludingShortenedCollectionAndTags).id
}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
}
} }
}; };
@@ -204,7 +238,7 @@ export default function UploadFileModal({ onClose }: Props) {
className="btn btn-accent dark:border-violet-400 text-white" className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit} onClick={submit}
> >
Upload File Create Link
</button> </button>
</div> </div>
</Modal> </Modal>
+72 -20
View File
@@ -1,32 +1,48 @@
import { signOut } from "next-auth/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler"; import ClickAwayHandler from "@/components/ClickAwayHandler";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import SearchBar from "@/components/SearchBar"; import SearchBar from "@/components/SearchBar";
import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto";
import useWindowDimensions from "@/hooks/useWindowDimensions"; import useWindowDimensions from "@/hooks/useWindowDimensions";
import ToggleDarkMode from "./ToggleDarkMode"; import ToggleDarkMode from "./ToggleDarkMode";
import useLocalSettingsStore from "@/store/localSettings";
import NewLinkModal from "./ModalContent/NewLinkModal"; import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal";
import Link from "next/link";
import UploadFileModal from "./ModalContent/UploadFileModal"; import UploadFileModal from "./ModalContent/UploadFileModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import MobileNavigation from "./MobileNavigation";
import ProfileDropdown from "./ProfileDropdown";
export default function Navbar() { export default function Navbar() {
const { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore();
const router = useRouter(); const router = useRouter();
const [sidebar, setSidebar] = useState(false); const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const handleToggle = () => {
const [colorTheme, mode] = (settings.theme || "default-light").split('-');
const newMode = mode === "dark" ? "light" : "dark";
const newTheme = `${colorTheme}-${newMode}`;
updateSettings({ theme: newTheme });
};
useEffect(() => { useEffect(() => {
setSidebar(false); setSidebar(false);
document.body.style.overflow = "auto"; }, [width]);
}, [width, router]);
useEffect(() => {
setSidebar(false);
}, [router]);
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebar(false); setSidebar(!sidebar);
document.body.style.overflow = "auto";
}; };
const [newLinkModal, setNewLinkModal] = useState(false); const [newLinkModal, setNewLinkModal] = useState(false);
@@ -36,11 +52,8 @@ export default function Navbar() {
return ( return (
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b"> <div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b">
<div <div
onClick={() => { onClick={toggleSidebar}
setSidebar(true); className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden"
document.body.style.overflow = "hidden";
}}
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden sm:inline-flex"
> >
<i className="bi-list text-2xl leading-none"></i> <i className="bi-list text-2xl leading-none"></i>
</div> </div>
@@ -48,12 +61,11 @@ export default function Navbar() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ToggleDarkMode className="hidden sm:inline-grid" /> <ToggleDarkMode className="hidden sm:inline-grid" />
<div className="dropdown dropdown-end sm:inline-block hidden"> <div className="dropdown dropdown-end">
<div className="tooltip tooltip-bottom" data-tip="Create New..."> <div className="tooltip tooltip-bottom" data-tip="Create New...">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="flex min-w-[3.4rem] items-center btn btn-accent dark:border-violet-400 text-white btn-sm max-h-[2rem] px-2 relative" className="flex min-w-[3.4rem] items-center btn btn-accent dark:border-violet-400 text-white btn-sm max-h-[2rem] px-2 relative"
> >
<span> <span>
@@ -77,7 +89,7 @@ export default function Navbar() {
New Link New Link
</div> </div>
</li> </li>
<li> {/* <li>
<div <div
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
@@ -88,7 +100,7 @@ export default function Navbar() {
> >
Upload File Upload File
</div> </div>
</li> </li> */}
<li> <li>
<div <div
onClick={() => { onClick={() => {
@@ -104,11 +116,51 @@ export default function Navbar() {
</ul> </ul>
</div> </div>
<ProfileDropdown /> <div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-circle btn-ghost">
<ProfilePhoto
src={account.image ? account.image : undefined}
priority={true}
/>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
>
Settings
</Link>
</li>
<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
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
>
Logout
</div>
</li>
</ul>
</div>
</div> </div>
<MobileNavigation />
{sidebar ? ( {sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40"> <div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}> <ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
-71
View File
@@ -1,71 +0,0 @@
import useLocalSettingsStore from "@/store/localSettings";
import { dropdownTriggerer } from "@/lib/client/utils";
import ProfilePhoto from "./ProfilePhoto";
import useAccountStore from "@/store/account";
import Link from "next/link";
import { signOut } from "next-auth/react";
export default function ProfileDropdown() {
const { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore();
const handleToggle = () => {
if (settings.theme === "dark") {
updateSettings({ theme: "light" });
} else {
updateSettings({ theme: "dark" });
}
};
return (
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-circle btn-ghost"
>
<ProfilePhoto
src={account.image ? account.image : undefined}
priority={true}
/>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
>
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>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
>
Logout
</div>
</li>
</ul>
</div>
);
}
+1 -1
View File
@@ -45,7 +45,7 @@ export default function ProfilePhoto({
<div <div
className={`avatar skeleton rounded-full drop-shadow-md ${ className={`avatar skeleton rounded-full drop-shadow-md ${
className || "" className || ""
} ${large ? "w-28 h-28" : "w-8 h-8"}`} } ${large || "w-8 h-8"}`}
> >
<div className="rounded-full w-full h-full ring-2 ring-neutral-content"> <div className="rounded-full w-full h-full ring-2 ring-neutral-content">
<Image <Image
+2 -6
View File
@@ -34,8 +34,6 @@ export default function ReadableView({ link }: Props) {
const [imageError, setImageError] = useState<boolean>(false); const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>(); const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const [date, setDate] = useState<Date | string>();
const colorThief = new ColorThief(); const colorThief = new ColorThief();
const router = useRouter(); const router = useRouter();
@@ -56,8 +54,6 @@ export default function ReadableView({ link }: Props) {
}; };
fetchLinkContent(); fetchLinkContent();
setDate(link.importDate || link.createdAt);
}, [link]); }, [link]);
useEffect(() => { useEffect(() => {
@@ -215,8 +211,8 @@ export default function ReadableView({ link }: Props) {
</div> </div>
<p className="min-w-fit text-sm text-neutral"> <p className="min-w-fit text-sm text-neutral">
{date {link?.createdAt
? new Date(date).toLocaleString("en-US", { ? new Date(link?.createdAt).toLocaleString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
+21 -8
View File
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
export default function SettingsSidebar({ className }: { className?: string }) { export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = process.env.version; const LINKWARDEN_VERSION = "v2.4.7";
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
@@ -37,30 +37,43 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div> </div>
</Link> </Link>
<Link href="/settings/preference"> <Link href="/settings/appearance">
<div <div
className={`${ className={`${
active === `/settings/preference` active === `/settings/appearance`
? "bg-primary/20" ? "bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i className="bi-sliders text-primary text-2xl"></i> <i className="bi-palette text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Preference</p> <p className="truncate w-full pr-7">Appearance</p>
</div> </div>
</Link> </Link>
<Link href="/settings/access-tokens"> <Link href="/settings/archive">
<div <div
className={`${ className={`${
active === `/settings/access-tokens` active === `/settings/archive`
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-archive text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Archive</p>
</div>
</Link>
<Link href="/settings/api">
<div
className={`${
active === `/settings/api`
? "bg-primary/20" ? "bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i className="bi-key text-primary text-2xl"></i> <i className="bi-key text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Access Tokens</p> <p className="truncate w-full pr-7">API Keys</p>
</div> </div>
</Link> </Link>
+45 -5
View File
@@ -5,7 +5,6 @@ import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import SidebarHighlightLink from "@/components/SidebarHighlightLink"; import SidebarHighlightLink from "@/components/SidebarHighlightLink";
import CollectionListing from "@/components/CollectionListing";
export default function Sidebar({ className }: { className?: string }) { export default function Sidebar({ className }: { className?: string }) {
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => { const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
@@ -22,10 +21,11 @@ export default function Sidebar({ className }: { className?: string }) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { tags } = useTagStore(); const { tags } = useTagStore();
const [active, setActive] = useState("");
const router = useRouter(); const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => { useEffect(() => {
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false"); localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
}, [tagDisclosure]); }, [tagDisclosure]);
@@ -44,7 +44,7 @@ export default function Sidebar({ className }: { className?: string }) {
return ( return (
<div <div
id="sidebar" id="sidebar"
className={`bg-base-200 h-full w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${ className={`bg-base-200 h-full w-72 lg:w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
className || "" className || ""
}`} }`}
> >
@@ -97,8 +97,48 @@ export default function Sidebar({ className }: { className?: string }) {
leaveFrom="transform opacity-100 translate-y-0" leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3" leaveTo="transform opacity-0 -translate-y-3"
> >
<Disclosure.Panel> <Disclosure.Panel className="flex flex-col gap-1">
<CollectionListing /> {collections[0] ? (
collections
.sort((a, b) => a.name.localeCompare(b.name))
.map((e, i) => {
return (
<Link key={i} href={`/collections/${e.id}`}>
<div
className={`${
active === `/collections/${e.id}`
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
style={{ color: e.color }}
></i>
<p className="truncate w-full">{e.name}</p>
{e.isPublic ? (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
) : undefined}
<div className="drop-shadow text-neutral text-xs">
{e._count?.links}
</div>
</div>
</Link>
);
})
) : (
<div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
You Have No Collections...
</p>
</div>
)}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
</Disclosure> </Disclosure>
+1 -3
View File
@@ -1,6 +1,5 @@
import React, { Dispatch, SetStateAction } from "react"; import React, { Dispatch, SetStateAction } from "react";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
sortBy: Sort; sortBy: Sort;
@@ -13,8 +12,7 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer} className="btn btn-sm btn-square btn-ghost"
className="btn btn-sm btn-square btn-ghost border-none"
> >
<i className="bi-chevron-expand text-neutral text-2xl"></i> <i className="bi-chevron-expand text-neutral text-2xl"></i>
</div> </div>
-3
View File
@@ -8,7 +8,6 @@ type Props = {
onChange: ChangeEventHandler<HTMLInputElement>; onChange: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined; onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined;
className?: string; className?: string;
spellCheck?: boolean;
}; };
export default function TextInput({ export default function TextInput({
@@ -19,11 +18,9 @@ export default function TextInput({
onChange, onChange,
onKeyDown, onKeyDown,
className, className,
spellCheck,
}: Props) { }: Props) {
return ( return (
<input <input
spellCheck={spellCheck}
autoFocus={autoFocus} autoFocus={autoFocus}
type={type ? type : "text"} type={type ? type : "text"}
placeholder={placeholder} placeholder={placeholder}
+35 -28
View File
@@ -2,39 +2,46 @@ import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
type Props = { type Props = {
className?: string; className?: string;
}; };
export default function ToggleDarkMode({ className }: Props) { export default function ToggleDarkMode({ className }: Props) {
const { settings, updateSettings } = useLocalSettingsStore(); const { updateSettings } = useLocalSettingsStore();
const [theme, setTheme] = useState('default-light');
const [theme, setTheme] = useState(localStorage.getItem("theme")); useEffect(() => {
const storedTheme = localStorage.getItem("theme");
if (storedTheme) {
setTheme(storedTheme);
} else {
// Default theme if not set in localStorage
localStorage.setItem("theme", "default-light");
setTheme("default-light");
}
console.log("Initial theme from localStorage:", storedTheme || "default-light");
}, []);
const handleToggle = (e: any) => { const handleToggle = () => {
setTheme(e.target.checked ? "dark" : "light"); const [currentColorTheme, currentMode] = theme.split('-');
}; const newMode = currentMode === 'light' ? 'dark' : 'light';
const newTheme = `${currentColorTheme}-${newMode}`;
useEffect(() => { setTheme(newTheme);
updateSettings({ theme: theme as string }); localStorage.setItem("theme", newTheme);
}, [theme]); document.documentElement.setAttribute('data-theme', newTheme);
updateSettings({ theme: newTheme });
console.log("New theme set:", newTheme);
};
return ( const isDarkMode = theme.endsWith('-dark');
<div
className="tooltip tooltip-bottom" return (
data-tip={`Switch to ${settings.theme === "light" ? "Dark" : "Light"}`} <div className="tooltip tooltip-bottom" data-tip={`Switch to ${isDarkMode ? "Light" : "Dark"}`}>
> <label className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}>
<label <input type="checkbox" onChange={handleToggle} className="theme-controller" checked={isDarkMode} />
className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`} <i className="bi-sun-fill text-xl swap-on"></i>
> <i className="bi-moon-fill text-xl swap-off"></i>
<input </label>
type="checkbox" </div>
onChange={handleToggle} );
className="theme-controller"
checked={localStorage.getItem("theme") === "light" ? false : true}
/>
<i className="bi-sun-fill text-xl swap-on"></i>
<i className="bi-moon-fill text-xl swap-off"></i>
</label>
</div>
);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { Dispatch, SetStateAction, useEffect } from "react"; import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@/types/global"; import { ViewMode } from "@/types/global";
-34
View File
@@ -1,34 +0,0 @@
import useAccountStore from "@/store/account";
import useCollectionStore from "@/store/collections";
import { Member } from "@/types/global";
import { useEffect, useState } from "react";
export default function useCollectivePermissions(collectionIds: number[]) {
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => {
for (const collectionId of collectionIds) {
const collection = collections.find((e) => e.id === collectionId);
if (collection) {
let getPermission: Member | undefined = collection.members.find(
(e) => e.userId === account.id
);
if (
getPermission?.canCreate === false &&
getPermission?.canUpdate === false &&
getPermission?.canDelete === false
)
getPermission = undefined;
setPermissions(account.id === collection.ownerId || getPermission);
}
}
}, [account, collections, collectionIds]);
return permissions;
}
+2 -15
View File
@@ -1,5 +1,5 @@
import { LinkRequestQuery } from "@/types/global"; import { LinkRequestQuery } from "@/types/global";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import useDetectPageBottom from "./useDetectPageBottom"; import useDetectPageBottom from "./useDetectPageBottom";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
@@ -18,12 +18,9 @@ export default function useLinks(
searchByTextContent, searchByTextContent,
}: LinkRequestQuery = { sort: 0 } }: LinkRequestQuery = { sort: 0 }
) { ) {
const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } = const { links, setLinks, resetLinks } = useLinkStore();
useLinkStore();
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const { reachedBottom, setReachedBottom } = useDetectPageBottom(); const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => { const getLinks = async (isInitialCall: boolean, cursor?: number) => {
@@ -63,24 +60,16 @@ export default function useLinks(
basePath = "/api/v1/public/collections/links"; basePath = "/api/v1/public/collections/links";
} else basePath = "/api/v1/links"; } else basePath = "/api/v1/links";
setIsLoading(true);
const response = await fetch(`${basePath}?${queryString}`); const response = await fetch(`${basePath}?${queryString}`);
const data = await response.json(); const data = await response.json();
setIsLoading(false);
if (response.ok) setLinks(data.response, isInitialCall); if (response.ok) setLinks(data.response, isInitialCall);
}; };
useEffect(() => { useEffect(() => {
// Save the selected links before resetting the links
// and then restore the selected links after resetting the links
const previouslySelected = selectedLinks;
resetLinks(); resetLinks();
setSelectedLinks(previouslySelected);
getLinks(true); getLinks(true);
}, [ }, [
router, router,
@@ -98,6 +87,4 @@ export default function useLinks(
setReachedBottom(false); setReachedBottom(false);
}, [reachedBottom]); }, [reachedBottom]);
return { isLoading };
} }
+1 -1
View File
@@ -46,7 +46,7 @@ export default function MainLayout({ children }: Props) {
</div> </div>
<div <div
className={`w-full sm:pb-0 pb-20 flex flex-col min-h-${ className={`w-full flex flex-col min-h-${
showAnnouncement ? "full" : "screen" showAnnouncement ? "full" : "screen"
} lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`} } lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
> >
+50 -45
View File
@@ -1,4 +1,4 @@
import { LaunchOptions, chromium, devices } from "playwright"; import { chromium, devices } from "playwright";
import { prisma } from "./db"; import { prisma } from "./db";
import createFile from "./storage/createFile"; import createFile from "./storage/createFile";
import sendToWayback from "./sendToWayback"; import sendToWayback from "./sendToWayback";
@@ -7,9 +7,9 @@ import { JSDOM } from "jsdom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Collection, Link, User } from "@prisma/client"; import { Collection, Link, User } from "@prisma/client";
import validateUrlSize from "./validateUrlSize"; import validateUrlSize from "./validateUrlSize";
import removeFile from "./storage/removeFile";
import Jimp from "jimp";
import createFolder from "./storage/createFolder"; import createFolder from "./storage/createFolder";
import generatePreview from "./generatePreview";
import { removeFiles } from "./manageLinkFiles";
type LinksAndCollectionAndOwner = Link & { type LinksAndCollectionAndOwner = Link & {
collection: Collection & { collection: Collection & {
@@ -20,23 +20,8 @@ type LinksAndCollectionAndOwner = Link & {
const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5; const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5;
export default async function archiveHandler(link: LinksAndCollectionAndOwner) { export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
// allow user to configure a proxy const browser = await chromium.launch();
let browserOptions: LaunchOptions = {}; const context = await browser.newContext(devices["Desktop Chrome"]);
if (process.env.PROXY) {
browserOptions.proxy = {
server: process.env.PROXY,
bypass: process.env.PROXY_BYPASS,
username: process.env.PROXY_USERNAME,
password: process.env.PROXY_PASSWORD,
};
}
const browser = await chromium.launch(browserOptions);
const context = await browser.newContext({
...devices["Desktop Chrome"],
ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === "true",
});
const page = await context.newPage(); const page = await context.newPage();
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
@@ -51,14 +36,6 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
); );
}); });
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
createFolder({
filePath: `archives/${link.collectionId}`,
});
try { try {
await Promise.race([ await Promise.race([
(async () => { (async () => {
@@ -66,11 +43,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
? await validateUrlSize(link.url) ? await validateUrlSize(link.url)
: undefined; : undefined;
if ( if (validatedUrl === null) throw "File is too large to be stored.";
validatedUrl === null &&
process.env.IGNORE_URL_SIZE_LIMIT !== "true"
)
throw "Something went wrong while retrieving the file size.";
const contentType = validatedUrl?.get("content-type"); const contentType = validatedUrl?.get("content-type");
let linkType = "url"; let linkType = "url";
@@ -96,11 +69,11 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
image: image:
user.archiveAsScreenshot && !link.image?.startsWith("archive") user.archiveAsScreenshot && !link.image?.startsWith("archive")
? "pending" ? "pending"
: "unavailable", : undefined,
pdf: pdf:
user.archiveAsPDF && !link.pdf?.startsWith("archive") user.archiveAsPDF && !link.pdf?.startsWith("archive")
? "pending" ? "pending"
: "unavailable", : undefined,
readable: !link.readable?.startsWith("archive") readable: !link.readable?.startsWith("archive")
? "pending" ? "pending"
: undefined, : undefined,
@@ -173,6 +146,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
return metaTag ? (metaTag as any).content : null; return metaTag ? (metaTag as any).content : null;
}); });
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
if (ogImageUrl) { if (ogImageUrl) {
console.log("Found og:image URL:", ogImageUrl); console.log("Found og:image URL:", ogImageUrl);
@@ -182,7 +159,35 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
// Check if imageResponse is not null // Check if imageResponse is not null
if (imageResponse && !link.preview?.startsWith("archive")) { if (imageResponse && !link.preview?.startsWith("archive")) {
const buffer = await imageResponse.body(); const buffer = await imageResponse.body();
await generatePreview(buffer, link.collectionId, link.id);
// Check if buffer is not null
if (buffer) {
// Load the image using Jimp
Jimp.read(buffer, async (err, image) => {
if (image && !err) {
image?.resize(1280, Jimp.AUTO).quality(20);
const processedBuffer = await image?.getBufferAsync(
Jimp.MIME_JPEG
);
createFile({
data: processedBuffer,
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
}).then(() => {
return prisma.link.update({
where: { id: link.id },
data: {
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
},
});
});
}
}).catch((err) => {
console.error("Error processing the image:", err);
});
} else {
console.log("No image data found.");
}
} }
await page.goBack(); await page.goBack();
@@ -232,13 +237,6 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
}) })
); );
} }
// apply administrator's defined pdf margins or default to 15px
const margins = {
top: process.env.PDF_MARGIN_TOP || "15px",
bottom: process.env.PDF_MARGIN_BOTTOM || "15px",
};
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) { if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
processingPromises.push( processingPromises.push(
page page
@@ -246,7 +244,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
width: "1366px", width: "1366px",
height: "1931px", height: "1931px",
printBackground: true, printBackground: true,
margin: margins, margin: { top: "15px", bottom: "15px" },
}) })
.then((pdf) => { .then((pdf) => {
return createFile({ return createFile({
@@ -302,7 +300,14 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
}, },
}); });
else { else {
await removeFiles(link.id, link.collectionId); removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
removeFile({
filePath: `archives/${link.collectionId}/${link.id}_readability.json`,
});
removeFile({
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
});
} }
await browser.close(); await browser.close();
+6 -7
View File
@@ -32,12 +32,11 @@ export default async function checkSubscriptionByEmail(email: string) {
customer.subscriptions?.data.some((subscription) => { customer.subscriptions?.data.some((subscription) => {
subscription.current_period_end; subscription.current_period_end;
active = active = subscription.items.data.some(
subscription.items.data.some( (e) =>
(e) => (e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) || (e.price.id === YEARLY_PRICE_ID && e.price.active === true)
(e.price.id === YEARLY_PRICE_ID && e.price.active === true) );
) || false;
stripeSubscriptionId = subscription.id; stripeSubscriptionId = subscription.id;
currentPeriodStart = subscription.current_period_start * 1000; currentPeriodStart = subscription.current_period_start * 1000;
currentPeriodEnd = subscription.current_period_end * 1000; currentPeriodEnd = subscription.current_period_end * 1000;
@@ -45,7 +44,7 @@ export default async function checkSubscriptionByEmail(email: string) {
}); });
return { return {
active: active || false, active,
stripeSubscriptionId, stripeSubscriptionId,
currentPeriodStart, currentPeriodStart,
currentPeriodEnd, currentPeriodEnd,
@@ -31,16 +31,12 @@ export default async function deleteCollection(
}, },
}); });
await removeFromOrders(userId, collectionId);
return { response: deletedUsersAndCollectionsRelation, status: 200 }; return { response: deletedUsersAndCollectionsRelation, status: 200 };
} else if (collectionIsAccessible?.ownerId !== userId) { } else if (collectionIsAccessible?.ownerId !== userId) {
return { response: "Collection is not accessible.", status: 401 }; return { response: "Collection is not accessible.", status: 401 };
} }
const deletedCollection = await prisma.$transaction(async () => { const deletedCollection = await prisma.$transaction(async () => {
await deleteSubCollections(collectionId);
await prisma.usersAndCollections.deleteMany({ await prisma.usersAndCollections.deleteMany({
where: { where: {
collection: { collection: {
@@ -57,9 +53,7 @@ export default async function deleteCollection(
}, },
}); });
await removeFolder({ filePath: `archives/${collectionId}` }); removeFolder({ filePath: `archives/${collectionId}` });
await removeFromOrders(userId, collectionId);
return await prisma.collection.delete({ return await prisma.collection.delete({
where: { where: {
@@ -70,60 +64,3 @@ export default async function deleteCollection(
return { response: deletedCollection, status: 200 }; return { response: deletedCollection, status: 200 };
} }
async function deleteSubCollections(collectionId: number) {
const subCollections = await prisma.collection.findMany({
where: { parentId: collectionId },
});
for (const subCollection of subCollections) {
await deleteSubCollections(subCollection.id);
await prisma.usersAndCollections.deleteMany({
where: {
collection: {
id: subCollection.id,
},
},
});
await prisma.link.deleteMany({
where: {
collection: {
id: subCollection.id,
},
},
});
await prisma.collection.delete({
where: { id: subCollection.id },
});
await removeFolder({ filePath: `archives/${subCollection.id}` });
}
}
async function removeFromOrders(userId: number, collectionId: number) {
const userCollectionOrder = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
collectionOrder: true,
},
});
if (userCollectionOrder)
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
set: userCollectionOrder.collectionOrder.filter(
(e: number) => e !== collectionId
),
},
},
});
}
@@ -1,34 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function getCollectionById(
userId: number,
collectionId: number
) {
const collections = await prisma.collection.findFirst({
where: {
id: collectionId,
OR: [
{ ownerId: userId },
{ members: { some: { user: { id: userId } } } },
],
},
include: {
_count: {
select: { links: true },
},
members: {
include: {
user: {
select: {
username: true,
name: true,
image: true,
},
},
},
},
},
});
return { response: collections, status: 200 };
}
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import { Collection, UsersAndCollections } from "@prisma/client";
export default async function updateCollection( export default async function updateCollection(
userId: number, userId: number,
@@ -18,32 +19,6 @@ export default async function updateCollection(
if (!(collectionIsAccessible?.ownerId === userId)) if (!(collectionIsAccessible?.ownerId === userId))
return { response: "Collection is not accessible.", status: 401 }; return { response: "Collection is not accessible.", status: 401 };
console.log(data);
if (data.parentId) {
if (data.parentId !== ("root" as any)) {
const findParentCollection = await prisma.collection.findUnique({
where: {
id: data.parentId,
},
select: {
ownerId: true,
parentId: true,
},
});
if (
findParentCollection?.ownerId !== userId ||
typeof data.parentId !== "number" ||
findParentCollection?.parentId === data.parentId
)
return {
response: "You are not authorized to create a sub-collection here.",
status: 403,
};
}
}
const updatedCollection = await prisma.$transaction(async () => { const updatedCollection = await prisma.$transaction(async () => {
await prisma.usersAndCollections.deleteMany({ await prisma.usersAndCollections.deleteMany({
where: { where: {
@@ -57,23 +32,12 @@ export default async function updateCollection(
where: { where: {
id: collectionId, id: collectionId,
}, },
data: { data: {
name: data.name.trim(), name: data.name.trim(),
description: data.description, description: data.description,
color: data.color, color: data.color,
isPublic: data.isPublic, isPublic: data.isPublic,
parent:
data.parentId && data.parentId !== ("root" as any)
? {
connect: {
id: data.parentId,
},
}
: data.parentId === ("root" as any)
? {
disconnect: true,
}
: undefined,
members: { members: {
create: data.members.map((e) => ({ create: data.members.map((e) => ({
user: { connect: { id: e.user.id || e.userId } }, user: { connect: { id: e.user.id || e.userId } },
@@ -12,12 +12,6 @@ export default async function getCollection(userId: number) {
_count: { _count: {
select: { links: true }, select: { links: true },
}, },
parent: {
select: {
id: true,
name: true,
},
},
members: { members: {
include: { include: {
user: { user: {
@@ -12,25 +12,23 @@ export default async function postCollection(
status: 400, status: 400,
}; };
if (collection.parentId) { const findCollection = await prisma.user.findUnique({
const findParentCollection = await prisma.collection.findUnique({ where: {
where: { id: userId,
id: collection.parentId, },
select: {
collections: {
where: {
name: collection.name,
},
}, },
select: { },
ownerId: true, });
},
});
if ( const checkIfCollectionExists = findCollection?.collections[0];
findParentCollection?.ownerId !== userId ||
typeof collection.parentId !== "number" if (checkIfCollectionExists)
) return { response: "Collection already exists.", status: 400 };
return {
response: "You are not authorized to create a sub-collection here.",
status: 403,
};
}
const newCollection = await prisma.collection.create({ const newCollection = await prisma.collection.create({
data: { data: {
@@ -42,13 +40,6 @@ export default async function postCollection(
name: collection.name.trim(), name: collection.name.trim(),
description: collection.description, description: collection.description,
color: collection.color, color: collection.color,
parent: collection.parentId
? {
connect: {
id: collection.parentId,
},
}
: undefined,
}, },
include: { include: {
_count: { _count: {
@@ -67,17 +58,6 @@ export default async function postCollection(
}, },
}); });
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
push: newCollection.id,
},
},
});
createFolder({ filePath: `archives/${newCollection.id}` }); createFolder({ filePath: `archives/${newCollection.id}` });
return { response: newCollection, status: 200 }; return { response: newCollection, status: 200 };
@@ -1,51 +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";
import { removeFiles } from "@/lib/api/manageLinkFiles";
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];
if (collectionIsAccessible) removeFiles(linkId, collectionIsAccessible.id);
}
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,8 +1,7 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { Link, UsersAndCollections } from "@prisma/client"; import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile"; import removeFile from "@/lib/api/storage/removeFile";
import { removeFiles } from "@/lib/api/manageLinkFiles";
export default async function deleteLink(userId: number, linkId: number) { export default async function deleteLink(userId: number, linkId: number) {
if (!linkId) return { response: "Please choose a valid link.", status: 401 }; if (!linkId) return { response: "Please choose a valid link.", status: 401 };
@@ -13,10 +12,7 @@ export default async function deleteLink(userId: number, linkId: number) {
(e: UsersAndCollections) => e.userId === userId && e.canDelete (e: UsersAndCollections) => e.userId === userId && e.canDelete
); );
if ( if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
!collectionIsAccessible ||
!(collectionIsAccessible?.ownerId === userId || memberHasAccess)
)
return { response: "Collection is not accessible.", status: 401 }; return { response: "Collection is not accessible.", status: 401 };
const deleteLink: Link = await prisma.link.delete({ const deleteLink: Link = await prisma.link.delete({
@@ -25,7 +21,15 @@ export default async function deleteLink(userId: number, linkId: number) {
}, },
}); });
removeFiles(linkId, collectionIsAccessible.id); removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
return { response: deleteLink, status: 200 }; return { response: deleteLink, status: 200 };
} }
@@ -1,8 +1,8 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { UsersAndCollections } from "@prisma/client"; import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import { moveFiles } from "@/lib/api/manageLinkFiles"; import moveFile from "@/lib/api/storage/moveFile";
export default async function updateLinkById( export default async function updateLinkById(
userId: number, userId: number,
@@ -17,70 +17,13 @@ export default async function updateLinkById(
const collectionIsAccessible = await getPermission({ userId, linkId }); const collectionIsAccessible = await getPermission({ userId, linkId });
const isCollectionOwner =
collectionIsAccessible?.ownerId === data.collection.ownerId &&
data.collection.ownerId === userId;
const canPinPermission = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId
);
// If the user is able to create a link, they can pin it to their dashboard only.
if (canPinPermission) {
const updatedLink = await prisma.link.update({
where: {
id: linkId,
},
data: {
pinnedBy:
data?.pinnedBy && data.pinnedBy[0]
? { connect: { id: userId } }
: { disconnect: { id: userId } },
},
include: {
collection: true,
pinnedBy: isCollectionOwner
? {
where: { id: userId },
select: { id: true },
}
: undefined,
},
});
return { response: updatedLink, status: 200 };
}
const targetCollectionIsAccessible = await getPermission({
userId,
collectionId: data.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some( const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate (e: UsersAndCollections) => e.userId === userId && e.canUpdate
); );
const targetCollectionsAccessible = const isCollectionOwner =
targetCollectionIsAccessible?.ownerId === userId; collectionIsAccessible?.ownerId === data.collection.ownerId &&
data.collection.ownerId === userId;
const targetCollectionMatchesData = data.collection.id
? data.collection.id === targetCollectionIsAccessible?.id
: true && data.collection.name
? data.collection.name === targetCollectionIsAccessible?.name
: true && data.collection.ownerId
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
: true;
if (!targetCollectionsAccessible)
return {
response: "Target collection is not accessible.",
status: 401,
};
else if (!targetCollectionMatchesData)
return {
response: "Target collection does not match the data.",
status: 401,
};
const unauthorizedSwitchCollection = const unauthorizedSwitchCollection =
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id; !isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
@@ -146,7 +89,20 @@ export default async function updateLinkById(
}); });
if (collectionIsAccessible?.id !== data.collection.id) { if (collectionIsAccessible?.id !== data.collection.id) {
await moveFiles(linkId, collectionIsAccessible?.id, data.collection.id); await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
`archives/${data.collection.id}/${linkId}.pdf`
);
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}.png`,
`archives/${data.collection.id}/${linkId}.png`
);
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
`archives/${data.collection.id}/${linkId}_readability.json`
);
} }
return { response: updatedLink, status: 200 }; return { response: updatedLink, status: 200 };
+43 -130
View File
@@ -12,136 +12,18 @@ export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags, link: LinkIncludingShortenedCollectionAndTags,
userId: number userId: number
) { ) {
if (link.url || link.type === "url") { try {
try { new URL(link.url || "");
new URL(link.url || ""); } catch (error) {
} catch (error) { return {
return { response:
response: "Please enter a valid Address for the Link. (It should start with http/https)",
"Please enter a valid Address for the Link. (It should start with http/https)", status: 400,
status: 400, };
};
}
} }
if (!link.collection.id && link.collection.name) { if (!link.collection.name) {
link.collection.name = link.collection.name.trim();
// find the collection with the name and the user's id
const findCollection = await prisma.collection.findFirst({
where: {
name: link.collection.name,
ownerId: userId,
parentId: link.collection.parentId,
},
});
if (findCollection) {
const collectionIsAccessible = await getPermission({
userId,
collectionId: findCollection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
link.collection.id = findCollection.id;
link.collection.ownerId = findCollection.ownerId;
} else {
const collection = await prisma.collection.create({
data: {
name: link.collection.name,
ownerId: userId,
},
});
link.collection.id = collection.id;
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
push: link.collection.id,
},
},
});
}
} else if (link.collection.id) {
const collectionIsAccessible = await getPermission({
userId,
collectionId: link.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
} else if (!link.collection.id) {
link.collection.name = "Unorganized"; link.collection.name = "Unorganized";
link.collection.parentId = null;
// find the collection with the name "Unorganized" and the user's id
const unorganizedCollection = await prisma.collection.findFirst({
where: {
name: "Unorganized",
ownerId: userId,
},
});
link.collection.id = unorganizedCollection?.id;
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
push: link.collection.id,
},
},
});
} else {
return { response: "Uncaught error.", status: 500 };
}
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (user?.preventDuplicateLinks) {
const url = link.url?.trim().replace(/\/+$/, ""); // trim and remove trailing slashes from the URL
const hasWwwPrefix = url?.includes(`://www.`);
const urlWithoutWww = hasWwwPrefix ? url?.replace(`://www.`, "://") : url;
const urlWithWww = hasWwwPrefix ? url : url?.replace("://", `://www.`);
console.log(url, urlWithoutWww, urlWithWww);
const existingLink = await prisma.link.findFirst({
where: {
OR: [{ url: urlWithWww }, { url: urlWithoutWww }],
collection: {
ownerId: userId,
},
},
});
console.log(url, urlWithoutWww, urlWithWww, "DONE!");
if (existingLink)
return {
response: "Link already exists",
status: 409,
};
} }
const numberOfLinksTheUserHas = await prisma.link.count({ const numberOfLinksTheUserHas = await prisma.link.count({
@@ -160,6 +42,22 @@ export default async function postLink(
link.collection.name = link.collection.name.trim(); link.collection.name = link.collection.name.trim();
if (link.collection.id) {
const collectionIsAccessible = await getPermission({
userId,
collectionId: link.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
} else {
link.collection.ownerId = userId;
}
const description = const description =
link.description && link.description !== "" link.description && link.description !== ""
? link.description ? link.description
@@ -169,6 +67,12 @@ export default async function postLink(
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined; const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
if (validatedUrl === null)
return {
response: "Something went wrong while retrieving the file size.",
status: 400,
};
const contentType = validatedUrl?.get("content-type"); const contentType = validatedUrl?.get("content-type");
let linkType = "url"; let linkType = "url";
let imageExtension = "png"; let imageExtension = "png";
@@ -183,13 +87,22 @@ export default async function postLink(
const newLink = await prisma.link.create({ const newLink = await prisma.link.create({
data: { data: {
url: link.url?.trim().replace(/\/+$/, "") || null, url: link.url,
name: link.name, name: link.name,
description, description,
type: linkType, type: linkType,
collection: { collection: {
connect: { connectOrCreate: {
id: link.collection.id, where: {
name_ownerId: {
ownerId: link.collection.ownerId,
name: link.collection.name,
},
},
create: {
name: link.collection.name.trim(),
ownerId: userId,
},
}, },
}, },
tags: { tags: {
@@ -13,8 +13,6 @@ export default async function exportData(userId: number) {
}, },
}, },
}, },
pinnedLinks: true,
whitelistedUsers: true,
}, },
}); });
@@ -1,8 +1,6 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { parse, Node, Element, TextNode } from "himalaya";
import { writeFileSync } from "fs";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
@@ -13,11 +11,6 @@ export default async function importFromHTMLFile(
const dom = new JSDOM(rawData); const dom = new JSDOM(rawData);
const document = dom.window.document; const document = dom.window.document;
// remove bad tags
document.querySelectorAll("meta").forEach((e) => (e.outerHTML = e.innerHTML));
document.querySelectorAll("META").forEach((e) => (e.outerHTML = e.innerHTML));
document.querySelectorAll("P").forEach((e) => (e.outerHTML = e.innerHTML));
const bookmarks = document.querySelectorAll("A"); const bookmarks = document.querySelectorAll("A");
const totalImports = bookmarks.length; const totalImports = bookmarks.length;
@@ -35,232 +28,94 @@ export default async function importFromHTMLFile(
status: 400, status: 400,
}; };
const jsonData = parse(document.documentElement.outerHTML); const folders = document.querySelectorAll("H3");
const processedArray = processNodes(jsonData); 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(),
},
},
},
});
for (const item of processedArray) { const checkIfCollectionExists = findCollection?.collections[0];
console.log(item);
await processBookmarks(userId, item as Element); let collectionId = findCollection?.collections[0]?.id;
}
if (!checkIfCollectionExists || !collectionId) {
const newCollection = await prisma.collection.create({
data: {
name: folder.textContent.trim(),
description: "",
color: "#0ea5e9",
isPublic: false,
ownerId: userId,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
createFolder({ filePath: `archives/${collectionId}` });
const bookmarks = folder.nextElementSibling.querySelectorAll("A");
for (const bookmark of bookmarks) {
await prisma.link.create({
data: {
name: bookmark.textContent.trim(),
url: bookmark.getAttribute("HREF"),
tags: bookmark.getAttribute("TAGS")
? {
connectOrCreate: bookmark
.getAttribute("TAGS")
.split(",")
.map((tag: string) =>
tag
? {
where: {
name_ownerId: {
name: tag.trim(),
ownerId: userId,
},
},
create: {
name: tag.trim(),
owner: {
connect: {
id: userId,
},
},
},
}
: undefined
),
}
: undefined,
description: bookmark.getAttribute("DESCRIPTION")
? bookmark.getAttribute("DESCRIPTION")
: "",
collectionId: collectionId,
createdAt: new Date(),
},
});
}
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
return { response: "Success.", status: 200 }; return { response: "Success.", status: 200 };
} }
async function processBookmarks(
userId: number,
data: Node,
parentCollectionId?: number
) {
if (data.type === "element") {
for (const item of data.children) {
if (item.type === "element" && item.tagName === "dt") {
// process collection or sub-collection
let collectionId;
const collectionName = item.children.find(
(e) => e.type === "element" && e.tagName === "h3"
) as Element;
if (collectionName) {
collectionId = await createCollection(
userId,
(collectionName.children[0] as TextNode).content,
parentCollectionId
);
}
await processBookmarks(
userId,
item,
collectionId || parentCollectionId
);
} else if (item.type === "element" && item.tagName === "a") {
// process link
const linkUrl = item?.attributes.find(
(e) => e.key.toLowerCase() === "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(",");
// set date if available
const linkDateValue = item?.attributes.find(
(e) => e.key.toLowerCase() === "add_date"
)?.value;
const linkDate = linkDateValue
? new Date(Number(linkDateValue) * 1000)
: undefined;
let linkDesc =
(
(
item?.children?.find(
(e) => e.type === "element" && e.tagName === "dd"
) as Element
)?.children[0] as TextNode
)?.content || "";
if (linkUrl && parentCollectionId) {
await createLink(
userId,
linkUrl,
parentCollectionId,
linkName,
linkDesc,
linkTags,
linkDate
);
} 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,
linkDesc,
linkTags,
linkDate
);
}
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[],
importDate?: Date
) => {
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,
importDate: importDate || undefined,
},
});
};
function processNodes(nodes: Node[]) {
const findAndProcessDL = (node: Node) => {
if (node.type === "element" && node.tagName === "dl") {
processDLChildren(node);
} else if (
node.type === "element" &&
node.children &&
node.children.length
) {
node.children.forEach((child) => findAndProcessDL(child));
}
};
const processDLChildren = (dlNode: Element) => {
dlNode.children.forEach((child, i) => {
if (child.type === "element" && child.tagName === "dt") {
const nextSibling = dlNode.children[i + 1];
if (
nextSibling &&
nextSibling.type === "element" &&
nextSibling.tagName === "dd"
) {
const aElement = child.children.find(
(el) => el.type === "element" && el.tagName === "a"
);
if (aElement && aElement.type === "element") {
// Add the 'dd' element as a child of the 'a' element
aElement.children.push(nextSibling);
// Remove the 'dd' from the parent 'dl' to avoid duplicate processing
dlNode.children.splice(i + 1, 1);
// Adjust the loop counter due to the removal
}
}
}
});
};
nodes.forEach(findAndProcessDL);
return nodes;
}
@@ -37,20 +37,41 @@ export default async function importFromLinkwarden(
for (const e of data.collections) { for (const e of data.collections) {
e.name = e.name.trim(); e.name = e.name.trim();
const newCollection = await prisma.collection.create({ const findCollection = await prisma.user.findUnique({
data: { where: {
owner: { id: userId,
connect: { },
id: userId, select: {
collections: {
where: {
name: e.name,
}, },
}, },
name: e.name,
description: e.description,
color: e.color,
}, },
}); });
createFolder({ filePath: `archives/${newCollection.id}` }); const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists) {
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
// Import Links // Import Links
for (const link of e.links) { for (const link of e.links) {
@@ -61,7 +82,7 @@ export default async function importFromLinkwarden(
description: link.description, description: link.description,
collection: { collection: {
connect: { connect: {
id: newCollection.id, id: collectionId,
}, },
}, },
// Import Tags // Import Tags
-21
View File
@@ -1,21 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function getToken(userId: number) {
const getTokens = await prisma.accessToken.findMany({
where: {
userId,
revoked: false,
},
select: {
id: true,
name: true,
expires: true,
createdAt: true,
},
});
return {
response: getTokens,
status: 200,
};
}
-92
View File
@@ -1,92 +0,0 @@
import { prisma } from "@/lib/api/db";
import { TokenExpiry } from "@/types/global";
import crypto from "crypto";
import { decode, encode } from "next-auth/jwt";
export default async function postToken(
body: {
name: string;
expires: TokenExpiry;
},
userId: number
) {
console.log(body);
const checkHasEmptyFields = !body.name || body.expires === undefined;
if (checkHasEmptyFields)
return {
response: "Please fill out all the fields.",
status: 400,
};
const checkIfTokenExists = await prisma.accessToken.findFirst({
where: {
name: body.name,
revoked: false,
userId,
},
});
if (checkIfTokenExists) {
return {
response: "Token with that name already exists.",
status: 400,
};
}
const now = Date.now();
let expiryDate = new Date();
const oneDayInSeconds = 86400;
let expiryDateSecond = 7 * oneDayInSeconds;
if (body.expires === TokenExpiry.oneMonth) {
expiryDate.setDate(expiryDate.getDate() + 30);
expiryDateSecond = 30 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.twoMonths) {
expiryDate.setDate(expiryDate.getDate() + 60);
expiryDateSecond = 60 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.threeMonths) {
expiryDate.setDate(expiryDate.getDate() + 90);
expiryDateSecond = 90 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.never) {
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
expiryDateSecond = 73050 * oneDayInSeconds;
} else {
expiryDate.setDate(expiryDate.getDate() + 7);
expiryDateSecond = 7 * oneDayInSeconds;
}
const token = await encode({
token: {
id: userId,
iat: now / 1000,
exp: (expiryDate as any) / 1000,
jti: crypto.randomUUID(),
},
maxAge: expiryDateSecond || 604800,
secret: process.env.NEXTAUTH_SECRET,
});
const tokenBody = await decode({
token,
secret: process.env.NEXTAUTH_SECRET,
});
const createToken = await prisma.accessToken.create({
data: {
name: body.name,
userId,
token: tokenBody?.jti as string,
expires: expiryDate,
},
});
return {
response: {
secretKey: token,
token: createToken,
},
status: 200,
};
}
@@ -1,24 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function deleteToken(userId: number, tokenId: number) {
if (!tokenId)
return { response: "Please choose a valid token.", status: 401 };
const tokenExists = await prisma.accessToken.findFirst({
where: {
id: tokenId,
userId,
},
});
const revokedToken = await prisma.accessToken.update({
where: {
id: tokenExists?.id,
},
data: {
revoked: true,
},
});
return { response: revokedToken, status: 200 };
}
-21
View File
@@ -1,21 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function getUsers() {
// Get all users
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
emailVerified: true,
subscriptions: {
select: {
active: true,
},
},
createdAt: true,
},
});
return { response: users, status: 200 };
}
+17 -72
View File
@@ -1,11 +1,9 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import verifyUser from "../../verifyUser";
const emailEnabled = const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
const stripeEnabled = process.env.STRIPE_SECRET_KEY ? true : false;
interface Data { interface Data {
response: string | object; response: string | object;
@@ -22,15 +20,7 @@ export default async function postUser(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse<Data> res: NextApiResponse<Data>
) { ) {
let isServerAdmin = false; if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
const user = await verifyUser({ req, res });
if (process.env.ADMINISTRATOR === user?.username) isServerAdmin = true;
if (
process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" &&
!isServerAdmin
) {
return res.status(400).json({ response: "Registration is disabled." }); return res.status(400).json({ response: "Registration is disabled." });
} }
@@ -67,16 +57,13 @@ export default async function postUser(
}); });
const checkIfUserExists = await prisma.user.findFirst({ const checkIfUserExists = await prisma.user.findFirst({
where: { where: emailEnabled
OR: [ ? {
{
email: body.email?.toLowerCase().trim(), email: body.email?.toLowerCase().trim(),
}, }
{ : {
username: (body.username as string).toLowerCase().trim(), username: (body.username as string).toLowerCase().trim(),
}, },
],
},
}); });
if (!checkIfUserExists) { if (!checkIfUserExists) {
@@ -84,63 +71,21 @@ export default async function postUser(
const hashedPassword = bcrypt.hashSync(body.password, saltRounds); const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
// Subscription dates await prisma.user.create({
const currentPeriodStart = new Date(); data: {
const currentPeriodEnd = new Date(); name: body.name,
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years... username: emailEnabled
? undefined
: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
},
});
if (isServerAdmin) { return res.status(201).json({ response: "User successfully created." });
const user = await prisma.user.create({
data: {
name: body.name,
username: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
emailVerified: new Date(),
subscriptions: stripeEnabled
? {
create: {
stripeSubscriptionId:
"fake_sub_" + Math.round(Math.random() * 10000000000000),
active: true,
currentPeriodStart,
currentPeriodEnd,
},
}
: undefined,
},
select: {
id: true,
username: true,
email: true,
emailVerified: true,
subscriptions: {
select: {
active: true,
},
},
createdAt: true,
},
});
return res.status(201).json({ response: user });
} else {
await prisma.user.create({
data: {
name: body.name,
username: emailEnabled
? undefined
: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
},
});
return res.status(201).json({ response: "User successfully created." });
}
} else if (checkIfUserExists) { } else if (checkIfUserExists) {
return res.status(400).json({ return res.status(400).json({
response: `Email or Username already exists.`, response: `${emailEnabled ? "Email" : "Username"} already exists.`,
}); });
} }
} }
@@ -10,8 +10,7 @@ const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET;
export default async function deleteUserById( export default async function deleteUserById(
userId: number, userId: number,
body: DeleteUserBody, body: DeleteUserBody
isServerAdmin?: boolean
) { ) {
// First, we retrieve the user from the database // First, we retrieve the user from the database
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@@ -26,13 +25,13 @@ export default async function deleteUserById(
} }
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration) // Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
if (!keycloakEnabled && !authentikEnabled && !isServerAdmin) { if (!keycloakEnabled && !authentikEnabled) {
const isPasswordValid = bcrypt.compareSync( const isPasswordValid = bcrypt.compareSync(
body.password, body.password,
user.password as string user.password as string
); );
if (!isPasswordValid && !isServerAdmin) { if (!isPasswordValid) {
return { return {
response: "Invalid credentials.", response: "Invalid credentials.",
status: 401, // Unauthorized status: 401, // Unauthorized
@@ -44,11 +43,6 @@ export default async function deleteUserById(
await prisma await prisma
.$transaction( .$transaction(
async (prisma) => { async (prisma) => {
// Delete Access Tokens
await prisma.accessToken.deleteMany({
where: { userId },
});
// Delete whitelisted users // Delete whitelisted users
await prisma.whitelistedUser.deleteMany({ await prisma.whitelistedUser.deleteMany({
where: { userId }, where: { userId },
@@ -77,10 +71,6 @@ export default async function deleteUserById(
// Delete archive folders // Delete archive folders
removeFolder({ filePath: `archives/${collection.id}` }); removeFolder({ filePath: `archives/${collection.id}` });
await removeFolder({
filePath: `archives/preview/${collection.id}`,
});
} }
// Delete collections after cleaning up related data // Delete collections after cleaning up related data
@@ -93,7 +83,6 @@ export default async function deleteUserById(
await prisma.subscription.delete({ await prisma.subscription.delete({
where: { userId }, where: { userId },
}); });
// .catch((err) => console.log(err));
await prisma.usersAndCollections.deleteMany({ await prisma.usersAndCollections.deleteMany({
where: { where: {
@@ -183,14 +183,9 @@ export default async function updateUserById(
email: data.email?.toLowerCase().trim(), email: data.email?.toLowerCase().trim(),
isPrivate: data.isPrivate, isPrivate: data.isPrivate,
image: data.image ? `uploads/avatar/${userId}.jpg` : "", image: data.image ? `uploads/avatar/${userId}.jpg` : "",
collectionOrder: data.collectionOrder.filter(
(value, index, self) => self.indexOf(value) === index
),
archiveAsScreenshot: data.archiveAsScreenshot, archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsPDF: data.archiveAsPDF, archiveAsPDF: data.archiveAsPDF,
archiveAsWaybackMachine: data.archiveAsWaybackMachine, archiveAsWaybackMachine: data.archiveAsWaybackMachine,
linksRouteTo: data.linksRouteTo,
preventDuplicateLinks: data.preventDuplicateLinks,
password: password:
data.newPassword && data.newPassword !== "" data.newPassword && data.newPassword !== ""
? newHashedPassword ? newHashedPassword
-36
View File
@@ -1,36 +0,0 @@
import Jimp from "jimp";
import { prisma } from "./db";
import createFile from "./storage/createFile";
import createFolder from "./storage/createFolder";
const generatePreview = async (
buffer: Buffer,
collectionId: number,
linkId: number
) => {
if (buffer && collectionId && linkId) {
// Load the image using Jimp
await Jimp.read(buffer, async (err, image) => {
if (image && !err) {
image?.resize(1280, Jimp.AUTO).quality(20);
const processedBuffer = await image?.getBufferAsync(Jimp.MIME_JPEG);
createFile({
data: processedBuffer,
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
}).then(() => {
return prisma.link.update({
where: { id: linkId },
data: {
preview: `archives/preview/${collectionId}/${linkId}.jpeg`,
},
});
});
}
}).catch((err) => {
console.error("Error processing the image:", err);
});
}
};
export default generatePreview;
+2 -5
View File
@@ -3,14 +3,12 @@ import { prisma } from "@/lib/api/db";
type Props = { type Props = {
userId: number; userId: number;
collectionId?: number; collectionId?: number;
collectionName?: string;
linkId?: number; linkId?: number;
}; };
export default async function getPermission({ export default async function getPermission({
userId, userId,
collectionId, collectionId,
collectionName,
linkId, linkId,
}: Props) { }: Props) {
if (linkId) { if (linkId) {
@@ -26,11 +24,10 @@ export default async function getPermission({
}); });
return check; return check;
} else if (collectionId || collectionName) { } else if (collectionId) {
const check = await prisma.collection.findFirst({ const check = await prisma.collection.findFirst({
where: { where: {
id: collectionId || undefined, id: collectionId,
name: collectionName || undefined,
OR: [{ ownerId: userId }, { members: { some: { userId } } }], OR: [{ ownerId: userId }, { members: { some: { userId } } }],
}, },
include: { members: true }, include: { members: true },
-61
View File
@@ -1,61 +0,0 @@
import moveFile from "./storage/moveFile";
import removeFile from "./storage/removeFile";
const removeFiles = async (linkId: number, collectionId: number) => {
// PDF
await removeFile({
filePath: `archives/${collectionId}/${linkId}.pdf`,
});
// Images
await removeFile({
filePath: `archives/${collectionId}/${linkId}.png`,
});
await removeFile({
filePath: `archives/${collectionId}/${linkId}.jpeg`,
});
await removeFile({
filePath: `archives/${collectionId}/${linkId}.jpg`,
});
// Preview
await removeFile({
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
});
// Readability
await removeFile({
filePath: `archives/${collectionId}/${linkId}_readability.json`,
});
};
const moveFiles = async (linkId: number, from: number, to: number) => {
await moveFile(
`archives/${from}/${linkId}.pdf`,
`archives/${to}/${linkId}.pdf`
);
await moveFile(
`archives/${from}/${linkId}.png`,
`archives/${to}/${linkId}.png`
);
await moveFile(
`archives/${from}/${linkId}.jpeg`,
`archives/${to}/${linkId}.jpeg`
);
await moveFile(
`archives/${from}/${linkId}.jpg`,
`archives/${to}/${linkId}.jpg`
);
await moveFile(
`archives/preview/${from}/${linkId}.jpeg`,
`archives/preview/${to}/${linkId}.jpeg`
);
await moveFile(
`archives/${from}/${linkId}_readability.json`,
`archives/${to}/${linkId}_readability.json`
);
};
export { removeFiles, moveFiles };
+2 -20
View File
@@ -1,35 +1,17 @@
import fetch from "node-fetch"; import fetch from "node-fetch";
import https from "https"; import https from "https";
import { SocksProxyAgent } from "socks-proxy-agent";
export default async function validateUrlSize(url: string) { export default async function validateUrlSize(url: string) {
if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null;
try { try {
const httpsAgent = new https.Agent({ const httpsAgent = new https.Agent({
rejectUnauthorized: rejectUnauthorized:
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true, process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
}); });
let fetchOpts = { const response = await fetch(url, {
method: "HEAD", method: "HEAD",
agent: httpsAgent, agent: httpsAgent,
}; });
if (process.env.PROXY) {
let proxy = new URL(process.env.PROXY);
if (process.env.PROXY_USERNAME) {
proxy.username = process.env.PROXY_USERNAME;
proxy.password = process.env.PROXY_PASSWORD || "";
}
fetchOpts = {
method: "HEAD",
agent: new SocksProxyAgent(proxy.toString()),
};
}
const response = await fetch(url, fetchOpts);
const totalSizeMB = const totalSizeMB =
Number(response.headers.get("content-length")) / Math.pow(1024, 2); Number(response.headers.get("content-length")) / Math.pow(1024, 2);
+17 -15
View File
@@ -17,7 +17,15 @@ export default async function verifySubscription(
const currentDate = new Date(); const currentDate = new Date();
if (!subscription?.active || currentDate > subscription.currentPeriodEnd) { if (
subscription &&
currentDate > subscription.currentPeriodEnd &&
!subscription.active
) {
return null;
}
if (!subscription || currentDate > subscription.currentPeriodEnd) {
const { const {
active, active,
stripeSubscriptionId, stripeSubscriptionId,
@@ -51,21 +59,15 @@ export default async function verifySubscription(
}, },
}) })
.catch((err) => console.log(err)); .catch((err) => console.log(err));
} else if (!active) { }
const subscription = await prisma.subscription.findFirst({
where: {
userId: user.id,
},
});
if (subscription) if (!active) {
await prisma.subscription.delete({ if (user.username)
where: { // await prisma.user.update({
userId: user.id, // where: { id: user.id },
}, // data: { username: null },
}); // });
return null;
return null;
} }
} }
-36
View File
@@ -1,36 +0,0 @@
import { NextApiRequest } from "next";
import { JWT, getToken } from "next-auth/jwt";
import { prisma } from "./db";
type Props = {
req: NextApiRequest;
};
export default async function verifyToken({
req,
}: Props): Promise<JWT | string> {
const token = await getToken({ req });
const userId = token?.id;
if (!userId) {
return "You must be logged in.";
}
if (token.exp < Date.now() / 1000) {
return "Your session has expired, please log in again.";
}
// check if token is revoked
const revoked = await prisma.accessToken.findFirst({
where: {
token: token.jti,
revoked: true,
},
});
if (revoked) {
return "Your session has expired, please log in again.";
}
return token;
}
+6 -7
View File
@@ -1,8 +1,8 @@
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
import { prisma } from "./db"; import { prisma } from "./db";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import verifySubscription from "./verifySubscription"; import verifySubscription from "./verifySubscription";
import verifyToken from "./verifyToken";
type Props = { type Props = {
req: NextApiRequest; req: NextApiRequest;
@@ -15,15 +15,14 @@ export default async function verifyUser({
req, req,
res, res,
}: Props): Promise<User | null> { }: Props): Promise<User | null> {
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = token?.id;
if (typeof token === "string") { if (!userId) {
res.status(401).json({ response: token }); res.status(401).json({ response: "You must be logged in." });
return null; return null;
} }
const userId = token?.id;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: userId, id: userId,
@@ -52,7 +51,7 @@ export default async function verifyUser({
} }
if (STRIPE_SECRET_KEY) { if (STRIPE_SECRET_KEY) {
const subscribedUser = await verifySubscription(user); const subscribedUser = verifySubscription(user);
if (!subscribedUser) { if (!subscribedUser) {
res.status(401).json({ res.status(401).json({
-45
View File
@@ -1,45 +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
if (account.linksRouteTo === LinksRouteTo.ORIGINAL && link.type === "url") {
return link.url || "";
} else if (account.linksRouteTo === LinksRouteTo.PDF || link.type === "pdf") {
if (!pdfAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
} else if (
account.linksRouteTo === LinksRouteTo.READABLE &&
link.type === "url"
) {
if (!readabilityAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
} else if (
account.linksRouteTo === LinksRouteTo.SCREENSHOT ||
link.type === "image"
) {
console.log(link);
if (!screenshotAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
}`;
} else {
return link.url || "";
}
};
-20
View File
@@ -1,20 +0,0 @@
export function isPWA() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
(window.navigator as any).standalone ||
document.referrer.includes("android-app://")
);
}
export function isIphone() {
return /iPhone/.test(navigator.userAgent) && !(window as any).MSStream;
}
export function dropdownTriggerer(e: any) {
let targetEl = e.currentTarget;
if (targetEl && targetEl.matches(":focus")) {
setTimeout(function () {
targetEl.blur();
}, 0);
}
}
+3 -9
View File
@@ -1,8 +1,4 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; export function screenshotAvailable(link: any) {
export function screenshotAvailable(
link: LinkIncludingShortenedCollectionAndTags
) {
return ( return (
link && link &&
link.image && link.image &&
@@ -11,15 +7,13 @@ export function screenshotAvailable(
); );
} }
export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) { export function pdfAvailable(link: any) {
return ( return (
link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable" link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable"
); );
} }
export function readabilityAvailable( export function readabilityAvailable(link: any) {
link: LinkIncludingShortenedCollectionAndTags
) {
return ( return (
link && link &&
link.readable && link.readable &&
+6 -36
View File
@@ -1,7 +1,5 @@
import fetch from "node-fetch"; import fetch from "node-fetch";
import https from "https"; import https from "https";
import { SocksProxyAgent } from "socks-proxy-agent";
export default async function getTitle(url: string) { export default async function getTitle(url: string) {
try { try {
const httpsAgent = new https.Agent({ const httpsAgent = new https.Agent({
@@ -9,43 +7,15 @@ export default async function getTitle(url: string) {
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true, process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
}); });
// fetchOpts allows a proxy to be defined const response = await fetch(url, {
let fetchOpts = {
agent: httpsAgent, agent: httpsAgent,
};
if (process.env.PROXY) {
// parse proxy url
let proxy = new URL(process.env.PROXY);
// if authentication set, apply to proxy URL
if (process.env.PROXY_USERNAME) {
proxy.username = process.env.PROXY_USERNAME;
proxy.password = process.env.PROXY_PASSWORD || "";
}
// add socks5 proxy to fetchOpts
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
}
const responsePromise = fetch(url, fetchOpts);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Fetch title timeout"));
}, 10 * 1000); // Stop after 10 seconds
}); });
const text = await response.text();
const response = await Promise.race([responsePromise, timeoutPromise]); // regular expression to find the <title> tag
let match = text.match(/<title.*>([^<]*)<\/title>/);
if ((response as any)?.status) { if (match) return match[1];
const text = await (response as any).text(); else return "";
// regular expression to find the <title> tag
let match = text.match(/<title.*>([^<]*)<\/title>/);
if (match) return match[1];
else return "";
} else {
return "";
}
} catch (err) { } catch (err) {
console.log(err); console.log(err);
} }
-5
View File
@@ -1,15 +1,10 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const { version } = require("./package.json");
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
images: { images: {
domains: ["t2.gstatic.com"], domains: ["t2.gstatic.com"],
minimumCacheTTL: 10, minimumCacheTTL: 10,
}, },
env: {
version,
},
}; };
module.exports = nextConfig; module.exports = nextConfig;
+4 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "linkwarden", "name": "linkwarden",
"version": "v2.5.4", "version": "2.4.7",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git", "repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>", "author": "Daniel31X13 <daniel31x13@gmail.com>",
@@ -10,16 +10,15 @@
"seed": "node ./prisma/seed.js" "seed": "node ./prisma/seed.js"
}, },
"scripts": { "scripts": {
"dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --", "dev": "concurrently -k \"next dev\" \"yarn worker:dev\"",
"worker:dev": "nodemon --skip-project scripts/worker.ts", "worker:dev": "nodemon --skip-project scripts/worker.ts",
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts", "worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
"start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --", "start": "concurrently \"next start\" \"yarn worker:prod\"",
"build": "next build", "build": "next build",
"lint": "next lint", "lint": "next lint",
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"" "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
}, },
"dependencies": { "dependencies": {
"@atlaskit/tree": "^8.8.7",
"@auth/prisma-adapter": "^1.0.1", "@auth/prisma-adapter": "^1.0.1",
"@aws-sdk/client-s3": "^3.379.1", "@aws-sdk/client-s3": "^3.379.1",
"@headlessui/react": "^1.7.15", "@headlessui/react": "^1.7.15",
@@ -45,14 +44,12 @@
"eslint-config-next": "13.4.9", "eslint-config-next": "13.4.9",
"formidable": "^3.5.1", "formidable": "^3.5.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"himalaya": "^1.1.0",
"jimp": "^0.22.10", "jimp": "^0.22.10",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"lottie-web": "^5.12.2", "lottie-web": "^5.12.2",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "13.4.12", "next": "13.4.12",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"node-fetch": "^2.7.0",
"nodemailer": "^6.9.3", "nodemailer": "^6.9.3",
"playwright": "^1.35.1", "playwright": "^1.35.1",
"react": "18.2.0", "react": "18.2.0",
@@ -61,10 +58,7 @@
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-image-file-resizer": "^0.4.8", "react-image-file-resizer": "^0.4.8",
"react-select": "^5.7.4", "react-select": "^5.7.4",
"react-spinners": "^0.13.8",
"socks-proxy-agent": "^8.0.2",
"stripe": "^12.13.0", "stripe": "^12.13.0",
"vaul": "^0.8.8",
"zustand": "^4.3.8" "zustand": "^4.3.8"
}, },
"devDependencies": { "devDependencies": {
@@ -79,7 +73,7 @@
"nodemon": "^3.0.2", "nodemon": "^3.0.2",
"postcss": "^8.4.26", "postcss": "^8.4.26",
"prettier": "3.1.1", "prettier": "3.1.1",
"prisma": "^4.16.2", "prisma": "^5.1.0",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "4.9.4" "typescript": "4.9.4"
+56 -80
View File
@@ -5,90 +5,66 @@ import { SessionProvider } from "next-auth/react";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import Head from "next/head"; import Head from "next/head";
import AuthRedirect from "@/layouts/AuthRedirect"; import AuthRedirect from "@/layouts/AuthRedirect";
import toast from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { Toaster, ToastBar } from "react-hot-toast";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { isPWA } from "@/lib/client/utils";
export default function App({ export default function App({
Component, Component,
pageProps, pageProps,
}: AppProps<{ }: AppProps<{
session: Session; session: Session;
}>) { }>) {
useEffect(() => {
if (isPWA()) {
const meta = document.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width, initial-scale=1, maximum-scale=1";
document.getElementsByTagName("head")[0].appendChild(meta);
}
}, []);
return ( useEffect(() => {
<SessionProvider let theme = localStorage.getItem("theme");
session={pageProps.session} if (!theme || !theme.includes("-")) {
refetchOnWindowFocus={false} theme = "default-dark"; // Default theme
basePath="/api/v1/auth" localStorage.setItem("theme", theme);
> }
<Head>
<title>Linkwarden</title> document.documentElement.setAttribute('data-theme', theme);
<meta name="viewport" content="width=device-width, initial-scale=1" /> }, []);
<meta name="theme-color" content="#000000" />
<link
rel="apple-touch-icon" return (
sizes="180x180" <SessionProvider
href="/apple-touch-icon.png" session={pageProps.session}
/> refetchOnWindowFocus={false}
<link basePath="/api/v1/auth"
rel="icon" >
type="image/png" <Head>
sizes="32x32" <title>Linkwarden</title>
href="/favicon-32x32.png" <meta name="viewport" content="width=device-width, initial-scale=1" />
/> <link
<link rel="apple-touch-icon"
rel="icon" sizes="180x180"
type="image/png" href="/apple-touch-icon.png"
sizes="16x16" />
href="/favicon-16x16.png" <link
/> rel="icon"
<link rel="manifest" href="/site.webmanifest" /> type="image/png"
</Head> sizes="32x32"
<AuthRedirect> href="/favicon-32x32.png"
<Toaster />
position="top-center" <link
reverseOrder={false} rel="icon"
toastOptions={{ type="image/png"
className: sizes="16x16"
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white", href="/favicon-16x16.png"
}} />
> <link rel="manifest" href="/site.webmanifest" />
{(t) => ( </Head>
<ToastBar toast={t}> <AuthRedirect>
{({ icon, message }) => ( <Toaster
<div position="top-center"
className="flex flex-row" reverseOrder={false}
data-testid="toast-message-container" toastOptions={{
data-type={t.type} className:
> "border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
{icon} }}
<span data-testid="toast-message">{message}</span> />
{t.type !== "loading" && ( <Component {...pageProps} />
<button </AuthRedirect>
className="btn btn-xs outline-none btn-circle btn-ghost" </SessionProvider>
data-testid="close-toast-button" );
onClick={() => toast.dismiss(t.id)}
>
<i className="bi bi-x"></i>
</button>
)}
</div>
)}
</ToastBar>
)}
</Toaster>
<Component {...pageProps} />
</AuthRedirect>
</SessionProvider>
);
} }
-174
View File
@@ -1,174 +0,0 @@
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
import NewUserModal from "@/components/ModalContent/NewUserModal";
import useUserStore from "@/store/admin/users";
import { User as U } from "@prisma/client";
import Link from "next/link";
import { Fragment, useEffect, useState } from "react";
interface User extends U {
subscriptions: {
active: boolean;
};
}
type UserModal = {
isOpen: boolean;
userId: number | null;
};
export default function Admin() {
const { users, setUsers } = useUserStore();
const [searchQuery, setSearchQuery] = useState("");
const [filteredUsers, setFilteredUsers] = useState<User[]>();
const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({
isOpen: false,
userId: null,
});
const [newUserModal, setNewUserModal] = useState(false);
useEffect(() => {
setUsers();
}, []);
return (
<div className="max-w-6xl mx-auto p-5">
<div className="flex sm:flex-row flex-col justify-between gap-2">
<div className="gap-2 inline-flex items-center">
<Link
href="/dashboard"
className="text-neutral btn btn-square btn-sm btn-ghost"
>
<i className="bi-chevron-left text-xl"></i>
</Link>
<p className="capitalize text-3xl font-thin inline">
User Administration
</p>
</div>
<div className="flex items-center relative justify-between gap-2">
<div>
<label
htmlFor="search-box"
className="inline-flex items-center w-fit absolute left-1 pointer-events-none rounded-md p-1 text-primary"
>
<i className="bi-search"></i>
</label>
<input
id="search-box"
type="text"
placeholder={"Search for Users"}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
if (users) {
setFilteredUsers(
users.filter((user) =>
JSON.stringify(user)
.toLowerCase()
.includes(e.target.value.toLowerCase())
)
);
}
}}
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-[15rem] md:max-w-full duration-200 outline-none"
/>
</div>
<div
onClick={() => setNewUserModal(true)}
className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative"
>
<i className="bi-plus text-3xl absolute"></i>
</div>
</div>
</div>
<div className="divider my-3"></div>
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal)
) : searchQuery !== "" ? (
<p>No users found with the given search query.</p>
) : users && users.length > 0 ? (
UserListing(users, deleteUserModal, setDeleteUserModal)
) : (
<p>No users found.</p>
)}
{newUserModal ? (
<NewUserModal onClose={() => setNewUserModal(false)} />
) : null}
</div>
);
}
const UserListing = (
users: User[],
deleteUserModal: UserModal,
setDeleteUserModal: Function
) => {
return (
<div className="overflow-x-auto whitespace-nowrap w-full">
<table className="table w-full">
<thead>
<tr>
<th></th>
<th>Username</th>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<th>Email</th>
)}
{process.env.NEXT_PUBLIC_STRIPE === "true" && <th>Subscribed</th>}
<th>Created At</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((user, index) => (
<tr
key={index}
className="group hover:bg-neutral-content hover:bg-opacity-30 duration-100"
>
<td className="text-primary">{index + 1}</td>
<td>{user.username ? user.username : <b>N/A</b>}</td>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<td>{user.email}</td>
)}
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
<td>
{user.subscriptions?.active ? (
JSON.stringify(user.subscriptions?.active)
) : (
<b>N/A</b>
)}
</td>
)}
<td>{new Date(user.createdAt).toLocaleString()}</td>
<td className="relative">
<button
className="btn btn-sm btn-ghost duration-100 hidden group-hover:block absolute z-20 right-[0.35rem] top-[0.35rem]"
onClick={() =>
setDeleteUserModal({ isOpen: true, userId: user.id })
}
>
<i className="bi bi-trash"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
{deleteUserModal.isOpen && deleteUserModal.userId ? (
<DeleteUserModal
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
userId={deleteUserModal.userId}
/>
) : null}
</div>
);
};
+81 -97
View File
@@ -1,5 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import readFile from "@/lib/api/storage/readFile"; import readFile from "@/lib/api/storage/readFile";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { ArchivedFormat } from "@/types/global"; import { ArchivedFormat } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
@@ -8,9 +9,6 @@ import { UsersAndCollections } from "@prisma/client";
import formidable from "formidable"; import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import fs from "fs"; import fs from "fs";
import verifyToken from "@/lib/api/verifyToken";
import generatePreview from "@/lib/api/generatePreview";
import createFolder from "@/lib/api/storage/createFolder";
export const config = { export const config = {
api: { api: {
@@ -35,8 +33,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ response: "Invalid parameters." }); return res.status(401).json({ response: "Invalid parameters." });
if (req.method === "GET") { if (req.method === "GET") {
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = typeof token === "string" ? undefined : token?.id; const userId = token?.id;
const collectionIsAccessible = await prisma.collection.findFirst({ const collectionIsAccessible = await prisma.collection.findFirst({
where: { where: {
@@ -75,97 +73,83 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.send(file); return res.send(file);
} }
} else if (req.method === "POST") {
const user = await verifyUser({ req, res });
if (!user) return;
const collectionPermissions = await getPermission({
userId: user.id,
linkId,
});
const memberHasAccess = collectionPermissions?.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
);
if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
// await uploadHandler(linkId, )
const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
const form = formidable({
maxFields: 1,
maxFiles: 1,
maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
});
form.parse(req, async (err, fields, files) => {
const allowedMIMETypes = [
"application/pdf",
"image/png",
"image/jpg",
"image/jpeg",
];
if (
err ||
!files.file ||
!files.file[0] ||
!allowedMIMETypes.includes(files.file[0].mimetype || "")
) {
// Handle parsing error
return res.status(500).json({
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
});
} else {
const fileBuffer = fs.readFileSync(files.file[0].filepath);
const linkStillExists = await prisma.link.findUnique({
where: { id: linkId },
});
if (linkStillExists && files.file[0].mimetype?.includes("image")) {
const collectionId = collectionPermissions?.id as number;
createFolder({
filePath: `archives/preview/${collectionId}`,
});
generatePreview(fileBuffer, collectionId, linkId);
}
if (linkStillExists) {
await createFile({
filePath: `archives/${collectionPermissions?.id}/${
linkId + suffix
}`,
data: fileBuffer,
});
await prisma.link.update({
where: { id: linkId },
data: {
preview: files.file[0].mimetype?.includes("pdf")
? "unavailable"
: undefined,
image: files.file[0].mimetype?.includes("image")
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
: null,
pdf: files.file[0].mimetype?.includes("pdf")
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
: null,
lastPreserved: new Date().toISOString(),
},
});
}
fs.unlinkSync(files.file[0].filepath);
}
return res.status(200).json({
response: files,
});
});
} }
// else if (req.method === "POST") {
// const user = await verifyUser({ req, res });
// if (!user) return;
// const collectionPermissions = await getPermission({
// userId: user.id,
// linkId,
// });
// const memberHasAccess = collectionPermissions?.members.some(
// (e: UsersAndCollections) => e.userId === user.id && e.canCreate
// );
// if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
// return { response: "Collection is not accessible.", status: 401 };
// // await uploadHandler(linkId, )
// const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
// const form = formidable({
// maxFields: 1,
// maxFiles: 1,
// maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
// });
// form.parse(req, async (err, fields, files) => {
// const allowedMIMETypes = [
// "application/pdf",
// "image/png",
// "image/jpg",
// "image/jpeg",
// ];
// if (
// err ||
// !files.file ||
// !files.file[0] ||
// !allowedMIMETypes.includes(files.file[0].mimetype || "")
// ) {
// // Handle parsing error
// return res.status(500).json({
// response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
// });
// } else {
// const fileBuffer = fs.readFileSync(files.file[0].filepath);
// const linkStillExists = await prisma.link.findUnique({
// where: { id: linkId },
// });
// if (linkStillExists) {
// await createFile({
// filePath: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// data: fileBuffer,
// });
// await prisma.link.update({
// where: { id: linkId },
// data: {
// image: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// lastPreserved: new Date().toISOString(),
// },
// });
// }
// fs.unlinkSync(files.file[0].filepath);
// }
// return res.status(200).json({
// response: files,
// });
// });
// }
} }
+67 -99
View File
@@ -65,7 +65,6 @@ import ZitadelProvider from "next-auth/providers/zitadel";
import ZohoProvider from "next-auth/providers/zoho"; import ZohoProvider from "next-auth/providers/zoho";
import ZoomProvider from "next-auth/providers/zoom"; import ZoomProvider from "next-auth/providers/zoom";
import * as process from "process"; import * as process from "process";
import type { NextApiRequest, NextApiResponse } from "next";
const emailEnabled = const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
@@ -98,19 +97,19 @@ if (
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: emailEnabled where: emailEnabled
? { ? {
OR: [ OR: [
{ {
username: username.toLowerCase(), username: username.toLowerCase(),
}, },
{ {
email: username?.toLowerCase(), email: username?.toLowerCase(),
}, },
], ],
emailVerified: { not: null }, emailVerified: { not: null },
} }
: { : {
username: username.toLowerCase(), username: username.toLowerCase(),
}, },
}); });
let passwordMatches: boolean = false; let passwordMatches: boolean = false;
@@ -240,37 +239,6 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") {
}; };
} }
// Authelia
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
providers.push(
{
id: "authelia",
name: "Authelia",
type: "oauth",
clientId: process.env.AUTHELIA_CLIENT_ID!,
clientSecret: process.env.AUTHELIA_CLIENT_SECRET!,
wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!,
authorization: { params: { scope: "openid email profile" } },
idToken: true,
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
username: profile.preferred_username,
}
},
}
);
const _linkAccount = adapter.linkAccount;
adapter.linkAccount = (account) => {
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
return _linkAccount ? _linkAccount(data) : undefined;
};
}
// Authentik // Authentik
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") { if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
providers.push( providers.push(
@@ -1091,60 +1059,60 @@ if (process.env.NEXT_PUBLIC_ZOOM_ENABLED_ENABLED === "true") {
}; };
} }
export default async function auth(req: NextApiRequest, res: NextApiResponse) { export const authOptions: AuthOptions = {
return await NextAuth(req, res, { adapter: adapter as Adapter,
adapter: adapter as Adapter, session: {
session: { strategy: "jwt",
strategy: "jwt", maxAge: 30 * 24 * 60 * 60, // 30 days
maxAge: 30 * 24 * 60 * 60, // 30 days },
}, providers,
providers, pages: {
pages: { signIn: "/login",
signIn: "/login", verifyRequest: "/confirmation",
verifyRequest: "/confirmation", },
}, callbacks: {
callbacks: { async signIn({ user, account, profile, email, credentials }) {
async signIn({ user, account, profile, email, credentials }) { if (account?.provider !== "credentials") {
if (account?.provider !== "credentials") { // registration via SSO can be separately disabled
// registration via SSO can be separately disabled const existingUser = await prisma.account.findFirst({
const existingUser = await prisma.account.findFirst({ where: {
where: { providerAccountId: account?.providerAccountId,
providerAccountId: account?.providerAccountId, },
}, });
}); if (existingUser && newSsoUsersDisabled) {
if (existingUser && newSsoUsersDisabled) { return false;
return false;
}
} }
return true; }
}, return true;
async jwt({ token, trigger, user }) {
token.sub = token.sub ? Number(token.sub) : undefined;
if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
return token;
},
async session({ session, token }) {
session.user.id = token.id;
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
include: {
subscriptions: true,
},
});
if (user) {
const subscribedUser = await verifySubscription(user);
}
}
return session;
},
}, },
}); async jwt({ token, trigger, user }) {
} token.sub = token.sub ? Number(token.sub) : undefined;
if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
return token;
},
async session({ session, token }) {
session.user.id = token.id;
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
include: {
subscriptions: true,
},
});
if (user) {
const subscribedUser = await verifySubscription(user);
}
}
return session;
},
},
};
export default NextAuth(authOptions);
+3 -3
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import readFile from "@/lib/api/storage/readFile"; import readFile from "@/lib/api/storage/readFile";
import verifyToken from "@/lib/api/verifyToken"; import { getToken } from "next-auth/jwt";
export default async function Index(req: NextApiRequest, res: NextApiResponse) { export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const queryId = Number(req.query.id); const queryId = Number(req.query.id);
@@ -12,8 +12,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
.status(401) .status(401)
.send("Invalid parameters."); .send("Invalid parameters.");
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = typeof token === "string" ? undefined : token?.id; const userId = token?.id;
if (req.method === "GET") { if (req.method === "GET") {
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({
+10 -11
View File
@@ -1,5 +1,4 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import getCollectionById from "@/lib/api/controllers/collections/collectionId/getCollectionById";
import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById"; import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById";
import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById"; import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
@@ -11,18 +10,18 @@ export default async function collections(
const user = await verifyUser({ req, res }); const user = await verifyUser({ req, res });
if (!user) return; if (!user) return;
const collectionId = Number(req.query.id); if (req.method === "PUT") {
const updated = await updateCollectionById(
if (req.method === "GET") { user.id,
const collections = await getCollectionById(user.id, collectionId); Number(req.query.id) as number,
return res req.body
.status(collections.status) );
.json({ response: collections.response });
} else if (req.method === "PUT") {
const updated = await updateCollectionById(user.id, collectionId, req.body);
return res.status(updated.status).json({ response: updated.response }); return res.status(updated.status).json({ response: updated.response });
} else if (req.method === "DELETE") { } else if (req.method === "DELETE") {
const deleted = await deleteCollectionById(user.id, collectionId); const deleted = await deleteCollectionById(
user.id,
Number(req.query.id) as number
);
return res.status(deleted.status).json({ response: deleted.response }); return res.status(deleted.status).json({ response: deleted.response });
} }
} }
+13 -2
View File
@@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import removeFile from "@/lib/api/storage/removeFile";
import { Collection, Link } from "@prisma/client"; import { Collection, Link } from "@prisma/client";
import { removeFiles } from "@/lib/api/manageLinkFiles";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@@ -80,5 +80,16 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
}, },
}); });
await removeFiles(link.id, link.collection.id); await removeFile({
filePath: `archives/${link.collection.id}/${link.id}.pdf`,
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}.png`,
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
});
await removeFile({
filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
});
}; };
-17
View File
@@ -3,8 +3,6 @@ import getLinks from "@/lib/api/controllers/links/getLinks";
import postLink from "@/lib/api/controllers/links/postLink"; import postLink from "@/lib/api/controllers/links/postLink";
import { LinkRequestQuery } from "@/types/global"; import { LinkRequestQuery } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import deleteLinksById from "@/lib/api/controllers/links/bulk/deleteLinksById";
import updateLinks from "@/lib/api/controllers/links/bulk/updateLinks";
export default async function links(req: NextApiRequest, res: NextApiResponse) { export default async function links(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res }); const user = await verifyUser({ req, res });
@@ -41,20 +39,5 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
return res.status(newlink.status).json({ return res.status(newlink.status).json({
response: newlink.response, response: newlink.response,
}); });
} else if (req.method === "PUT") {
const updated = await updateLinks(
user.id,
req.body.links,
req.body.removePreviousTags,
req.body.newData
);
return res.status(updated.status).json({
response: updated.response,
});
} else if (req.method === "DELETE") {
const deleted = await deleteLinksById(user.id, req.body.linkIds);
return res.status(deleted.status).json({
response: deleted.response,
});
} }
} }
+1 -8
View File
@@ -391,17 +391,10 @@ export function getLogins() {
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom", name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
}); });
} }
// Authelia
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
buttonAuths.push({
method: "authelia",
name: process.env.AUTHELIA_CUSTOM_NAME ?? "Authelia",
});
}
return { return {
credentialsEnabled: credentialsEnabled:
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" || process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
? "true" ? "true"
: "false", : "false",
emailEnabled: emailEnabled:
+3 -3
View File
@@ -1,10 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser"; import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser";
import verifyToken from "@/lib/api/verifyToken"; import { getToken } from "next-auth/jwt";
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await verifyToken({ req }); const token = await getToken({ req });
const requestingId = typeof token === "string" ? undefined : token?.id; const requestingId = token?.id;
const lookupId = req.query.id as string; const lookupId = req.query.id as string;
-13
View File
@@ -1,13 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import deleteToken from "@/lib/api/controllers/tokens/tokenId/deleteTokenById";
export default async function token(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
if (!user) return;
if (req.method === "DELETE") {
const deleted = await deleteToken(user.id, Number(req.query.id) as number);
return res.status(deleted.status).json({ response: deleted.response });
}
}
-20
View File
@@ -1,20 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import postToken from "@/lib/api/controllers/tokens/postToken";
import getTokens from "@/lib/api/controllers/tokens/getTokens";
export default async function tokens(
req: NextApiRequest,
res: NextApiResponse
) {
const user = await verifyUser({ req, res });
if (!user) return;
if (req.method === "POST") {
const token = await postToken(JSON.parse(req.body), user.id);
return res.status(token.status).json({ response: token.response });
} else if (req.method === "GET") {
const token = await getTokens(user.id);
return res.status(token.status).json({ response: token.response });
}
}
+7 -17
View File
@@ -2,31 +2,21 @@ import type { NextApiRequest, NextApiResponse } from "next";
import getUserById from "@/lib/api/controllers/users/userId/getUserById"; import getUserById from "@/lib/api/controllers/users/userId/getUserById";
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById"; import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById"; import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifySubscription from "@/lib/api/verifySubscription"; import verifySubscription from "@/lib/api/verifySubscription";
import verifyToken from "@/lib/api/verifyToken";
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = token?.id;
if (typeof token === "string") { if (!userId) {
res.status(401).json({ response: token }); return res.status(401).json({ response: "You must be logged in." });
return null;
} }
const user = await prisma.user.findUnique({ if (userId !== Number(req.query.id))
where: {
id: token?.id,
},
});
const isServerAdmin = process.env.ADMINISTRATOR === user?.username;
const userId = isServerAdmin ? Number(req.query.id) : token.id;
if (userId !== Number(req.query.id) && !isServerAdmin)
return res.status(401).json({ response: "Permission denied." }); return res.status(401).json({ response: "Permission denied." });
if (req.method === "GET") { if (req.method === "GET") {
@@ -61,7 +51,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
const updated = await updateUserById(userId, req.body); const updated = await updateUserById(userId, req.body);
return res.status(updated.status).json({ response: updated.response }); return res.status(updated.status).json({ response: updated.response });
} else if (req.method === "DELETE") { } else if (req.method === "DELETE") {
const updated = await deleteUserById(userId, req.body, isServerAdmin); const updated = await deleteUserById(userId, req.body);
return res.status(updated.status).json({ response: updated.response }); return res.status(updated.status).json({ response: updated.response });
} }
} }

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