",
@@ -39,13 +39,6 @@
"@stripe/stripe-js": "^7.8.0",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-query-devtools": "^5.51.15",
- "@types/crypto-js": "^4.1.1",
- "@types/formidable": "^3.4.5",
- "@types/node": "^20.10.4",
- "@types/papaparse": "^5.3.16",
- "@types/react": "18.3.20",
- "@types/react-dom": "18.3.7",
- "@types/rss": "^0.0.32",
"axios": "^1.5.1",
"bcrypt": "^5.1.0",
"bootstrap-icons": "^1.11.2",
@@ -53,8 +46,7 @@
"clsx": "^2.1.1",
"colorjs.io": "^0.5.2",
"csstype": "^3.1.2",
- "dompurify": "^3.0.6",
- "eslint": "8.46.0",
+ "dompurify": "^3.2.4",
"eslint-config-next": "13.4.9",
"formidable": "^3.5.1",
"fuse.js": "^7.0.0",
@@ -67,15 +59,16 @@
"jszip": "^3.10.1",
"lucide-react": "^0.511.0",
"micro": "^10.0.1",
- "next": "13.4.12",
+ "next": "14.2.35",
"next-auth": "^4.22.1",
"next-i18next": "^15.3.0",
"node-fetch": "^2.7.0",
+ "nodemailer": "^7.0.11",
"papaparse": "^5.5.3",
- "playwright": "^1.55.0",
+ "playwright": "1.57.0",
"react": "18.3.1",
"react-colorful": "^5.6.1",
- "react-dom": "18.2.0",
+ "react-dom": "18.3.1",
"react-hot-toast": "^2.4.1",
"react-i18next": "^14.1.2",
"react-image-file-resizer": "^0.4.8",
@@ -86,25 +79,33 @@
"react-window": "^1.8.10",
"rss": "^1.2.2",
"rss-parser": "^3.13.0",
+ "sharp": "^0.34.5",
"socks-proxy-agent": "^8.0.2",
"stripe": "^18.4.0",
"tailwind-merge": "^3.3.0",
- "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.1",
- "zod": "^3.23.8",
+ "zod": "^4.1.13",
"zustand": "^4.3.8"
},
"devDependencies": {
- "@playwright/test": "^1.55.0",
+ "@playwright/test": "1.57.0",
"@types/bcrypt": "^5.0.0",
- "@types/dompurify": "^3.0.4",
+ "@types/crypto-js": "^4.1.1",
+ "@types/formidable": "^3.4.5",
"@types/jsdom": "^21.1.3",
+ "@types/node": "^20.10.4",
"@types/node-fetch": "^2.6.10",
+ "@types/nodemailer": "^7.0.4",
+ "@types/papaparse": "^5.3.16",
+ "@types/react": "18.3.1",
+ "@types/react-dom": "18.3.1",
"@types/react-window": "^1.8.8",
+ "@types/rss": "^0.0.32",
"@types/shelljs": "^0.8.15",
"autoprefixer": "^10.4.14",
"daisyui": "^4.4.2",
+ "eslint": "8.46.0",
"postcss": "^8.4.26",
"prettier": "3.1.1",
"tailwindcss": "^3.4.17",
diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx
index 868f6449..44ed3dee 100644
--- a/apps/web/pages/_app.tsx
+++ b/apps/web/pages/_app.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect } from "react";
+import React, { ReactElement, ReactNode, useEffect } from "react";
import "@/styles/globals.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import { SessionProvider } from "next-auth/react";
@@ -13,6 +13,7 @@ import { isPWA } from "@/lib/utils";
import { appWithTranslation } from "next-i18next";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { NextPage } from "next";
const queryClient = new QueryClient({
defaultOptions: {
@@ -22,12 +23,19 @@ const queryClient = new QueryClient({
},
});
-function App({
- Component,
- pageProps,
-}: AppProps<{
- session: Session;
-}>) {
+export type NextPageWithLayout = NextPage
& {
+ getLayout?: (page: ReactElement) => ReactNode;
+};
+
+type PageProps = { session?: Session | null };
+
+type AppPropsWithLayout = AppProps & {
+ Component: NextPageWithLayout;
+};
+
+function App({ Component, pageProps }: AppPropsWithLayout) {
+ const getLayout = Component.getLayout ?? ((page) => page);
+
useEffect(() => {
if (isPWA()) {
const meta = document.createElement("meta");
@@ -98,7 +106,7 @@ function App({
)}
-
+ {getLayout()}
{/* */}
diff --git a/apps/web/pages/api/v1/archives/[linkId].ts b/apps/web/pages/api/v1/archives/[linkId].ts
index 7bc69ebd..764d13bd 100644
--- a/apps/web/pages/api/v1/archives/[linkId].ts
+++ b/apps/web/pages/api/v1/archives/[linkId].ts
@@ -130,7 +130,10 @@ async function handleGet(req: NextApiRequest, res: NextApiResponse) {
: `archives/${collection.id}/${linkId + suffix}`;
const { file, contentType, status } = await readFile(filePath);
- res.setHeader("Content-Type", contentType).status(status as number);
+ res
+ .setHeader("Content-Type", contentType)
+ .setHeader("Cache-Control", "private, max-age=31536000, immutable")
+ .status(status as number);
return res.send(file);
}
diff --git a/apps/web/pages/api/v1/auth/[...nextauth].ts b/apps/web/pages/api/v1/auth/[...nextauth].ts
index 5743a5e2..e74a4507 100644
--- a/apps/web/pages/api/v1/auth/[...nextauth].ts
+++ b/apps/web/pages/api/v1/auth/[...nextauth].ts
@@ -139,7 +139,7 @@ if (emailEnabled) {
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
maxAge: 1200,
- async sendVerificationRequest({ identifier, url, provider, token }) {
+ async sendVerificationRequest({ identifier, url, provider, token }: any) {
const recentVerificationRequestsCount =
await prisma.verificationToken.count({
where: {
@@ -160,13 +160,13 @@ if (emailEnabled) {
token,
});
},
- }),
+ } as any),
EmailProvider({
id: "invite",
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
maxAge: 1200,
- async sendVerificationRequest({ identifier, url, provider, token }) {
+ async sendVerificationRequest({ identifier, url, provider, token }: any) {
const parentSubscriptionEmail = (
await prisma.user.findFirst({
where: {
@@ -210,7 +210,7 @@ if (emailEnabled) {
token,
});
},
- })
+ } as any)
);
}
diff --git a/apps/web/pages/api/v1/avatar/[id].ts b/apps/web/pages/api/v1/avatar/[id].ts
index 47b255b7..111b10d9 100644
--- a/apps/web/pages/api/v1/avatar/[id].ts
+++ b/apps/web/pages/api/v1/avatar/[id].ts
@@ -94,6 +94,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res
.setHeader("Content-Type", contentType)
+ .setHeader("Cache-Control", "private, max-age=31536000, immutable")
.status(status as number)
.send(file);
}
diff --git a/apps/web/pages/api/v1/getFavicon/index.ts b/apps/web/pages/api/v1/getFavicon/index.ts
new file mode 100644
index 00000000..0c39b5d0
--- /dev/null
+++ b/apps/web/pages/api/v1/getFavicon/index.ts
@@ -0,0 +1,91 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { Readable } from "node:stream";
+
+function isImage(ct: string | null) {
+ return !!ct && ct.toLowerCase().startsWith("image/");
+}
+
+async function fetchImage(src: string, timeoutMs = 1500) {
+ const controller = new AbortController();
+ const t = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const r = await fetch(src, {
+ method: "GET",
+ headers: { Accept: "image/*" },
+ redirect: "follow",
+ signal: controller.signal,
+ });
+
+ if (!r.ok || !r.body) return null;
+
+ const ct = r.headers.get("content-type");
+ if (!isImage(ct)) return null;
+
+ return { body: r.body, contentType: ct! };
+ } catch {
+ return null;
+ } finally {
+ clearTimeout(t);
+ }
+}
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ if (req.method !== "GET") return res.status(405).end();
+
+ const raw = req.query.url;
+ const urlStr = Array.isArray(raw) ? raw[0] : raw;
+ if (!urlStr) return res.status(400).end();
+
+ let u: URL;
+ try {
+ u = new URL(decodeURIComponent(urlStr));
+ } catch {
+ return res.status(204).end();
+ }
+
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
+ return res.status(204).end();
+ }
+
+ const origin = u.origin;
+ const hostname = u.hostname;
+
+ const canonical = `/api/v1/getFavicon?url=${encodeURIComponent(origin)}`;
+ if (req.url !== canonical) {
+ res.setHeader("Cache-Control", "public, max-age=3600");
+ return res.redirect(308, canonical);
+ }
+
+ const sources = [
+ `https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(
+ origin
+ )}&size=64`,
+ `https://icons.duckduckgo.com/ip3/${hostname}.ico`,
+ ];
+
+ for (const src of sources) {
+ const hit = await fetchImage(src);
+ if (!hit) continue;
+
+ res.status(200);
+ res.setHeader("Content-Type", hit.contentType);
+ res.setHeader(
+ "Cache-Control",
+ "public, max-age=86400, s-maxage=2592000, stale-while-revalidate=604800, immutable"
+ );
+
+ Readable.fromWeb(hit.body as any).pipe(res);
+ return;
+ }
+
+ res.status(204);
+ res.setHeader(
+ "Cache-Control",
+ "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800"
+ );
+ return res.end();
+}
diff --git a/apps/web/pages/api/v1/links/[id]/index.ts b/apps/web/pages/api/v1/links/[id]/index.ts
index 7f33c662..3e95bbb5 100644
--- a/apps/web/pages/api/v1/links/[id]/index.ts
+++ b/apps/web/pages/api/v1/links/[id]/index.ts
@@ -23,7 +23,8 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
const updated = await updateLinkById(
user.id,
Number(req.query.id),
- req.body
+ req.body,
+ true // since we're passing the existing tags into the request body
);
return res.status(updated.status).json({
response: updated.response,
diff --git a/apps/web/pages/auth/reset-password.tsx b/apps/web/pages/auth/reset-password.tsx
index cd7173c9..7b590ebc 100644
--- a/apps/web/pages/auth/reset-password.tsx
+++ b/apps/web/pages/auth/reset-password.tsx
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import TextInput from "@/components/TextInput";
-import CenteredForm from "@/layouts/CenteredForm";
+import CenteredForm from "@/components/CenteredForm";
import Link from "next/link";
import { useRouter } from "next/router";
import { FormEvent, useState } from "react";
diff --git a/apps/web/pages/collections/[id].tsx b/apps/web/pages/collections/[id].tsx
index 87bba518..d70c8cc2 100644
--- a/apps/web/pages/collections/[id].tsx
+++ b/apps/web/pages/collections/[id].tsx
@@ -6,7 +6,7 @@ import {
ViewMode,
} from "@linkwarden/types";
import { useRouter } from "next/router";
-import React, { useEffect, useState } from "react";
+import React, { ReactElement, useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout";
import ProfilePhoto from "@/components/ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
@@ -36,9 +36,9 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
-import DragNDrop from "@/components/DragNDrop";
+import { NextPageWithLayout } from "../_app";
-export default function Index() {
+const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const router = useRouter();
@@ -112,300 +112,291 @@ export default function Index() {
);
return (
-
-
-
- {activeCollection && (
-
-
- {activeCollection.icon ? (
-
- ) : (
-
- )}
-
-
- {activeCollection?.name}
-
-
-
-
-
-
-
-
-
- {
- for (const link of links) {
- if (link.url) window.open(link.url, "_blank");
- }
- }}
- >
-
- {t("open_all_links")}
-
-
- {permissions === true && (
- setEditCollectionModal(true)}
- >
-
- {t("edit_collection_info")}
-
- )}
-
- setEditCollectionSharingModal(true)}
- >
-
- {permissions === true
- ? t("share_and_collaborate")
- : t("view_team")}
-
-
- {permissions === true && (
- setNewCollectionModal(true)}
- >
-
- {t("create_subcollection")}
-
- )}
-
-
-
- setDeleteCollectionModal(true)}
- className="text-error"
- >
- {permissions === true ? (
- <>
-
- {t("delete_collection")}
- >
- ) : (
- <>
-
- {t("leave_collection")}
- >
- )}
-
-
-
-
- )}
-
- {activeCollection && (
-
-
-
setEditCollectionSharingModal(true)}
- >
- {collectionOwner.id && (
-
- )}
- {activeCollection.members
- .sort((a, b) => (a.userId as number) - (b.userId as number))
- .map((e, i) => {
- return (
-
- );
- })
- .slice(0, 3)}
- {activeCollection.members.length - 3 > 0 && (
-
-
- +{activeCollection.members.length - 3}
-
-
- )}
-
-
-
- {activeCollection.members.length > 0
- ? activeCollection.members.length === 1
- ? t("by_author_and_other", {
- author: collectionOwner.name,
- count: activeCollection.members.length,
- })
- : t("by_author_and_others", {
- author: collectionOwner.name,
- count: activeCollection.members.length,
- })
- : t("by_author", { author: collectionOwner.name })}
-
-
-
- )}
-
- {activeCollection?.description && (
-
{activeCollection.description}
- )}
-
-
-
- {collections.some((e) => e.parentId === activeCollection?.id) && (
- <>
-
e.parentId === activeCollection?.id)
- .length === 1
- ? "showing_count_result"
- : "showing_count_results",
- {
- count: collections.filter(
- (e) => e.parentId === activeCollection?.id
- ).length,
- }
- )}
- className="scale-90 w-fit"
- />
-
- {collections
- .filter((e) => e.parentId === activeCollection?.id)
- .map((e) => (
-
- ))}
-
- >
- )}
-
-
- {collections.some((e) => e.parentId === activeCollection?.id) ? (
-
+
+ {activeCollection.icon ? (
+
) : (
-
- {activeCollection?._count?.links === 1
- ? t("showing_count_result", {
- count: activeCollection?._count?.links,
- })
- : t("showing_count_results", {
- count: activeCollection?._count?.links,
- })}
-
+
)}
-
-
- {!data.isLoading && links && !links[0] &&
}
+
+ {activeCollection?.name}
+
+
+
+
+
+
+
+
+
+ {
+ for (const link of links) {
+ if (link.url) window.open(link.url, "_blank");
+ }
+ }}
+ >
+
+ {t("open_all_links")}
+
+
+ {permissions === true && (
+ setEditCollectionModal(true)}>
+
+ {t("edit_collection_info")}
+
+ )}
+
+ setEditCollectionSharingModal(true)}
+ >
+
+ {permissions === true
+ ? t("share_and_collaborate")
+ : t("view_team")}
+
+
+ {permissions === true && (
+ setNewCollectionModal(true)}>
+
+ {t("create_subcollection")}
+
+ )}
+
+
+
+ setDeleteCollectionModal(true)}
+ className="text-error"
+ >
+ {permissions === true ? (
+ <>
+
+ {t("delete_collection")}
+ >
+ ) : (
+ <>
+
+ {t("leave_collection")}
+ >
+ )}
+
+
+
- {activeCollection && (
- <>
- {editCollectionModal && (
- setEditCollectionModal(false)}
- activeCollection={activeCollection}
- />
+ )}
+
+ {activeCollection && (
+
+
+
setEditCollectionSharingModal(true)}
+ >
+ {collectionOwner.id && (
+
+ )}
+ {activeCollection.members
+ .sort((a, b) => (a.userId as number) - (b.userId as number))
+ .map((e, i) => {
+ return (
+
+ );
+ })
+ .slice(0, 3)}
+ {activeCollection.members.length - 3 > 0 && (
+
+
+ +{activeCollection.members.length - 3}
+
+
+ )}
+
+
+
+ {activeCollection.members.length > 0
+ ? activeCollection.members.length === 1
+ ? t("by_author_and_other", {
+ author: collectionOwner.name,
+ count: activeCollection.members.length,
+ })
+ : t("by_author_and_others", {
+ author: collectionOwner.name,
+ count: activeCollection.members.length,
+ })
+ : t("by_author", { author: collectionOwner.name })}
+
+
+
+ )}
+
+ {activeCollection?.description && {activeCollection.description}
}
+
+
+
+ {collections.some((e) => e.parentId === activeCollection?.id) && (
+ <>
+ e.parentId === activeCollection?.id)
+ .length === 1
+ ? "showing_count_result"
+ : "showing_count_results",
+ {
+ count: collections.filter(
+ (e) => e.parentId === activeCollection?.id
+ ).length,
+ }
)}
- {editCollectionSharingModal && (
- setEditCollectionSharingModal(false)}
- activeCollection={activeCollection}
- />
- )}
- {newCollectionModal && (
- setNewCollectionModal(false)}
- parent={activeCollection}
- />
- )}
- {deleteCollectionModal && (
- setDeleteCollectionModal(false)}
- activeCollection={activeCollection}
- />
- )}
- >
+ className="scale-90 w-fit"
+ />
+
+ {collections
+ .filter((e) => e.parentId === activeCollection?.id)
+ .map((e) => (
+
+ ))}
+
+ >
+ )}
+
+
+ {collections.some((e) => e.parentId === activeCollection?.id) ? (
+
+ ) : (
+
+ {activeCollection?._count?.links === 1
+ ? t("showing_count_result", {
+ count: activeCollection?._count?.links,
+ })
+ : t("showing_count_results", {
+ count: activeCollection?._count?.links,
+ })}
+
)}
-
-
+
+
+
+ {!data.isLoading && links && !links[0] && }
+ {activeCollection && (
+ <>
+ {editCollectionModal && (
+ setEditCollectionModal(false)}
+ activeCollection={activeCollection}
+ />
+ )}
+ {editCollectionSharingModal && (
+ setEditCollectionSharingModal(false)}
+ activeCollection={activeCollection}
+ />
+ )}
+ {newCollectionModal && (
+ setNewCollectionModal(false)}
+ parent={activeCollection}
+ />
+ )}
+ {deleteCollectionModal && (
+ setDeleteCollectionModal(false)}
+ activeCollection={activeCollection}
+ />
+ )}
+ >
+ )}
+