Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e14149dfe | |||
| dbd096ab76 | |||
| e37702aa14 | |||
| 9103f67db5 |
@@ -59,10 +59,10 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "18"
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
@@ -135,7 +135,7 @@ jobs:
|
|||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: npx playwright test --grep ${{ matrix.test_case }}
|
run: npx playwright test --grep ${{ matrix.test_case }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
+20
-22
@@ -1,16 +1,4 @@
|
|||||||
# Stage: monolith-builder
|
FROM node:18.18-bullseye-slim
|
||||||
# Purpose: Uses the Rust image to build monolith
|
|
||||||
# Notes:
|
|
||||||
# - Fine to leave extra here, as only the resulting binary is copied out
|
|
||||||
FROM docker.io/rust:1.80-bullseye AS monolith-builder
|
|
||||||
|
|
||||||
RUN set -eux && cargo install --locked monolith
|
|
||||||
|
|
||||||
# Stage: main-app
|
|
||||||
# Purpose: Compiles the frontend and
|
|
||||||
# Notes:
|
|
||||||
# - Nothing extra should be left here. All commands should cleanup
|
|
||||||
FROM node:18.18-bullseye-slim AS main-app
|
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -20,23 +8,33 @@ WORKDIR /data
|
|||||||
|
|
||||||
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
|
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
|
||||||
|
|
||||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
|
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000
|
||||||
set -eux && \
|
|
||||||
yarn install --network-timeout 10000000
|
|
||||||
|
|
||||||
# Copy the compiled monolith binary from the builder stage
|
RUN apt-get update
|
||||||
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
|
|
||||||
|
|
||||||
RUN set -eux && \
|
RUN apt-get install -y \
|
||||||
npx playwright install --with-deps chromium && \
|
build-essential \
|
||||||
|
curl \
|
||||||
|
libssl-dev \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
RUN apt-get update
|
||||||
|
|
||||||
|
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
|
||||||
|
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
|
RUN cargo install monolith
|
||||||
|
|
||||||
|
RUN npx playwright install-deps && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
yarn cache clean
|
yarn cache clean
|
||||||
|
|
||||||
|
RUN yarn playwright install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN yarn prisma generate && \
|
RUN yarn prisma generate && \
|
||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD yarn prisma migrate deploy && yarn start
|
CMD yarn prisma migrate deploy && yarn start
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
AccountSettings,
|
|
||||||
CollectionIncludingMembersAndLinkCount,
|
|
||||||
} from "@/types/global";
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import ProfilePhoto from "./ProfilePhoto";
|
import ProfilePhoto from "./ProfilePhoto";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
@@ -15,11 +12,12 @@ import { dropdownTriggerer } from "@/lib/client/utils";
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useUser } from "@/hooks/store/user";
|
import { useUser } from "@/hooks/store/user";
|
||||||
|
|
||||||
export default function CollectionCard({
|
type Props = {
|
||||||
collection,
|
|
||||||
}: {
|
|
||||||
collection: CollectionIncludingMembersAndLinkCount;
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
}) {
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CollectionCard({ collection, className }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings } = useLocalSettingsStore();
|
const { settings } = useLocalSettingsStore();
|
||||||
const { data: user = {} } = useUser();
|
const { data: user = {} } = useUser();
|
||||||
@@ -35,9 +33,15 @@ export default function CollectionCard({
|
|||||||
|
|
||||||
const permissions = usePermissions(collection.id as number);
|
const permissions = usePermissions(collection.id as number);
|
||||||
|
|
||||||
const [collectionOwner, setCollectionOwner] = useState<
|
const [collectionOwner, setCollectionOwner] = useState({
|
||||||
Partial<AccountSettings>
|
id: null as unknown as number,
|
||||||
>({});
|
name: "",
|
||||||
|
username: "",
|
||||||
|
image: "",
|
||||||
|
archiveAsScreenshot: undefined as unknown as boolean,
|
||||||
|
archiveAsMonolith: undefined as unknown as boolean,
|
||||||
|
archiveAsPDF: undefined as unknown as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchOwner = async () => {
|
const fetchOwner = async () => {
|
||||||
@@ -128,12 +132,12 @@ export default function CollectionCard({
|
|||||||
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
|
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
|
||||||
onClick={() => setEditCollectionSharingModal(true)}
|
onClick={() => setEditCollectionSharingModal(true)}
|
||||||
>
|
>
|
||||||
{collectionOwner.id && (
|
{collectionOwner.id ? (
|
||||||
<ProfilePhoto
|
<ProfilePhoto
|
||||||
src={collectionOwner.image || undefined}
|
src={collectionOwner.image || undefined}
|
||||||
name={collectionOwner.name}
|
name={collectionOwner.name}
|
||||||
/>
|
/>
|
||||||
)}
|
) : undefined}
|
||||||
{collection.members
|
{collection.members
|
||||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||||
.map((e, i) => {
|
.map((e, i) => {
|
||||||
@@ -147,13 +151,13 @@ export default function CollectionCard({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.slice(0, 3)}
|
.slice(0, 3)}
|
||||||
{collection.members.length - 3 > 0 && (
|
{collection.members.length - 3 > 0 ? (
|
||||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||||
<span>+{collection.members.length - 3}</span>
|
<span>+{collection.members.length - 3}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={`/collections/${collection.id}`}
|
href={`/collections/${collection.id}`}
|
||||||
@@ -177,12 +181,12 @@ export default function CollectionCard({
|
|||||||
<div className="flex justify-end items-center">
|
<div className="flex justify-end items-center">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<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-globe2 drop-shadow text-neutral"
|
||||||
title="This collection is being shared publicly."
|
title="This collection is being shared publicly."
|
||||||
></i>
|
></i>
|
||||||
)}
|
) : undefined}
|
||||||
<i
|
<i
|
||||||
className="bi-link-45deg text-lg text-neutral"
|
className="bi-link-45deg text-lg text-neutral"
|
||||||
title="This collection is being shared publicly."
|
title="This collection is being shared publicly."
|
||||||
@@ -202,24 +206,24 @@ export default function CollectionCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{editCollectionModal && (
|
{editCollectionModal ? (
|
||||||
<EditCollectionModal
|
<EditCollectionModal
|
||||||
onClose={() => setEditCollectionModal(false)}
|
onClose={() => setEditCollectionModal(false)}
|
||||||
activeCollection={collection}
|
activeCollection={collection}
|
||||||
/>
|
/>
|
||||||
)}
|
) : undefined}
|
||||||
{editCollectionSharingModal && (
|
{editCollectionSharingModal ? (
|
||||||
<EditCollectionSharingModal
|
<EditCollectionSharingModal
|
||||||
onClose={() => setEditCollectionSharingModal(false)}
|
onClose={() => setEditCollectionSharingModal(false)}
|
||||||
activeCollection={collection}
|
activeCollection={collection}
|
||||||
/>
|
/>
|
||||||
)}
|
) : undefined}
|
||||||
{deleteCollectionModal && (
|
{deleteCollectionModal ? (
|
||||||
<DeleteCollectionModal
|
<DeleteCollectionModal
|
||||||
onClose={() => setDeleteCollectionModal(false)}
|
onClose={() => setDeleteCollectionModal(false)}
|
||||||
activeCollection={collection}
|
activeCollection={collection}
|
||||||
/>
|
/>
|
||||||
)}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import toast from "react-hot-toast";
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
|
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
|
||||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||||
import Icon from "./Icon";
|
|
||||||
import { IconWeight } from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
interface ExtendedTreeItem extends TreeItem {
|
interface ExtendedTreeItem extends TreeItem {
|
||||||
data: Collection;
|
data: Collection;
|
||||||
@@ -42,7 +40,6 @@ const CollectionListing = () => {
|
|||||||
return buildTreeFromCollections(
|
return buildTreeFromCollections(
|
||||||
collections,
|
collections,
|
||||||
router,
|
router,
|
||||||
tree,
|
|
||||||
user.collectionOrder
|
user.collectionOrder
|
||||||
);
|
);
|
||||||
} else return undefined;
|
} else return undefined;
|
||||||
@@ -252,7 +249,7 @@ const renderItem = (
|
|||||||
: "hover:bg-neutral/20"
|
: "hover:bg-neutral/20"
|
||||||
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
|
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
|
||||||
>
|
>
|
||||||
{Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
|
{Icon(item as ExtendedTreeItem, onExpand, onCollapse)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/collections/${collection.id}`}
|
href={`/collections/${collection.id}`}
|
||||||
@@ -262,29 +259,18 @@ const renderItem = (
|
|||||||
<div
|
<div
|
||||||
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
||||||
>
|
>
|
||||||
{collection.icon ? (
|
<i
|
||||||
<Icon
|
className="bi-folder-fill text-2xl drop-shadow"
|
||||||
icon={collection.icon}
|
style={{ color: collection.color }}
|
||||||
size={30}
|
></i>
|
||||||
weight={(collection.iconWeight || "regular") as IconWeight}
|
|
||||||
color={collection.color}
|
|
||||||
className="-mr-[0.15rem]"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<i
|
|
||||||
className="bi-folder-fill text-2xl"
|
|
||||||
style={{ color: collection.color }}
|
|
||||||
></i>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="truncate w-full">{collection.name}</p>
|
<p className="truncate w-full">{collection.name}</p>
|
||||||
|
|
||||||
{collection.isPublic && (
|
{collection.isPublic ? (
|
||||||
<i
|
<i
|
||||||
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
||||||
title="This collection is being shared publicly."
|
title="This collection is being shared publicly."
|
||||||
></i>
|
></i>
|
||||||
)}
|
) : undefined}
|
||||||
<div className="drop-shadow text-neutral text-xs">
|
<div className="drop-shadow text-neutral text-xs">
|
||||||
{collection._count?.links}
|
{collection._count?.links}
|
||||||
</div>
|
</div>
|
||||||
@@ -295,7 +281,7 @@ const renderItem = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Dropdown = (
|
const Icon = (
|
||||||
item: ExtendedTreeItem,
|
item: ExtendedTreeItem,
|
||||||
onExpand: (id: ItemId) => void,
|
onExpand: (id: ItemId) => void,
|
||||||
onCollapse: (id: ItemId) => void
|
onCollapse: (id: ItemId) => void
|
||||||
@@ -318,7 +304,6 @@ const Dropdown = (
|
|||||||
const buildTreeFromCollections = (
|
const buildTreeFromCollections = (
|
||||||
collections: CollectionIncludingMembersAndLinkCount[],
|
collections: CollectionIncludingMembersAndLinkCount[],
|
||||||
router: ReturnType<typeof useRouter>,
|
router: ReturnType<typeof useRouter>,
|
||||||
tree?: TreeData,
|
|
||||||
order?: number[]
|
order?: number[]
|
||||||
): TreeData => {
|
): TreeData => {
|
||||||
if (order) {
|
if (order) {
|
||||||
@@ -333,15 +318,13 @@ const buildTreeFromCollections = (
|
|||||||
id: collection.id,
|
id: collection.id,
|
||||||
children: [],
|
children: [],
|
||||||
hasChildren: false,
|
hasChildren: false,
|
||||||
isExpanded: tree?.items[collection.id as number]?.isExpanded || false,
|
isExpanded: false,
|
||||||
data: {
|
data: {
|
||||||
id: collection.id,
|
id: collection.id,
|
||||||
parentId: collection.parentId,
|
parentId: collection.parentId,
|
||||||
name: collection.name,
|
name: collection.name,
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
color: collection.color,
|
color: collection.color,
|
||||||
icon: collection.icon,
|
|
||||||
iconWeight: collection.iconWeight,
|
|
||||||
isPublic: collection.isPublic,
|
isPublic: collection.isPublic,
|
||||||
ownerId: collection.ownerId,
|
ownerId: collection.ownerId,
|
||||||
createdAt: collection.createdAt,
|
createdAt: collection.createdAt,
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CopyButton: React.FC<Props> = ({ text }) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopied(false);
|
|
||||||
}, 1000);
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`text-xl text-neutral btn btn-sm btn-square btn-ghost ${
|
|
||||||
copied ? "bi-check2 text-success" : "bi-copy"
|
|
||||||
}`}
|
|
||||||
onClick={handleCopy}
|
|
||||||
></div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyButton;
|
|
||||||
+40
-42
@@ -60,49 +60,47 @@ export default function Dropdown({
|
|||||||
}
|
}
|
||||||
}, [points, dropdownHeight]);
|
}, [points, dropdownHeight]);
|
||||||
|
|
||||||
return (
|
return !points || pos ? (
|
||||||
(!points || pos) && (
|
<ClickAwayHandler
|
||||||
<ClickAwayHandler
|
onMount={(e) => {
|
||||||
onMount={(e) => {
|
setDropdownHeight(e.height);
|
||||||
setDropdownHeight(e.height);
|
setDropdownWidth(e.width);
|
||||||
setDropdownWidth(e.width);
|
}}
|
||||||
}}
|
style={
|
||||||
style={
|
points
|
||||||
points
|
? {
|
||||||
? {
|
position: "fixed",
|
||||||
position: "fixed",
|
top: `${pos?.y}px`,
|
||||||
top: `${pos?.y}px`,
|
left: `${pos?.x}px`,
|
||||||
left: `${pos?.x}px`,
|
}
|
||||||
}
|
: undefined
|
||||||
: undefined
|
}
|
||||||
}
|
onClickOutside={onClickOutside}
|
||||||
onClickOutside={onClickOutside}
|
className={`${
|
||||||
className={`${
|
className || ""
|
||||||
className || ""
|
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
|
||||||
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
|
>
|
||||||
>
|
{items.map((e, i) => {
|
||||||
{items.map((e, i) => {
|
const inner = e && (
|
||||||
const inner = e && (
|
<div className="cursor-pointer rounded-md">
|
||||||
<div className="cursor-pointer rounded-md">
|
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
|
||||||
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
|
<p className="select-none">{e.name}</p>
|
||||||
<p className="select-none">{e.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return e && e.href ? (
|
return e && e.href ? (
|
||||||
<Link key={i} href={e.href}>
|
<Link key={i} href={e.href}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
e && (
|
||||||
|
<div key={i} onClick={e.onClick}>
|
||||||
{inner}
|
{inner}
|
||||||
</Link>
|
</div>
|
||||||
) : (
|
)
|
||||||
e && (
|
);
|
||||||
<div key={i} onClick={e.onClick}>
|
})}
|
||||||
{inner}
|
</ClickAwayHandler>
|
||||||
</div>
|
) : null;
|
||||||
)
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ClickAwayHandler>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import React, { forwardRef } from "react";
|
|
||||||
import * as Icons from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
icon: string;
|
|
||||||
} & Icons.IconProps;
|
|
||||||
|
|
||||||
const Icon = forwardRef<SVGSVGElement, Props>(({ icon, ...rest }, ref) => {
|
|
||||||
const IconComponent: any = Icons[icon as keyof typeof Icons];
|
|
||||||
|
|
||||||
if (!IconComponent) {
|
|
||||||
return null;
|
|
||||||
} else return <IconComponent ref={ref} {...rest} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
Icon.displayName = "Icon";
|
|
||||||
|
|
||||||
export default Icon;
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import TextInput from "./TextInput";
|
|
||||||
import Popover from "./Popover";
|
|
||||||
import { HexColorPicker } from "react-colorful";
|
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
import { IconWeight } from "@phosphor-icons/react";
|
|
||||||
import IconGrid from "./IconGrid";
|
|
||||||
import IconPopover from "./IconPopover";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
alignment?: string;
|
|
||||||
color: string;
|
|
||||||
setColor: Function;
|
|
||||||
iconName?: string;
|
|
||||||
setIconName: Function;
|
|
||||||
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
|
|
||||||
setWeight: Function;
|
|
||||||
hideDefaultIcon?: boolean;
|
|
||||||
reset: Function;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const IconPicker = ({
|
|
||||||
alignment,
|
|
||||||
color,
|
|
||||||
setColor,
|
|
||||||
iconName,
|
|
||||||
setIconName,
|
|
||||||
weight,
|
|
||||||
setWeight,
|
|
||||||
hideDefaultIcon,
|
|
||||||
className,
|
|
||||||
reset,
|
|
||||||
}: Props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [iconPicker, setIconPicker] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
onClick={() => setIconPicker(!iconPicker)}
|
|
||||||
className="btn btn-square w-20 h-20"
|
|
||||||
>
|
|
||||||
{iconName ? (
|
|
||||||
<Icon
|
|
||||||
icon={iconName}
|
|
||||||
size={60}
|
|
||||||
weight={(weight || "regular") as IconWeight}
|
|
||||||
color={color || "#0ea5e9"}
|
|
||||||
/>
|
|
||||||
) : !iconName && hideDefaultIcon ? (
|
|
||||||
<p className="p-1">{t("set_custom_icon")}</p>
|
|
||||||
) : (
|
|
||||||
<i
|
|
||||||
className="bi-folder-fill text-6xl"
|
|
||||||
style={{ color: color || "#0ea5e9" }}
|
|
||||||
></i>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{iconPicker && (
|
|
||||||
<IconPopover
|
|
||||||
alignment={alignment}
|
|
||||||
color={color}
|
|
||||||
setColor={setColor}
|
|
||||||
iconName={iconName}
|
|
||||||
setIconName={setIconName}
|
|
||||||
weight={weight}
|
|
||||||
setWeight={setWeight}
|
|
||||||
reset={reset}
|
|
||||||
onClose={() => setIconPicker(false)}
|
|
||||||
className={clsx(
|
|
||||||
className,
|
|
||||||
alignment || "lg:-translate-x-1/3 top-20 left-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IconPicker;
|
|
||||||
@@ -16,8 +16,6 @@ type Props = {
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
creatable?: boolean;
|
creatable?: boolean;
|
||||||
autoFocus?: boolean;
|
|
||||||
onBlur?: any;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CollectionSelection({
|
export default function CollectionSelection({
|
||||||
@@ -25,8 +23,6 @@ export default function CollectionSelection({
|
|||||||
defaultValue,
|
defaultValue,
|
||||||
showDefaultValue = true,
|
showDefaultValue = true,
|
||||||
creatable = true,
|
creatable = true,
|
||||||
autoFocus,
|
|
||||||
onBlur,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { data: collections = [] } = useCollections();
|
const { data: collections = [] } = useCollections();
|
||||||
|
|
||||||
@@ -80,7 +76,7 @@ export default function CollectionSelection({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...innerProps}
|
{...innerProps}
|
||||||
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content duration-100 cursor-pointer"
|
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">
|
<div className="flex w-full justify-between items-center">
|
||||||
<span>{data.label}</span>
|
<span>{data.label}</span>
|
||||||
@@ -108,8 +104,6 @@ export default function CollectionSelection({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={options}
|
options={options}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
autoFocus={autoFocus}
|
|
||||||
onBlur={onBlur}
|
|
||||||
defaultValue={showDefaultValue ? defaultValue : null}
|
defaultValue={showDefaultValue ? defaultValue : null}
|
||||||
components={{
|
components={{
|
||||||
Option: customOption,
|
Option: customOption,
|
||||||
@@ -126,9 +120,7 @@ export default function CollectionSelection({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={options}
|
options={options}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
autoFocus={autoFocus}
|
|
||||||
defaultValue={showDefaultValue ? defaultValue : null}
|
defaultValue={showDefaultValue ? defaultValue : null}
|
||||||
onBlur={onBlur}
|
|
||||||
components={{
|
components={{
|
||||||
Option: customOption,
|
Option: customOption,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,19 +7,12 @@ import { useTags } from "@/hooks/store/tags";
|
|||||||
type Props = {
|
type Props = {
|
||||||
onChange: any;
|
onChange: any;
|
||||||
defaultValue?: {
|
defaultValue?: {
|
||||||
value?: number;
|
value: number;
|
||||||
label: string;
|
label: string;
|
||||||
}[];
|
}[];
|
||||||
autoFocus?: boolean;
|
|
||||||
onBlur?: any;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TagSelection({
|
export default function TagSelection({ onChange, defaultValue }: Props) {
|
||||||
onChange,
|
|
||||||
defaultValue,
|
|
||||||
autoFocus,
|
|
||||||
onBlur,
|
|
||||||
}: Props) {
|
|
||||||
const { data: tags = [] } = useTags();
|
const { data: tags = [] } = useTags();
|
||||||
|
|
||||||
const [options, setOptions] = useState<Options[]>([]);
|
const [options, setOptions] = useState<Options[]>([]);
|
||||||
@@ -41,9 +34,8 @@ export default function TagSelection({
|
|||||||
options={options}
|
options={options}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
|
// menuPosition="fixed"
|
||||||
isMulti
|
isMulti
|
||||||
autoFocus={autoFocus}
|
|
||||||
onBlur={onBlur}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const styles: StylesConfig = {
|
|||||||
? "oklch(var(--p))"
|
? "oklch(var(--p))"
|
||||||
: "oklch(var(--nc))",
|
: "oklch(var(--nc))",
|
||||||
},
|
},
|
||||||
transition: "all 100ms",
|
transition: "all 50ms",
|
||||||
}),
|
}),
|
||||||
menu: (styles) => ({
|
menu: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
@@ -54,28 +54,19 @@ export const styles: StylesConfig = {
|
|||||||
multiValue: (styles) => {
|
multiValue: (styles) => {
|
||||||
return {
|
return {
|
||||||
...styles,
|
...styles,
|
||||||
backgroundColor: "oklch(var(--b2))",
|
backgroundColor: "#0ea5e9",
|
||||||
color: "oklch(var(--bc))",
|
color: "white",
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.1rem",
|
|
||||||
marginRight: "0.4rem",
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
multiValueLabel: (styles) => ({
|
multiValueLabel: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
color: "oklch(var(--bc))",
|
color: "white",
|
||||||
}),
|
}),
|
||||||
multiValueRemove: (styles) => ({
|
multiValueRemove: (styles) => ({
|
||||||
...styles,
|
...styles,
|
||||||
height: "1.2rem",
|
|
||||||
width: "1.2rem",
|
|
||||||
borderRadius: "100px",
|
|
||||||
transition: "all 100ms",
|
|
||||||
color: "oklch(var(--w))",
|
|
||||||
":hover": {
|
":hover": {
|
||||||
color: "red",
|
color: "white",
|
||||||
backgroundColor: "oklch(var(--nc))",
|
backgroundColor: "#38bdf8",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import Icon from "@/components/Icon";
|
|
||||||
import {
|
import {
|
||||||
CollectionIncludingMembersAndLinkCount,
|
CollectionIncludingMembersAndLinkCount,
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
} from "@/types/global";
|
} from "@/types/global";
|
||||||
import { IconWeight } from "@phosphor-icons/react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -29,19 +27,10 @@ export default function LinkCollection({
|
|||||||
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 select-none"
|
||||||
title={collection?.name}
|
title={collection?.name}
|
||||||
>
|
>
|
||||||
{link.collection.icon ? (
|
<i
|
||||||
<Icon
|
className="bi-folder-fill text-lg drop-shadow"
|
||||||
icon={link.collection.icon}
|
style={{ color: collection?.color }}
|
||||||
size={20}
|
></i>
|
||||||
weight={(link.collection.iconWeight || "regular") as IconWeight}
|
|
||||||
color={link.collection.color}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<i
|
|
||||||
className="bi-folder-fill text-lg"
|
|
||||||
style={{ color: link.collection.color }}
|
|
||||||
></i>
|
|
||||||
)}
|
|
||||||
<p className="truncate capitalize">{collection?.name}</p>
|
<p className="truncate capitalize">{collection?.name}</p>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import usePinLink from "@/lib/client/pinLink";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
|
||||||
btnStyle?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LinkPin({ link, btnStyle }: Props) {
|
|
||||||
const pinLink = usePinLink();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
|
||||||
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute top-3 right-[3.25rem] group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
|
|
||||||
onClick={() => pinLink(link)}
|
|
||||||
>
|
|
||||||
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
|
|
||||||
<i
|
|
||||||
title="Pin"
|
|
||||||
className={clsx(
|
|
||||||
"text-xl",
|
|
||||||
isAlreadyPinned ? "bi-pin-fill" : "bi-pin"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+23
-121
@@ -3,7 +3,7 @@ import {
|
|||||||
LinkIncludingShortenedCollectionAndTags,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
ViewMode,
|
ViewMode,
|
||||||
} from "@/types/global";
|
} from "@/types/global";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
||||||
import Masonry from "react-masonry-css";
|
import Masonry from "react-masonry-css";
|
||||||
@@ -11,7 +11,6 @@ import resolveConfig from "tailwindcss/resolveConfig";
|
|||||||
import tailwindConfig from "../../tailwind.config.js";
|
import tailwindConfig from "../../tailwind.config.js";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
|
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
|
||||||
import useLocalSettingsStore from "@/store/localSettings";
|
|
||||||
|
|
||||||
export function CardView({
|
export function CardView({
|
||||||
links,
|
links,
|
||||||
@@ -28,68 +27,16 @@ export function CardView({
|
|||||||
hasNextPage?: boolean;
|
hasNextPage?: boolean;
|
||||||
placeHolderRef?: any;
|
placeHolderRef?: any;
|
||||||
}) {
|
}) {
|
||||||
const settings = useLocalSettingsStore((state) => state.settings);
|
|
||||||
|
|
||||||
const gridMap = {
|
|
||||||
1: "grid-cols-1",
|
|
||||||
2: "grid-cols-2",
|
|
||||||
3: "grid-cols-3",
|
|
||||||
4: "grid-cols-4",
|
|
||||||
5: "grid-cols-5",
|
|
||||||
6: "grid-cols-6",
|
|
||||||
7: "grid-cols-7",
|
|
||||||
8: "grid-cols-8",
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColumnCount = () => {
|
|
||||||
const width = window.innerWidth;
|
|
||||||
if (width >= 1901) return 5;
|
|
||||||
if (width >= 1501) return 4;
|
|
||||||
if (width >= 881) return 3;
|
|
||||||
if (width >= 551) return 2;
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [columnCount, setColumnCount] = useState(
|
|
||||||
settings.columns || getColumnCount()
|
|
||||||
);
|
|
||||||
|
|
||||||
const gridColClass = useMemo(
|
|
||||||
() => gridMap[columnCount as keyof typeof gridMap],
|
|
||||||
[columnCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (settings.columns === 0) {
|
|
||||||
// Only recalculate if zustandColumns is zero
|
|
||||||
setColumnCount(getColumnCount());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (settings.columns === 0) {
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
setColumnCount(settings.columns || getColumnCount());
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (settings.columns === 0) {
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [settings.columns]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${gridColClass} grid gap-5 pb-5`}>
|
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5">
|
||||||
{links?.map((e, i) => {
|
{links?.map((e, i) => {
|
||||||
return (
|
return (
|
||||||
<LinkCard
|
<LinkCard
|
||||||
key={i}
|
key={i}
|
||||||
link={e}
|
link={e}
|
||||||
count={i}
|
count={i}
|
||||||
|
flipDropdown={i === links.length - 1}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
columns={columnCount}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -129,58 +76,6 @@ export function MasonryView({
|
|||||||
hasNextPage?: boolean;
|
hasNextPage?: boolean;
|
||||||
placeHolderRef?: any;
|
placeHolderRef?: any;
|
||||||
}) {
|
}) {
|
||||||
const settings = useLocalSettingsStore((state) => state.settings);
|
|
||||||
|
|
||||||
const gridMap = {
|
|
||||||
1: "grid-cols-1",
|
|
||||||
2: "grid-cols-2",
|
|
||||||
3: "grid-cols-3",
|
|
||||||
4: "grid-cols-4",
|
|
||||||
5: "grid-cols-5",
|
|
||||||
6: "grid-cols-6",
|
|
||||||
7: "grid-cols-7",
|
|
||||||
8: "grid-cols-8",
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColumnCount = () => {
|
|
||||||
const width = window.innerWidth;
|
|
||||||
if (width >= 1901) return 5;
|
|
||||||
if (width >= 1501) return 4;
|
|
||||||
if (width >= 881) return 3;
|
|
||||||
if (width >= 551) return 2;
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [columnCount, setColumnCount] = useState(
|
|
||||||
settings.columns || getColumnCount()
|
|
||||||
);
|
|
||||||
|
|
||||||
const gridColClass = useMemo(
|
|
||||||
() => gridMap[columnCount as keyof typeof gridMap],
|
|
||||||
[columnCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
if (settings.columns === 0) {
|
|
||||||
// Only recalculate if zustandColumns is zero
|
|
||||||
setColumnCount(getColumnCount());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (settings.columns === 0) {
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
setColumnCount(settings.columns || getColumnCount());
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (settings.columns === 0) {
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [settings.columns]);
|
|
||||||
|
|
||||||
const fullConfig = resolveConfig(tailwindConfig as any);
|
const fullConfig = resolveConfig(tailwindConfig as any);
|
||||||
|
|
||||||
const breakpointColumnsObj = useMemo(() => {
|
const breakpointColumnsObj = useMemo(() => {
|
||||||
@@ -195,19 +90,18 @@ export function MasonryView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Masonry
|
<Masonry
|
||||||
breakpointCols={
|
breakpointCols={breakpointColumnsObj}
|
||||||
settings.columns === 0 ? breakpointColumnsObj : columnCount
|
|
||||||
}
|
|
||||||
columnClassName="flex flex-col gap-5 !w-full"
|
columnClassName="flex flex-col gap-5 !w-full"
|
||||||
className={`${gridColClass} grid gap-5 pb-5`}
|
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"
|
||||||
>
|
>
|
||||||
{links?.map((e, i) => {
|
{links?.map((e, i) => {
|
||||||
return (
|
return (
|
||||||
<LinkMasonry
|
<LinkMasonry
|
||||||
key={i}
|
key={i}
|
||||||
link={e}
|
link={e}
|
||||||
|
count={i}
|
||||||
|
flipDropdown={i === links.length - 1}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
columns={columnCount}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -248,9 +142,17 @@ export function ListView({
|
|||||||
placeHolderRef?: any;
|
placeHolderRef?: any;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex gap-1 flex-col">
|
||||||
{links?.map((e, i) => {
|
{links?.map((e, i) => {
|
||||||
return <LinkList key={i} link={e} count={i} editMode={editMode} />;
|
return (
|
||||||
|
<LinkList
|
||||||
|
key={i}
|
||||||
|
link={e}
|
||||||
|
count={i}
|
||||||
|
flipDropdown={i === links.length - 1}
|
||||||
|
editMode={editMode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{(hasNextPage || isLoading) &&
|
{(hasNextPage || isLoading) &&
|
||||||
@@ -259,13 +161,13 @@ export function ListView({
|
|||||||
<div
|
<div
|
||||||
ref={e === 1 ? placeHolderRef : undefined}
|
ref={e === 1 ? placeHolderRef : undefined}
|
||||||
key={i}
|
key={i}
|
||||||
className="flex gap-2 py-2 px-1"
|
className="flex gap-4 p-4"
|
||||||
>
|
>
|
||||||
<div className="skeleton h-12 w-12"></div>
|
<div className="skeleton h-16 w-16"></div>
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<div className="skeleton h-2 w-2/3"></div>
|
<div className="skeleton h-3 w-2/3"></div>
|
||||||
<div className="skeleton h-2 w-full"></div>
|
<div className="skeleton h-3 w-full"></div>
|
||||||
<div className="skeleton h-2 w-1/3"></div>
|
<div className="skeleton h-3 w-1/3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,13 +87,15 @@ export default function MobileNavigation({}: Props) {
|
|||||||
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
|
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
{newLinkModal ? (
|
||||||
{newCollectionModal && (
|
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||||
|
) : undefined}
|
||||||
|
{newCollectionModal ? (
|
||||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||||
)}
|
) : undefined}
|
||||||
{uploadFileModal && (
|
{uploadFileModal ? (
|
||||||
<UploadFileModal onClose={() => setUploadFileModal(false)} />
|
<UploadFileModal onClose={() => setUploadFileModal(false)} />
|
||||||
)}
|
) : undefined}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
setSubmitLoader(false);
|
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -59,6 +58,8 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export default function DeleteCollectionModal({
|
|||||||
|
|
||||||
deleteCollection.mutateAsync(collection.id as number, {
|
deleteCollection.mutateAsync(collection.id as number, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
setSubmitLoader(false);
|
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -56,6 +55,8 @@ export default function DeleteCollectionModal({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Button from "../ui/Button";
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useDeleteUser } from "@/hooks/store/admin/users";
|
import { useDeleteUser } from "@/hooks/store/admin/users";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: Function;
|
onClose: Function;
|
||||||
@@ -24,40 +23,31 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onSettled: (data, error) => {
|
|
||||||
setSubmitLoader(false);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data } = useSession();
|
|
||||||
const isAdmin = data?.user?.id === Number(process.env.NEXT_PUBLIC_ADMIN);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal toggleModal={onClose}>
|
<Modal toggleModal={onClose}>
|
||||||
<p className="text-xl font-thin text-red-500">
|
<p className="text-xl font-thin text-red-500">{t("delete_user")}</p>
|
||||||
{isAdmin ? t("delete_user") : t("remove_user")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="divider mb-3 mt-1"></div>
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p>{t("confirm_user_deletion")}</p>
|
<p>{t("confirm_user_deletion")}</p>
|
||||||
<p>{t("confirm_user_removal_desc")}</p>
|
|
||||||
|
|
||||||
{isAdmin && (
|
<div role="alert" className="alert alert-warning">
|
||||||
<div role="alert" className="alert alert-warning">
|
<i className="bi-exclamation-triangle text-xl" />
|
||||||
<i className="bi-exclamation-triangle text-xl" />
|
<span>
|
||||||
<span>
|
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
|
||||||
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button className="ml-auto" intent="destructive" onClick={submit}>
|
<Button className="ml-auto" intent="destructive" onClick={submit}>
|
||||||
<i className="bi-trash text-xl" />
|
<i className="bi-trash text-xl" />
|
||||||
{isAdmin ? t("delete_confirmation") : t("confirm")}
|
{t("delete_confirmation")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import TextInput from "@/components/TextInput";
|
import TextInput from "@/components/TextInput";
|
||||||
|
import { HexColorPicker } from "react-colorful";
|
||||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useUpdateCollection } from "@/hooks/store/collections";
|
import { useUpdateCollection } from "@/hooks/store/collections";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import IconPicker from "../IconPicker";
|
|
||||||
import { IconWeight } from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: Function;
|
onClose: Function;
|
||||||
@@ -35,7 +34,6 @@ export default function EditCollectionModal({
|
|||||||
|
|
||||||
await updateCollection.mutateAsync(collection, {
|
await updateCollection.mutateAsync(collection, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
setSubmitLoader(false);
|
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -46,6 +44,8 @@ export default function EditCollectionModal({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,32 +56,10 @@ export default function EditCollectionModal({
|
|||||||
<div className="divider mb-3 mt-1"></div>
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<div className="flex gap-3 items-end">
|
<div className="w-full">
|
||||||
<IconPicker
|
<p className="mb-2">{t("name")}</p>
|
||||||
color={collection.color}
|
<div className="flex flex-col gap-3">
|
||||||
setColor={(color: string) =>
|
|
||||||
setCollection({ ...collection, color })
|
|
||||||
}
|
|
||||||
weight={(collection.iconWeight || "regular") as IconWeight}
|
|
||||||
setWeight={(iconWeight: string) =>
|
|
||||||
setCollection({ ...collection, iconWeight })
|
|
||||||
}
|
|
||||||
iconName={collection.icon as string}
|
|
||||||
setIconName={(icon: string) =>
|
|
||||||
setCollection({ ...collection, icon })
|
|
||||||
}
|
|
||||||
reset={() =>
|
|
||||||
setCollection({
|
|
||||||
...collection,
|
|
||||||
color: "#0ea5e9",
|
|
||||||
icon: "",
|
|
||||||
iconWeight: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="w-full">
|
|
||||||
<p className="mb-2">{t("name")}</p>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
className="bg-base-200"
|
className="bg-base-200"
|
||||||
value={collection.name}
|
value={collection.name}
|
||||||
@@ -90,13 +68,38 @@ export default function EditCollectionModal({
|
|||||||
setCollection({ ...collection, name: e.target.value })
|
setCollection({ ...collection, name: e.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="w-full mb-2">{t("color")}</p>
|
||||||
|
<div className="color-picker flex justify-between items-center">
|
||||||
|
<HexColorPicker
|
||||||
|
color={collection.color}
|
||||||
|
onChange={(color) =>
|
||||||
|
setCollection({ ...collection, color })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 items-center w-32">
|
||||||
|
<i
|
||||||
|
className="bi-folder-fill text-5xl"
|
||||||
|
style={{ color: collection.color }}
|
||||||
|
></i>
|
||||||
|
<div
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setCollection({ ...collection, color: "#0ea5e9" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("reset")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="mb-2">{t("description")}</p>
|
<p className="mb-2">{t("description")}</p>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
|
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
|
||||||
placeholder={t("collection_description_placeholder")}
|
placeholder={t("collection_description_placeholder")}
|
||||||
value={collection.description}
|
value={collection.description}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
||||||
|
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { useUpdateLink } from "@/hooks/store/links";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditLinkModal({ onClose, activeLink }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [link, setLink] =
|
||||||
|
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||||
|
|
||||||
|
let shortenedURL;
|
||||||
|
try {
|
||||||
|
shortenedURL = new URL(link.url || "").host.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
|
const updateLink = useUpdateLink();
|
||||||
|
|
||||||
|
const setCollection = (e: any) => {
|
||||||
|
if (e?.__isNew__) e.value = null;
|
||||||
|
setLink({
|
||||||
|
...link,
|
||||||
|
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTags = (e: any) => {
|
||||||
|
const tagNames = e.map((e: any) => ({ name: e.label }));
|
||||||
|
setLink({ ...link, tags: tagNames });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLink(activeLink);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!submitLoader) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading(t("updating"));
|
||||||
|
|
||||||
|
await updateLink.mutateAsync(link, {
|
||||||
|
onSettled: (data, error) => {
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
toast.success(t("updated"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">{t("edit_link")}</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
{link.url ? (
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
className="truncate text-neutral flex gap-2 mb-5 w-fit max-w-full"
|
||||||
|
title={link.url}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<i className="bi-link-45deg text-xl" />
|
||||||
|
<p>{shortenedURL}</p>
|
||||||
|
</Link>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="mb-2">{t("name")}</p>
|
||||||
|
<TextInput
|
||||||
|
value={link.name}
|
||||||
|
onChange={(e) => setLink({ ...link, name: e.target.value })}
|
||||||
|
placeholder={t("placeholder_example_link")}
|
||||||
|
className="bg-base-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">{t("collection")}</p>
|
||||||
|
{link.collection.name ? (
|
||||||
|
<CollectionSelection
|
||||||
|
onChange={setCollection}
|
||||||
|
defaultValue={
|
||||||
|
link.collection.id
|
||||||
|
? { value: link.collection.id, label: link.collection.name }
|
||||||
|
: { value: null as unknown as number, label: "Unorganized" }
|
||||||
|
}
|
||||||
|
creatable={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">{t("tags")}</p>
|
||||||
|
<TagSelection
|
||||||
|
onChange={setTags}
|
||||||
|
defaultValue={link.tags.map((e) => ({
|
||||||
|
label: e.name,
|
||||||
|
value: e.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="mb-2">{t("description")}</p>
|
||||||
|
<textarea
|
||||||
|
value={unescapeString(link.description) as string}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLink({ ...link, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("link_description_placeholder")}
|
||||||
|
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 className="flex justify-end items-center mt-5">
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{t("save_changes")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import TextInput from "@/components/TextInput";
|
import TextInput from "@/components/TextInput";
|
||||||
|
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 { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useCreateCollection } from "@/hooks/store/collections";
|
import { useCreateCollection } from "@/hooks/store/collections";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import IconPicker from "../IconPicker";
|
|
||||||
import { IconWeight } from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: Function;
|
onClose: Function;
|
||||||
@@ -43,7 +42,6 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
|||||||
|
|
||||||
await createCollection.mutateAsync(collection, {
|
await createCollection.mutateAsync(collection, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
setSubmitLoader(false);
|
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -54,6 +52,8 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,32 +72,10 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
|||||||
<div className="divider mb-3 mt-1"></div>
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<div className="flex gap-3 items-end">
|
<div className="w-full">
|
||||||
<IconPicker
|
<p className="mb-2">{t("name")}</p>
|
||||||
color={collection.color || "#0ea5e9"}
|
<div className="flex flex-col gap-2">
|
||||||
setColor={(color: string) =>
|
|
||||||
setCollection({ ...collection, color })
|
|
||||||
}
|
|
||||||
weight={(collection.iconWeight || "regular") as IconWeight}
|
|
||||||
setWeight={(iconWeight: string) =>
|
|
||||||
setCollection({ ...collection, iconWeight })
|
|
||||||
}
|
|
||||||
iconName={collection.icon as string}
|
|
||||||
setIconName={(icon: string) =>
|
|
||||||
setCollection({ ...collection, icon })
|
|
||||||
}
|
|
||||||
reset={() =>
|
|
||||||
setCollection({
|
|
||||||
...collection,
|
|
||||||
color: "#0ea5e9",
|
|
||||||
icon: "",
|
|
||||||
iconWeight: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="w-full">
|
|
||||||
<p className="mb-2">{t("name")}</p>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
className="bg-base-200"
|
className="bg-base-200"
|
||||||
value={collection.name}
|
value={collection.name}
|
||||||
@@ -106,13 +84,38 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
|||||||
setCollection({ ...collection, name: e.target.value })
|
setCollection({ ...collection, name: e.target.value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="w-full mb-2">{t("color")}</p>
|
||||||
|
<div className="color-picker flex justify-between items-center">
|
||||||
|
<HexColorPicker
|
||||||
|
color={collection.color}
|
||||||
|
onChange={(color) =>
|
||||||
|
setCollection({ ...collection, color })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 items-center w-32">
|
||||||
|
<i
|
||||||
|
className={"bi-folder-fill text-5xl"}
|
||||||
|
style={{ color: collection.color }}
|
||||||
|
></i>
|
||||||
|
<div
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setCollection({ ...collection, color: "#0ea5e9" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("reset")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="mb-2">{t("description")}</p>
|
<p className="mb-2">{t("description")}</p>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
|
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
|
||||||
placeholder={t("collection_description_placeholder")}
|
placeholder={t("collection_description_placeholder")}
|
||||||
value={collection.description}
|
value={collection.description}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
|||||||
import TagSelection from "@/components/InputSelect/TagSelection";
|
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||||
import TextInput from "@/components/TextInput";
|
import TextInput from "@/components/TextInput";
|
||||||
import unescapeString from "@/lib/client/unescapeString";
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useCollections } from "@/hooks/store/collections";
|
import { useCollections } from "@/hooks/store/collections";
|
||||||
import { useAddLink } from "@/hooks/store/links";
|
import { useAddLink } from "@/hooks/store/links";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: Function;
|
onClose: Function;
|
||||||
@@ -17,19 +18,27 @@ type Props = {
|
|||||||
|
|
||||||
export default function NewLinkModal({ onClose }: Props) {
|
export default function NewLinkModal({ onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data } = useSession();
|
||||||
const initial = {
|
const initial = {
|
||||||
name: "",
|
name: "",
|
||||||
url: "",
|
url: "",
|
||||||
description: "",
|
description: "",
|
||||||
type: "url",
|
type: "url",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
preview: "",
|
||||||
|
image: "",
|
||||||
|
pdf: "",
|
||||||
|
readable: "",
|
||||||
|
monolith: "",
|
||||||
|
textContent: "",
|
||||||
collection: {
|
collection: {
|
||||||
id: undefined,
|
|
||||||
name: "",
|
name: "",
|
||||||
|
ownerId: data?.user.id as number,
|
||||||
},
|
},
|
||||||
} as PostLinkSchemaType;
|
} as LinkIncludingShortenedCollectionAndTags;
|
||||||
|
|
||||||
const [link, setLink] = useState<PostLinkSchemaType>(initial);
|
const [link, setLink] =
|
||||||
|
useState<LinkIncludingShortenedCollectionAndTags>(initial);
|
||||||
|
|
||||||
const addLink = useAddLink();
|
const addLink = useAddLink();
|
||||||
|
|
||||||
@@ -39,10 +48,10 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||||||
const { data: collections = [] } = useCollections();
|
const { data: collections = [] } = useCollections();
|
||||||
|
|
||||||
const setCollection = (e: any) => {
|
const setCollection = (e: any) => {
|
||||||
if (e?.__isNew__) e.value = undefined;
|
if (e?.__isNew__) e.value = null;
|
||||||
setLink({
|
setLink({
|
||||||
...link,
|
...link,
|
||||||
collection: { id: e?.value, name: e?.label },
|
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,23 +61,27 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (router.pathname.startsWith("/collections/") && router.query.id) {
|
if (router.query.id) {
|
||||||
const currentCollection = collections.find(
|
const currentCollection = collections.find(
|
||||||
(e) => e.id == Number(router.query.id)
|
(e) => e.id == Number(router.query.id)
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
if (currentCollection && currentCollection.ownerId)
|
currentCollection &&
|
||||||
|
currentCollection.ownerId &&
|
||||||
|
router.asPath.startsWith("/collections/")
|
||||||
|
)
|
||||||
setLink({
|
setLink({
|
||||||
...initial,
|
...initial,
|
||||||
collection: {
|
collection: {
|
||||||
id: currentCollection.id,
|
id: currentCollection.id,
|
||||||
name: currentCollection.name,
|
name: currentCollection.name,
|
||||||
|
ownerId: currentCollection.ownerId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else
|
} else
|
||||||
setLink({
|
setLink({
|
||||||
...initial,
|
...initial,
|
||||||
collection: { name: "Unorganized" },
|
collection: { name: "Unorganized", ownerId: data?.user.id as number },
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -80,17 +93,18 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||||||
|
|
||||||
await addLink.mutateAsync(link, {
|
await addLink.mutateAsync(link, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
setSubmitLoader(false);
|
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(t(error.message));
|
toast.error(error.message);
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
toast.success(t("link_created"));
|
toast.success(t("link_created"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,19 +124,19 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 col-span-5">
|
<div className="sm:col-span-2 col-span-5">
|
||||||
<p className="mb-2">{t("collection")}</p>
|
<p className="mb-2">{t("collection")}</p>
|
||||||
{link.collection?.name && (
|
{link.collection.name ? (
|
||||||
<CollectionSelection
|
<CollectionSelection
|
||||||
onChange={setCollection}
|
onChange={setCollection}
|
||||||
defaultValue={{
|
defaultValue={{
|
||||||
value: link.collection?.id,
|
label: link.collection.name,
|
||||||
label: link.collection?.name || "Unorganized",
|
value: link.collection.id,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={"mt-2"}>
|
<div className={"mt-2"}>
|
||||||
{optionsExpanded && (
|
{optionsExpanded ? (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -138,7 +152,7 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||||||
<p className="mb-2">{t("tags")}</p>
|
<p className="mb-2">{t("tags")}</p>
|
||||||
<TagSelection
|
<TagSelection
|
||||||
onChange={setTags}
|
onChange={setTags}
|
||||||
defaultValue={link.tags?.map((e) => ({
|
defaultValue={link.tags.map((e) => ({
|
||||||
label: e.name,
|
label: e.name,
|
||||||
value: e.id,
|
value: e.id,
|
||||||
}))}
|
}))}
|
||||||
@@ -147,17 +161,17 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<p className="mb-2">{t("description")}</p>
|
<p className="mb-2">{t("description")}</p>
|
||||||
<textarea
|
<textarea
|
||||||
value={unescapeString(link.description || "") || ""}
|
value={unescapeString(link.description) as string}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setLink({ ...link, description: e.target.value })
|
setLink({ ...link, description: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder={t("link_description_placeholder")}
|
placeholder={t("link_description_placeholder")}
|
||||||
className="resize-none w-full h-32 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-primary border-solid border outline-none duration-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center mt-5">
|
<div className="flex justify-between items-center mt-5">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { dropdownTriggerer } from "@/lib/client/utils";
|
|||||||
import Button from "../ui/Button";
|
import Button from "../ui/Button";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useAddToken } from "@/hooks/store/tokens";
|
import { useAddToken } from "@/hooks/store/tokens";
|
||||||
import CopyButton from "../CopyButton";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: Function;
|
onClose: Function;
|
||||||
@@ -34,7 +33,6 @@ export default function NewTokenModal({ onClose }: Props) {
|
|||||||
|
|
||||||
await addToken.mutateAsync(token, {
|
await addToken.mutateAsync(token, {
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
setSubmitLoader(false);
|
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -44,6 +42,8 @@ export default function NewTokenModal({ onClose }: Props) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,14 +68,21 @@ export default function NewTokenModal({ onClose }: Props) {
|
|||||||
<div className="flex flex-col justify-center space-y-4">
|
<div className="flex flex-col justify-center space-y-4">
|
||||||
<p className="text-xl font-thin">{t("access_token_created")}</p>
|
<p className="text-xl font-thin">{t("access_token_created")}</p>
|
||||||
<p>{t("token_creation_notice")}</p>
|
<p>{t("token_creation_notice")}</p>
|
||||||
<div className="relative">
|
<TextInput
|
||||||
<div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between pr-14">
|
spellCheck={false}
|
||||||
{newToken}
|
value={newToken}
|
||||||
<div className="absolute right-0 px-2 border-neutral-content border-solid border-r bg-base-200">
|
onChange={() => {}}
|
||||||
<CopyButton text={newToken} />
|
className="w-full"
|
||||||
</div>
|
/>
|
||||||
</div>
|
<button
|
||||||
</div>
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(newToken);
|
||||||
|
toast.success(t("copied_to_clipboard"));
|
||||||
|
}}
|
||||||
|
className="btn btn-primary w-fit mx-auto"
|
||||||
|
>
|
||||||
|
{t("copy_to_clipboard")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ export default function NewUserModal({ onClose }: Props) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!submitLoader) {
|
if (!submitLoader) {
|
||||||
if (form.password.length < 8)
|
|
||||||
return toast.error(t("password_length_error"));
|
|
||||||
|
|
||||||
const checkFields = () => {
|
const checkFields = () => {
|
||||||
if (emailEnabled) {
|
if (emailEnabled) {
|
||||||
return form.name !== "" && form.email !== "" && form.password !== "";
|
return form.name !== "" && form.email !== "" && form.password !== "";
|
||||||
@@ -55,10 +52,9 @@ export default function NewUserModal({ onClose }: Props) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
|
||||||
setSubmitLoader(false);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("fill_all_fields_error"));
|
toast.error(t("fill_all_fields_error"));
|
||||||
}
|
}
|
||||||
@@ -83,7 +79,7 @@ export default function NewUserModal({ onClose }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{emailEnabled && (
|
{emailEnabled ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2">{t("email")}</p>
|
<p className="mb-2">{t("email")}</p>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -93,7 +89,7 @@ export default function NewUserModal({ onClose }: Props) {
|
|||||||
value={form.email}
|
value={form.email}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : undefined}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
ArchivedFormat,
|
||||||
|
} from "@/types/global";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import {
|
||||||
|
pdfAvailable,
|
||||||
|
readabilityAvailable,
|
||||||
|
monolithAvailable,
|
||||||
|
screenshotAvailable,
|
||||||
|
} from "@/lib/shared/getArchiveValidity";
|
||||||
|
import PreservedFormatRow from "@/components/PreserverdFormatRow";
|
||||||
|
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||||
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { BeatLoader } from "react-spinners";
|
||||||
|
import { useUser } from "@/hooks/store/user";
|
||||||
|
import { useGetLink } from "@/hooks/store/links";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PreservedFormatsModal({ onClose, link }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const getLink = useGetLink();
|
||||||
|
const { data: user = {} } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||||
|
|
||||||
|
const [collectionOwner, setCollectionOwner] = useState({
|
||||||
|
id: null as unknown as number,
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
image: "",
|
||||||
|
archiveAsScreenshot: undefined as unknown as boolean,
|
||||||
|
archiveAsMonolith: undefined as unknown as boolean,
|
||||||
|
archiveAsPDF: undefined as unknown as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOwner = async () => {
|
||||||
|
if (link.collection.ownerId !== user.id) {
|
||||||
|
const owner = await getPublicUserData(
|
||||||
|
link.collection.ownerId as number
|
||||||
|
);
|
||||||
|
setCollectionOwner(owner);
|
||||||
|
} else if (link.collection.ownerId === user.id) {
|
||||||
|
setCollectionOwner({
|
||||||
|
id: user.id as number,
|
||||||
|
name: user.name,
|
||||||
|
username: user.username as string,
|
||||||
|
image: user.image as string,
|
||||||
|
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
|
||||||
|
archiveAsMonolith: user.archiveAsScreenshot as boolean,
|
||||||
|
archiveAsPDF: user.archiveAsPDF as boolean,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOwner();
|
||||||
|
}, [link.collection.ownerId]);
|
||||||
|
|
||||||
|
const isReady = () => {
|
||||||
|
return (
|
||||||
|
link &&
|
||||||
|
(collectionOwner.archiveAsScreenshot === true
|
||||||
|
? link.pdf && link.pdf !== "pending"
|
||||||
|
: true) &&
|
||||||
|
(collectionOwner.archiveAsMonolith === true
|
||||||
|
? link.monolith && link.monolith !== "pending"
|
||||||
|
: true) &&
|
||||||
|
(collectionOwner.archiveAsPDF === true
|
||||||
|
? link.pdf && link.pdf !== "pending"
|
||||||
|
: true) &&
|
||||||
|
link.readable &&
|
||||||
|
link.readable !== "pending"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const atLeastOneFormatAvailable = () => {
|
||||||
|
return (
|
||||||
|
screenshotAvailable(link) ||
|
||||||
|
pdfAvailable(link) ||
|
||||||
|
readabilityAvailable(link) ||
|
||||||
|
monolithAvailable(link)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await getLink.mutateAsync(link.id as number);
|
||||||
|
})();
|
||||||
|
|
||||||
|
let interval: any;
|
||||||
|
|
||||||
|
if (!isReady()) {
|
||||||
|
interval = setInterval(async () => {
|
||||||
|
await getLink.mutateAsync(link.id as number);
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [link?.monolith]);
|
||||||
|
|
||||||
|
const updateArchive = async () => {
|
||||||
|
const load = toast.loading(t("sending_request"));
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await getLink.mutateAsync(link?.id as number);
|
||||||
|
|
||||||
|
toast.success(t("link_being_archived"));
|
||||||
|
} else toast.error(data.response);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">{t("preserved_formats")}</p>
|
||||||
|
<div className="divider mb-2 mt-1"></div>
|
||||||
|
{screenshotAvailable(link) ||
|
||||||
|
pdfAvailable(link) ||
|
||||||
|
readabilityAvailable(link) ||
|
||||||
|
monolithAvailable(link) ? (
|
||||||
|
<p className="mb-3">{t("available_formats")}</p>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`flex flex-col gap-3`}>
|
||||||
|
{monolithAvailable(link) ? (
|
||||||
|
<PreservedFormatRow
|
||||||
|
name={t("webpage")}
|
||||||
|
icon={"bi-filetype-html"}
|
||||||
|
format={ArchivedFormat.monolith}
|
||||||
|
link={link}
|
||||||
|
downloadable={true}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{screenshotAvailable(link) ? (
|
||||||
|
<PreservedFormatRow
|
||||||
|
name={t("screenshot")}
|
||||||
|
icon={"bi-file-earmark-image"}
|
||||||
|
format={
|
||||||
|
link?.image?.endsWith("png")
|
||||||
|
? ArchivedFormat.png
|
||||||
|
: ArchivedFormat.jpeg
|
||||||
|
}
|
||||||
|
link={link}
|
||||||
|
downloadable={true}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{pdfAvailable(link) ? (
|
||||||
|
<PreservedFormatRow
|
||||||
|
name={t("pdf")}
|
||||||
|
icon={"bi-file-earmark-pdf"}
|
||||||
|
format={ArchivedFormat.pdf}
|
||||||
|
link={link}
|
||||||
|
downloadable={true}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{readabilityAvailable(link) ? (
|
||||||
|
<PreservedFormatRow
|
||||||
|
name={t("readable")}
|
||||||
|
icon={"bi-file-earmark-text"}
|
||||||
|
format={ArchivedFormat.readability}
|
||||||
|
link={link}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{!isReady() && !atLeastOneFormatAvailable() ? (
|
||||||
|
<div className={`w-full h-full flex flex-col justify-center p-10`}>
|
||||||
|
<BeatLoader
|
||||||
|
color="oklch(var(--p))"
|
||||||
|
className="mx-auto mb-3"
|
||||||
|
size={30}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-center text-2xl">{t("preservation_in_queue")}</p>
|
||||||
|
<p className="text-center text-lg">{t("check_back_later")}</p>
|
||||||
|
</div>
|
||||||
|
) : !isReady() && atLeastOneFormatAvailable() ? (
|
||||||
|
<div className={`w-full h-full flex flex-col justify-center p-5`}>
|
||||||
|
<BeatLoader
|
||||||
|
color="oklch(var(--p))"
|
||||||
|
className="mx-auto mb-3"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<p className="text-center">{t("there_are_more_formats")}</p>
|
||||||
|
<p className="text-center text-sm">{t("check_back_later")}</p>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${
|
||||||
|
isReady() ? "sm:mt " : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`https://web.archive.org/web/${link?.url?.replace(
|
||||||
|
/(^\w+:|^)\/\//,
|
||||||
|
""
|
||||||
|
)}`}
|
||||||
|
target="_blank"
|
||||||
|
className="text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
|
||||||
|
>
|
||||||
|
<p className="whitespace-nowrap">{t("view_latest_snapshot")}</p>
|
||||||
|
<i className="bi-box-arrow-up-right" />
|
||||||
|
</Link>
|
||||||
|
{link?.collection.ownerId === session.data?.user.id && (
|
||||||
|
<div className="btn btn-outline" onClick={updateArchive}>
|
||||||
|
<div>
|
||||||
|
<p>{t("refresh_preserved_formats")}</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{t("this_deletes_current_preservations")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import Modal from "../Modal";
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useCollections } from "@/hooks/store/collections";
|
import { useCollections } from "@/hooks/store/collections";
|
||||||
import { useUploadFile } from "@/hooks/store/links";
|
import { useUploadFile } from "@/hooks/store/links";
|
||||||
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: Function;
|
onClose: Function;
|
||||||
@@ -26,16 +25,24 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
|
|
||||||
const initial = {
|
const initial = {
|
||||||
name: "",
|
name: "",
|
||||||
|
url: "",
|
||||||
description: "",
|
description: "",
|
||||||
type: "url",
|
type: "url",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
preview: "",
|
||||||
|
image: "",
|
||||||
|
pdf: "",
|
||||||
|
readable: "",
|
||||||
|
monolith: "",
|
||||||
|
textContent: "",
|
||||||
collection: {
|
collection: {
|
||||||
id: undefined,
|
|
||||||
name: "",
|
name: "",
|
||||||
|
ownerId: data?.user.id as number,
|
||||||
},
|
},
|
||||||
} as PostLinkSchemaType;
|
} as LinkIncludingShortenedCollectionAndTags;
|
||||||
|
|
||||||
const [link, setLink] = useState<PostLinkSchemaType>(initial);
|
const [link, setLink] =
|
||||||
|
useState<LinkIncludingShortenedCollectionAndTags>(initial);
|
||||||
const [file, setFile] = useState<File>();
|
const [file, setFile] = useState<File>();
|
||||||
|
|
||||||
const uploadFile = useUploadFile();
|
const uploadFile = useUploadFile();
|
||||||
@@ -45,11 +52,11 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
const { data: collections = [] } = useCollections();
|
const { data: collections = [] } = useCollections();
|
||||||
|
|
||||||
const setCollection = (e: any) => {
|
const setCollection = (e: any) => {
|
||||||
if (e?.__isNew__) e.value = undefined;
|
if (e?.__isNew__) e.value = null;
|
||||||
|
|
||||||
setLink({
|
setLink({
|
||||||
...link,
|
...link,
|
||||||
collection: { id: e?.value, name: e?.label },
|
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,11 +70,10 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOptionsExpanded(false);
|
setOptionsExpanded(false);
|
||||||
if (router.pathname.startsWith("/collections/") && router.query.id) {
|
if (router.query.id) {
|
||||||
const currentCollection = collections.find(
|
const currentCollection = collections.find(
|
||||||
(e) => e.id == Number(router.query.id)
|
(e) => e.id == Number(router.query.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentCollection &&
|
currentCollection &&
|
||||||
currentCollection.ownerId &&
|
currentCollection.ownerId &&
|
||||||
@@ -78,12 +84,13 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
collection: {
|
collection: {
|
||||||
id: currentCollection.id,
|
id: currentCollection.id,
|
||||||
name: currentCollection.name,
|
name: currentCollection.name,
|
||||||
|
ownerId: currentCollection.ownerId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else
|
} else
|
||||||
setLink({
|
setLink({
|
||||||
...initial,
|
...initial,
|
||||||
collection: { name: "Unorganized" },
|
collection: { name: "Unorganized", ownerId: data?.user.id as number },
|
||||||
});
|
});
|
||||||
}, [router, collections]);
|
}, [router, collections]);
|
||||||
|
|
||||||
@@ -115,7 +122,6 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
{ link, file },
|
{ link, file },
|
||||||
{
|
{
|
||||||
onSettled: (data, error) => {
|
onSettled: (data, error) => {
|
||||||
setSubmitLoader(false);
|
|
||||||
toast.dismiss(load);
|
toast.dismiss(load);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -127,6 +133,8 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,7 +150,7 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
|
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pdf,.png,.jpg,.jpeg"
|
accept=".pdf,.png,.jpg,.jpeg,.html"
|
||||||
className="cursor-pointer custom-file-input"
|
className="cursor-pointer custom-file-input"
|
||||||
onChange={(e) => e.target.files && setFile(e.target.files[0])}
|
onChange={(e) => e.target.files && setFile(e.target.files[0])}
|
||||||
/>
|
/>
|
||||||
@@ -155,18 +163,18 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 col-span-5">
|
<div className="sm:col-span-2 col-span-5">
|
||||||
<p className="mb-2">{t("collection")}</p>
|
<p className="mb-2">{t("collection")}</p>
|
||||||
{link.collection?.name && (
|
{link.collection.name ? (
|
||||||
<CollectionSelection
|
<CollectionSelection
|
||||||
onChange={setCollection}
|
onChange={setCollection}
|
||||||
defaultValue={{
|
defaultValue={{
|
||||||
value: link.collection?.id,
|
label: link.collection.name,
|
||||||
label: link.collection?.name || "Unorganized",
|
value: link.collection.id,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{optionsExpanded && (
|
{optionsExpanded ? (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -182,26 +190,26 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
<p className="mb-2">{t("tags")}</p>
|
<p className="mb-2">{t("tags")}</p>
|
||||||
<TagSelection
|
<TagSelection
|
||||||
onChange={setTags}
|
onChange={setTags}
|
||||||
defaultValue={link.tags?.map((e) => ({
|
defaultValue={link.tags.map((e) => ({
|
||||||
value: e.id,
|
|
||||||
label: e.name,
|
label: e.name,
|
||||||
|
value: e.id,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<p className="mb-2">{t("description")}</p>
|
<p className="mb-2">{t("description")}</p>
|
||||||
<textarea
|
<textarea
|
||||||
value={unescapeString(link.description || "") || ""}
|
value={unescapeString(link.description) as string}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setLink({ ...link, description: e.target.value })
|
setLink({ ...link, description: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder={t("description_placeholder")}
|
placeholder={t("description_placeholder")}
|
||||||
className="resize-none w-full h-32 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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : undefined}
|
||||||
<div className="flex justify-between items-center mt-5">
|
<div className="flex justify-between items-center mt-5">
|
||||||
<div
|
<div
|
||||||
onClick={() => setOptionsExpanded(!optionsExpanded)}
|
onClick={() => setOptionsExpanded(!optionsExpanded)}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function Navbar() {
|
|||||||
|
|
||||||
<MobileNavigation />
|
<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}>
|
||||||
<div className="slide-right h-full shadow-lg">
|
<div className="slide-right h-full shadow-lg">
|
||||||
@@ -122,14 +122,16 @@ export default function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
</ClickAwayHandler>
|
</ClickAwayHandler>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
{newLinkModal ? (
|
||||||
{newCollectionModal && (
|
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||||
|
) : undefined}
|
||||||
|
{newCollectionModal ? (
|
||||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||||
)}
|
) : undefined}
|
||||||
{uploadFileModal && (
|
{uploadFileModal ? (
|
||||||
<UploadFileModal onClose={() => setUploadFileModal(false)} />
|
<UploadFileModal onClose={() => setUploadFileModal(false)} />
|
||||||
)}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export default function NoLinksFound({ text }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
{newLinkModal ? (
|
||||||
|
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||||
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import ClickAwayHandler from "./ClickAwayHandler";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
onClose: Function;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Popover = ({ children, className, onClose }: Props) => {
|
|
||||||
return (
|
|
||||||
<ClickAwayHandler
|
|
||||||
onClickOutside={() => onClose()}
|
|
||||||
className={`absolute z-50 ${className || ""}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ClickAwayHandler>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Popover;
|
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from "@/types/global";
|
} from "@/types/global";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useGetLink } from "@/hooks/store/links";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,6 +21,8 @@ export default function PreservedFormatRow({
|
|||||||
link,
|
link,
|
||||||
downloadable,
|
downloadable,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const getLink = useGetLink();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||||
@@ -49,9 +52,11 @@ export default function PreservedFormatRow({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<i className={`${icon} text-2xl text-primary`} />
|
<div className="bg-primary text-primary-content p-2 rounded-l-md">
|
||||||
|
<i className={`${icon} text-2xl`} />
|
||||||
|
</div>
|
||||||
<p>{name}</p>
|
<p>{name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,7 +64,7 @@ export default function PreservedFormatRow({
|
|||||||
{downloadable || false ? (
|
{downloadable || false ? (
|
||||||
<div
|
<div
|
||||||
onClick={() => handleDownload()}
|
onClick={() => handleDownload()}
|
||||||
className="btn btn-sm btn-square btn-ghost"
|
className="btn btn-sm btn-square"
|
||||||
>
|
>
|
||||||
<i className="bi-cloud-arrow-down text-xl text-neutral" />
|
<i className="bi-cloud-arrow-down text-xl text-neutral" />
|
||||||
</div>
|
</div>
|
||||||
@@ -70,9 +75,9 @@ export default function PreservedFormatRow({
|
|||||||
isPublic ? "/public" : ""
|
isPublic ? "/public" : ""
|
||||||
}/preserved/${link?.id}?format=${format}`}
|
}/preserved/${link?.id}?format=${format}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="btn btn-sm btn-square btn-ghost"
|
className="btn btn-sm btn-square"
|
||||||
>
|
>
|
||||||
<i className="bi-box-arrow-up-right text-lg text-neutral" />
|
<i className="bi-box-arrow-up-right text-xl text-neutral" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ type Props = {
|
|||||||
src?: string;
|
src?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
name?: string | null;
|
name?: string;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+30
-28
@@ -3,6 +3,7 @@ import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
|
|||||||
import isValidUrl from "@/lib/shared/isValidUrl";
|
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||||
import {
|
import {
|
||||||
ArchivedFormat,
|
ArchivedFormat,
|
||||||
|
CollectionIncludingMembersAndLinkCount,
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
} from "@/types/global";
|
} from "@/types/global";
|
||||||
import ColorThief, { RGBColor } from "colorthief";
|
import ColorThief, { RGBColor } from "colorthief";
|
||||||
@@ -10,11 +11,11 @@ import DOMPurify from "dompurify";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import LinkActions from "./LinkViews/LinkComponents/LinkActions";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { useCollections } from "@/hooks/store/collections";
|
||||||
import { useGetLink } from "@/hooks/store/links";
|
import { useGetLink } from "@/hooks/store/links";
|
||||||
import { IconWeight } from "@phosphor-icons/react";
|
|
||||||
import Icon from "./Icon";
|
|
||||||
|
|
||||||
type LinkContent = {
|
type LinkContent = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -45,6 +46,13 @@ export default function ReadableView({ link }: Props) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const getLink = useGetLink();
|
const getLink = useGetLink();
|
||||||
|
const { data: collections = [] } = useCollections();
|
||||||
|
|
||||||
|
const collection = useMemo(() => {
|
||||||
|
return collections.find(
|
||||||
|
(e) => e.id === link.collection.id
|
||||||
|
) as CollectionIncludingMembersAndLinkCount;
|
||||||
|
}, [collections, link]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchLinkContent = async () => {
|
const fetchLinkContent = async () => {
|
||||||
@@ -65,9 +73,9 @@ export default function ReadableView({ link }: Props) {
|
|||||||
}, [link]);
|
}, [link]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (link) getLink.mutateAsync({ id: link.id as number });
|
if (link) getLink.mutateAsync(link?.id as number);
|
||||||
|
|
||||||
let interval: NodeJS.Timeout | null = null;
|
let interval: any;
|
||||||
if (
|
if (
|
||||||
link &&
|
link &&
|
||||||
(link?.image === "pending" ||
|
(link?.image === "pending" ||
|
||||||
@@ -80,10 +88,7 @@ export default function ReadableView({ link }: Props) {
|
|||||||
!link?.monolith)
|
!link?.monolith)
|
||||||
) {
|
) {
|
||||||
interval = setInterval(
|
interval = setInterval(
|
||||||
() =>
|
() => getLink.mutateAsync(link.id as number),
|
||||||
getLink.mutateAsync({
|
|
||||||
id: link.id as number,
|
|
||||||
}),
|
|
||||||
5000
|
5000
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -181,7 +186,7 @@ export default function ReadableView({ link }: Props) {
|
|||||||
link?.name || link?.description || link?.url || ""
|
link?.name || link?.description || link?.url || ""
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{link?.url && (
|
{link?.url ? (
|
||||||
<Link
|
<Link
|
||||||
href={link?.url || ""}
|
href={link?.url || ""}
|
||||||
title={link?.url}
|
title={link?.url}
|
||||||
@@ -190,10 +195,11 @@ export default function ReadableView({ link }: Props) {
|
|||||||
>
|
>
|
||||||
<i className="bi-link-45deg"></i>
|
<i className="bi-link-45deg"></i>
|
||||||
|
|
||||||
{isValidUrl(link?.url || "") &&
|
{isValidUrl(link?.url || "")
|
||||||
new URL(link?.url as string).host}
|
? new URL(link?.url as string).host
|
||||||
|
: undefined}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -202,21 +208,10 @@ export default function ReadableView({ link }: Props) {
|
|||||||
href={`/collections/${link?.collection.id}`}
|
href={`/collections/${link?.collection.id}`}
|
||||||
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
|
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
|
||||||
>
|
>
|
||||||
{link.collection.icon ? (
|
<i
|
||||||
<Icon
|
className="bi-folder-fill drop-shadow text-2xl"
|
||||||
icon={link.collection.icon}
|
style={{ color: link?.collection.color }}
|
||||||
size={30}
|
></i>
|
||||||
weight={
|
|
||||||
(link.collection.iconWeight || "regular") as IconWeight
|
|
||||||
}
|
|
||||||
color={link.collection.color}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<i
|
|
||||||
className="bi-folder-fill text-2xl"
|
|
||||||
style={{ color: link.collection.color }}
|
|
||||||
></i>
|
|
||||||
)}
|
|
||||||
<p
|
<p
|
||||||
title={link?.collection.name}
|
title={link?.collection.name}
|
||||||
className="text-lg truncate max-w-[12rem]"
|
className="text-lg truncate max-w-[12rem]"
|
||||||
@@ -248,6 +243,13 @@ export default function ReadableView({ link }: Props) {
|
|||||||
|
|
||||||
{link?.name ? <p>{unescapeString(link?.description)}</p> : undefined}
|
{link?.name ? <p>{unescapeString(link?.description)}</p> : undefined}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LinkActions
|
||||||
|
link={link}
|
||||||
|
collection={collection}
|
||||||
|
position="top-3 right-3"
|
||||||
|
alignToTop
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-5 h-full">
|
<div className="flex flex-col gap-5 h-full">
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useUser } from "@/hooks/store/user";
|
|
||||||
|
|
||||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const LINKWARDEN_VERSION = process.env.version;
|
const LINKWARDEN_VERSION = process.env.version;
|
||||||
|
|
||||||
const { data: user } = useUser();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [active, setActive] = useState("");
|
const [active, setActive] = useState("");
|
||||||
|
|
||||||
@@ -76,7 +73,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && (
|
{process.env.NEXT_PUBLIC_STRIPE && (
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
|
|||||||
@@ -1,34 +1,28 @@
|
|||||||
import useLocalSettingsStore from "@/store/localSettings";
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
import { useEffect, useState, ChangeEvent } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
align?: "left" | "right";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ToggleDarkMode({ className, align }: Props) {
|
export default function ToggleDarkMode({ className }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings, updateSettings } = useLocalSettingsStore();
|
const { settings, updateSettings } = useLocalSettingsStore();
|
||||||
|
|
||||||
const [theme, setTheme] = useState<string | null>(
|
const [theme, setTheme] = useState(localStorage.getItem("theme"));
|
||||||
localStorage.getItem("theme")
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleToggle = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleToggle = (e: any) => {
|
||||||
setTheme(e.target.checked ? "dark" : "light");
|
setTheme(e.target.checked ? "dark" : "light");
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme) {
|
updateSettings({ theme: theme as string });
|
||||||
updateSettings({ theme });
|
|
||||||
}
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("tooltip", align ? `tooltip-${align}` : "tooltip-bottom")}
|
className="tooltip tooltip-bottom"
|
||||||
data-tip={t("switch_to", {
|
data-tip={t("switch_to", {
|
||||||
theme: settings.theme === "light" ? "Dark" : "Light",
|
theme: settings.theme === "light" ? "Dark" : "Light",
|
||||||
})}
|
})}
|
||||||
@@ -40,7 +34,7 @@ export default function ToggleDarkMode({ className, align }: Props) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
onChange={handleToggle}
|
onChange={handleToggle}
|
||||||
className="theme-controller"
|
className="theme-controller"
|
||||||
checked={theme === "dark"}
|
checked={localStorage.getItem("theme") === "light" ? false : true}
|
||||||
/>
|
/>
|
||||||
<i className="bi-sun-fill text-xl swap-on"></i>
|
<i className="bi-sun-fill text-xl swap-on"></i>
|
||||||
<i className="bi-moon-fill text-xl swap-off"></i>
|
<i className="bi-moon-fill text-xl swap-off"></i>
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ const UserListing = (
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{deleteUserModal.isOpen && deleteUserModal.userId && (
|
{deleteUserModal.isOpen && deleteUserModal.userId ? (
|
||||||
<DeleteUserModal
|
<DeleteUserModal
|
||||||
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
|
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
|
||||||
userId={deleteUserModal.userId}
|
userId={deleteUserModal.userId}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-6
@@ -2,17 +2,19 @@ import axios, { AxiosError } from "axios"
|
|||||||
|
|
||||||
axios.defaults.baseURL = "http://localhost:3000"
|
axios.defaults.baseURL = "http://localhost:3000"
|
||||||
|
|
||||||
export async function seedUser(username?: string, password?: string, name?: string) {
|
export async function seedUser (username?: string, password?: string, name?: string) {
|
||||||
try {
|
try {
|
||||||
return await axios.post("/api/v1/users", {
|
return await axios.post("/api/v1/users", {
|
||||||
username: username || "test",
|
username: username || "test",
|
||||||
password: password || "password",
|
password: password || "password",
|
||||||
name: name || "Test User",
|
name: name || "Test User",
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (e: any) {
|
||||||
const axiosError = error as AxiosError;
|
if (e instanceof AxiosError) {
|
||||||
if (axiosError && axiosError.response?.status === 400) return
|
if (e.response?.status === 400) {
|
||||||
|
return
|
||||||
throw error
|
}
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,9 @@ const useUsers = () => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch("/api/v1/users");
|
const response = await fetch("/api/v1/users");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
}
|
||||||
throw new Error("Failed to fetch users.");
|
throw new Error("Failed to fetch users.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +30,8 @@ const useAddUser = () => {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: any) => {
|
mutationFn: async (body: any) => {
|
||||||
|
if (body.password.length < 8) throw new Error(t("password_length_error"));
|
||||||
|
|
||||||
const load = toast.loading(t("creating_account"));
|
const load = toast.loading(t("creating_account"));
|
||||||
|
|
||||||
const response = await fetch("/api/v1/users", {
|
const response = await fetch("/api/v1/users", {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
@@ -6,11 +7,11 @@ const useDashboardData = () => {
|
|||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboardData"],
|
queryKey: ["dashboardData"],
|
||||||
queryFn: async () => {
|
queryFn: async (): Promise<LinkIncludingShortenedCollectionAndTags[]> => {
|
||||||
const response = await fetch("/api/v2/dashboard");
|
const response = await fetch("/api/v1/dashboard");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return data.data;
|
return data.response;
|
||||||
},
|
},
|
||||||
enabled: status === "authenticated",
|
enabled: status === "authenticated",
|
||||||
});
|
});
|
||||||
|
|||||||
+18
-126
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "@/types/global";
|
} from "@/types/global";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
const useLinks = (params: LinkRequestQuery = {}) => {
|
const useLinks = (params: LinkRequestQuery = {}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -104,15 +103,7 @@ const useAddLink = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (link: PostLinkSchemaType) => {
|
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
|
||||||
if (link.url || link.type === "url") {
|
|
||||||
try {
|
|
||||||
new URL(link.url || "");
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error("invalid_url_guide");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch("/api/v1/links", {
|
const response = await fetch("/api/v1/links", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -129,11 +120,8 @@ const useAddLink = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
if (!oldData?.links) return undefined;
|
if (!oldData) return undefined;
|
||||||
return {
|
return [data, ...oldData];
|
||||||
...oldData,
|
|
||||||
links: [data, ...oldData?.links],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
||||||
@@ -172,8 +160,8 @@ const useUpdateLink = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
// queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
// queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
// if (!oldData?.links) return undefined;
|
// if (!oldData) return undefined;
|
||||||
// return oldData.links.map((e: any) => (e.id === data.id ? data : e));
|
// return oldData.map((e: any) => (e.id === data.id ? data : e));
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
// queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
||||||
@@ -213,11 +201,8 @@ const useDeleteLink = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
if (!oldData?.links) return undefined;
|
if (!oldData) return undefined;
|
||||||
return {
|
return oldData.filter((e: any) => e.id !== data.id);
|
||||||
...oldData,
|
|
||||||
links: oldData.links.filter((e: any) => e.id !== data.id),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
||||||
@@ -240,21 +225,9 @@ const useDeleteLink = () => {
|
|||||||
const useGetLink = () => {
|
const useGetLink = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async (id: number) => {
|
||||||
id,
|
const response = await fetch(`/api/v1/links/${id}`);
|
||||||
isPublicRoute = router.pathname.startsWith("/public") ? true : undefined,
|
|
||||||
}: {
|
|
||||||
id: number;
|
|
||||||
isPublicRoute?: boolean;
|
|
||||||
}) => {
|
|
||||||
const path = isPublicRoute
|
|
||||||
? `/api/v1/public/links/${id}`
|
|
||||||
: `/api/v1/links/${id}`;
|
|
||||||
|
|
||||||
const response = await fetch(path);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) throw new Error(data.response);
|
if (!response.ok) throw new Error(data.response);
|
||||||
@@ -263,11 +236,8 @@ const useGetLink = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
if (!oldData?.links) return undefined;
|
if (!oldData) return undefined;
|
||||||
return {
|
return oldData.map((e: any) => (e.id === data.id ? data : e));
|
||||||
...oldData,
|
|
||||||
links: oldData.links.map((e: any) => (e.id === data.id ? data : e)),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
||||||
@@ -280,20 +250,7 @@ const useGetLink = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueriesData(
|
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
|
||||||
{ queryKey: ["publicLinks"] },
|
|
||||||
(oldData: any) => {
|
|
||||||
if (!oldData) return undefined;
|
|
||||||
return {
|
|
||||||
pages: oldData.pages.map((page: any) =>
|
|
||||||
page.map((item: any) => (item.id === data.id ? data : item))
|
|
||||||
),
|
|
||||||
pageParams: oldData.pageParams,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -319,8 +276,8 @@ const useBulkDeleteLinks = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
if (!oldData?.links) return undefined;
|
if (!oldData) return undefined;
|
||||||
return oldData.links.filter((e: any) => !data.includes(e.id));
|
return oldData.filter((e: any) => !data.includes(e.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
||||||
@@ -394,11 +351,8 @@ const useUploadFile = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
if (!oldData?.links) return undefined;
|
if (!oldData) return undefined;
|
||||||
return {
|
return [data, ...oldData];
|
||||||
...oldData,
|
|
||||||
links: [data, ...oldData?.links],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
||||||
@@ -416,67 +370,6 @@ const useUploadFile = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const useUpdatePreview = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({ linkId, file }: { linkId: number; file: File }) => {
|
|
||||||
const formBody = new FormData();
|
|
||||||
|
|
||||||
if (!linkId || !file)
|
|
||||||
throw new Error("Error generating preview: Invalid parameters");
|
|
||||||
|
|
||||||
formBody.append("file", file);
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/v1/archives/${linkId}?format=` + ArchivedFormat.jpeg,
|
|
||||||
{
|
|
||||||
body: formBody,
|
|
||||||
method: "PUT",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = res.json();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
|
||||||
if (!oldData?.links) return undefined;
|
|
||||||
return {
|
|
||||||
...oldData,
|
|
||||||
links: oldData.links.map((e: any) =>
|
|
||||||
e.id === data.response.id
|
|
||||||
? {
|
|
||||||
...e,
|
|
||||||
preview: `archives/preview/${e.collectionId}/${e.id}.jpeg`,
|
|
||||||
}
|
|
||||||
: e
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
|
|
||||||
if (!oldData) return undefined;
|
|
||||||
return {
|
|
||||||
pages: oldData.pages.map((page: any) =>
|
|
||||||
page.map((item: any) =>
|
|
||||||
item.id === data.response.id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
preview: `archives/preview/${item.collectionId}/${item.id}.jpeg`,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
),
|
|
||||||
pageParams: oldData.pageParams,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const useBulkEditLinks = () => {
|
const useBulkEditLinks = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -510,8 +403,8 @@ const useBulkEditLinks = () => {
|
|||||||
onSuccess: (data, { links, newData, removePreviousTags }) => {
|
onSuccess: (data, { links, newData, removePreviousTags }) => {
|
||||||
// TODO: Fix these
|
// TODO: Fix these
|
||||||
// queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
// queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||||
// if (!oldData?.links) return undefined;
|
// if (!oldData) return undefined;
|
||||||
// return oldData.links.map((e: any) =>
|
// return oldData.map((e: any) =>
|
||||||
// data.find((d: any) => d.id === e.id) ? data : e
|
// data.find((d: any) => d.id === e.id) ? data : e
|
||||||
// );
|
// );
|
||||||
// });
|
// });
|
||||||
@@ -561,5 +454,4 @@ export {
|
|||||||
useGetLink,
|
useGetLink,
|
||||||
useBulkEditLinks,
|
useBulkEditLinks,
|
||||||
resetInfiniteQueryPagination,
|
resetInfiniteQueryPagination,
|
||||||
useUpdatePreview,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ const useAddToken = () => {
|
|||||||
const response = await fetch("/api/v1/tokens", {
|
const response = await fetch("/api/v1/tokens", {
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ export default function AuthRedirect({ children }: Props) {
|
|||||||
const isUnauthenticated = status === "unauthenticated";
|
const isUnauthenticated = status === "unauthenticated";
|
||||||
const isPublicPage = router.pathname.startsWith("/public");
|
const isPublicPage = router.pathname.startsWith("/public");
|
||||||
const hasInactiveSubscription =
|
const hasInactiveSubscription =
|
||||||
user.id &&
|
user.id && !user.subscription?.active && stripeEnabled;
|
||||||
!user.subscription?.active &&
|
|
||||||
!user.parentSubscription?.active &&
|
|
||||||
stripeEnabled;
|
|
||||||
|
|
||||||
// There are better ways of doing this... but this one works for now
|
// There are better ways of doing this... but this one works for now
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -52,8 +49,6 @@ export default function AuthRedirect({ children }: Props) {
|
|||||||
} else {
|
} else {
|
||||||
if (isLoggedIn && hasInactiveSubscription) {
|
if (isLoggedIn && hasInactiveSubscription) {
|
||||||
redirectTo("/subscribe");
|
redirectTo("/subscribe");
|
||||||
} else if (isLoggedIn && !user.name && user.parentSubscriptionId) {
|
|
||||||
redirectTo("/member-onboarding");
|
|
||||||
} else if (
|
} else if (
|
||||||
isLoggedIn &&
|
isLoggedIn &&
|
||||||
!routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected)
|
!routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function CenteredForm({
|
|||||||
data-testid={dataTestId}
|
data-testid={dataTestId}
|
||||||
>
|
>
|
||||||
<div className="m-auto flex flex-col gap-2 w-full">
|
<div className="m-auto flex flex-col gap-2 w-full">
|
||||||
{settings.theme && (
|
{settings.theme ? (
|
||||||
<Image
|
<Image
|
||||||
src={`/linkwarden_${
|
src={`/linkwarden_${
|
||||||
settings.theme === "dark" ? "dark" : "light"
|
settings.theme === "dark" ? "dark" : "light"
|
||||||
@@ -33,12 +33,12 @@ export default function CenteredForm({
|
|||||||
alt="Linkwarden"
|
alt="Linkwarden"
|
||||||
className="h-12 w-fit mx-auto"
|
className="h-12 w-fit mx-auto"
|
||||||
/>
|
/>
|
||||||
)}
|
) : undefined}
|
||||||
{text && (
|
{text ? (
|
||||||
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold px-2 text-center">
|
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold px-2 text-center">
|
||||||
{text}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : undefined}
|
||||||
{children}
|
{children}
|
||||||
<p className="text-center text-xs text-neutral mb-5">
|
<p className="text-center text-xs text-neutral mb-5">
|
||||||
<Trans
|
<Trans
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ export default function MainLayout({ children }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex" data-testid="dashboard-wrapper">
|
<div className="flex" data-testid="dashboard-wrapper">
|
||||||
{showAnnouncement && (
|
{showAnnouncement ? (
|
||||||
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
|
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
|
||||||
)}
|
) : undefined}
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<Sidebar className={`fixed top-0`} />
|
<Sidebar className={`fixed top-0`} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function SettingsLayout({ children }: Props) {
|
|||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{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-30">
|
<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-30">
|
||||||
<ClickAwayHandler
|
<ClickAwayHandler
|
||||||
className="h-full"
|
className="h-full"
|
||||||
@@ -65,7 +65,7 @@ export default function SettingsLayout({ children }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</ClickAwayHandler>
|
</ClickAwayHandler>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID;
|
||||||
|
const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID;
|
||||||
|
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||||
|
|
||||||
|
export default async function checkSubscriptionByEmail(email: string) {
|
||||||
|
let active: boolean | undefined,
|
||||||
|
stripeSubscriptionId: string | undefined,
|
||||||
|
currentPeriodStart: number | undefined,
|
||||||
|
currentPeriodEnd: number | undefined;
|
||||||
|
|
||||||
|
if (!STRIPE_SECRET_KEY)
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
||||||
|
apiVersion: "2022-11-15",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Request made to Stripe by:", email);
|
||||||
|
const listByEmail = await stripe.customers.list({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
expand: ["data.subscriptions"],
|
||||||
|
});
|
||||||
|
|
||||||
|
listByEmail.data.some((customer) => {
|
||||||
|
customer.subscriptions?.data.some((subscription) => {
|
||||||
|
subscription.current_period_end;
|
||||||
|
|
||||||
|
active =
|
||||||
|
subscription.items.data.some(
|
||||||
|
(e) =>
|
||||||
|
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
|
||||||
|
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
|
||||||
|
) || false;
|
||||||
|
stripeSubscriptionId = subscription.id;
|
||||||
|
currentPeriodStart = subscription.current_period_start * 1000;
|
||||||
|
currentPeriodEnd = subscription.current_period_end * 1000;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: active || false,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,31 +1,15 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
import getPermission from "@/lib/api/getPermission";
|
||||||
import {
|
|
||||||
UpdateCollectionSchema,
|
|
||||||
UpdateCollectionSchemaType,
|
|
||||||
} from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
export default async function updateCollection(
|
export default async function updateCollection(
|
||||||
userId: number,
|
userId: number,
|
||||||
collectionId: number,
|
collectionId: number,
|
||||||
body: UpdateCollectionSchemaType
|
data: CollectionIncludingMembersAndLinkCount
|
||||||
) {
|
) {
|
||||||
if (!collectionId)
|
if (!collectionId)
|
||||||
return { response: "Please choose a valid collection.", status: 401 };
|
return { response: "Please choose a valid collection.", status: 401 };
|
||||||
|
|
||||||
const dataValidation = UpdateCollectionSchema.safeParse(body);
|
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
|
||||||
return {
|
|
||||||
response: `Error: ${
|
|
||||||
dataValidation.error.issues[0].message
|
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = dataValidation.data;
|
|
||||||
|
|
||||||
const collectionIsAccessible = await getPermission({
|
const collectionIsAccessible = await getPermission({
|
||||||
userId,
|
userId,
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -34,8 +18,10 @@ 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) {
|
||||||
if (data.parentId !== "root") {
|
if (data.parentId !== ("root" as any)) {
|
||||||
const findParentCollection = await prisma.collection.findUnique({
|
const findParentCollection = await prisma.collection.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: data.parentId,
|
id: data.parentId,
|
||||||
@@ -58,12 +44,6 @@ export default async function updateCollection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueMembers = data.members.filter(
|
|
||||||
(e, i, a) =>
|
|
||||||
a.findIndex((el) => el.userId === e.userId) === i &&
|
|
||||||
e.userId !== collectionIsAccessible.ownerId
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedCollection = await prisma.$transaction(async () => {
|
const updatedCollection = await prisma.$transaction(async () => {
|
||||||
await prisma.usersAndCollections.deleteMany({
|
await prisma.usersAndCollections.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -81,24 +61,22 @@ export default async function updateCollection(
|
|||||||
name: data.name.trim(),
|
name: data.name.trim(),
|
||||||
description: data.description,
|
description: data.description,
|
||||||
color: data.color,
|
color: data.color,
|
||||||
icon: data.icon,
|
|
||||||
iconWeight: data.iconWeight,
|
|
||||||
isPublic: data.isPublic,
|
isPublic: data.isPublic,
|
||||||
parent:
|
parent:
|
||||||
data.parentId && data.parentId !== "root"
|
data.parentId && data.parentId !== ("root" as any)
|
||||||
? {
|
? {
|
||||||
connect: {
|
connect: {
|
||||||
id: data.parentId,
|
id: data.parentId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: data.parentId === "root"
|
: data.parentId === ("root" as any)
|
||||||
? {
|
? {
|
||||||
disconnect: true,
|
disconnect: true,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
members: {
|
members: {
|
||||||
create: uniqueMembers.map((e) => ({
|
create: data.members.map((e) => ({
|
||||||
user: { connect: { id: e.userId } },
|
user: { connect: { id: e.user.id || e.userId } },
|
||||||
canCreate: e.canCreate,
|
canCreate: e.canCreate,
|
||||||
canUpdate: e.canUpdate,
|
canUpdate: e.canUpdate,
|
||||||
canDelete: e.canDelete,
|
canDelete: e.canDelete,
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import {
|
|
||||||
PostCollectionSchema,
|
|
||||||
PostCollectionSchemaType,
|
|
||||||
} from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
export default async function postCollection(
|
export default async function postCollection(
|
||||||
body: PostCollectionSchemaType,
|
collection: CollectionIncludingMembersAndLinkCount,
|
||||||
userId: number
|
userId: number
|
||||||
) {
|
) {
|
||||||
const dataValidation = PostCollectionSchema.safeParse(body);
|
if (!collection || collection.name.trim() === "")
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
|
||||||
return {
|
return {
|
||||||
response: `Error: ${
|
response: "Please enter a valid collection.",
|
||||||
dataValidation.error.issues[0].message
|
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const collection = dataValidation.data;
|
|
||||||
|
|
||||||
if (collection.parentId) {
|
if (collection.parentId) {
|
||||||
const findParentCollection = await prisma.collection.findUnique({
|
const findParentCollection = await prisma.collection.findUnique({
|
||||||
@@ -44,11 +34,14 @@ export default async function postCollection(
|
|||||||
|
|
||||||
const newCollection = await prisma.collection.create({
|
const newCollection = await prisma.collection.create({
|
||||||
data: {
|
data: {
|
||||||
|
owner: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
name: collection.name.trim(),
|
name: collection.name.trim(),
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
color: collection.color,
|
color: collection.color,
|
||||||
icon: collection.icon,
|
|
||||||
iconWeight: collection.iconWeight,
|
|
||||||
parent: collection.parentId
|
parent: collection.parentId
|
||||||
? {
|
? {
|
||||||
connect: {
|
connect: {
|
||||||
@@ -56,16 +49,6 @@ export default async function postCollection(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
owner: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createdBy: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { LinkRequestQuery, Order, Sort } from "@/types/global";
|
import { LinkRequestQuery, Sort } from "@/types/global";
|
||||||
|
|
||||||
export default async function getDashboardData(
|
export default async function getDashboardData(
|
||||||
userId: number,
|
userId: number,
|
||||||
query: LinkRequestQuery
|
query: LinkRequestQuery
|
||||||
) {
|
) {
|
||||||
let order: Order = { id: "desc" };
|
let order: any = { id: "desc" };
|
||||||
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||||
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { LinkRequestQuery, Order, Sort } from "@/types/global";
|
import { LinkRequestQuery, Sort } from "@/types/global";
|
||||||
|
|
||||||
type Response<D> =
|
type Response<D> =
|
||||||
| {
|
| {
|
||||||
@@ -17,7 +17,7 @@ export default async function getDashboardData(
|
|||||||
userId: number,
|
userId: number,
|
||||||
query: LinkRequestQuery
|
query: LinkRequestQuery
|
||||||
): Promise<Response<any>> {
|
): Promise<Response<any>> {
|
||||||
let order: Order = { id: "desc" };
|
let order: any;
|
||||||
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||||
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||||
@@ -48,7 +48,7 @@ export default async function getDashboardData(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pinnedLinks = await prisma.link.findMany({
|
const pinnedLinks = await prisma.link.findMany({
|
||||||
take: 16,
|
take: 10,
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
@@ -80,7 +80,7 @@ export default async function getDashboardData(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const recentlyAddedLinks = await prisma.link.findMany({
|
const recentlyAddedLinks = await prisma.link.findMany({
|
||||||
take: 16,
|
take: 10,
|
||||||
where: {
|
where: {
|
||||||
collection: {
|
collection: {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -105,17 +105,12 @@ export default async function getDashboardData(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
|
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
|
||||||
(a, b) => new Date(b.id).getTime() - new Date(a.id).getTime()
|
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any)
|
||||||
);
|
|
||||||
|
|
||||||
// Make sure links are unique
|
|
||||||
const uniqueLinks = links.filter(
|
|
||||||
(link, index, self) => index === self.findIndex((t) => t.id === link.id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
links: uniqueLinks,
|
links,
|
||||||
numberOfPinnedLinks,
|
numberOfPinnedLinks,
|
||||||
},
|
},
|
||||||
message: "Dashboard data fetched successfully.",
|
message: "Dashboard data fetched successfully.",
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
import updateLinkById from "../linkId/updateLinkById";
|
import updateLinkById from "../linkId/updateLinkById";
|
||||||
import { UpdateLinkSchemaType } from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
export default async function updateLinks(
|
export default async function updateLinks(
|
||||||
userId: number,
|
userId: number,
|
||||||
links: UpdateLinkSchemaType[],
|
links: LinkIncludingShortenedCollectionAndTags[],
|
||||||
removePreviousTags: boolean,
|
removePreviousTags: boolean,
|
||||||
newData: Pick<
|
newData: Pick<
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
@@ -23,7 +22,7 @@ export default async function updateLinks(
|
|||||||
updatedTags = [...(newData.tags ?? [])];
|
updatedTags = [...(newData.tags ?? [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedData: UpdateLinkSchemaType = {
|
const updatedData: LinkIncludingShortenedCollectionAndTags = {
|
||||||
...link,
|
...link,
|
||||||
tags: updatedTags,
|
tags: updatedTags,
|
||||||
collection: {
|
collection: {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { LinkRequestQuery, Order, Sort } from "@/types/global";
|
import { LinkRequestQuery, Sort } from "@/types/global";
|
||||||
|
|
||||||
export default async function getLink(userId: number, query: LinkRequestQuery) {
|
export default async function getLink(userId: number, query: LinkRequestQuery) {
|
||||||
const POSTGRES_IS_ENABLED =
|
const POSTGRES_IS_ENABLED =
|
||||||
process.env.DATABASE_URL?.startsWith("postgresql");
|
process.env.DATABASE_URL?.startsWith("postgresql");
|
||||||
|
|
||||||
let order: Order = { id: "desc" };
|
let order: any = { id: "desc" };
|
||||||
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||||
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
import { UsersAndCollections } from "@prisma/client";
|
import { UsersAndCollections } from "@prisma/client";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
import getPermission from "@/lib/api/getPermission";
|
||||||
import { moveFiles, removeFiles } from "@/lib/api/manageLinkFiles";
|
import { moveFiles } from "@/lib/api/manageLinkFiles";
|
||||||
import isValidUrl from "@/lib/shared/isValidUrl";
|
|
||||||
import {
|
|
||||||
UpdateLinkSchema,
|
|
||||||
UpdateLinkSchemaType,
|
|
||||||
} from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
export default async function updateLinkById(
|
export default async function updateLinkById(
|
||||||
userId: number,
|
userId: number,
|
||||||
linkId: number,
|
linkId: number,
|
||||||
body: UpdateLinkSchemaType
|
data: LinkIncludingShortenedCollectionAndTags
|
||||||
) {
|
) {
|
||||||
const dataValidation = UpdateLinkSchema.safeParse(body);
|
if (!data || !data.collection.id)
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
|
||||||
return {
|
return {
|
||||||
response: `Error: ${
|
response: "Please choose a valid link and collection.",
|
||||||
dataValidation.error.issues[0].message
|
status: 401,
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
status: 400,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const data = dataValidation.data;
|
|
||||||
|
|
||||||
const collectionIsAccessible = await getPermission({ userId, linkId });
|
const collectionIsAccessible = await getPermission({ userId, linkId });
|
||||||
|
|
||||||
@@ -36,18 +25,17 @@ export default async function updateLinkById(
|
|||||||
(e: UsersAndCollections) => e.userId === userId
|
(e: UsersAndCollections) => e.userId === userId
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the user is part of a collection, they can pin it to their dashboard
|
// If the user is able to create a link, they can pin it to their dashboard only.
|
||||||
if (canPinPermission && data.pinnedBy && data.pinnedBy[0]) {
|
if (canPinPermission) {
|
||||||
const updatedLink = await prisma.link.update({
|
const updatedLink = await prisma.link.update({
|
||||||
where: {
|
where: {
|
||||||
id: linkId,
|
id: linkId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
pinnedBy: data?.pinnedBy
|
pinnedBy:
|
||||||
? data.pinnedBy[0]?.id === userId
|
data?.pinnedBy && data.pinnedBy[0]
|
||||||
? { connect: { id: userId } }
|
? { connect: { id: userId } }
|
||||||
: { disconnect: { id: userId } }
|
: { disconnect: { id: userId } },
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
collection: true,
|
collection: true,
|
||||||
@@ -60,7 +48,7 @@ export default async function updateLinkById(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { response: updatedLink, status: 200 };
|
// return { response: updatedLink, status: 200 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetCollectionIsAccessible = await getPermission({
|
const targetCollectionIsAccessible = await getPermission({
|
||||||
@@ -74,9 +62,11 @@ export default async function updateLinkById(
|
|||||||
|
|
||||||
const targetCollectionMatchesData = data.collection.id
|
const targetCollectionMatchesData = data.collection.id
|
||||||
? data.collection.id === targetCollectionIsAccessible?.id
|
? data.collection.id === targetCollectionIsAccessible?.id
|
||||||
: true && data.collection.ownerId
|
: true && data.collection.name
|
||||||
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
|
? data.collection.name === targetCollectionIsAccessible?.name
|
||||||
: true;
|
: true && data.collection.ownerId
|
||||||
|
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
|
||||||
|
: true;
|
||||||
|
|
||||||
if (!targetCollectionMatchesData)
|
if (!targetCollectionMatchesData)
|
||||||
return {
|
return {
|
||||||
@@ -99,41 +89,13 @@ export default async function updateLinkById(
|
|||||||
status: 401,
|
status: 401,
|
||||||
};
|
};
|
||||||
else {
|
else {
|
||||||
const oldLink = await prisma.link.findUnique({
|
|
||||||
where: {
|
|
||||||
id: linkId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
data.url &&
|
|
||||||
oldLink &&
|
|
||||||
oldLink?.url !== data.url &&
|
|
||||||
isValidUrl(data.url)
|
|
||||||
) {
|
|
||||||
await removeFiles(oldLink.id, oldLink.collectionId);
|
|
||||||
} else if (oldLink?.url !== data.url)
|
|
||||||
return {
|
|
||||||
response: "Invalid URL.",
|
|
||||||
status: 401,
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedLink = await prisma.link.update({
|
const updatedLink = await prisma.link.update({
|
||||||
where: {
|
where: {
|
||||||
id: linkId,
|
id: linkId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
url: data.url,
|
|
||||||
description: data.description,
|
description: data.description,
|
||||||
icon: data.icon,
|
|
||||||
iconWeight: data.iconWeight,
|
|
||||||
color: data.color,
|
|
||||||
image: oldLink?.url !== data.url ? null : undefined,
|
|
||||||
pdf: oldLink?.url !== data.url ? null : undefined,
|
|
||||||
readable: oldLink?.url !== data.url ? null : undefined,
|
|
||||||
monolith: oldLink?.url !== data.url ? null : undefined,
|
|
||||||
preview: oldLink?.url !== data.url ? null : undefined,
|
|
||||||
collection: {
|
collection: {
|
||||||
connect: {
|
connect: {
|
||||||
id: data.collection.id,
|
id: data.collection.id,
|
||||||
@@ -158,11 +120,10 @@ export default async function updateLinkById(
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
pinnedBy: data?.pinnedBy
|
pinnedBy:
|
||||||
? data.pinnedBy[0]?.id === userId
|
data?.pinnedBy && data.pinnedBy[0]
|
||||||
? { connect: { id: userId } }
|
? { connect: { id: userId } }
|
||||||
: { disconnect: { id: userId } }
|
: { disconnect: { id: userId } },
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
tags: true,
|
tags: true,
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
import fetchTitleAndHeaders from "@/lib/shared/fetchTitleAndHeaders";
|
import fetchTitleAndHeaders from "@/lib/shared/fetchTitleAndHeaders";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import setLinkCollection from "../../setLinkCollection";
|
import setLinkCollection from "../../setLinkCollection";
|
||||||
import {
|
|
||||||
PostLinkSchema,
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
PostLinkSchemaType,
|
|
||||||
} from "@/lib/shared/schemaValidation";
|
|
||||||
import { hasPassedLimit } from "../../verifyCapacity";
|
|
||||||
|
|
||||||
export default async function postLink(
|
export default async function postLink(
|
||||||
body: PostLinkSchemaType,
|
link: LinkIncludingShortenedCollectionAndTags,
|
||||||
userId: number
|
userId: number
|
||||||
) {
|
) {
|
||||||
const dataValidation = PostLinkSchema.safeParse(body);
|
if (link.url || link.type === "url") {
|
||||||
|
try {
|
||||||
if (!dataValidation.success) {
|
new URL(link.url || "");
|
||||||
return {
|
} catch (error) {
|
||||||
response: `Error: ${
|
return {
|
||||||
dataValidation.error.issues[0].message
|
response:
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
"Please enter a valid Address for the Link. (It should start with http/https)",
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = dataValidation.data;
|
|
||||||
|
|
||||||
const linkCollection = await setLinkCollection(link, userId);
|
const linkCollection = await setLinkCollection(link, userId);
|
||||||
|
|
||||||
if (!linkCollection)
|
if (!linkCollection)
|
||||||
@@ -58,14 +55,19 @@ export default async function postLink(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasTooManyLinks = await hasPassedLimit(userId, 1);
|
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
collection: {
|
||||||
|
ownerId: linkCollection.ownerId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (hasTooManyLinks) {
|
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||||
return {
|
return {
|
||||||
response: `Your subscription have reached the maximum number of links allowed.`,
|
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const { title, headers } = await fetchTitleAndHeaders(link.url || "");
|
const { title, headers } = await fetchTitleAndHeaders(link.url || "");
|
||||||
|
|
||||||
@@ -92,11 +94,6 @@ export default async function postLink(
|
|||||||
name,
|
name,
|
||||||
description: link.description,
|
description: link.description,
|
||||||
type: linkType,
|
type: linkType,
|
||||||
createdBy: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
collection: {
|
collection: {
|
||||||
connect: {
|
connect: {
|
||||||
id: linkCollection.id,
|
id: linkCollection.id,
|
||||||
|
|||||||
@@ -22,5 +22,18 @@ export default async function exportData(userId: number) {
|
|||||||
|
|
||||||
const { password, id, ...userData } = user;
|
const { password, id, ...userData } = user;
|
||||||
|
|
||||||
|
function redactIds(obj: any) {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
obj.forEach((o) => redactIds(o));
|
||||||
|
} else if (obj !== null && typeof obj === "object") {
|
||||||
|
delete obj.id;
|
||||||
|
for (let key in obj) {
|
||||||
|
redactIds(obj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redactIds(userData);
|
||||||
|
|
||||||
return { response: userData, status: 200 };
|
return { response: userData, status: 200 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ 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 { parse, Node, Element, TextNode } from "himalaya";
|
||||||
import { hasPassedLimit } from "../../verifyCapacity";
|
import { writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
|
||||||
export default async function importFromHTMLFile(
|
export default async function importFromHTMLFile(
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -19,14 +21,19 @@ export default async function importFromHTMLFile(
|
|||||||
const bookmarks = document.querySelectorAll("A");
|
const bookmarks = document.querySelectorAll("A");
|
||||||
const totalImports = bookmarks.length;
|
const totalImports = bookmarks.length;
|
||||||
|
|
||||||
const hasTooManyLinks = await hasPassedLimit(userId, totalImports);
|
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
collection: {
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (hasTooManyLinks) {
|
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||||
return {
|
return {
|
||||||
response: `Your subscription have reached the maximum number of links allowed.`,
|
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const jsonData = parse(document.documentElement.outerHTML);
|
const jsonData = parse(document.documentElement.outerHTML);
|
||||||
|
|
||||||
@@ -148,8 +155,6 @@ const createCollection = async (
|
|||||||
collectionName: string,
|
collectionName: string,
|
||||||
parentId?: number
|
parentId?: number
|
||||||
) => {
|
) => {
|
||||||
collectionName = collectionName.trim().slice(0, 254);
|
|
||||||
|
|
||||||
const findCollection = await prisma.collection.findFirst({
|
const findCollection = await prisma.collection.findFirst({
|
||||||
where: {
|
where: {
|
||||||
parentId,
|
parentId,
|
||||||
@@ -177,11 +182,6 @@ const createCollection = async (
|
|||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createdBy: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,49 +199,34 @@ const createLink = async (
|
|||||||
tags?: string[],
|
tags?: string[],
|
||||||
importDate?: Date
|
importDate?: Date
|
||||||
) => {
|
) => {
|
||||||
url = url.trim().slice(0, 254);
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
} catch (e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tags = tags?.map((tag) => tag.trim().slice(0, 49));
|
|
||||||
name = name?.trim().slice(0, 254);
|
|
||||||
description = description?.trim().slice(0, 254);
|
|
||||||
if (importDate) {
|
|
||||||
const dateString = importDate.toISOString();
|
|
||||||
if (dateString.length > 50) {
|
|
||||||
importDate = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.link.create({
|
await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
name: name || "",
|
name: name || "",
|
||||||
url,
|
url,
|
||||||
description,
|
description,
|
||||||
collectionId,
|
collectionId,
|
||||||
createdById: userId,
|
|
||||||
tags:
|
tags:
|
||||||
tags && tags[0]
|
tags && tags[0]
|
||||||
? {
|
? {
|
||||||
connectOrCreate: tags.map((tag: string) => {
|
connectOrCreate: tags.map((tag: string) => {
|
||||||
return {
|
return (
|
||||||
where: {
|
{
|
||||||
name_ownerId: {
|
where: {
|
||||||
name: tag.trim(),
|
name_ownerId: {
|
||||||
ownerId: userId,
|
name: tag.trim(),
|
||||||
},
|
ownerId: userId,
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: tag.trim(),
|
|
||||||
owner: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
create: {
|
||||||
};
|
name: tag.trim(),
|
||||||
|
owner: {
|
||||||
|
connect: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} || undefined
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { Backup } from "@/types/global";
|
import { Backup } from "@/types/global";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import { hasPassedLimit } from "../../verifyCapacity";
|
|
||||||
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
|
||||||
export default async function importFromLinkwarden(
|
export default async function importFromLinkwarden(
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -15,14 +16,19 @@ export default async function importFromLinkwarden(
|
|||||||
totalImports += collection.links.length;
|
totalImports += collection.links.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasTooManyLinks = await hasPassedLimit(userId, totalImports);
|
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
collection: {
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (hasTooManyLinks) {
|
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||||
return {
|
return {
|
||||||
response: `Your subscription have reached the maximum number of links allowed.`,
|
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
await prisma
|
await prisma
|
||||||
.$transaction(
|
.$transaction(
|
||||||
@@ -38,14 +44,9 @@ export default async function importFromLinkwarden(
|
|||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
name: e.name?.trim().slice(0, 254),
|
name: e.name,
|
||||||
description: e.description?.trim().slice(0, 254),
|
description: e.description,
|
||||||
color: e.color?.trim().slice(0, 50),
|
color: e.color,
|
||||||
createdBy: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,40 +54,27 @@ export default async function importFromLinkwarden(
|
|||||||
|
|
||||||
// Import Links
|
// Import Links
|
||||||
for (const link of e.links) {
|
for (const link of e.links) {
|
||||||
if (link.url) {
|
|
||||||
try {
|
|
||||||
new URL(link.url.trim());
|
|
||||||
} catch (err) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.link.create({
|
await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
url: link.url?.trim().slice(0, 254),
|
url: link.url,
|
||||||
name: link.name?.trim().slice(0, 254),
|
name: link.name,
|
||||||
description: link.description?.trim().slice(0, 254),
|
description: link.description,
|
||||||
collection: {
|
collection: {
|
||||||
connect: {
|
connect: {
|
||||||
id: newCollection.id,
|
id: newCollection.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createdBy: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Import Tags
|
// Import Tags
|
||||||
tags: {
|
tags: {
|
||||||
connectOrCreate: link.tags.map((tag) => ({
|
connectOrCreate: link.tags.map((tag) => ({
|
||||||
where: {
|
where: {
|
||||||
name_ownerId: {
|
name_ownerId: {
|
||||||
name: tag.name?.slice(0, 49),
|
name: tag.name.trim(),
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: tag.name?.trim().slice(0, 49),
|
name: tag.name.trim(),
|
||||||
owner: {
|
owner: {
|
||||||
connect: {
|
connect: {
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
|
import { Backup } from "@/types/global";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import { hasPassedLimit } from "../../verifyCapacity";
|
|
||||||
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
|
||||||
type WallabagBackup = {
|
type WallabagBackup = {
|
||||||
is_archived: number;
|
is_archived: number;
|
||||||
@@ -35,14 +37,19 @@ export default async function importFromWallabag(
|
|||||||
|
|
||||||
let totalImports = backup.length;
|
let totalImports = backup.length;
|
||||||
|
|
||||||
const hasTooManyLinks = await hasPassedLimit(userId, totalImports);
|
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
collection: {
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (hasTooManyLinks) {
|
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||||
return {
|
return {
|
||||||
response: `Your subscription have reached the maximum number of links allowed.`,
|
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
await prisma
|
await prisma
|
||||||
.$transaction(
|
.$transaction(
|
||||||
@@ -55,56 +62,38 @@ export default async function importFromWallabag(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
name: "Imports",
|
name: "Imports",
|
||||||
createdBy: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
createFolder({ filePath: `archives/${newCollection.id}` });
|
createFolder({ filePath: `archives/${newCollection.id}` });
|
||||||
|
|
||||||
for (const link of backup) {
|
for (const link of backup) {
|
||||||
if (link.url) {
|
|
||||||
try {
|
|
||||||
new URL(link.url.trim());
|
|
||||||
} catch (err) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.link.create({
|
await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
pinnedBy: link.is_starred
|
pinnedBy: link.is_starred
|
||||||
? { connect: { id: userId } }
|
? { connect: { id: userId } }
|
||||||
: undefined,
|
: undefined,
|
||||||
url: link.url?.trim().slice(0, 254),
|
url: link.url,
|
||||||
name: link.title?.trim().slice(0, 254) || "",
|
name: link.title || "",
|
||||||
textContent: link.content?.trim() || "",
|
textContent: link.content || "",
|
||||||
importDate: link.created_at || null,
|
importDate: link.created_at || null,
|
||||||
collection: {
|
collection: {
|
||||||
connect: {
|
connect: {
|
||||||
id: newCollection.id,
|
id: newCollection.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
createdBy: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tags:
|
tags:
|
||||||
link.tags && link.tags[0]
|
link.tags && link.tags[0]
|
||||||
? {
|
? {
|
||||||
connectOrCreate: link.tags.map((tag) => ({
|
connectOrCreate: link.tags.map((tag) => ({
|
||||||
where: {
|
where: {
|
||||||
name_ownerId: {
|
name_ownerId: {
|
||||||
name: tag?.trim().slice(0, 49),
|
name: tag.trim(),
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: tag?.trim().slice(0, 49),
|
name: tag.trim(),
|
||||||
owner: {
|
owner: {
|
||||||
connect: {
|
connect: {
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { LinkRequestQuery, Order, Sort } from "@/types/global";
|
import { LinkRequestQuery, Sort } from "@/types/global";
|
||||||
|
|
||||||
export default async function getLink(
|
export default async function getLink(
|
||||||
query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly">
|
query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly">
|
||||||
@@ -7,7 +7,7 @@ export default async function getLink(
|
|||||||
const POSTGRES_IS_ENABLED =
|
const POSTGRES_IS_ENABLED =
|
||||||
process.env.DATABASE_URL?.startsWith("postgresql");
|
process.env.DATABASE_URL?.startsWith("postgresql");
|
||||||
|
|
||||||
let order: Order = { id: "desc" };
|
let order: any;
|
||||||
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||||
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||||
|
|||||||
@@ -5,20 +5,13 @@ export default async function getPublicUser(
|
|||||||
isId: boolean,
|
isId: boolean,
|
||||||
requestingId?: number
|
requestingId?: number
|
||||||
) {
|
) {
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findUnique({
|
||||||
where: isId
|
where: isId
|
||||||
? {
|
? {
|
||||||
id: Number(targetId) as number,
|
id: Number(targetId) as number,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
OR: [
|
username: targetId as string,
|
||||||
{
|
|
||||||
username: targetId as string,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: targetId as string,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
whitelistedUsers: {
|
whitelistedUsers: {
|
||||||
@@ -29,7 +22,7 @@ export default async function getPublicUser(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.id)
|
if (!user)
|
||||||
return { response: "User not found or profile is private.", status: 404 };
|
return { response: "User not found or profile is private.", status: 404 };
|
||||||
|
|
||||||
const whitelistedUsernames = user.whitelistedUsers?.map(
|
const whitelistedUsernames = user.whitelistedUsers?.map(
|
||||||
@@ -38,7 +31,7 @@ export default async function getPublicUser(
|
|||||||
|
|
||||||
const isInAPublicCollection = await prisma.collection.findFirst({
|
const isInAPublicCollection = await prisma.collection.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
["OR"]: [
|
||||||
{ ownerId: user.id },
|
{ ownerId: user.id },
|
||||||
{
|
{
|
||||||
members: {
|
members: {
|
||||||
@@ -80,7 +73,6 @@ export default async function getPublicUser(
|
|||||||
id: lessSensitiveInfo.id,
|
id: lessSensitiveInfo.id,
|
||||||
name: lessSensitiveInfo.name,
|
name: lessSensitiveInfo.name,
|
||||||
username: lessSensitiveInfo.username,
|
username: lessSensitiveInfo.username,
|
||||||
email: lessSensitiveInfo.email,
|
|
||||||
image: lessSensitiveInfo.image,
|
image: lessSensitiveInfo.image,
|
||||||
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
|
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
|
||||||
archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith,
|
archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function createSession(
|
|||||||
secret: process.env.NEXTAUTH_SECRET as string,
|
secret: process.env.NEXTAUTH_SECRET as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.accessToken.create({
|
const createToken = await prisma.accessToken.create({
|
||||||
data: {
|
data: {
|
||||||
name: sessionName || "Unknown Device",
|
name: sessionName || "Unknown Device",
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -1,31 +1,18 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import {
|
import { Tag } from "@prisma/client";
|
||||||
UpdateTagSchema,
|
|
||||||
UpdateTagSchemaType,
|
|
||||||
} from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
export default async function updeteTagById(
|
export default async function updeteTagById(
|
||||||
userId: number,
|
userId: number,
|
||||||
tagId: number,
|
tagId: number,
|
||||||
body: UpdateTagSchemaType
|
data: Tag
|
||||||
) {
|
) {
|
||||||
const dataValidation = UpdateTagSchema.safeParse(body);
|
if (!tagId || !data.name)
|
||||||
|
return { response: "Please choose a valid name for the tag.", status: 401 };
|
||||||
if (!dataValidation.success) {
|
|
||||||
return {
|
|
||||||
response: `Error: ${
|
|
||||||
dataValidation.error.issues[0].message
|
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name } = dataValidation.data;
|
|
||||||
|
|
||||||
const tagNameIsTaken = await prisma.tag.findFirst({
|
const tagNameIsTaken = await prisma.tag.findFirst({
|
||||||
where: {
|
where: {
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
name: name,
|
name: data.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +39,7 @@ export default async function updeteTagById(
|
|||||||
id: tagId,
|
id: tagId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
name: name,
|
name: data.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import {
|
|
||||||
PostTokenSchemaType,
|
|
||||||
PostTokenSchema,
|
|
||||||
} from "@/lib/shared/schemaValidation";
|
|
||||||
import { TokenExpiry } from "@/types/global";
|
import { TokenExpiry } from "@/types/global";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { decode, encode } from "next-auth/jwt";
|
import { decode, encode } from "next-auth/jwt";
|
||||||
|
|
||||||
export default async function postToken(
|
export default async function postToken(
|
||||||
body: PostTokenSchemaType,
|
body: {
|
||||||
|
name: string;
|
||||||
|
expires: TokenExpiry;
|
||||||
|
},
|
||||||
userId: number
|
userId: number
|
||||||
) {
|
) {
|
||||||
const dataValidation = PostTokenSchema.safeParse(body);
|
console.log(body);
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
const checkHasEmptyFields = !body.name || body.expires === undefined;
|
||||||
|
|
||||||
|
if (checkHasEmptyFields)
|
||||||
return {
|
return {
|
||||||
response: `Error: ${
|
response: "Please fill out all the fields.",
|
||||||
dataValidation.error.issues[0].message
|
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const { name, expires } = dataValidation.data;
|
|
||||||
|
|
||||||
const checkIfTokenExists = await prisma.accessToken.findFirst({
|
const checkIfTokenExists = await prisma.accessToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
name: name,
|
name: body.name,
|
||||||
revoked: false,
|
revoked: false,
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
@@ -44,16 +40,16 @@ export default async function postToken(
|
|||||||
const oneDayInSeconds = 86400;
|
const oneDayInSeconds = 86400;
|
||||||
let expiryDateSecond = 7 * oneDayInSeconds;
|
let expiryDateSecond = 7 * oneDayInSeconds;
|
||||||
|
|
||||||
if (expires === TokenExpiry.oneMonth) {
|
if (body.expires === TokenExpiry.oneMonth) {
|
||||||
expiryDate.setDate(expiryDate.getDate() + 30);
|
expiryDate.setDate(expiryDate.getDate() + 30);
|
||||||
expiryDateSecond = 30 * oneDayInSeconds;
|
expiryDateSecond = 30 * oneDayInSeconds;
|
||||||
} else if (expires === TokenExpiry.twoMonths) {
|
} else if (body.expires === TokenExpiry.twoMonths) {
|
||||||
expiryDate.setDate(expiryDate.getDate() + 60);
|
expiryDate.setDate(expiryDate.getDate() + 60);
|
||||||
expiryDateSecond = 60 * oneDayInSeconds;
|
expiryDateSecond = 60 * oneDayInSeconds;
|
||||||
} else if (expires === TokenExpiry.threeMonths) {
|
} else if (body.expires === TokenExpiry.threeMonths) {
|
||||||
expiryDate.setDate(expiryDate.getDate() + 90);
|
expiryDate.setDate(expiryDate.getDate() + 90);
|
||||||
expiryDateSecond = 90 * oneDayInSeconds;
|
expiryDateSecond = 90 * oneDayInSeconds;
|
||||||
} else if (expires === TokenExpiry.never) {
|
} else if (body.expires === TokenExpiry.never) {
|
||||||
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
|
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
|
||||||
expiryDateSecond = 73050 * oneDayInSeconds;
|
expiryDateSecond = 73050 * oneDayInSeconds;
|
||||||
} else {
|
} else {
|
||||||
@@ -79,7 +75,7 @@ export default async function postToken(
|
|||||||
|
|
||||||
const createToken = await prisma.accessToken.create({
|
const createToken = await prisma.accessToken.create({
|
||||||
data: {
|
data: {
|
||||||
name: name,
|
name: body.name,
|
||||||
userId,
|
userId,
|
||||||
token: tokenBody?.jti as string,
|
token: tokenBody?.jti as string,
|
||||||
expires: expiryDate,
|
expires: expiryDate,
|
||||||
|
|||||||
@@ -1,71 +1,21 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { User } from "@prisma/client";
|
|
||||||
|
|
||||||
export default async function getUsers(user: User) {
|
export default async function getUsers() {
|
||||||
if (user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) {
|
// Get all users
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
email: true,
|
email: true,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
select: {
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
response: users.sort((a: any, b: any) => a.id - b.id),
|
|
||||||
status: 200,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
let subscriptionId = (
|
|
||||||
await prisma.subscription.findFirst({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
active: true,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
)?.id;
|
|
||||||
|
|
||||||
if (!subscriptionId)
|
|
||||||
return {
|
|
||||||
response: "Subscription not found.",
|
|
||||||
status: 404,
|
|
||||||
};
|
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
parentSubscriptionId: subscriptionId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subscriptions: {
|
|
||||||
id: subscriptionId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
select: {
|
createdAt: true,
|
||||||
id: true,
|
},
|
||||||
name: true,
|
});
|
||||||
username: true,
|
|
||||||
email: true,
|
|
||||||
emailVerified: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return { response: users, status: 200 };
|
||||||
response: users.sort((a: any, b: any) => a.id - b.id),
|
|
||||||
status: 200,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ export default async function getUserById(userId: number) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
parentSubscription: {
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,21 +22,13 @@ export default async function getUserById(userId: number) {
|
|||||||
(usernames) => usernames.username
|
(usernames) => usernames.username
|
||||||
);
|
);
|
||||||
|
|
||||||
const { password, subscriptions, parentSubscription, ...lessSensitiveInfo } =
|
const { password, subscriptions, ...lessSensitiveInfo } = user;
|
||||||
user;
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
...lessSensitiveInfo,
|
...lessSensitiveInfo,
|
||||||
whitelistedUsers: whitelistedUsernames,
|
whitelistedUsers: whitelistedUsernames,
|
||||||
subscription: {
|
subscription: {
|
||||||
active: subscriptions?.active,
|
active: subscriptions?.active,
|
||||||
quantity: subscriptions?.quantity,
|
|
||||||
},
|
|
||||||
parentSubscription: {
|
|
||||||
active: parentSubscription?.active,
|
|
||||||
user: {
|
|
||||||
email: parentSubscription?.user.email,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,27 +6,42 @@ import createFile from "@/lib/api/storage/createFile";
|
|||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import sendChangeEmailVerificationRequest from "@/lib/api/sendChangeEmailVerificationRequest";
|
import sendChangeEmailVerificationRequest from "@/lib/api/sendChangeEmailVerificationRequest";
|
||||||
import { i18n } from "next-i18next.config";
|
import { i18n } from "next-i18next.config";
|
||||||
import { UpdateUserSchema } from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
const emailEnabled =
|
const emailEnabled =
|
||||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||||
|
|
||||||
export default async function updateUserById(
|
export default async function updateUserById(
|
||||||
userId: number,
|
userId: number,
|
||||||
body: AccountSettings
|
data: AccountSettings
|
||||||
) {
|
) {
|
||||||
const dataValidation = UpdateUserSchema().safeParse(body);
|
if (emailEnabled && !data.email)
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
|
||||||
return {
|
return {
|
||||||
response: `Error: ${
|
response: "Email invalid.",
|
||||||
dataValidation.error.issues[0].message
|
status: 400,
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
};
|
||||||
|
else if (!data.username)
|
||||||
|
return {
|
||||||
|
response: "Username invalid.",
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const data = dataValidation.data;
|
// Check email (if enabled)
|
||||||
|
const checkEmail =
|
||||||
|
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
|
||||||
|
if (emailEnabled && !checkEmail.test(data.email?.toLowerCase() || ""))
|
||||||
|
return {
|
||||||
|
response: "Please enter a valid email.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
|
||||||
|
|
||||||
|
if (!checkUsername.test(data.username.toLowerCase()))
|
||||||
|
return {
|
||||||
|
response:
|
||||||
|
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
const userIsTaken = await prisma.user.findFirst({
|
const userIsTaken = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -101,6 +116,7 @@ export default async function updateUserById(
|
|||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
select: { email: true, password: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user && user.email && data.email && data.email !== user.email) {
|
if (user && user.email && data.email && data.email !== user.email) {
|
||||||
@@ -132,7 +148,7 @@ export default async function updateUserById(
|
|||||||
sendChangeEmailVerificationRequest(
|
sendChangeEmailVerificationRequest(
|
||||||
user.email,
|
user.email,
|
||||||
data.email,
|
data.email,
|
||||||
data.name?.trim() || user.name || "Linkwarden User"
|
data.name.trim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,28 +185,16 @@ export default async function updateUserById(
|
|||||||
|
|
||||||
// Other settings / Apply changes
|
// Other settings / Apply changes
|
||||||
|
|
||||||
const isInvited =
|
|
||||||
user?.name === null && user.parentSubscriptionId && !user.password;
|
|
||||||
|
|
||||||
if (isInvited && data.password === "")
|
|
||||||
return {
|
|
||||||
response: "Password is required.",
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
const newHashedPassword = bcrypt.hashSync(
|
const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
|
||||||
data.newPassword || data.password || "",
|
|
||||||
saltRounds
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: data.name.trim(),
|
||||||
username: data.username,
|
username: data.username?.toLowerCase().trim(),
|
||||||
isPrivate: data.isPrivate,
|
isPrivate: data.isPrivate,
|
||||||
image:
|
image:
|
||||||
data.image && data.image.startsWith("http")
|
data.image && data.image.startsWith("http")
|
||||||
@@ -198,10 +202,10 @@ export default async function updateUserById(
|
|||||||
: data.image
|
: data.image
|
||||||
? `uploads/avatar/${userId}.jpg`
|
? `uploads/avatar/${userId}.jpg`
|
||||||
: "",
|
: "",
|
||||||
collectionOrder: data.collectionOrder?.filter(
|
collectionOrder: data.collectionOrder.filter(
|
||||||
(value, index, self) => self.indexOf(value) === index
|
(value, index, self) => self.indexOf(value) === index
|
||||||
),
|
),
|
||||||
locale: i18n.locales.includes(data.locale || "") ? data.locale : "en",
|
locale: i18n.locales.includes(data.locale) ? data.locale : "en",
|
||||||
archiveAsScreenshot: data.archiveAsScreenshot,
|
archiveAsScreenshot: data.archiveAsScreenshot,
|
||||||
archiveAsMonolith: data.archiveAsMonolith,
|
archiveAsMonolith: data.archiveAsMonolith,
|
||||||
archiveAsPDF: data.archiveAsPDF,
|
archiveAsPDF: data.archiveAsPDF,
|
||||||
@@ -211,28 +215,18 @@ export default async function updateUserById(
|
|||||||
referredBy:
|
referredBy:
|
||||||
!user?.referredBy && data.referredBy ? data.referredBy : undefined,
|
!user?.referredBy && data.referredBy ? data.referredBy : undefined,
|
||||||
password:
|
password:
|
||||||
isInvited || (data.newPassword && data.newPassword !== "")
|
data.newPassword && data.newPassword !== ""
|
||||||
? newHashedPassword
|
? newHashedPassword
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
whitelistedUsers: true,
|
whitelistedUsers: true,
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
parentSubscription: {
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const { whitelistedUsers, password, subscriptions, ...userInfo } =
|
||||||
whitelistedUsers,
|
updatedUser;
|
||||||
password,
|
|
||||||
subscriptions,
|
|
||||||
parentSubscription,
|
|
||||||
...userInfo
|
|
||||||
} = updatedUser;
|
|
||||||
|
|
||||||
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
|
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
|
||||||
const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
|
const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
|
||||||
@@ -273,20 +267,11 @@ export default async function updateUserById(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = {
|
const response: Omit<AccountSettings, "password"> = {
|
||||||
...userInfo,
|
...userInfo,
|
||||||
whitelistedUsers: newWhitelistedUsernames,
|
whitelistedUsers: newWhitelistedUsernames,
|
||||||
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
|
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
|
||||||
subscription: {
|
subscription: { active: subscriptions?.active },
|
||||||
active: subscriptions?.active,
|
|
||||||
quantity: subscriptions?.quantity,
|
|
||||||
},
|
|
||||||
parentSubscription: {
|
|
||||||
active: parentSubscription?.active,
|
|
||||||
user: {
|
|
||||||
email: parentSubscription?.user.email,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { response, status: 200 };
|
return { response, status: 200 };
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient };
|
|||||||
export const prisma =
|
export const prisma =
|
||||||
globalForPrisma.prisma ||
|
globalForPrisma.prisma ||
|
||||||
new PrismaClient({
|
new PrismaClient({
|
||||||
log: process.env.DEBUG === "true" ? ["query", "info", "warn", "error"] : ["warn", "error"]
|
log: ["query"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const generatePreview = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
image.resize(1000, Jimp.AUTO).quality(20);
|
image.resize(1280, Jimp.AUTO).quality(20);
|
||||||
const processedBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
|
const processedBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ type Props = {
|
|||||||
req: NextApiRequest;
|
req: NextApiRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function isAuthenticatedRequest({ req }: Props) {
|
export default async function isServerAdmin({ req }: Props): Promise<boolean> {
|
||||||
const token = await getToken({ req });
|
const token = await getToken({ req });
|
||||||
const userId = token?.id;
|
const userId = token?.id;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token.exp < Date.now() / 1000) {
|
if (token.exp < Date.now() / 1000) {
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if token is revoked
|
// check if token is revoked
|
||||||
@@ -27,21 +27,18 @@ export default async function isAuthenticatedRequest({ req }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (revoked) {
|
if (revoked) {
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const findUser = await prisma.user.findFirst({
|
const findUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
include: {
|
|
||||||
subscriptions: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (findUser && !findUser?.subscriptions) {
|
if (findUser?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) {
|
||||||
return null;
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return findUser;
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import verifySubscription from "./stripe/verifySubscription";
|
|
||||||
import { prisma } from "./db";
|
|
||||||
|
|
||||||
export default async function paymentCheckout(
|
export default async function paymentCheckout(
|
||||||
stripeSecretKey: string,
|
stripeSecretKey: string,
|
||||||
@@ -11,23 +9,6 @@ export default async function paymentCheckout(
|
|||||||
apiVersion: "2022-11-15",
|
apiVersion: "2022-11-15",
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
email: email.toLowerCase(),
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
subscriptions: true,
|
|
||||||
parentSubscription: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscription = await verifySubscription(user);
|
|
||||||
|
|
||||||
if (subscription) {
|
|
||||||
// To prevent users from creating multiple subscriptions
|
|
||||||
return { response: "/dashboard", status: 200 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const listByEmail = await stripe.customers.list({
|
const listByEmail = await stripe.customers.list({
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
expand: ["data.subscriptions"],
|
expand: ["data.subscriptions"],
|
||||||
@@ -37,7 +18,6 @@ export default async function paymentCheckout(
|
|||||||
|
|
||||||
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
|
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
|
||||||
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
|
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: isExistingCustomer ? isExistingCustomer : undefined,
|
customer: isExistingCustomer ? isExistingCustomer : undefined,
|
||||||
line_items: [
|
line_items: [
|
||||||
@@ -48,7 +28,7 @@ export default async function paymentCheckout(
|
|||||||
],
|
],
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
|
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
|
||||||
success_url: `${process.env.BASE_URL}/dashboard`,
|
success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.BASE_URL}/login`,
|
cancel_url: `${process.env.BASE_URL}/login`,
|
||||||
automatic_tax: {
|
automatic_tax: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { readFileSync } from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import Handlebars from "handlebars";
|
|
||||||
import transporter from "./transporter";
|
|
||||||
|
|
||||||
type Params = {
|
|
||||||
parentSubscriptionEmail: string;
|
|
||||||
identifier: string;
|
|
||||||
url: string;
|
|
||||||
from: string;
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function sendInvitationRequest({
|
|
||||||
parentSubscriptionEmail,
|
|
||||||
identifier,
|
|
||||||
url,
|
|
||||||
from,
|
|
||||||
token,
|
|
||||||
}: Params) {
|
|
||||||
const emailsDir = path.resolve(process.cwd(), "templates");
|
|
||||||
|
|
||||||
const templateFile = readFileSync(
|
|
||||||
path.join(emailsDir, "acceptInvitation.html"),
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
|
|
||||||
const emailTemplate = Handlebars.compile(templateFile);
|
|
||||||
|
|
||||||
const { host } = new URL(url);
|
|
||||||
const result = await transporter.sendMail({
|
|
||||||
to: identifier,
|
|
||||||
from: {
|
|
||||||
name: "Linkwarden",
|
|
||||||
address: from as string,
|
|
||||||
},
|
|
||||||
subject: `You have been invited to join Linkwarden`,
|
|
||||||
text: text({ url, host }),
|
|
||||||
html: emailTemplate({
|
|
||||||
parentSubscriptionEmail,
|
|
||||||
identifier,
|
|
||||||
url: `${
|
|
||||||
process.env.NEXTAUTH_URL
|
|
||||||
}/callback/email?token=${token}&email=${encodeURIComponent(identifier)}`,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
|
||||||
if (failed.length) {
|
|
||||||
throw new Error(`Email (${failed.join(", ")}) could not be sent`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
|
|
||||||
function text({ url, host }: { url: string; host: string }) {
|
|
||||||
return `Sign in to ${host}\n${url}\n\n`;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import getPermission from "./getPermission";
|
import getPermission from "./getPermission";
|
||||||
import { UsersAndCollections } from "@prisma/client";
|
import { UsersAndCollections } from "@prisma/client";
|
||||||
import { PostLinkSchemaType } from "../shared/schemaValidation";
|
|
||||||
|
|
||||||
const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
|
const setLinkCollection = async (
|
||||||
if (link.collection?.id && typeof link.collection?.id === "number") {
|
link: LinkIncludingShortenedCollectionAndTags,
|
||||||
|
userId: number
|
||||||
|
) => {
|
||||||
|
if (link?.collection?.id && typeof link?.collection?.id === "number") {
|
||||||
const existingCollection = await prisma.collection.findUnique({
|
const existingCollection = await prisma.collection.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: link.collection.id,
|
id: link.collection.id,
|
||||||
@@ -26,7 +29,7 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
return existingCollection;
|
return existingCollection;
|
||||||
} else if (link.collection?.name) {
|
} else if (link?.collection?.name) {
|
||||||
if (link.collection.name === "Unorganized") {
|
if (link.collection.name === "Unorganized") {
|
||||||
const firstTopLevelUnorganizedCollection =
|
const firstTopLevelUnorganizedCollection =
|
||||||
await prisma.collection.findFirst({
|
await prisma.collection.findFirst({
|
||||||
@@ -45,7 +48,6 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
|
|||||||
data: {
|
data: {
|
||||||
name: link.collection.name.trim(),
|
name: link.collection.name.trim(),
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
createdById: userId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +81,6 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
|
|||||||
name: "Unorganized",
|
name: "Unorganized",
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
parentId: null,
|
parentId: null,
|
||||||
createdById: userId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default async function moveFile(from: string, to: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
s3Client.copyObject(copyParams, async (err: unknown) => {
|
s3Client.copyObject(copyParams, async (err: any) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Error copying the object:", err);
|
console.error("Error copying the object:", err);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import Stripe from "stripe";
|
|
||||||
|
|
||||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
|
||||||
|
|
||||||
export default async function checkSubscriptionByEmail(email: string) {
|
|
||||||
if (!STRIPE_SECRET_KEY) return null;
|
|
||||||
|
|
||||||
const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
|
||||||
apiVersion: "2022-11-15",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Request made to Stripe by:", email);
|
|
||||||
const listByEmail = await stripe.customers.list({
|
|
||||||
email: email.toLowerCase(),
|
|
||||||
expand: ["data.subscriptions"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (listByEmail?.data[0]?.subscriptions?.data[0]) {
|
|
||||||
return {
|
|
||||||
active: (listByEmail.data[0].subscriptions?.data[0] as any).plan.active,
|
|
||||||
stripeSubscriptionId: listByEmail.data[0].subscriptions?.data[0].id,
|
|
||||||
currentPeriodStart:
|
|
||||||
listByEmail.data[0].subscriptions?.data[0].current_period_start * 1000,
|
|
||||||
currentPeriodEnd:
|
|
||||||
listByEmail.data[0].subscriptions?.data[0].current_period_end * 1000,
|
|
||||||
quantity: (listByEmail?.data[0]?.subscriptions?.data[0] as any).quantity,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import Stripe from "stripe";
|
|
||||||
import { prisma } from "../db";
|
|
||||||
|
|
||||||
type Data = {
|
|
||||||
id: string;
|
|
||||||
active: boolean;
|
|
||||||
quantity: number;
|
|
||||||
periodStart: number;
|
|
||||||
periodEnd: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function handleSubscription({
|
|
||||||
id,
|
|
||||||
active,
|
|
||||||
quantity,
|
|
||||||
periodStart,
|
|
||||||
periodEnd,
|
|
||||||
}: Data) {
|
|
||||||
const subscription = await prisma.subscription.findUnique({
|
|
||||||
where: {
|
|
||||||
stripeSubscriptionId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (subscription) {
|
|
||||||
await prisma.subscription.update({
|
|
||||||
where: {
|
|
||||||
stripeSubscriptionId: id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
active,
|
|
||||||
quantity,
|
|
||||||
currentPeriodStart: new Date(periodStart * 1000),
|
|
||||||
currentPeriodEnd: new Date(periodEnd * 1000),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
if (!process.env.STRIPE_SECRET_KEY)
|
|
||||||
throw new Error("Missing Stripe secret key");
|
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
|
||||||
apiVersion: "2022-11-15",
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscription = await stripe.subscriptions.retrieve(id);
|
|
||||||
const customerId = subscription.customer;
|
|
||||||
|
|
||||||
const customer = await stripe.customers.retrieve(customerId.toString());
|
|
||||||
const email = (customer as Stripe.Customer).email;
|
|
||||||
|
|
||||||
if (!email) throw new Error("Email not found");
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) throw new Error("User not found");
|
|
||||||
|
|
||||||
const userId = user.id;
|
|
||||||
|
|
||||||
await prisma.subscription
|
|
||||||
.upsert({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
active,
|
|
||||||
stripeSubscriptionId: id,
|
|
||||||
quantity,
|
|
||||||
currentPeriodStart: new Date(periodStart * 1000),
|
|
||||||
currentPeriodEnd: new Date(periodEnd * 1000),
|
|
||||||
user: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
active,
|
|
||||||
stripeSubscriptionId: id,
|
|
||||||
quantity,
|
|
||||||
currentPeriodStart: new Date(periodStart * 1000),
|
|
||||||
currentPeriodEnd: new Date(periodEnd * 1000),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import Stripe from "stripe";
|
|
||||||
|
|
||||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
|
||||||
|
|
||||||
const updateSeats = async (subscriptionId: string, seats: number) => {
|
|
||||||
if (!STRIPE_SECRET_KEY) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
|
||||||
apiVersion: "2022-11-15",
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
||||||
|
|
||||||
const trialing = subscription.status === "trialing";
|
|
||||||
|
|
||||||
if (subscription) {
|
|
||||||
await stripe.subscriptions.update(subscriptionId, {
|
|
||||||
billing_cycle_anchor: trialing ? undefined : "now",
|
|
||||||
proration_behavior: trialing ? undefined : "create_prorations",
|
|
||||||
quantity: seats,
|
|
||||||
} as Stripe.SubscriptionUpdateParams);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default updateSeats;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { prisma } from "../db";
|
|
||||||
import { Subscription, User } from "@prisma/client";
|
|
||||||
import checkSubscriptionByEmail from "./checkSubscriptionByEmail";
|
|
||||||
|
|
||||||
interface UserIncludingSubscription extends User {
|
|
||||||
subscriptions: Subscription | null;
|
|
||||||
parentSubscription: Subscription | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function verifySubscription(
|
|
||||||
user?: UserIncludingSubscription | null
|
|
||||||
) {
|
|
||||||
if (!user || (!user.subscriptions && !user.parentSubscription)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.parentSubscription?.active) {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!user.subscriptions?.active ||
|
|
||||||
new Date() > user.subscriptions.currentPeriodEnd
|
|
||||||
) {
|
|
||||||
const subscription = await checkSubscriptionByEmail(user.email as string);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!subscription ||
|
|
||||||
!subscription.stripeSubscriptionId ||
|
|
||||||
!subscription.currentPeriodEnd ||
|
|
||||||
!subscription.currentPeriodStart ||
|
|
||||||
!subscription.quantity
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
active,
|
|
||||||
stripeSubscriptionId,
|
|
||||||
currentPeriodStart,
|
|
||||||
currentPeriodEnd,
|
|
||||||
quantity,
|
|
||||||
} = subscription;
|
|
||||||
|
|
||||||
await prisma.subscription
|
|
||||||
.upsert({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
active,
|
|
||||||
stripeSubscriptionId,
|
|
||||||
currentPeriodStart: new Date(currentPeriodStart),
|
|
||||||
currentPeriodEnd: new Date(currentPeriodEnd),
|
|
||||||
quantity,
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
active,
|
|
||||||
stripeSubscriptionId,
|
|
||||||
currentPeriodStart: new Date(currentPeriodStart),
|
|
||||||
currentPeriodEnd: new Date(currentPeriodEnd),
|
|
||||||
quantity,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err));
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import verifySubscription from "./stripe/verifySubscription";
|
import verifySubscription from "./verifySubscription";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -33,7 +33,6 @@ export default async function verifyByCredentials({
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
parentSubscription: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { prisma } from "./db";
|
|
||||||
|
|
||||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
|
||||||
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
|
|
||||||
|
|
||||||
export const hasPassedLimit = async (
|
|
||||||
userId: number,
|
|
||||||
numberOfImports: number
|
|
||||||
) => {
|
|
||||||
if (!stripeEnabled) {
|
|
||||||
const totalLinks = await prisma.link.count({
|
|
||||||
where: {
|
|
||||||
createdBy: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
include: {
|
|
||||||
parentSubscription: true,
|
|
||||||
subscriptions: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
user.parentSubscription ||
|
|
||||||
(user.subscriptions && user.subscriptions?.quantity > 1)
|
|
||||||
) {
|
|
||||||
const subscription = user.parentSubscription || user.subscriptions;
|
|
||||||
|
|
||||||
if (!subscription) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the total allowed links for the organization
|
|
||||||
const totalCapacity = subscription.quantity * MAX_LINKS_PER_USER;
|
|
||||||
|
|
||||||
const totalLinks = await prisma.link.count({
|
|
||||||
where: {
|
|
||||||
createdBy: {
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
parentSubscriptionId: subscription.id || undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subscriptions: {
|
|
||||||
id: subscription.id || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return totalCapacity - (numberOfImports + totalLinks) < 0;
|
|
||||||
} else {
|
|
||||||
const totalLinks = await prisma.link.count({
|
|
||||||
where: {
|
|
||||||
createdBy: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { prisma } from "./db";
|
||||||
|
import { Subscription, User } from "@prisma/client";
|
||||||
|
import checkSubscriptionByEmail from "./checkSubscriptionByEmail";
|
||||||
|
|
||||||
|
interface UserIncludingSubscription extends User {
|
||||||
|
subscriptions: Subscription | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function verifySubscription(
|
||||||
|
user?: UserIncludingSubscription
|
||||||
|
) {
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = user.subscriptions;
|
||||||
|
|
||||||
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
if (!subscription?.active || currentDate > subscription.currentPeriodEnd) {
|
||||||
|
const {
|
||||||
|
active,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd,
|
||||||
|
} = await checkSubscriptionByEmail(user.email as string);
|
||||||
|
|
||||||
|
if (
|
||||||
|
active &&
|
||||||
|
stripeSubscriptionId &&
|
||||||
|
currentPeriodStart &&
|
||||||
|
currentPeriodEnd
|
||||||
|
) {
|
||||||
|
await prisma.subscription
|
||||||
|
.upsert({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
active,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
currentPeriodStart: new Date(currentPeriodStart),
|
||||||
|
currentPeriodEnd: new Date(currentPeriodEnd),
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
active,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
currentPeriodStart: new Date(currentPeriodStart),
|
||||||
|
currentPeriodEnd: new Date(currentPeriodEnd),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
} else if (!active) {
|
||||||
|
const subscription = await prisma.subscription.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription)
|
||||||
|
await prisma.subscription.delete({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import verifySubscription from "./stripe/verifySubscription";
|
import verifySubscription from "./verifySubscription";
|
||||||
import verifyToken from "./verifyToken";
|
import verifyToken from "./verifyToken";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -30,7 +30,6 @@ export default async function verifyUser({
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
parentSubscription: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,36 +2,29 @@ import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
|
|||||||
import getPublicUserData from "./getPublicUserData";
|
import getPublicUserData from "./getPublicUserData";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import { User } from "@prisma/client";
|
|
||||||
|
|
||||||
const addMemberToCollection = async (
|
const addMemberToCollection = async (
|
||||||
owner: User,
|
ownerUsername: string,
|
||||||
memberIdentifier: string,
|
memberUsername: string,
|
||||||
collection: CollectionIncludingMembersAndLinkCount,
|
collection: CollectionIncludingMembersAndLinkCount,
|
||||||
setMember: (newMember: Member) => null | undefined,
|
setMember: (newMember: Member) => null | undefined,
|
||||||
t: TFunction<"translation", undefined>
|
t: TFunction<"translation", undefined>
|
||||||
) => {
|
) => {
|
||||||
const checkIfMemberAlreadyExists = collection.members.find((e) => {
|
const checkIfMemberAlreadyExists = collection.members.find((e) => {
|
||||||
const username = (e.user.username || "").toLowerCase();
|
const username = (e.user.username || "").toLowerCase();
|
||||||
const email = (e.user.email || "").toLowerCase();
|
return username === memberUsername.toLowerCase();
|
||||||
|
|
||||||
return (
|
|
||||||
username === memberIdentifier.toLowerCase() ||
|
|
||||||
email === memberIdentifier.toLowerCase()
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
// no duplicate members
|
// no duplicate members
|
||||||
!checkIfMemberAlreadyExists &&
|
!checkIfMemberAlreadyExists &&
|
||||||
// member can't be empty
|
// member can't be empty
|
||||||
memberIdentifier.trim() !== "" &&
|
memberUsername.trim() !== "" &&
|
||||||
// member can't be the owner
|
// member can't be the owner
|
||||||
memberIdentifier.trim().toLowerCase() !== owner.username?.toLowerCase() &&
|
memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase()
|
||||||
memberIdentifier.trim().toLowerCase() !== owner.email?.toLowerCase()
|
|
||||||
) {
|
) {
|
||||||
// Lookup, get data/err, list ...
|
// Lookup, get data/err, list ...
|
||||||
const user = await getPublicUserData(memberIdentifier.trim().toLowerCase());
|
const user = await getPublicUserData(memberUsername.trim().toLowerCase());
|
||||||
|
|
||||||
if (user.username) {
|
if (user.username) {
|
||||||
setMember({
|
setMember({
|
||||||
@@ -44,16 +37,12 @@ const addMemberToCollection = async (
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
|
||||||
image: user.image,
|
image: user.image,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (checkIfMemberAlreadyExists) toast.error(t("user_already_member"));
|
} else if (checkIfMemberAlreadyExists) toast.error(t("user_already_member"));
|
||||||
else if (
|
else if (memberUsername.trim().toLowerCase() === ownerUsername.toLowerCase())
|
||||||
memberIdentifier.trim().toLowerCase() === owner.username?.toLowerCase() ||
|
|
||||||
memberIdentifier.trim().toLowerCase() === owner.email?.toLowerCase()
|
|
||||||
)
|
|
||||||
toast.error(t("you_are_already_collection_owner"));
|
toast.error(t("you_are_already_collection_owner"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import * as Icons from "@phosphor-icons/react";
|
|
||||||
import { icons as iconData } from "@phosphor-icons/core";
|
|
||||||
import { IconEntry as CoreEntry } from "@phosphor-icons/core";
|
|
||||||
|
|
||||||
interface IconEntry extends CoreEntry {
|
|
||||||
Icon: Icons.Icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const icons: ReadonlyArray<IconEntry> = iconData.map((entry) => ({
|
|
||||||
...entry,
|
|
||||||
Icon: Icons[entry.pascal_name as keyof typeof Icons] as Icons.Icon,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// if (process.env.NODE_ENV === "development") {
|
|
||||||
// console.log(`${icons.length} icons`);
|
|
||||||
// }
|
|
||||||
|
|
||||||
export const iconCount = Intl.NumberFormat("en-US").format(icons.length * 6);
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useUpdateLink } from "@/hooks/store/links";
|
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useTranslation } from "next-i18next";
|
|
||||||
import { useUser } from "@/hooks/store/user";
|
|
||||||
|
|
||||||
const usePinLink = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const updateLink = useUpdateLink();
|
|
||||||
const { data: user = {} } = useUser();
|
|
||||||
|
|
||||||
// Return a function that can be used to pin/unpin the link
|
|
||||||
const pinLink = async (link: LinkIncludingShortenedCollectionAndTags) => {
|
|
||||||
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
|
|
||||||
|
|
||||||
const load = toast.loading(t("updating"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateLink.mutateAsync(
|
|
||||||
{
|
|
||||||
...link,
|
|
||||||
pinnedBy: isAlreadyPinned ? [{ id: undefined }] : [{ id: user.id }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSettled: (data, error) => {
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
toast.error(error.message);
|
|
||||||
} else {
|
|
||||||
toast.success(
|
|
||||||
isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
toast.dismiss(load);
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return pinLink;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default usePinLink;
|
|
||||||
@@ -9,7 +9,7 @@ export const resizeImage = (file: File): Promise<Blob> =>
|
|||||||
"JPEG", // output format
|
"JPEG", // output format
|
||||||
100, // quality
|
100, // quality
|
||||||
0, // rotation
|
0, // rotation
|
||||||
(uri) => {
|
(uri: any) => {
|
||||||
resolve(uri as Blob);
|
resolve(uri as Blob);
|
||||||
},
|
},
|
||||||
"blob" // output type
|
"blob" // output type
|
||||||
|
|||||||
+2
-7
@@ -7,15 +7,10 @@ export function isPWA() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isIphone() {
|
export function isIphone() {
|
||||||
return (
|
return /iPhone/.test(navigator.userAgent) && !(window as any).MSStream;
|
||||||
/iPhone/.test(navigator.userAgent) &&
|
|
||||||
!(window as unknown as { MSStream?: any }).MSStream
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dropdownTriggerer(
|
export function dropdownTriggerer(e: any) {
|
||||||
e: React.FocusEvent<HTMLElement> | React.MouseEvent<HTMLElement>
|
|
||||||
) {
|
|
||||||
let targetEl = e.currentTarget;
|
let targetEl = e.currentTarget;
|
||||||
if (targetEl && targetEl.matches(":focus")) {
|
if (targetEl && targetEl.matches(":focus")) {
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ export function monolithAvailable(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function previewAvailable(
|
export function previewAvailable(link: any) {
|
||||||
link: LinkIncludingShortenedCollectionAndTags
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
link &&
|
link &&
|
||||||
link.preview &&
|
link.preview &&
|
||||||
|
|||||||
+1
-14
@@ -2,20 +2,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: "en",
|
defaultLocale: "en",
|
||||||
locales: [
|
locales: ["en", "it", "fr", "zh"],
|
||||||
"en",
|
|
||||||
"it",
|
|
||||||
"fr",
|
|
||||||
"zh",
|
|
||||||
"zh-TW",
|
|
||||||
"uk",
|
|
||||||
"pt-BR",
|
|
||||||
"ja",
|
|
||||||
"es",
|
|
||||||
"de",
|
|
||||||
"nl",
|
|
||||||
"tr",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
reloadOnPrerender: process.env.NODE_ENV === "development",
|
reloadOnPrerender: process.env.NODE_ENV === "development",
|
||||||
};
|
};
|
||||||
|
|||||||
+5
-2
@@ -88,10 +88,13 @@ function App({
|
|||||||
{icon}
|
{icon}
|
||||||
<span data-testid="toast-message">{message}</span>
|
<span data-testid="toast-message">{message}</span>
|
||||||
{t.type !== "loading" && (
|
{t.type !== "loading" && (
|
||||||
<div
|
<button
|
||||||
|
className="btn btn-xs outline-none btn-circle btn-ghost"
|
||||||
data-testid="close-toast-button"
|
data-testid="close-toast-button"
|
||||||
onClick={() => toast.dismiss(t.id)}
|
onClick={() => toast.dismiss(t.id)}
|
||||||
></div>
|
>
|
||||||
|
<i className="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+4
-3
@@ -6,7 +6,6 @@ import { useTranslation } from "next-i18next";
|
|||||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||||
import UserListing from "@/components/UserListing";
|
import UserListing from "@/components/UserListing";
|
||||||
import { useUsers } from "@/hooks/store/admin/users";
|
import { useUsers } from "@/hooks/store/admin/users";
|
||||||
import Divider from "@/components/ui/Divider";
|
|
||||||
|
|
||||||
interface User extends U {
|
interface User extends U {
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
@@ -89,7 +88,7 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider className="my-3" />
|
<div className="divider my-3"></div>
|
||||||
|
|
||||||
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
||||||
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
|
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
|
||||||
@@ -101,7 +100,9 @@ export default function Admin() {
|
|||||||
<p>{t("no_users_found")}</p>
|
<p>{t("no_users_found")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{newUserModal && <NewUserModal onClose={() => setNewUserModal(false)} />}
|
{newUserModal ? (
|
||||||
|
<NewUserModal onClose={() => setNewUserModal(false)} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import fs from "fs";
|
|||||||
import verifyToken from "@/lib/api/verifyToken";
|
import verifyToken from "@/lib/api/verifyToken";
|
||||||
import generatePreview from "@/lib/api/generatePreview";
|
import generatePreview from "@/lib/api/generatePreview";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import { UploadFileSchema } from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
@@ -106,6 +105,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
response: "Collection is not accessible.",
|
response: "Collection is not accessible.",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// await uploadHandler(linkId, )
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||||
@@ -118,7 +119,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
response:
|
||||||
|
"Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.",
|
||||||
});
|
});
|
||||||
|
|
||||||
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
|
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
|
||||||
@@ -139,20 +141,6 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
];
|
];
|
||||||
|
|
||||||
const dataValidation = UploadFileSchema.safeParse({
|
|
||||||
id: Number(req.query.linkId),
|
|
||||||
format: Number(req.query.format),
|
|
||||||
file: files.file,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
|
||||||
return res.status(400).json({
|
|
||||||
response: `Error: ${
|
|
||||||
dataValidation.error.issues[0].message
|
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
err ||
|
err ||
|
||||||
!files.file ||
|
!files.file ||
|
||||||
@@ -178,12 +166,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
where: { id: linkId },
|
where: { id: linkId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mimetype } = files.file[0];
|
if (linkStillExists && files.file[0].mimetype?.includes("image")) {
|
||||||
const isPDF = mimetype?.includes("pdf");
|
const collectionId = collectionPermissions.id as number;
|
||||||
const isImage = mimetype?.includes("image");
|
|
||||||
|
|
||||||
if (linkStillExists && isImage) {
|
|
||||||
const collectionId = collectionPermissions.id;
|
|
||||||
createFolder({
|
createFolder({
|
||||||
filePath: `archives/preview/${collectionId}`,
|
filePath: `archives/preview/${collectionId}`,
|
||||||
});
|
});
|
||||||
@@ -200,11 +184,13 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
await prisma.link.update({
|
await prisma.link.update({
|
||||||
where: { id: linkId },
|
where: { id: linkId },
|
||||||
data: {
|
data: {
|
||||||
preview: isPDF ? "unavailable" : undefined,
|
preview: files.file[0].mimetype?.includes("pdf")
|
||||||
image: isImage
|
? "unavailable"
|
||||||
|
: undefined,
|
||||||
|
image: files.file[0].mimetype?.includes("image")
|
||||||
? `archives/${collectionPermissions.id}/${linkId + suffix}`
|
? `archives/${collectionPermissions.id}/${linkId + suffix}`
|
||||||
: null,
|
: null,
|
||||||
pdf: isPDF
|
pdf: files.file[0].mimetype?.includes("pdf")
|
||||||
? `archives/${collectionPermissions.id}/${linkId + suffix}`
|
? `archives/${collectionPermissions.id}/${linkId + suffix}`
|
||||||
: null,
|
: null,
|
||||||
lastPreserved: new Date().toISOString(),
|
lastPreserved: new Date().toISOString(),
|
||||||
@@ -220,94 +206,4 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// To update the link preview
|
|
||||||
else if (req.method === "PUT") {
|
|
||||||
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
|
||||||
return res.status(400).json({
|
|
||||||
response:
|
|
||||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = await verifyUser({ req, res });
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const collectionPermissions = await getPermission({
|
|
||||||
userId: user.id,
|
|
||||||
linkId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!collectionPermissions)
|
|
||||||
return res.status(400).json({
|
|
||||||
response: "Collection is not accessible.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberHasAccess = collectionPermissions.members.some(
|
|
||||||
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(collectionPermissions.ownerId === user.id || memberHasAccess))
|
|
||||||
return res.status(400).json({
|
|
||||||
response: "Collection is not accessible.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
|
|
||||||
process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = formidable({
|
|
||||||
maxFields: 1,
|
|
||||||
maxFiles: 1,
|
|
||||||
maxFileSize: NEXT_PUBLIC_MAX_FILE_BUFFER * 1024 * 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.parse(req, async (err, fields, files) => {
|
|
||||||
const allowedMIMETypes = ["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(400).json({
|
|
||||||
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const fileBuffer = fs.readFileSync(files.file[0].filepath);
|
|
||||||
|
|
||||||
if (
|
|
||||||
Buffer.byteLength(fileBuffer) >
|
|
||||||
1024 * 1024 * Number(NEXT_PUBLIC_MAX_FILE_BUFFER)
|
|
||||||
)
|
|
||||||
return res.status(400).json({
|
|
||||||
response: `Sorry, we couldn't process your file. Please ensure it's a PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkStillExists = await prisma.link.update({
|
|
||||||
where: { id: linkId },
|
|
||||||
data: {
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (linkStillExists) {
|
|
||||||
const collectionId = collectionPermissions.id;
|
|
||||||
createFolder({
|
|
||||||
filePath: `archives/preview/${collectionId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await generatePreview(fileBuffer, collectionId, linkId);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.unlinkSync(files.file[0].filepath);
|
|
||||||
|
|
||||||
if (linkStillExists)
|
|
||||||
return res.status(200).json({
|
|
||||||
response: linkStillExists,
|
|
||||||
});
|
|
||||||
else return res.status(400).json({ response: "Link not found." });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import sendInvitationRequest from "@/lib/api/sendInvitationRequest";
|
|
||||||
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
|
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
|
||||||
import updateSeats from "@/lib/api/stripe/updateSeats";
|
import verifySubscription from "@/lib/api/verifySubscription";
|
||||||
import verifySubscription from "@/lib/api/stripe/verifySubscription";
|
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
import { User } from "@prisma/client";
|
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { Adapter } from "next-auth/adapters";
|
import { Adapter } from "next-auth/adapters";
|
||||||
import NextAuth from "next-auth/next";
|
import NextAuth from "next-auth/next";
|
||||||
@@ -135,7 +133,6 @@ if (process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED !== "false") {
|
|||||||
if (emailEnabled) {
|
if (emailEnabled) {
|
||||||
providers.push(
|
providers.push(
|
||||||
EmailProvider({
|
EmailProvider({
|
||||||
id: "email",
|
|
||||||
server: process.env.EMAIL_SERVER,
|
server: process.env.EMAIL_SERVER,
|
||||||
from: process.env.EMAIL_FROM,
|
from: process.env.EMAIL_FROM,
|
||||||
maxAge: 1200,
|
maxAge: 1200,
|
||||||
@@ -160,56 +157,6 @@ if (emailEnabled) {
|
|||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
EmailProvider({
|
|
||||||
id: "invite",
|
|
||||||
server: process.env.EMAIL_SERVER,
|
|
||||||
from: process.env.EMAIL_FROM,
|
|
||||||
maxAge: 1200,
|
|
||||||
async sendVerificationRequest({ identifier, url, provider, token }) {
|
|
||||||
const parentSubscriptionEmail = (
|
|
||||||
await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
email: identifier,
|
|
||||||
emailVerified: null,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
parentSubscription: {
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)?.parentSubscription?.user.email;
|
|
||||||
|
|
||||||
if (!parentSubscriptionEmail) throw Error("Invalid email.");
|
|
||||||
|
|
||||||
const recentVerificationRequestsCount =
|
|
||||||
await prisma.verificationToken.count({
|
|
||||||
where: {
|
|
||||||
identifier,
|
|
||||||
createdAt: {
|
|
||||||
gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (recentVerificationRequestsCount >= 4)
|
|
||||||
throw Error("Too many requests. Please try again later.");
|
|
||||||
|
|
||||||
sendInvitationRequest({
|
|
||||||
parentSubscriptionEmail,
|
|
||||||
identifier,
|
|
||||||
url,
|
|
||||||
from: provider.from as string,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1232,52 +1179,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async signIn({ user, account, profile, email, credentials }) {
|
async signIn({ user, account, profile, email, credentials }) {
|
||||||
if (
|
|
||||||
!(user as User).emailVerified &&
|
|
||||||
!email?.verificationRequest
|
|
||||||
// && (account?.provider === "email" || account?.provider === "google")
|
|
||||||
) {
|
|
||||||
// Email is being verified for the first time...
|
|
||||||
console.log("Email is being verified for the first time...");
|
|
||||||
|
|
||||||
const parentSubscriptionId = (user as User).parentSubscriptionId;
|
|
||||||
|
|
||||||
if (parentSubscriptionId) {
|
|
||||||
// Add seat request to Stripe
|
|
||||||
const parentSubscription = await prisma.subscription.findFirst({
|
|
||||||
where: {
|
|
||||||
id: parentSubscriptionId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Count child users with verified email under a specific subscription, excluding the current user
|
|
||||||
const verifiedChildUsersCount = await prisma.user.count({
|
|
||||||
where: {
|
|
||||||
parentSubscriptionId: parentSubscriptionId,
|
|
||||||
id: {
|
|
||||||
not: user.id as number,
|
|
||||||
},
|
|
||||||
emailVerified: {
|
|
||||||
not: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
STRIPE_SECRET_KEY &&
|
|
||||||
parentSubscription?.quantity &&
|
|
||||||
verifiedChildUsersCount + 2 > // add current user and the admin
|
|
||||||
parentSubscription.quantity
|
|
||||||
) {
|
|
||||||
// Add seat if the user count exceeds the subscription limit
|
|
||||||
await updateSeats(
|
|
||||||
parentSubscription.stripeSubscriptionId,
|
|
||||||
verifiedChildUsersCount + 2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
||||||
@@ -1386,6 +1287,8 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
session.user.id = token.id;
|
session.user.id = token.id;
|
||||||
|
|
||||||
|
console.log("session", session);
|
||||||
|
|
||||||
if (STRIPE_SECRET_KEY) {
|
if (STRIPE_SECRET_KEY) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -1393,7 +1296,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
parentSubscription: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import sendPasswordResetRequest from "@/lib/api/sendPasswordResetRequest";
|
import sendPasswordResetRequest from "@/lib/api/sendPasswordResetRequest";
|
||||||
import { ForgotPasswordSchema } from "@/lib/shared/schemaValidation";
|
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
export default async function forgotPassword(
|
export default async function forgotPassword(
|
||||||
@@ -14,18 +13,14 @@ export default async function forgotPassword(
|
|||||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataValidation = ForgotPasswordSchema.safeParse(req.body);
|
const email = req.body.email;
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
if (!email) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
response: `Error: ${
|
response: "Invalid email.",
|
||||||
dataValidation.error.issues[0].message
|
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email } = dataValidation.data;
|
|
||||||
|
|
||||||
const recentPasswordRequestsCount = await prisma.passwordResetToken.count({
|
const recentPasswordRequestsCount = await prisma.passwordResetToken.count({
|
||||||
where: {
|
where: {
|
||||||
identifier: email,
|
identifier: email,
|
||||||
@@ -50,11 +45,11 @@ export default async function forgotPassword(
|
|||||||
|
|
||||||
if (!user || !user.email) {
|
if (!user || !user.email) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
response: "No user found with that email.",
|
response: "Invalid email.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sendPasswordResetRequest(user.email, user.name || "Linkwarden User");
|
sendPasswordResetRequest(user.email, user.name);
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
response: "Password reset email sent.",
|
response: "Password reset email sent.",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 { ResetPasswordSchema } from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
export default async function resetPassword(
|
export default async function resetPassword(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
@@ -14,17 +13,20 @@ export default async function resetPassword(
|
|||||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataValidation = ResetPasswordSchema.safeParse(req.body);
|
const token = req.body.token;
|
||||||
|
const password = req.body.password;
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
if (!password || password.length < 8) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
response: `Error: ${
|
response: "Password must be at least 8 characters.",
|
||||||
dataValidation.error.issues[0].message
|
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token, password } = dataValidation.data;
|
if (!token || typeof token !== "string") {
|
||||||
|
return res.status(400).json({
|
||||||
|
response: "Invalid token.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Hashed password
|
// Hashed password
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import updateCustomerEmail from "@/lib/api/stripe/updateCustomerEmail";
|
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
|
||||||
import { VerifyEmailSchema } from "@/lib/shared/schemaValidation";
|
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
export default async function verifyEmail(
|
export default async function verifyEmail(
|
||||||
@@ -14,18 +13,14 @@ export default async function verifyEmail(
|
|||||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||||
});
|
});
|
||||||
|
|
||||||
const dataValidation = VerifyEmailSchema.safeParse(req.query);
|
const token = req.query.token;
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
if (!token || typeof token !== "string") {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
response: `Error: ${
|
response: "Invalid token.",
|
||||||
dataValidation.error.issues[0].message
|
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token } = dataValidation.data;
|
|
||||||
|
|
||||||
// Check token in db
|
// Check token in db
|
||||||
const verifyToken = await prisma.verificationToken.findFirst({
|
const verifyToken = await prisma.verificationToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -2,10 +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 { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
import { Collection, Link } from "@prisma/client";
|
||||||
import { UsersAndCollections } from "@prisma/client";
|
import { removeFiles } from "@/lib/api/manageLinkFiles";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
|
||||||
import { moveFiles, 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;
|
||||||
|
|
||||||
@@ -25,16 +23,7 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
response: "Link not found.",
|
response: "Link not found.",
|
||||||
});
|
});
|
||||||
|
|
||||||
const collectionIsAccessible = await getPermission({
|
if (link.collection.ownerId !== user.id)
|
||||||
userId: user.id,
|
|
||||||
collectionId: link.collectionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
|
||||||
(e: UsersAndCollections) => e.userId === user.id && e.canUpdate
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(collectionIsAccessible?.ownerId === user.id || memberHasAccess))
|
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
response: "Permission denied.",
|
response: "Permission denied.",
|
||||||
});
|
});
|
||||||
@@ -65,20 +54,7 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
response: "Invalid URL.",
|
response: "Invalid URL.",
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.link.update({
|
await deleteArchivedFiles(link);
|
||||||
where: {
|
|
||||||
id: link.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
image: null,
|
|
||||||
pdf: null,
|
|
||||||
readable: null,
|
|
||||||
monolith: null,
|
|
||||||
preview: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await removeFiles(link.id, link.collection.id);
|
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
response: "Link is being archived.",
|
response: "Link is being archived.",
|
||||||
@@ -96,3 +72,20 @@ const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
|
|||||||
|
|
||||||
return diffInMinutes;
|
return diffInMinutes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
|
||||||
|
await prisma.link.update({
|
||||||
|
where: {
|
||||||
|
id: link.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
image: null,
|
||||||
|
pdf: null,
|
||||||
|
readable: null,
|
||||||
|
monolith: null,
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await removeFiles(link.id, link.collection.id);
|
||||||
|
};
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
req.body.removePreviousTags,
|
req.body.removePreviousTags,
|
||||||
req.body.newData
|
req.body.newData
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(updated.status).json({
|
return res.status(updated.status).json({
|
||||||
response: updated.response,
|
response: updated.response,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import verifyByCredentials from "@/lib/api/verifyByCredentials";
|
import verifyByCredentials from "@/lib/api/verifyByCredentials";
|
||||||
import createSession from "@/lib/api/controllers/session/createSession";
|
import createSession from "@/lib/api/controllers/session/createSession";
|
||||||
import { PostSessionSchema } from "@/lib/shared/schemaValidation";
|
|
||||||
|
|
||||||
export default async function session(
|
export default async function session(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse
|
res: NextApiResponse
|
||||||
) {
|
) {
|
||||||
const dataValidation = PostSessionSchema.safeParse(req.body);
|
const { username, password, sessionName } = req.body;
|
||||||
|
|
||||||
if (!dataValidation.success) {
|
|
||||||
return res.status(400).json({
|
|
||||||
response: `Error: ${
|
|
||||||
dataValidation.error.issues[0].message
|
|
||||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, password, sessionName } = dataValidation.data;
|
|
||||||
|
|
||||||
const user = await verifyByCredentials({ username, password });
|
const user = await verifyByCredentials({ username, password });
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,6 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
const tagId = Number(req.query.id);
|
const tagId = Number(req.query.id);
|
||||||
|
|
||||||
if (!tagId)
|
|
||||||
return res.status(400).json({
|
|
||||||
response: "Please choose a valid name for the tag.",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (req.method === "PUT") {
|
if (req.method === "PUT") {
|
||||||
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user