From cac90524ed39c0755a0d9ae80be7c37df12d1025 Mon Sep 17 00:00:00 2001 From: GoodM4ven Date: Fri, 8 Mar 2024 14:34:56 +0300 Subject: [PATCH 01/79] [Enhancement] Accounting for "www." prefix for duplicates --- lib/api/controllers/links/postLink.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index ec03d2a9..e286454c 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -116,9 +116,18 @@ export default async function postLink( }); if (user?.preventDuplicateLinks) { + const trimmedUrl = link.url?.trim(); + const wwwPrefix = 'www.'; + const hasWwwPrefix = trimmedUrl?.includes(`://${wwwPrefix}`); + const urlWithoutWww = hasWwwPrefix ? trimmedUrl?.replace(`://${wwwPrefix}`, '://') : trimmedUrl; + const urlWithWww = hasWwwPrefix ? trimmedUrl : trimmedUrl?.replace('://', `://${wwwPrefix}`); + const existingLink = await prisma.link.findFirst({ where: { - url: link.url?.trim(), + OR: [ + { url: trimmedUrl }, + { url: hasWwwPrefix ? urlWithoutWww : urlWithWww }, // Toggling "www." + ], collection: { ownerId: userId, }, From cc2d7c863d2828b26786d4cde0ff7ca8d0c058a3 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Sun, 14 Jan 2024 11:42:30 -0500 Subject: [PATCH 02/79] Add Authelia as a custom oidc source set a path to browsers outside of /root Grant root ownership over /data set umask + perms after yarn build revert local testing to upstream --- .env.sample | 7 ++++ Dockerfile | 2 +- pages/api/v1/auth/[...nextauth].ts | 55 +++++++++++++++++++++++------- pages/api/v1/logins/index.ts | 9 ++++- types/enviornment.d.ts | 9 ++++- 5 files changed, 67 insertions(+), 15 deletions(-) diff --git a/.env.sample b/.env.sample index d2a8d608..7939458e 100644 --- a/.env.sample +++ b/.env.sample @@ -65,6 +65,13 @@ AUTH0_ISSUER= AUTH0_CLIENT_SECRET= AUTH0_CLIENT_ID= +# Authelia +NEXT_PUBLIC_AUTHELIA_ENABLED="" +AUTHELIA_CLIENT_ID="" +AUTHELIA_CLIENT_SECRET="" +AUTHELIA_WELLKNOWN_URL="" + + # Authentik NEXT_PUBLIC_AUTHENTIK_ENABLED= AUTHENTIK_CUSTOM_NAME= diff --git a/Dockerfile b/Dockerfile index a6d6129f..9de15417 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,4 +20,4 @@ COPY . . RUN yarn prisma generate && \ yarn build -CMD yarn prisma migrate deploy && yarn start +CMD yarn prisma migrate deploy && yarn start \ No newline at end of file diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index d7549de8..53c25888 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -97,19 +97,19 @@ if ( const user = await prisma.user.findFirst({ where: emailEnabled ? { - OR: [ - { - username: username.toLowerCase(), - }, - { - email: username?.toLowerCase(), - }, - ], - emailVerified: { not: null }, - } + OR: [ + { + username: username.toLowerCase(), + }, + { + email: username?.toLowerCase(), + }, + ], + emailVerified: { not: null }, + } : { - username: username.toLowerCase(), - }, + username: username.toLowerCase(), + }, }); let passwordMatches: boolean = false; @@ -239,6 +239,37 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") { }; } +// Authelia +if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") { + providers.push( + { + id: "authelia", + name: "Authelia", + type: "oauth", + clientId: process.env.AUTHELIA_CLIENT_ID!, + clientSecret: process.env.AUTHELIA_CLIENT_SECRET!, + wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!, + authorization: { params: { scope: "openid email profile" } }, + idToken: true, + checks: ["pkce", "state"], + profile(profile) { + return { + id: profile.sub, + name: profile.name, + email: profile.email, + username: profile.preferred_username, + } + }, + } + ); + + const _linkAccount = adapter.linkAccount; + adapter.linkAccount = (account) => { + const { "not-before-policy": _, refresh_expires_in, ...data } = account; + return _linkAccount ? _linkAccount(data) : undefined; + }; +} + // Authentik if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") { providers.push( diff --git a/pages/api/v1/logins/index.ts b/pages/api/v1/logins/index.ts index 34b3aaf3..bdf65889 100644 --- a/pages/api/v1/logins/index.ts +++ b/pages/api/v1/logins/index.ts @@ -391,10 +391,17 @@ export function getLogins() { name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom", }); } + // Authelia + if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") { + buttonAuths.push({ + method: "authelia", + name: process.env.AUTHELIA_CUSTOM_NAME ?? "Authelia", + }); + } return { credentialsEnabled: process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" || - process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined + process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined ? "true" : "false", emailEnabled: diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index 58d74c53..ed733963 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -66,6 +66,13 @@ declare global { AUTH0_CLIENT_SECRET?: string; AUTH0_CLIENT_ID?: string; + // Authelia + NEXT_PUBLIC_AUTHELIA_ENABLED?: string; + AUTHELIA_CUSTOM_NAME?: string; + AUTHELIA_CLIENT_ID?: string; + AUTHELIA_CLIENT_SECRET?: string; + AUTHELIA_WELLKNOWN_URL?: string; + // Authentik NEXT_PUBLIC_AUTHENTIK_ENABLED?: string; AUTHENTIK_CUSTOM_NAME?: string; @@ -400,4 +407,4 @@ declare global { } } -export {}; +export { }; From ede3882a94215521a46211759d553e8dad2501ff Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Wed, 20 Mar 2024 09:56:14 -0400 Subject: [PATCH 03/79] uncomment code --- components/Navbar.tsx | 4 +- lib/api/controllers/links/postLink.ts | 18 +-- pages/api/v1/archives/[linkId].ts | 153 +++++++++++++------------- 3 files changed, 87 insertions(+), 88 deletions(-) diff --git a/components/Navbar.tsx b/components/Navbar.tsx index cb38c93d..df672c1f 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -93,7 +93,7 @@ export default function Navbar() { New Link - {/*
  • +
  • { (document?.activeElement as HTMLElement)?.blur(); @@ -104,7 +104,7 @@ export default function Navbar() { > Upload File
    -
  • */} +
  • { diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index ba4e513d..80205e4a 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -12,14 +12,16 @@ export default async function postLink( link: LinkIncludingShortenedCollectionAndTags, userId: number ) { - try { - new URL(link.url || ""); - } catch (error) { - return { - response: - "Please enter a valid Address for the Link. (It should start with http/https)", - status: 400, - }; + if (link.url || link.type === "url") { + try { + new URL(link.url || ""); + } catch (error) { + return { + response: + "Please enter a valid Address for the Link. (It should start with http/https)", + status: 400, + }; + } } if (!link.collection.id && link.collection.name) { diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts index b13e690f..5e5ada2f 100644 --- a/pages/api/v1/archives/[linkId].ts +++ b/pages/api/v1/archives/[linkId].ts @@ -73,83 +73,80 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { return res.send(file); } + } else if (req.method === "POST") { + const user = await verifyUser({ req, res }); + if (!user) return; + + const collectionPermissions = await getPermission({ + userId: user.id, + linkId, + }); + + const memberHasAccess = collectionPermissions?.members.some( + (e: UsersAndCollections) => e.userId === user.id && e.canCreate + ); + + if (!(collectionPermissions?.ownerId === user.id || memberHasAccess)) + return { response: "Collection is not accessible.", status: 401 }; + + // await uploadHandler(linkId, ) + + const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE); + + const form = formidable({ + maxFields: 1, + maxFiles: 1, + maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576, + }); + + form.parse(req, async (err, fields, files) => { + const allowedMIMETypes = [ + "application/pdf", + "image/png", + "image/jpg", + "image/jpeg", + ]; + + if ( + err || + !files.file || + !files.file[0] || + !allowedMIMETypes.includes(files.file[0].mimetype || "") + ) { + // Handle parsing error + return res.status(500).json({ + response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`, + }); + } else { + const fileBuffer = fs.readFileSync(files.file[0].filepath); + + const linkStillExists = await prisma.link.findUnique({ + where: { id: linkId }, + }); + + if (linkStillExists) { + await createFile({ + filePath: `archives/${collectionPermissions?.id}/${ + linkId + suffix + }`, + data: fileBuffer, + }); + + await prisma.link.update({ + where: { id: linkId }, + data: { + image: `archives/${collectionPermissions?.id}/${linkId + suffix}`, + lastPreserved: new Date().toISOString(), + }, + }); + } + + fs.unlinkSync(files.file[0].filepath); + } + + return res.status(200).json({ + response: files, + }); + }); } - // else if (req.method === "POST") { - // const user = await verifyUser({ req, res }); - // if (!user) return; - - // const collectionPermissions = await getPermission({ - // userId: user.id, - // linkId, - // }); - - // const memberHasAccess = collectionPermissions?.members.some( - // (e: UsersAndCollections) => e.userId === user.id && e.canCreate - // ); - - // if (!(collectionPermissions?.ownerId === user.id || memberHasAccess)) - // return { response: "Collection is not accessible.", status: 401 }; - - // // await uploadHandler(linkId, ) - - // const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE); - - // const form = formidable({ - // maxFields: 1, - // maxFiles: 1, - // maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576, - // }); - - // form.parse(req, async (err, fields, files) => { - // const allowedMIMETypes = [ - // "application/pdf", - // "image/png", - // "image/jpg", - // "image/jpeg", - // ]; - - // if ( - // err || - // !files.file || - // !files.file[0] || - // !allowedMIMETypes.includes(files.file[0].mimetype || "") - // ) { - // // Handle parsing error - // return res.status(500).json({ - // response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`, - // }); - // } else { - // const fileBuffer = fs.readFileSync(files.file[0].filepath); - - // const linkStillExists = await prisma.link.findUnique({ - // where: { id: linkId }, - // }); - - // if (linkStillExists) { - // await createFile({ - // filePath: `archives/${collectionPermissions?.id}/${ - // linkId + suffix - // }`, - // data: fileBuffer, - // }); - - // await prisma.link.update({ - // where: { id: linkId }, - // data: { - // image: `archives/${collectionPermissions?.id}/${ - // linkId + suffix - // }`, - // lastPreserved: new Date().toISOString(), - // }, - // }); - // } - - // fs.unlinkSync(files.file[0].filepath); - // } - - // return res.status(200).json({ - // response: files, - // }); - // }); - // } } From 495af0a7523df1cf4071760799ef17ad53e22acb Mon Sep 17 00:00:00 2001 From: Paul Hovey Date: Sat, 23 Mar 2024 14:57:34 -0500 Subject: [PATCH 04/79] adds description and tags parsing for pinboard html import --- .../migration/importFromHTMLFile.ts | 92 ++++++++++++------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/lib/api/controllers/migration/importFromHTMLFile.ts b/lib/api/controllers/migration/importFromHTMLFile.ts index 43986007..833643b6 100644 --- a/lib/api/controllers/migration/importFromHTMLFile.ts +++ b/lib/api/controllers/migration/importFromHTMLFile.ts @@ -74,7 +74,7 @@ async function processBookmarks( } else if (item.type === "element" && item.tagName === "a") { // process link - const linkUrl = item?.attributes.find((e) => e.key === "href")?.value; + const linkUrl = item?.attributes.find((e) => e.key.toLowerCase() === "href")?.value; const linkName = ( item?.children.find((e) => e.type === "text") as TextNode )?.content; @@ -82,14 +82,41 @@ async function processBookmarks( .find((e) => e.key === "tags") ?.value.split(","); + // set date if available + const linkDateValue = item?.attributes.find((e) => e.key.toLowerCase() === "add_date")?.value; + let linkDate = Date.now(); + if (linkDateValue) { + try { + linkDate = Number.parseInt(linkDateValue); + // use the year 2000 as an arbitrary cutoff to determine if a link is in seconds or milliseconds + const year2000ms = 946684800000; + if ((linkDate > 0) && (linkDate < year2000ms)) { + linkDate = linkDate * 1000; // turn epoch seconds into milliseconds + } + } catch (error) { + // just ignore the error if it happens + } + } + + let linkDesc = ""; + const descNode = data.children.find((e) => (e as Element).tagName?.toLowerCase() === "dd") as Element; + if (descNode && descNode.children.length > 0) { + try { + linkDesc = (descNode.children[0] as TextNode).content; + } catch (error) { + // just ignore the error if it happens + } + } + if (linkUrl && parentCollectionId) { await createLink( userId, linkUrl, parentCollectionId, linkName, - "", - linkTags + linkDesc, + linkTags, + linkDate ); } else if (linkUrl) { // create a collection named "Imported Bookmarks" and add the link to it @@ -100,8 +127,9 @@ async function processBookmarks( linkUrl, collectionId, linkName, - "", - linkTags + linkDesc, + linkTags, + linkDate ); } @@ -136,10 +164,10 @@ const createCollection = async ( name: collectionName, parent: parentId ? { - connect: { - id: parentId, - }, - } + connect: { + id: parentId, + }, + } : undefined, owner: { connect: { @@ -160,39 +188,41 @@ const createLink = async ( collectionId: number, name?: string, description?: string, - tags?: string[] + tags?: string[], + createdAt?: number, ) => { await prisma.link.create({ data: { name: name || "", - url, - description, - collectionId, + type: url, + description: description, + collectionId: collectionId, tags: tags && tags[0] ? { - connectOrCreate: tags.map((tag: string) => { - return ( - { - where: { - name_ownerId: { - name: tag.trim(), - ownerId: userId, - }, - }, - create: { + connectOrCreate: tags.map((tag: string) => { + return ( + { + where: { + name_ownerId: { name: tag.trim(), - owner: { - connect: { - id: userId, - }, + ownerId: userId, + }, + }, + create: { + name: tag.trim(), + owner: { + connect: { + id: userId, }, }, - } || undefined - ); - }), - } + }, + } || undefined + ); + }), + } : undefined, + createdAt: createdAt }, }); }; From 2e2d7baee1564066bb6a407829a6b1021da86481 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Wed, 27 Mar 2024 03:20:00 -0400 Subject: [PATCH 05/79] fix imports --- .../LinkViews/LinkComponents/LinkDate.tsx | 15 +- components/ReadableView.tsx | 8 +- .../migration/importFromHTMLFile.ts | 144 +++++++++++------- .../migration.sql | 2 + prisma/schema.prisma | 1 + 5 files changed, 107 insertions(+), 63 deletions(-) create mode 100644 prisma/migrations/20240327070238_add_import_date_field_for_links/migration.sql diff --git a/components/LinkViews/LinkComponents/LinkDate.tsx b/components/LinkViews/LinkComponents/LinkDate.tsx index e512dcb3..7bed676a 100644 --- a/components/LinkViews/LinkComponents/LinkDate.tsx +++ b/components/LinkViews/LinkComponents/LinkDate.tsx @@ -6,14 +6,13 @@ export default function LinkDate({ }: { link: LinkIncludingShortenedCollectionAndTags; }) { - const formattedDate = new Date(link.createdAt as string).toLocaleString( - "en-US", - { - year: "numeric", - month: "short", - day: "numeric", - } - ); + const formattedDate = new Date( + (link.importDate || link.createdAt) as string + ).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); return (
    diff --git a/components/ReadableView.tsx b/components/ReadableView.tsx index f8eb3b67..9c7ce176 100644 --- a/components/ReadableView.tsx +++ b/components/ReadableView.tsx @@ -34,6 +34,8 @@ export default function ReadableView({ link }: Props) { const [imageError, setImageError] = useState(false); const [colorPalette, setColorPalette] = useState(); + const [date, setDate] = useState(); + const colorThief = new ColorThief(); const router = useRouter(); @@ -54,6 +56,8 @@ export default function ReadableView({ link }: Props) { }; fetchLinkContent(); + + setDate(link.importDate || link.createdAt); }, [link]); useEffect(() => { @@ -211,8 +215,8 @@ export default function ReadableView({ link }: Props) {

    - {link?.createdAt - ? new Date(link?.createdAt).toLocaleString("en-US", { + {date + ? new Date(date).toLocaleString("en-US", { year: "numeric", month: "long", day: "numeric", diff --git a/lib/api/controllers/migration/importFromHTMLFile.ts b/lib/api/controllers/migration/importFromHTMLFile.ts index 833643b6..21fb3c48 100644 --- a/lib/api/controllers/migration/importFromHTMLFile.ts +++ b/lib/api/controllers/migration/importFromHTMLFile.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db"; import createFolder from "@/lib/api/storage/createFolder"; import { JSDOM } from "jsdom"; import { parse, Node, Element, TextNode } from "himalaya"; +import { writeFileSync } from "fs"; const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; @@ -36,7 +37,9 @@ export default async function importFromHTMLFile( const jsonData = parse(document.documentElement.outerHTML); - for (const item of jsonData) { + const processedArray = processNodes(jsonData); + + for (const item of processedArray) { console.log(item); await processBookmarks(userId, item as Element); } @@ -74,7 +77,9 @@ async function processBookmarks( } else if (item.type === "element" && item.tagName === "a") { // process link - const linkUrl = item?.attributes.find((e) => e.key.toLowerCase() === "href")?.value; + const linkUrl = item?.attributes.find( + (e) => e.key.toLowerCase() === "href" + )?.value; const linkName = ( item?.children.find((e) => e.type === "text") as TextNode )?.content; @@ -83,30 +88,22 @@ async function processBookmarks( ?.value.split(","); // set date if available - const linkDateValue = item?.attributes.find((e) => e.key.toLowerCase() === "add_date")?.value; - let linkDate = Date.now(); - if (linkDateValue) { - try { - linkDate = Number.parseInt(linkDateValue); - // use the year 2000 as an arbitrary cutoff to determine if a link is in seconds or milliseconds - const year2000ms = 946684800000; - if ((linkDate > 0) && (linkDate < year2000ms)) { - linkDate = linkDate * 1000; // turn epoch seconds into milliseconds - } - } catch (error) { - // just ignore the error if it happens - } - } + const linkDateValue = item?.attributes.find( + (e) => e.key.toLowerCase() === "add_date" + )?.value; - let linkDesc = ""; - const descNode = data.children.find((e) => (e as Element).tagName?.toLowerCase() === "dd") as Element; - if (descNode && descNode.children.length > 0) { - try { - linkDesc = (descNode.children[0] as TextNode).content; - } catch (error) { - // just ignore the error if it happens - } - } + const linkDate = linkDateValue + ? new Date(Number(linkDateValue) * 1000) + : undefined; + + let linkDesc = + ( + ( + item?.children?.find( + (e) => e.type === "element" && e.tagName === "dd" + ) as Element + )?.children[0] as TextNode + )?.content || ""; if (linkUrl && parentCollectionId) { await createLink( @@ -164,10 +161,10 @@ const createCollection = async ( name: collectionName, parent: parentId ? { - connect: { - id: parentId, - }, - } + connect: { + id: parentId, + }, + } : undefined, owner: { connect: { @@ -189,40 +186,81 @@ const createLink = async ( name?: string, description?: string, tags?: string[], - createdAt?: number, + importDate?: Date ) => { await prisma.link.create({ data: { name: name || "", - type: url, - description: description, - collectionId: collectionId, + url, + description, + collectionId, tags: tags && tags[0] ? { - connectOrCreate: tags.map((tag: string) => { - return ( - { - where: { - name_ownerId: { - name: tag.trim(), - ownerId: userId, - }, - }, - create: { - name: tag.trim(), - owner: { - connect: { - id: userId, + connectOrCreate: tags.map((tag: string) => { + return ( + { + where: { + name_ownerId: { + name: tag.trim(), + ownerId: userId, }, }, - }, - } || undefined - ); - }), - } + create: { + name: tag.trim(), + owner: { + connect: { + id: userId, + }, + }, + }, + } || undefined + ); + }), + } : undefined, - createdAt: createdAt + importDate: importDate || undefined, }, }); }; + +function processNodes(nodes: Node[]) { + const findAndProcessDL = (node: Node) => { + if (node.type === "element" && node.tagName === "dl") { + processDLChildren(node); + } else if ( + node.type === "element" && + node.children && + node.children.length + ) { + node.children.forEach((child) => findAndProcessDL(child)); + } + }; + + const processDLChildren = (dlNode: Element) => { + dlNode.children.forEach((child, i) => { + if (child.type === "element" && child.tagName === "dt") { + const nextSibling = dlNode.children[i + 1]; + if ( + nextSibling && + nextSibling.type === "element" && + nextSibling.tagName === "dd" + ) { + const aElement = child.children.find( + (el) => el.type === "element" && el.tagName === "a" + ); + if (aElement && aElement.type === "element") { + // Add the 'dd' element as a child of the 'a' element + aElement.children.push(nextSibling); + // Remove the 'dd' from the parent 'dl' to avoid duplicate processing + dlNode.children.splice(i + 1, 1); + // Adjust the loop counter due to the removal + } + } + } + }); + }; + + nodes.forEach(findAndProcessDL); + return nodes; +} diff --git a/prisma/migrations/20240327070238_add_import_date_field_for_links/migration.sql b/prisma/migrations/20240327070238_add_import_date_field_for_links/migration.sql new file mode 100644 index 00000000..7ebb1fe9 --- /dev/null +++ b/prisma/migrations/20240327070238_add_import_date_field_for_links/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Link" ADD COLUMN "importDate" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index da731152..26d6dc90 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -128,6 +128,7 @@ model Link { pdf String? readable String? lastPreserved DateTime? + importDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt } From 3929f32e63f3b57b08a40bee616f8978facf64e9 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Wed, 27 Mar 2024 03:27:59 -0400 Subject: [PATCH 06/79] minor fix --- types/global.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/types/global.ts b/types/global.ts index e77454dd..b347659a 100644 --- a/types/global.ts +++ b/types/global.ts @@ -7,10 +7,16 @@ type OptionalExcluding = Partial & export interface LinkIncludingShortenedCollectionAndTags extends Omit< Link, - "id" | "createdAt" | "collectionId" | "updatedAt" | "lastPreserved" + | "id" + | "createdAt" + | "collectionId" + | "updatedAt" + | "lastPreserved" + | "importDate" > { id?: number; createdAt?: string; + importDate?: string; collectionId?: number; tags: Tag[]; pinnedBy?: { From c659711181b0fc896ac603366a1211c268b187f3 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Wed, 27 Mar 2024 12:07:29 -0400 Subject: [PATCH 07/79] make the status of the script independent from the app --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cb0c35ab..4568588d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --", "worker:dev": "nodemon --skip-project scripts/worker.ts", "worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts", - "start": "concurrently -k -P \"next start {@}\" \"yarn worker:prod\" --", + "start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --", "build": "next build", "lint": "next lint", "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"" From 58b6f7339c9e7f71c807802ac3011715414b1529 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Wed, 27 Mar 2024 12:08:19 -0400 Subject: [PATCH 08/79] make the status of the script independent from the app --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cb0c35ab..4568588d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --", "worker:dev": "nodemon --skip-project scripts/worker.ts", "worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts", - "start": "concurrently -k -P \"next start {@}\" \"yarn worker:prod\" --", + "start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --", "build": "next build", "lint": "next lint", "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"" From e67fef1d04a69caa24b5bddebbc50191288e57fd Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 1 Apr 2024 02:56:54 -0400 Subject: [PATCH 09/79] progressed file uploads feature (almost done!) --- components/LinkViews/LinkCard.tsx | 19 ++--- .../LinkViews/LinkComponents/LinkActions.tsx | 26 +++--- .../LinkViews/LinkComponents/LinkIcon.tsx | 62 +++++++++----- .../LinkComponents/LinkTypeBadge.tsx | 38 +++++++++ components/LinkViews/LinkList.tsx | 34 ++------ components/ModalContent/UploadFileModal.tsx | 58 +++---------- lib/api/archiveHandler.ts | 31 +------ lib/api/controllers/links/postLink.ts | 2 +- lib/api/generatePreview.ts | 35 ++++++++ lib/client/generateLinkHref.ts | 38 +++++---- package.json | 2 +- pages/api/v1/archives/[linkId].ts | 20 ++++- store/links.ts | 85 ++++++++++++++++++- yarn.lock | 18 ++-- 14 files changed, 292 insertions(+), 176 deletions(-) create mode 100644 components/LinkViews/LinkComponents/LinkTypeBadge.tsx create mode 100644 lib/api/generatePreview.ts diff --git a/components/LinkViews/LinkCard.tsx b/components/LinkViews/LinkCard.tsx index d93904a1..92994422 100644 --- a/components/LinkViews/LinkCard.tsx +++ b/components/LinkViews/LinkCard.tsx @@ -19,6 +19,7 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref"; import useAccountStore from "@/store/account"; import usePermissions from "@/hooks/usePermissions"; import toast from "react-hot-toast"; +import LinkTypeBadge from "./LinkComponents/LinkTypeBadge"; type Props = { link: LinkIncludingShortenedCollectionAndTags; @@ -53,7 +54,9 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { let shortendURL; try { - shortendURL = new URL(link.url || "").host.toLowerCase(); + if (link.url) { + shortendURL = new URL(link.url).host.toLowerCase(); + } } catch (error) { console.log(error); } @@ -109,7 +112,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { editMode && (permissions === true || permissions?.canCreate || permissions?.canDelete); - // window.open ('www.yourdomain.com', '_ blank'); return (

    - { - e.stopPropagation(); - }} - className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100" - > - -

    {shortendURL}

    - +

    diff --git a/components/LinkViews/LinkComponents/LinkActions.tsx b/components/LinkViews/LinkComponents/LinkActions.tsx index d809dfc8..6c250934 100644 --- a/components/LinkViews/LinkComponents/LinkActions.tsx +++ b/components/LinkViews/LinkComponents/LinkActions.tsx @@ -122,18 +122,20 @@ export default function LinkActions({
  • ) : undefined} -
  • -
    { - (document?.activeElement as HTMLElement)?.blur(); - setPreservedFormatsModal(true); - }} - > - Preserved Formats -
    -
  • + {link.type === "url" && ( +
  • +
    { + (document?.activeElement as HTMLElement)?.blur(); + setPreservedFormatsModal(true); + }} + > + Preserved Formats +
    +
  • + )} {permissions === true || permissions?.canDelete ? (
  • (true); return ( <> - {link.url && url && showFavicon ? ( - { - setShowFavicon(false); - }} - /> - ) : showFavicon === false ? ( -
    - -
    + {link.type === "url" && url ? ( + showFavicon ? ( + { + setShowFavicon(false); + }} + /> + ) : ( + + ) ) : link.type === "pdf" ? ( - + ) : link.type === "image" ? ( - + ) : undefined} ); } + +const LinkPlaceholderIcon = ({ + iconClasses, + icon, +}: { + iconClasses: string; + icon: string; +}) => { + return ( +
    + +
    + ); +}; diff --git a/components/LinkViews/LinkComponents/LinkTypeBadge.tsx b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx new file mode 100644 index 00000000..e491330a --- /dev/null +++ b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx @@ -0,0 +1,38 @@ +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import Link from "next/link"; +import React from "react"; + +export default function LinkTypeBadge({ + link, +}: { + link: LinkIncludingShortenedCollectionAndTags; +}) { + let shortendURL; + + if (link.type === "url" && link.url) { + try { + shortendURL = new URL(link.url).host.toLowerCase(); + } catch (error) { + console.log(error); + } + } + + return link.url && shortendURL ? ( + { + e.stopPropagation(); + }} + className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100" + > + +

    {shortendURL}

    + + ) : ( +
    + {link.type} +
    + ); +} diff --git a/components/LinkViews/LinkList.tsx b/components/LinkViews/LinkList.tsx index a535adf7..e5bd8bc4 100644 --- a/components/LinkViews/LinkList.tsx +++ b/components/LinkViews/LinkList.tsx @@ -16,6 +16,7 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref"; import useAccountStore from "@/store/account"; import usePermissions from "@/hooks/usePermissions"; import toast from "react-hot-toast"; +import LinkTypeBadge from "./LinkComponents/LinkTypeBadge"; type Props = { link: LinkIncludingShortenedCollectionAndTags; @@ -56,14 +57,6 @@ export default function LinkCardCompact({ } }; - let shortendURL; - - try { - shortendURL = new URL(link.url || "").host.toLowerCase(); - } catch (error) { - console.log(error); - } - const [collection, setCollection] = useState( collections.find( @@ -130,7 +123,11 @@ export default function LinkCardCompact({ } >
    - +
    @@ -143,24 +140,7 @@ export default function LinkCardCompact({ {collection ? ( ) : undefined} - {link.url ? ( - { - e.stopPropagation(); - }} - className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100" - > - -

    {shortendURL}

    - - ) : ( -
    - {link.type} -
    - )} +
    diff --git a/components/ModalContent/UploadFileModal.tsx b/components/ModalContent/UploadFileModal.tsx index 497bc91e..49524aa2 100644 --- a/components/ModalContent/UploadFileModal.tsx +++ b/components/ModalContent/UploadFileModal.tsx @@ -43,7 +43,7 @@ export default function UploadFileModal({ onClose }: Props) { const [file, setFile] = useState(); - const { addLink } = useLinkStore(); + const { uploadFile } = useLinkStore(); const [submitLoader, setSubmitLoader] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false); @@ -100,56 +100,22 @@ export default function UploadFileModal({ onClose }: Props) { const submit = async () => { if (!submitLoader && file) { - let fileType: ArchivedFormat | null = null; - let linkType: "url" | "image" | "pdf" | null = null; + setSubmitLoader(true); - if (file?.type === "image/jpg" || file.type === "image/jpeg") { - fileType = ArchivedFormat.jpeg; - linkType = "image"; - } else if (file.type === "image/png") { - fileType = ArchivedFormat.png; - linkType = "image"; - } else if (file.type === "application/pdf") { - fileType = ArchivedFormat.pdf; - linkType = "pdf"; - } + const load = toast.loading("Creating..."); - if (fileType !== null && linkType !== null) { - setSubmitLoader(true); + const response = await uploadFile(link, file); - let response; + toast.dismiss(load); - const load = toast.loading("Creating..."); + if (response.ok) { + toast.success(`Created!`); + onClose(); + } else toast.error(response.data as string); - response = await addLink({ - ...link, - type: linkType, - name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""), - }); + setSubmitLoader(false); - toast.dismiss(load); - - if (response.ok) { - const formBody = new FormData(); - file && formBody.append("file", file); - - await fetch( - `/api/v1/archives/${ - (response.data as LinkIncludingShortenedCollectionAndTags).id - }?format=${fileType}`, - { - body: formBody, - method: "POST", - } - ); - toast.success(`Created!`); - onClose(); - } else toast.error(response.data as string); - - setSubmitLoader(false); - - return response; - } + return response; } }; @@ -238,7 +204,7 @@ export default function UploadFileModal({ onClose }: Props) { className="btn btn-accent dark:border-violet-400 text-white" onClick={submit} > - Create Link + Upload File diff --git a/lib/api/archiveHandler.ts b/lib/api/archiveHandler.ts index 08a35b50..c4b5c5e7 100644 --- a/lib/api/archiveHandler.ts +++ b/lib/api/archiveHandler.ts @@ -10,6 +10,7 @@ import validateUrlSize from "./validateUrlSize"; import removeFile from "./storage/removeFile"; import Jimp from "jimp"; import createFolder from "./storage/createFolder"; +import generatePreview from "./generatePreview"; type LinksAndCollectionAndOwner = Link & { collection: Collection & { @@ -175,35 +176,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) { // Check if imageResponse is not null if (imageResponse && !link.preview?.startsWith("archive")) { const buffer = await imageResponse.body(); - - // Check if buffer is not null - if (buffer) { - // Load the image using Jimp - Jimp.read(buffer, async (err, image) => { - if (image && !err) { - image?.resize(1280, Jimp.AUTO).quality(20); - const processedBuffer = await image?.getBufferAsync( - Jimp.MIME_JPEG - ); - - createFile({ - data: processedBuffer, - filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`, - }).then(() => { - return prisma.link.update({ - where: { id: link.id }, - data: { - preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`, - }, - }); - }); - } - }).catch((err) => { - console.error("Error processing the image:", err); - }); - } else { - console.log("No image data found."); - } + await generatePreview(buffer, link.collectionId, link.id); } await page.goBack(); diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index 80205e4a..85fd5378 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -174,7 +174,7 @@ export default async function postLink( const newLink = await prisma.link.create({ data: { - url: link.url?.trim(), + url: link.url?.trim() || null, name: link.name, description, type: linkType, diff --git a/lib/api/generatePreview.ts b/lib/api/generatePreview.ts new file mode 100644 index 00000000..3c2da0f2 --- /dev/null +++ b/lib/api/generatePreview.ts @@ -0,0 +1,35 @@ +import Jimp from "jimp"; +import { prisma } from "./db"; +import createFile from "./storage/createFile"; + +const generatePreview = async ( + buffer: Buffer, + collectionId: number, + linkId: number +) => { + if (buffer && collectionId && linkId) { + // Load the image using Jimp + await Jimp.read(buffer, async (err, image) => { + if (image && !err) { + image?.resize(1280, Jimp.AUTO).quality(20); + const processedBuffer = await image?.getBufferAsync(Jimp.MIME_JPEG); + + createFile({ + data: processedBuffer, + filePath: `archives/preview/${collectionId}/${linkId}.jpeg`, + }).then(() => { + return prisma.link.update({ + where: { id: linkId }, + data: { + preview: `archives/preview/${collectionId}/${linkId}.jpeg`, + }, + }); + }); + } + }).catch((err) => { + console.error("Error processing the image:", err); + }); + } +}; + +export default generatePreview; diff --git a/lib/client/generateLinkHref.ts b/lib/client/generateLinkHref.ts index 47c1888e..f012ab90 100644 --- a/lib/client/generateLinkHref.ts +++ b/lib/client/generateLinkHref.ts @@ -16,24 +16,30 @@ export const generateLinkHref = ( ): string => { // Return the links href based on the account's preference // If the user's preference is not available, return the original link - switch (account.linksRouteTo) { - case LinksRouteTo.ORIGINAL: - return link.url || ""; - case LinksRouteTo.PDF: - if (!pdfAvailable(link)) return link.url || ""; + if (account.linksRouteTo === LinksRouteTo.ORIGINAL && link.type === "url") { + return link.url || ""; + } else if (account.linksRouteTo === LinksRouteTo.PDF || link.type === "pdf") { + if (!pdfAvailable(link)) return link.url || ""; - return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`; - case LinksRouteTo.READABLE: - if (!readabilityAvailable(link)) return link.url || ""; + return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`; + } else if ( + account.linksRouteTo === LinksRouteTo.READABLE && + link.type === "url" + ) { + if (!readabilityAvailable(link)) return link.url || ""; - return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`; - case LinksRouteTo.SCREENSHOT: - if (!screenshotAvailable(link)) return link.url || ""; + return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`; + } else if ( + account.linksRouteTo === LinksRouteTo.SCREENSHOT || + link.type === "image" + ) { + console.log(link); + if (!screenshotAvailable(link)) return link.url || ""; - return `/preserved/${link?.id}?format=${ - link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg - }`; - default: - return link.url || ""; + return `/preserved/${link?.id}?format=${ + link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg + }`; + } else { + return link.url || ""; } }; diff --git a/package.json b/package.json index 4568588d..6b03cf3a 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "nodemon": "^3.0.2", "postcss": "^8.4.26", "prettier": "3.1.1", - "prisma": "^5.1.0", + "prisma": "^4.16.2", "tailwindcss": "^3.3.3", "ts-node": "^10.9.2", "typescript": "4.9.4" diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts index 5e5ada2f..554d4b93 100644 --- a/pages/api/v1/archives/[linkId].ts +++ b/pages/api/v1/archives/[linkId].ts @@ -9,6 +9,8 @@ import formidable from "formidable"; import createFile from "@/lib/api/storage/createFile"; import fs from "fs"; import verifyToken from "@/lib/api/verifyToken"; +import Jimp from "jimp"; +import generatePreview from "@/lib/api/generatePreview"; export const config = { api: { @@ -124,6 +126,14 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { where: { id: linkId }, }); + if (linkStillExists && files.file[0].mimetype?.includes("image")) { + generatePreview( + fileBuffer, + collectionPermissions?.id as number, + linkId + ); + } + if (linkStillExists) { await createFile({ filePath: `archives/${collectionPermissions?.id}/${ @@ -135,7 +145,15 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { await prisma.link.update({ where: { id: linkId }, data: { - image: `archives/${collectionPermissions?.id}/${linkId + suffix}`, + preview: files.file[0].mimetype?.includes("pdf") + ? "unavailable" + : undefined, + image: files.file[0].mimetype?.includes("image") + ? `archives/${collectionPermissions?.id}/${linkId + suffix}` + : null, + pdf: files.file[0].mimetype?.includes("pdf") + ? `archives/${collectionPermissions?.id}/${linkId + suffix}` + : null, lastPreserved: new Date().toISOString(), }, }); diff --git a/store/links.ts b/store/links.ts index 408a3eea..c2c3a8a1 100644 --- a/store/links.ts +++ b/store/links.ts @@ -1,5 +1,8 @@ import { create } from "zustand"; -import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import { + ArchivedFormat, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; import useTagStore from "./tags"; import useCollectionStore from "./collections"; @@ -19,6 +22,10 @@ type LinkStore = { addLink: ( body: LinkIncludingShortenedCollectionAndTags ) => Promise; + uploadFile: ( + link: LinkIncludingShortenedCollectionAndTags, + file: File + ) => Promise; getLink: (linkId: number, publicRoute?: boolean) => Promise; updateLink: ( link: LinkIncludingShortenedCollectionAndTags @@ -79,6 +86,82 @@ const useLinkStore = create()((set) => ({ return { ok: response.ok, data: data.response }; }, + uploadFile: async (link, file) => { + let fileType: ArchivedFormat | null = null; + let linkType: "url" | "image" | "pdf" | null = null; + + if (file?.type === "image/jpg" || file.type === "image/jpeg") { + fileType = ArchivedFormat.jpeg; + linkType = "image"; + } else if (file.type === "image/png") { + fileType = ArchivedFormat.png; + linkType = "image"; + } else if (file.type === "application/pdf") { + fileType = ArchivedFormat.pdf; + linkType = "pdf"; + } else { + return { ok: false, data: "Invalid file type." }; + } + + const response = await fetch("/api/v1/links", { + body: JSON.stringify({ + ...link, + type: linkType, + name: link.name ? link.name : file.name, + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + const data = await response.json(); + + const createdLink: LinkIncludingShortenedCollectionAndTags = data.response; + + console.log(data); + + if (response.ok) { + const formBody = new FormData(); + file && formBody.append("file", file); + + await fetch( + `/api/v1/archives/${(data as any).response.id}?format=${fileType}`, + { + body: formBody, + method: "POST", + } + ); + + // get file extension + const extension = file.name.split(".").pop() || ""; + + set((state) => ({ + links: [ + { + ...createdLink, + image: + linkType === "image" + ? `archives/${createdLink.collectionId}/${ + createdLink.id + extension + }` + : null, + pdf: + linkType === "pdf" + ? `archives/${createdLink.collectionId}/${ + createdLink.id + ".pdf" + }` + : null, + }, + ...state.links, + ], + })); + useTagStore.getState().setTags(); + useCollectionStore.getState().setCollections(); + } + + return { ok: response.ok, data: data.response }; + }, getLink: async (linkId, publicRoute) => { const path = publicRoute ? `/api/v1/public/links/${linkId}` diff --git a/yarn.lock b/yarn.lock index 37c68397..d016ae93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1301,10 +1301,10 @@ resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz#d3b5dcf95b6d220e258cbf6ae19b06d30a7e9f14" integrity sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg== -"@prisma/engines@5.1.0": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.0.tgz#4ccf7f344eaeee08ca1e4a1bb2dc14e36ff1d5ec" - integrity sha512-HqaFsnPmZOdMWkPq6tT2eTVTQyaAXEDdKszcZ4yc7DGMBIYRP6j/zAJTtZUG9SsMV8FaucdL5vRyxY/p5Ni28g== +"@prisma/engines@4.16.2": + version "4.16.2" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.16.2.tgz#5ec8dd672c2173d597e469194916ad4826ce2e5f" + integrity sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw== "@radix-ui/primitive@1.0.1": version "1.0.1" @@ -5038,12 +5038,12 @@ pretty-format@^3.8.0: resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385" integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew== -prisma@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.1.0.tgz#29e316b54844f5694a83017a9781a6d6f7cb99ea" - integrity sha512-wkXvh+6wxk03G8qwpZMOed4Y3j+EQ+bMTlvbDZHeal6k1E8QuGKzRO7DRXlE1NV0WNgOAas8kwZqcLETQ2+BiQ== +prisma@^4.16.2: + version "4.16.2" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.16.2.tgz#469e0a0991c6ae5bcde289401726bb012253339e" + integrity sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g== dependencies: - "@prisma/engines" "5.1.0" + "@prisma/engines" "4.16.2" process@^0.11.10: version "0.11.10" From 07b87be7f190ac9c38eedac7c701364e8c160df3 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 8 Apr 2024 19:35:06 -0400 Subject: [PATCH 10/79] many bug fixes and improvements --- lib/api/archiveHandler.ts | 24 +++----- .../controllers/links/bulk/deleteLinksById.ts | 11 +--- .../links/linkId/deleteLinkById.ts | 16 ++--- .../links/linkId/updateLinkById.ts | 17 +----- .../users/userId/deleteUserById.ts | 4 ++ lib/api/generatePreview.ts | 1 + lib/api/manageLinkFiles.ts | 61 +++++++++++++++++++ pages/api/v1/archives/[linkId].ts | 12 ++-- pages/api/v1/links/[id]/archive/index.ts | 15 +---- 9 files changed, 95 insertions(+), 66 deletions(-) create mode 100644 lib/api/manageLinkFiles.ts diff --git a/lib/api/archiveHandler.ts b/lib/api/archiveHandler.ts index c4b5c5e7..a32498fa 100644 --- a/lib/api/archiveHandler.ts +++ b/lib/api/archiveHandler.ts @@ -7,10 +7,9 @@ import { JSDOM } from "jsdom"; import DOMPurify from "dompurify"; import { Collection, Link, User } from "@prisma/client"; import validateUrlSize from "./validateUrlSize"; -import removeFile from "./storage/removeFile"; -import Jimp from "jimp"; import createFolder from "./storage/createFolder"; import generatePreview from "./generatePreview"; +import { removeFiles } from "./manageLinkFiles"; type LinksAndCollectionAndOwner = Link & { collection: Collection & { @@ -52,6 +51,14 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) { ); }); + createFolder({ + filePath: `archives/preview/${link.collectionId}`, + }); + + createFolder({ + filePath: `archives/${link.collectionId}`, + }); + try { await Promise.race([ (async () => { @@ -163,10 +170,6 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) { return metaTag ? (metaTag as any).content : null; }); - createFolder({ - filePath: `archives/preview/${link.collectionId}`, - }); - if (ogImageUrl) { console.log("Found og:image URL:", ogImageUrl); @@ -296,14 +299,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) { }, }); else { - removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` }); - removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` }); - removeFile({ - filePath: `archives/${link.collectionId}/${link.id}_readability.json`, - }); - removeFile({ - filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`, - }); + await removeFiles(link.id, link.collectionId); } await browser.close(); diff --git a/lib/api/controllers/links/bulk/deleteLinksById.ts b/lib/api/controllers/links/bulk/deleteLinksById.ts index 466db983..2db38969 100644 --- a/lib/api/controllers/links/bulk/deleteLinksById.ts +++ b/lib/api/controllers/links/bulk/deleteLinksById.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db"; import { UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; import removeFile from "@/lib/api/storage/removeFile"; +import { removeFiles } from "@/lib/api/manageLinkFiles"; export default async function deleteLinksById( userId: number, @@ -43,15 +44,7 @@ export default async function deleteLinksById( const linkId = linkIds[i]; const collectionIsAccessible = collectionIsAccessibleArray[i]; - removeFile({ - filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`, - }); - removeFile({ - filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`, - }); - removeFile({ - filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`, - }); + if (collectionIsAccessible) removeFiles(linkId, collectionIsAccessible.id); } return { response: deletedLinks, status: 200 }; diff --git a/lib/api/controllers/links/linkId/deleteLinkById.ts b/lib/api/controllers/links/linkId/deleteLinkById.ts index db68ee7d..dba90cbd 100644 --- a/lib/api/controllers/links/linkId/deleteLinkById.ts +++ b/lib/api/controllers/links/linkId/deleteLinkById.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db"; import { Link, UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; import removeFile from "@/lib/api/storage/removeFile"; +import { removeFiles } from "@/lib/api/manageLinkFiles"; export default async function deleteLink(userId: number, linkId: number) { if (!linkId) return { response: "Please choose a valid link.", status: 401 }; @@ -12,7 +13,10 @@ export default async function deleteLink(userId: number, linkId: number) { (e: UsersAndCollections) => e.userId === userId && e.canDelete ); - if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess)) + if ( + !collectionIsAccessible || + !(collectionIsAccessible?.ownerId === userId || memberHasAccess) + ) return { response: "Collection is not accessible.", status: 401 }; const deleteLink: Link = await prisma.link.delete({ @@ -21,15 +25,7 @@ export default async function deleteLink(userId: number, linkId: number) { }, }); - removeFile({ - filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`, - }); - removeFile({ - filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`, - }); - removeFile({ - filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`, - }); + removeFiles(linkId, collectionIsAccessible.id); return { response: deleteLink, status: 200 }; } diff --git a/lib/api/controllers/links/linkId/updateLinkById.ts b/lib/api/controllers/links/linkId/updateLinkById.ts index e6f7f0d1..4a24f4a3 100644 --- a/lib/api/controllers/links/linkId/updateLinkById.ts +++ b/lib/api/controllers/links/linkId/updateLinkById.ts @@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; -import moveFile from "@/lib/api/storage/moveFile"; +import { moveFiles } from "@/lib/api/manageLinkFiles"; export default async function updateLinkById( userId: number, @@ -146,20 +146,7 @@ export default async function updateLinkById( }); if (collectionIsAccessible?.id !== data.collection.id) { - await moveFile( - `archives/${collectionIsAccessible?.id}/${linkId}.pdf`, - `archives/${data.collection.id}/${linkId}.pdf` - ); - - await moveFile( - `archives/${collectionIsAccessible?.id}/${linkId}.png`, - `archives/${data.collection.id}/${linkId}.png` - ); - - await moveFile( - `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`, - `archives/${data.collection.id}/${linkId}_readability.json` - ); + await moveFiles(linkId, collectionIsAccessible?.id, data.collection.id); } return { response: updatedLink, status: 200 }; diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index 2a5a0833..976bd715 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -71,6 +71,10 @@ export default async function deleteUserById( // Delete archive folders removeFolder({ filePath: `archives/${collection.id}` }); + + await removeFolder({ + filePath: `archives/preview/${collection.id}`, + }); } // Delete collections after cleaning up related data diff --git a/lib/api/generatePreview.ts b/lib/api/generatePreview.ts index 3c2da0f2..6e816303 100644 --- a/lib/api/generatePreview.ts +++ b/lib/api/generatePreview.ts @@ -1,6 +1,7 @@ import Jimp from "jimp"; import { prisma } from "./db"; import createFile from "./storage/createFile"; +import createFolder from "./storage/createFolder"; const generatePreview = async ( buffer: Buffer, diff --git a/lib/api/manageLinkFiles.ts b/lib/api/manageLinkFiles.ts new file mode 100644 index 00000000..7bacdab5 --- /dev/null +++ b/lib/api/manageLinkFiles.ts @@ -0,0 +1,61 @@ +import moveFile from "./storage/moveFile"; +import removeFile from "./storage/removeFile"; + +const removeFiles = async (linkId: number, collectionId: number) => { + // PDF + await removeFile({ + filePath: `archives/${collectionId}/${linkId}.pdf`, + }); + // Images + await removeFile({ + filePath: `archives/${collectionId}/${linkId}.png`, + }); + await removeFile({ + filePath: `archives/${collectionId}/${linkId}.jpeg`, + }); + await removeFile({ + filePath: `archives/${collectionId}/${linkId}.jpg`, + }); + // Preview + await removeFile({ + filePath: `archives/preview/${collectionId}/${linkId}.jpeg`, + }); + // Readability + await removeFile({ + filePath: `archives/${collectionId}/${linkId}_readability.json`, + }); +}; + +const moveFiles = async (linkId: number, from: number, to: number) => { + await moveFile( + `archives/${from}/${linkId}.pdf`, + `archives/${to}/${linkId}.pdf` + ); + + await moveFile( + `archives/${from}/${linkId}.png`, + `archives/${to}/${linkId}.png` + ); + + await moveFile( + `archives/${from}/${linkId}.jpeg`, + `archives/${to}/${linkId}.jpeg` + ); + + await moveFile( + `archives/${from}/${linkId}.jpg`, + `archives/${to}/${linkId}.jpg` + ); + + await moveFile( + `archives/preview/${from}/${linkId}.jpeg`, + `archives/preview/${to}/${linkId}.jpeg` + ); + + await moveFile( + `archives/${from}/${linkId}_readability.json`, + `archives/${to}/${linkId}_readability.json` + ); +}; + +export { removeFiles, moveFiles }; diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts index 554d4b93..9a439ec4 100644 --- a/pages/api/v1/archives/[linkId].ts +++ b/pages/api/v1/archives/[linkId].ts @@ -11,6 +11,7 @@ import fs from "fs"; import verifyToken from "@/lib/api/verifyToken"; import Jimp from "jimp"; import generatePreview from "@/lib/api/generatePreview"; +import createFolder from "@/lib/api/storage/createFolder"; export const config = { api: { @@ -127,11 +128,12 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { }); if (linkStillExists && files.file[0].mimetype?.includes("image")) { - generatePreview( - fileBuffer, - collectionPermissions?.id as number, - linkId - ); + const collectionId = collectionPermissions?.id as number; + createFolder({ + filePath: `archives/preview/${collectionId}`, + }); + + generatePreview(fileBuffer, collectionId, linkId); } if (linkStillExists) { diff --git a/pages/api/v1/links/[id]/archive/index.ts b/pages/api/v1/links/[id]/archive/index.ts index 4693fac6..78353bbb 100644 --- a/pages/api/v1/links/[id]/archive/index.ts +++ b/pages/api/v1/links/[id]/archive/index.ts @@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { prisma } from "@/lib/api/db"; import verifyUser from "@/lib/api/verifyUser"; import isValidUrl from "@/lib/shared/isValidUrl"; -import removeFile from "@/lib/api/storage/removeFile"; import { Collection, Link } from "@prisma/client"; +import { removeFiles } from "@/lib/api/manageLinkFiles"; const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; @@ -80,16 +80,5 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => { }, }); - await removeFile({ - filePath: `archives/${link.collection.id}/${link.id}.pdf`, - }); - await removeFile({ - filePath: `archives/${link.collection.id}/${link.id}.png`, - }); - await removeFile({ - filePath: `archives/${link.collection.id}/${link.id}_readability.json`, - }); - await removeFile({ - filePath: `archives/preview/${link.collection.id}/${link.id}.png`, - }); + await removeFiles(link.id, link.collection.id); }; From ece09c6f3b0ec4ef40215e10f1b228ab724dc918 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 9 Apr 2024 04:43:20 -0400 Subject: [PATCH 11/79] minor change --- pages/api/v1/archives/[linkId].ts | 1 - 1 file changed, 1 deletion(-) diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts index 9a439ec4..17e75187 100644 --- a/pages/api/v1/archives/[linkId].ts +++ b/pages/api/v1/archives/[linkId].ts @@ -9,7 +9,6 @@ import formidable from "formidable"; import createFile from "@/lib/api/storage/createFile"; import fs from "fs"; import verifyToken from "@/lib/api/verifyToken"; -import Jimp from "jimp"; import generatePreview from "@/lib/api/generatePreview"; import createFolder from "@/lib/api/storage/createFolder"; From 4a71af8a67f68289ab95df318e2b71479ac190af Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 15 Apr 2024 00:37:18 -0400 Subject: [PATCH 12/79] remove trailing slashes + small improvement --- lib/api/controllers/links/postLink.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index e286454c..7746dd60 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -116,24 +116,24 @@ export default async function postLink( }); if (user?.preventDuplicateLinks) { - const trimmedUrl = link.url?.trim(); - const wwwPrefix = 'www.'; - const hasWwwPrefix = trimmedUrl?.includes(`://${wwwPrefix}`); - const urlWithoutWww = hasWwwPrefix ? trimmedUrl?.replace(`://${wwwPrefix}`, '://') : trimmedUrl; - const urlWithWww = hasWwwPrefix ? trimmedUrl : trimmedUrl?.replace('://', `://${wwwPrefix}`); + const url = link.url?.trim().replace(/\/+$/, ""); // trim and remove trailing slashes from the URL + const hasWwwPrefix = url?.includes(`://www.`); + const urlWithoutWww = hasWwwPrefix ? url?.replace(`://www.`, "://") : url; + const urlWithWww = hasWwwPrefix ? url : url?.replace("://", `://www.`); + + console.log(url, urlWithoutWww, urlWithWww); const existingLink = await prisma.link.findFirst({ where: { - OR: [ - { url: trimmedUrl }, - { url: hasWwwPrefix ? urlWithoutWww : urlWithWww }, // Toggling "www." - ], + OR: [{ url: urlWithWww }, { url: urlWithoutWww }], collection: { ownerId: userId, }, }, }); + console.log(url, urlWithoutWww, urlWithWww, "DONE!"); + if (existingLink) return { response: "Link already exists", @@ -180,7 +180,7 @@ export default async function postLink( const newLink = await prisma.link.create({ data: { - url: link.url?.trim(), + url: link.url?.trim().replace(/\/+$/, ""), name: link.name, description, type: linkType, From 8cf621bc62eefeb8e069f6c5b5664ba2c1b7fb1c Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Wed, 17 Apr 2024 18:02:54 -0400 Subject: [PATCH 13/79] added a new env var + bug fixed --- .env.sample | 1 + lib/api/validateUrlSize.ts | 22 ++++++++++++++++++++-- lib/shared/getTitle.ts | 23 +++++++++++++++++------ types/enviornment.d.ts | 1 + 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.env.sample b/.env.sample index 14f74dfc..6cee3840 100644 --- a/.env.sample +++ b/.env.sample @@ -21,6 +21,7 @@ ARCHIVE_TAKE_COUNT= BROWSER_TIMEOUT= IGNORE_UNAUTHORIZED_CA= IGNORE_HTTPS_ERRORS= +IGNORE_URL_SIZE_LIMIT= # AWS S3 Settings SPACES_KEY= diff --git a/lib/api/validateUrlSize.ts b/lib/api/validateUrlSize.ts index eae0b432..d5826b87 100644 --- a/lib/api/validateUrlSize.ts +++ b/lib/api/validateUrlSize.ts @@ -1,17 +1,35 @@ import fetch from "node-fetch"; import https from "https"; +import { SocksProxyAgent } from "socks-proxy-agent"; export default async function validateUrlSize(url: string) { + if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null; + try { const httpsAgent = new https.Agent({ rejectUnauthorized: process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true, }); - const response = await fetch(url, { + let fetchOpts = { method: "HEAD", agent: httpsAgent, - }); + }; + + if (process.env.PROXY) { + let proxy = new URL(process.env.PROXY); + if (process.env.PROXY_USERNAME) { + proxy.username = process.env.PROXY_USERNAME; + proxy.password = process.env.PROXY_PASSWORD || ""; + } + + fetchOpts = { + method: "HEAD", + agent: new SocksProxyAgent(proxy.toString()), + }; + } + + const response = await fetch(url, fetchOpts); const totalSizeMB = Number(response.headers.get("content-length")) / Math.pow(1024, 2); diff --git a/lib/shared/getTitle.ts b/lib/shared/getTitle.ts index 82fee373..01488fdd 100644 --- a/lib/shared/getTitle.ts +++ b/lib/shared/getTitle.ts @@ -27,14 +27,25 @@ export default async function getTitle(url: string) { fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies } - const response = await fetch(url, fetchOpts); + const responsePromise = fetch(url, fetchOpts); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Fetch title timeout")); + }, 10 * 1000); // Stop after 10 seconds + }); - const text = await response.text(); + const response = await Promise.race([responsePromise, timeoutPromise]); - // regular expression to find the tag - let match = text.match(/<title.*>([^<]*)<\/title>/); - if (match) return match[1]; - else return ""; + if ((response as any)?.status) { + const text = await (response as any).text(); + + // regular expression to find the <title> tag + let match = text.match(/<title.*>([^<]*)<\/title>/); + if (match) return match[1]; + else return ""; + } else { + return ""; + } } catch (err) { console.log(err); } diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index a0295c91..b7379152 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -13,6 +13,7 @@ declare global { MAX_LINKS_PER_USER?: string; ARCHIVE_TAKE_COUNT?: string; IGNORE_UNAUTHORIZED_CA?: string; + IGNORE_URL_SIZE_LIMIT?: string; SPACES_KEY?: string; SPACES_SECRET?: string; From e8edd1c9a06acc28a9a976ffe7e4753d266d272c Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Wed, 17 Apr 2024 18:06:04 -0400 Subject: [PATCH 14/79] update version number --- components/SettingsSidebar.tsx | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index 9e169dfd..ca6f6cfb 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; export default function SettingsSidebar({ className }: { className?: string }) { - const LINKWARDEN_VERSION = "v2.5.1"; + const LINKWARDEN_VERSION = "v2.5.2"; const { collections } = useCollectionStore(); diff --git a/package.json b/package.json index 4568588d..c8f48ec0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkwarden", - "version": "2.5.1", + "version": "0.0.0", "main": "index.js", "repository": "https://github.com/linkwarden/linkwarden.git", "author": "Daniel31X13 <daniel31x13@gmail.com>", From 3610e73d3b79850c5c7ac81eb6a1514fcb8828df Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Wed, 17 Apr 2024 18:18:50 -0400 Subject: [PATCH 15/79] minor fix --- lib/api/archiveHandler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/api/archiveHandler.ts b/lib/api/archiveHandler.ts index 08a35b50..aaf1c98d 100644 --- a/lib/api/archiveHandler.ts +++ b/lib/api/archiveHandler.ts @@ -58,7 +58,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) { ? await validateUrlSize(link.url) : undefined; - if (validatedUrl === null) + if ( + validatedUrl === null && + process.env.IGNORE_URL_SIZE_LIMIT !== "true" + ) throw "Something went wrong while retrieving the file size."; const contentType = validatedUrl?.get("content-type"); From 4640c1c966d37b7fc22e4ebfcb244d03da1d6d82 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Thu, 18 Apr 2024 06:14:28 -0400 Subject: [PATCH 16/79] hotfix --- lib/api/archiveHandler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/api/archiveHandler.ts b/lib/api/archiveHandler.ts index 08a35b50..aaf1c98d 100644 --- a/lib/api/archiveHandler.ts +++ b/lib/api/archiveHandler.ts @@ -58,7 +58,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) { ? await validateUrlSize(link.url) : undefined; - if (validatedUrl === null) + if ( + validatedUrl === null && + process.env.IGNORE_URL_SIZE_LIMIT !== "true" + ) throw "Something went wrong while retrieving the file size."; const contentType = validatedUrl?.get("content-type"); From 8278878673126e7c2fe98df76783a4ac4a6c78ac Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Thu, 18 Apr 2024 11:34:29 -0600 Subject: [PATCH 17/79] feat: add close button and data-testids to toast messages --- pages/_app.tsx | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/pages/_app.tsx b/pages/_app.tsx index b9659411..3cdd0f50 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -5,7 +5,8 @@ import { SessionProvider } from "next-auth/react"; import type { AppProps } from "next/app"; import Head from "next/head"; import AuthRedirect from "@/layouts/AuthRedirect"; -import { Toaster } from "react-hot-toast"; +import toast from "react-hot-toast"; +import { Toaster, ToastBar } from "react-hot-toast"; import { Session } from "next-auth"; import { isPWA } from "@/lib/client/utils"; @@ -61,7 +62,30 @@ export default function App({ className: "border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white", }} - /> + > + {(t) => ( + <ToastBar toast={t}> + {({ icon, message }) => ( + <div + className="flex flex-row" + data-testid="toast-message-container" + data-type={t.type} + > + {icon} + <span data-testid="toast-message">{message}</span> + {t.type !== "loading" && ( + <button + data-testid="close-toast-button" + onClick={() => toast.dismiss(t.id)} + > + <i className="bi bi-x"></i> + </button> + )} + </div> + )} + </ToastBar> + )} + </Toaster> <Component {...pageProps} /> </AuthRedirect> </SessionProvider> From 9a92b4d229add8881a91f945e0cf9a564cae8c39 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Fri, 19 Apr 2024 06:16:11 -0400 Subject: [PATCH 18/79] code cleanup --- components/Navbar.tsx | 68 +------------------------------- components/ProfileDropdown.tsx | 71 ++++++++++++++++++++++++++++++++++ components/SettingsSidebar.tsx | 2 +- next.config.js | 5 +++ package.json | 2 +- 5 files changed, 80 insertions(+), 68 deletions(-) create mode 100644 components/ProfileDropdown.tsx diff --git a/components/Navbar.tsx b/components/Navbar.tsx index df672c1f..f826f9de 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,40 +1,24 @@ -import { signOut } from "next-auth/react"; import { useEffect, useState } from "react"; import ClickAwayHandler from "@/components/ClickAwayHandler"; import Sidebar from "@/components/Sidebar"; import { useRouter } from "next/router"; import SearchBar from "@/components/SearchBar"; -import useAccountStore from "@/store/account"; -import ProfilePhoto from "@/components/ProfilePhoto"; import useWindowDimensions from "@/hooks/useWindowDimensions"; import ToggleDarkMode from "./ToggleDarkMode"; -import useLocalSettingsStore from "@/store/localSettings"; import NewLinkModal from "./ModalContent/NewLinkModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal"; -import Link from "next/link"; import UploadFileModal from "./ModalContent/UploadFileModal"; import { dropdownTriggerer } from "@/lib/client/utils"; import MobileNavigation from "./MobileNavigation"; +import ProfileDropdown from "./ProfileDropdown"; export default function Navbar() { - const { settings, updateSettings } = useLocalSettingsStore(); - - const { account } = useAccountStore(); - const router = useRouter(); const [sidebar, setSidebar] = useState(false); const { width } = useWindowDimensions(); - const handleToggle = () => { - if (settings.theme === "dark") { - updateSettings({ theme: "light" }); - } else { - updateSettings({ theme: "dark" }); - } - }; - useEffect(() => { setSidebar(false); document.body.style.overflow = "auto"; @@ -120,55 +104,7 @@ export default function Navbar() { </ul> </div> - <div className="dropdown dropdown-end"> - <div - tabIndex={0} - role="button" - onMouseDown={dropdownTriggerer} - className="btn btn-circle btn-ghost" - > - <ProfilePhoto - src={account.image ? account.image : undefined} - priority={true} - /> - </div> - <ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1"> - <li> - <Link - href="/settings/account" - onClick={() => (document?.activeElement as HTMLElement)?.blur()} - tabIndex={0} - role="button" - > - Settings - </Link> - </li> - <li className="block sm:hidden"> - <div - onClick={() => { - (document?.activeElement as HTMLElement)?.blur(); - handleToggle(); - }} - tabIndex={0} - role="button" - > - Switch to {settings.theme === "light" ? "Dark" : "Light"} - </div> - </li> - <li> - <div - onClick={() => { - (document?.activeElement as HTMLElement)?.blur(); - signOut(); - }} - tabIndex={0} - role="button" - > - Logout - </div> - </li> - </ul> - </div> + <ProfileDropdown /> </div> <MobileNavigation /> diff --git a/components/ProfileDropdown.tsx b/components/ProfileDropdown.tsx new file mode 100644 index 00000000..2ee27ac9 --- /dev/null +++ b/components/ProfileDropdown.tsx @@ -0,0 +1,71 @@ +import useLocalSettingsStore from "@/store/localSettings"; +import { dropdownTriggerer } from "@/lib/client/utils"; +import ProfilePhoto from "./ProfilePhoto"; +import useAccountStore from "@/store/account"; +import Link from "next/link"; +import { signOut } from "next-auth/react"; + +export default function ProfileDropdown() { + const { settings, updateSettings } = useLocalSettingsStore(); + const { account } = useAccountStore(); + + const handleToggle = () => { + if (settings.theme === "dark") { + updateSettings({ theme: "light" }); + } else { + updateSettings({ theme: "dark" }); + } + }; + + return ( + <div className="dropdown dropdown-end"> + <div + tabIndex={0} + role="button" + onMouseDown={dropdownTriggerer} + className="btn btn-circle btn-ghost" + > + <ProfilePhoto + src={account.image ? account.image : undefined} + priority={true} + /> + </div> + <ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1"> + <li> + <Link + href="/settings/account" + onClick={() => (document?.activeElement as HTMLElement)?.blur()} + tabIndex={0} + role="button" + > + Settings + </Link> + </li> + <li className="block sm:hidden"> + <div + onClick={() => { + (document?.activeElement as HTMLElement)?.blur(); + handleToggle(); + }} + tabIndex={0} + role="button" + > + Switch to {settings.theme === "light" ? "Dark" : "Light"} + </div> + </li> + <li> + <div + onClick={() => { + (document?.activeElement as HTMLElement)?.blur(); + signOut(); + }} + tabIndex={0} + role="button" + > + Logout + </div> + </li> + </ul> + </div> + ); +} diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index ca6f6cfb..a6b37b14 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; export default function SettingsSidebar({ className }: { className?: string }) { - const LINKWARDEN_VERSION = "v2.5.2"; + const LINKWARDEN_VERSION = process.env.version; const { collections } = useCollectionStore(); diff --git a/next.config.js b/next.config.js index 71cfd8e6..ebb9b54b 100644 --- a/next.config.js +++ b/next.config.js @@ -1,10 +1,15 @@ /** @type {import('next').NextConfig} */ +const { version } = require("./package.json"); + const nextConfig = { reactStrictMode: true, images: { domains: ["t2.gstatic.com"], minimumCacheTTL: 10, }, + env: { + version, + }, }; module.exports = nextConfig; diff --git a/package.json b/package.json index 39069be9..a08ae19d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkwarden", - "version": "0.0.0", + "version": "v2.5.4", "main": "index.js", "repository": "https://github.com/linkwarden/linkwarden.git", "author": "Daniel31X13 <daniel31x13@gmail.com>", From b702aa04015bf96f76441892bffc203ab721040b Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Sat, 20 Apr 2024 10:49:06 -0400 Subject: [PATCH 19/79] small improvement --- components/ClickAwayHandler.tsx | 31 +++++++++++++++++++++++++------ pages/_app.tsx | 1 + 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/components/ClickAwayHandler.tsx b/components/ClickAwayHandler.tsx index 63378a1d..e8e88784 100644 --- a/components/ClickAwayHandler.tsx +++ b/components/ClickAwayHandler.tsx @@ -8,19 +8,38 @@ type Props = { onMount?: (rect: DOMRect) => void; }; +function getZIndex(element: HTMLElement): number { + let zIndex = 0; + while (element) { + const zIndexStyle = window + .getComputedStyle(element) + .getPropertyValue("z-index"); + const numericZIndex = Number(zIndexStyle); + if (zIndexStyle !== "auto" && !isNaN(numericZIndex)) { + zIndex = numericZIndex; + break; + } + element = element.parentElement as HTMLElement; + } + return zIndex; +} + function useOutsideAlerter( ref: RefObject<HTMLElement>, onClickOutside: Function ) { useEffect(() => { - function handleClickOutside(event: Event) { - if ( - ref.current && - !ref.current.contains(event.target as HTMLInputElement) - ) { - onClickOutside(event); + function handleClickOutside(event: MouseEvent) { + const clickedElement = event.target as HTMLElement; + if (ref.current && !ref.current.contains(clickedElement)) { + const refZIndex = getZIndex(ref.current); + const clickedZIndex = getZIndex(clickedElement); + if (clickedZIndex <= refZIndex) { + onClickOutside(event); + } } } + document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); diff --git a/pages/_app.tsx b/pages/_app.tsx index 3cdd0f50..0ece19db 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -75,6 +75,7 @@ export default function App({ <span data-testid="toast-message">{message}</span> {t.type !== "loading" && ( <button + className="btn btn-xs outline-none btn-circle btn-ghost" data-testid="close-toast-button" onClick={() => toast.dismiss(t.id)} > From cd09843b9984eac0dde38c127febc19a158ec071 Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Sun, 21 Apr 2024 17:15:27 -0600 Subject: [PATCH 20/79] feat(e2e): add data-testids to components --- components/AccentSubmitButton.tsx | 3 +++ components/Modal.tsx | 25 ++++++++++++++++++++----- components/TextInput.tsx | 3 +++ layouts/CenteredForm.tsx | 12 ++++++++++-- layouts/MainLayout.tsx | 2 +- pages/login.tsx | 9 +++++++-- pages/register.tsx | 16 +++++++++++++++- 7 files changed, 59 insertions(+), 11 deletions(-) diff --git a/components/AccentSubmitButton.tsx b/components/AccentSubmitButton.tsx index 1d877873..930c1a61 100644 --- a/components/AccentSubmitButton.tsx +++ b/components/AccentSubmitButton.tsx @@ -4,6 +4,7 @@ type Props = { loading?: boolean; className?: string; type?: "button" | "submit" | "reset" | undefined; + "data-testid"?: string; }; export default function AccentSubmitButton({ @@ -12,6 +13,7 @@ export default function AccentSubmitButton({ loading, className, type, + "data-testid": dataTestId, }: Props) { return ( <button @@ -19,6 +21,7 @@ export default function AccentSubmitButton({ className={`border primary-btn-gradient select-none duration-200 bg-black border-[oklch(var(--p))] hover:border-[#0070b5] rounded-lg text-center px-4 py-2 text-white active:scale-95 tracking-wider w-fit flex justify-center items-center gap-2 ${ className || "" }`} + data-testid={dataTestId} onClick={() => { if (loading !== undefined && !loading && onClick) onClick(); }} diff --git a/components/Modal.tsx b/components/Modal.tsx index 6d1bb9f0..1620b2cd 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -32,8 +32,14 @@ export default function Modal({ toggleModal, className, children }: Props) { <Drawer.Overlay className="fixed inset-0 bg-black/40" /> <ClickAwayHandler onClickOutside={() => setDrawerIsOpen(false)}> <Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30"> - <div className="p-4 pb-32 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"> - <div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5" /> + <div + className="p-4 pb-32 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto" + data-testid="mobile-modal-container" + > + <div + className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5" + data-testid="mobile-modal-slider" + /> {children} </div> @@ -44,19 +50,28 @@ export default function Modal({ toggleModal, className, children }: Props) { ); } else { return ( - <div className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-40"> + <div + className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-40" + data-testid="modal-outer" + > <ClickAwayHandler onClickOutside={toggleModal} className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${ className || "" }`} > - <div className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible"> + <div + className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible" + data-testid="modal-container" + > <div onClick={toggleModal as MouseEventHandler<HTMLDivElement>} className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10" > - <i className="bi-x text-neutral text-2xl"></i> + <i + className="bi-x text-neutral text-2xl" + data-testid="close-modal-button" + ></i> </div> {children} </div> diff --git a/components/TextInput.tsx b/components/TextInput.tsx index c8099d0e..2003823a 100644 --- a/components/TextInput.tsx +++ b/components/TextInput.tsx @@ -9,6 +9,7 @@ type Props = { onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined; className?: string; spellCheck?: boolean; + "data-testid"?: string; }; export default function TextInput({ @@ -20,9 +21,11 @@ export default function TextInput({ onKeyDown, className, spellCheck, + "data-testid": dataTestId, }: Props) { return ( <input + data-testid={dataTestId} spellCheck={spellCheck} autoFocus={autoFocus} type={type ? type : "text"} diff --git a/layouts/CenteredForm.tsx b/layouts/CenteredForm.tsx index 2ea8a8fa..5960ffbd 100644 --- a/layouts/CenteredForm.tsx +++ b/layouts/CenteredForm.tsx @@ -6,13 +6,21 @@ import React, { ReactNode, useEffect } from "react"; interface Props { text?: string; children: ReactNode; + "data-testid"?: string; } -export default function CenteredForm({ text, children }: Props) { +export default function CenteredForm({ + text, + children, + "data-testid": dataTestId, +}: Props) { const { settings } = useLocalSettingsStore(); return ( - <div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5"> + <div + className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5" + data-testid={dataTestId} + > <div className="m-auto flex flex-col gap-2 w-full"> {settings.theme ? ( <Image diff --git a/layouts/MainLayout.tsx b/layouts/MainLayout.tsx index cc7fc8fa..a1178a50 100644 --- a/layouts/MainLayout.tsx +++ b/layouts/MainLayout.tsx @@ -38,7 +38,7 @@ export default function MainLayout({ children }: Props) { <AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} /> ) : undefined} - <div className="flex"> + <div className="flex" data-testid="dashboard-wrapper"> <div className="hidden lg:block"> <Sidebar className={`fixed ${showAnnouncement ? "top-10" : "top-0"}`} diff --git a/pages/login.tsx b/pages/login.tsx index 5b528b98..13941d1f 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -69,7 +69,7 @@ export default function Login({ function displayLoginCredential() { if (availableLogins.credentialsEnabled === "true") { return ( - <> + <div data-testid="login-form"> <p className="text-3xl text-black dark:text-white text-center font-extralight"> Enter your credentials </p> @@ -87,6 +87,7 @@ export default function Login({ placeholder="johnny" value={form.username} className="bg-base-100" + data-testid="username-input" onChange={(e) => setForm({ ...form, username: e.target.value })} /> </div> @@ -100,6 +101,7 @@ export default function Login({ placeholder="••••••••••••••" value={form.password} className="bg-base-100" + data-testid="password-input" onChange={(e) => setForm({ ...form, password: e.target.value })} /> {availableLogins.emailEnabled === "true" && ( @@ -107,6 +109,7 @@ export default function Login({ <Link href={"/forgot"} className="text-gray-500 dark:text-gray-400 font-semibold" + data-testid="forgot-password-link" > Forgot Password? </Link> @@ -117,13 +120,14 @@ export default function Login({ type="submit" label="Login" className=" w-full text-center" + data-testid="submit-login-button" loading={submitLoader} /> {availableLogins.buttonAuths.length > 0 ? ( <div className="divider my-1">OR</div> ) : undefined} - </> + </div> ); } } @@ -155,6 +159,7 @@ export default function Login({ <Link href={"/register"} className="block text-black dark:text-white font-semibold" + data-testid="register-link" > Sign Up </Link> diff --git a/pages/register.tsx b/pages/register.tsx index 2f114888..eb5c6025 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -102,6 +102,7 @@ export default function Register() { } days of Premium Service at no cost!` : "Create a new account" } + data-testid="registration-form" > {process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" ? ( <div className="p-4 flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content"> @@ -127,6 +128,7 @@ export default function Register() { placeholder="Johnny" value={form.name} className="bg-base-100" + data-testid="display-name-input" onChange={(e) => setForm({ ...form, name: e.target.value })} /> </div> @@ -139,6 +141,7 @@ export default function Register() { placeholder="john" value={form.username} className="bg-base-100" + data-testid="username-input" onChange={(e) => setForm({ ...form, username: e.target.value }) } @@ -155,6 +158,7 @@ export default function Register() { placeholder="johnny@example.com" value={form.email} className="bg-base-100" + data-testid="email-input" onChange={(e) => setForm({ ...form, email: e.target.value })} /> </div> @@ -168,6 +172,7 @@ export default function Register() { placeholder="••••••••••••••" value={form.password} className="bg-base-100" + data-testid="password-input" onChange={(e) => setForm({ ...form, password: e.target.value })} /> </div> @@ -182,6 +187,7 @@ export default function Register() { placeholder="••••••••••••••" value={form.passwordConfirmation} className="bg-base-100" + data-testid="password-confirm-input" onChange={(e) => setForm({ ...form, passwordConfirmation: e.target.value }) } @@ -195,6 +201,7 @@ export default function Register() { <Link href="https://linkwarden.app/tos" className="font-semibold underline" + data-testid="terms-of-service-link" > Terms of Service </Link>{" "} @@ -202,6 +209,7 @@ export default function Register() { <Link href="https://linkwarden.app/privacy-policy" className="font-semibold underline" + data-testid="privacy-policy-link" > Privacy Policy </Link> @@ -212,6 +220,7 @@ export default function Register() { <Link href="mailto:support@linkwarden.app" className="font-semibold underline" + data-testid="support-link" > Get in touch </Link> @@ -225,10 +234,15 @@ export default function Register() { label="Sign Up" className="w-full" loading={submitLoader} + data-testid="register-button" /> <div className="flex items-baseline gap-1 justify-center"> <p className="w-fit text-neutral">Already have an account?</p> - <Link href={"/login"} className="block font-bold"> + <Link + href={"/login"} + className="block font-bold" + data-testid="login-link" + > Login </Link> </div> From ce7a94e492e0248550b12f8067d3e8b5ca140c31 Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Sun, 21 Apr 2024 17:16:30 -0600 Subject: [PATCH 21/79] feat(e2e): add fixtures and data seeding --- e2e/data/user.ts | 20 +++++++++++++ e2e/fixtures/base/dashboard-page.ts | 10 +++++++ e2e/fixtures/base/modal.ts | 45 +++++++++++++++++++++++++++++ e2e/fixtures/base/page.ts | 20 +++++++++++++ e2e/fixtures/index.ts | 27 +++++++++++++++++ e2e/fixtures/login-page.ts | 27 +++++++++++++++++ e2e/fixtures/registration-page.ts | 28 ++++++++++++++++++ e2e/index.ts | 2 ++ 8 files changed, 179 insertions(+) create mode 100644 e2e/data/user.ts create mode 100644 e2e/fixtures/base/dashboard-page.ts create mode 100644 e2e/fixtures/base/modal.ts create mode 100644 e2e/fixtures/base/page.ts create mode 100644 e2e/fixtures/index.ts create mode 100644 e2e/fixtures/login-page.ts create mode 100644 e2e/fixtures/registration-page.ts create mode 100644 e2e/index.ts diff --git a/e2e/data/user.ts b/e2e/data/user.ts new file mode 100644 index 00000000..3ae0b73e --- /dev/null +++ b/e2e/data/user.ts @@ -0,0 +1,20 @@ +import axios, { AxiosError } from "axios" + +axios.defaults.baseURL = "http://localhost:3000" + +export async function seedUser (username?: string, password?: string, name?: string) { + try { + return await axios.post("/api/v1/users", { + username: username || "test", + password: password || "password", + name: name || "Test User", + }) + } catch (e: any) { + if (e instanceof AxiosError) { + if (e.response?.status === 400) { + return + } + } + throw e + } +} \ No newline at end of file diff --git a/e2e/fixtures/base/dashboard-page.ts b/e2e/fixtures/base/dashboard-page.ts new file mode 100644 index 00000000..559a084c --- /dev/null +++ b/e2e/fixtures/base/dashboard-page.ts @@ -0,0 +1,10 @@ +import { Locator, Page } from "playwright"; +import { BasePage } from "./page"; + +export class DashboardPage extends BasePage { + container: Locator; + constructor(page: Page) { + super(page); + this.container = this.page.getByTestId("dashboard-wrapper"); + } +} diff --git a/e2e/fixtures/base/modal.ts b/e2e/fixtures/base/modal.ts new file mode 100644 index 00000000..7cf788bb --- /dev/null +++ b/e2e/fixtures/base/modal.ts @@ -0,0 +1,45 @@ +import { Locator, Page } from "@playwright/test"; + +export class BaseModal { + page: Page; + container: Locator; + mobileContainer: Locator; + closeModalButton: Locator; + mobileModalSlider: Locator; + + constructor(page: Page) { + this.page = page; + this.container = page.getByTestId("modal-container"); + this.mobileContainer = page.getByTestId("mobile-modal-container"); + this.closeModalButton = this.container.getByTestId("close-modal-button"); + this.mobileModalSlider = this.mobileContainer.getByTestId( + "mobile-modal-slider" + ); + } + + async close() { + if (await this.container.isVisible()) { + await this.closeModalButton.click(); + } + if (await this.mobileContainer.isVisible()) { + const box = await this.mobileModalSlider.boundingBox(); + if (!box) { + return; + } + const pageHeight = await this.page.evaluate(() => window.innerHeight); + const startX = box.x + box.width / 2; + const startY = box.y + box.height / 2; + await this.page.mouse.move(startX, startY); + await this.page.mouse.down(); + await this.page.mouse.move(startX, startY + pageHeight / 2); + await this.page.mouse.up(); + } + } + + async isOpen() { + return ( + (await this.container.isVisible()) || + (await this.mobileContainer.isVisible()) + ); + } +} diff --git a/e2e/fixtures/base/page.ts b/e2e/fixtures/base/page.ts new file mode 100644 index 00000000..b078f6c4 --- /dev/null +++ b/e2e/fixtures/base/page.ts @@ -0,0 +1,20 @@ +import { Locator, Page } from "@playwright/test"; + +export class BasePage { + page: Page; + toastMessage: Locator; + + constructor(page: Page) { + this.page = page; + this.toastMessage = this.page.getByTestId("toast-message-container"); + } + + async getLatestToast() { + const toast = this.toastMessage.first(); + return { + locator: toast, + closeButton: toast.getByTestId("close-toast-button"), + message: toast.getByTestId("toast-message"), + }; + } +} diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts new file mode 100644 index 00000000..a0cebe2f --- /dev/null +++ b/e2e/fixtures/index.ts @@ -0,0 +1,27 @@ +import { test as baseTest } from "@playwright/test"; +import { LoginPage } from "./login-page"; +import { RegistrationPage } from "./registration-page"; +import { DashboardPage } from "./base/dashboard-page"; + +export const test = baseTest.extend<{ + dashboardPage: DashboardPage; + loginPage: LoginPage; + registrationPage: RegistrationPage; +}>({ + page: async ({ page }, use) => { + await page.goto("/"); + use(page); + }, + dashboardPage: async ({ page }, use) => { + const dashboardPage = new DashboardPage(page); + await use(dashboardPage); + }, + loginPage: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await use(loginPage); + }, + registrationPage: async ({ page }, use) => { + const registrationPage = new RegistrationPage(page); + await use(registrationPage); + }, +}); diff --git a/e2e/fixtures/login-page.ts b/e2e/fixtures/login-page.ts new file mode 100644 index 00000000..375da6b0 --- /dev/null +++ b/e2e/fixtures/login-page.ts @@ -0,0 +1,27 @@ +import { Locator, Page } from "@playwright/test"; +import { BasePage } from "./base/page"; + +export class LoginPage extends BasePage { + submitLoginButton: Locator; + loginForm: Locator; + registerLink: Locator; + passwordInput: Locator; + usernameInput: Locator; + + constructor(page: Page) { + super(page); + + this.submitLoginButton = page.getByTestId("submit-login-button"); + + this.loginForm = page.getByTestId("login-form"); + + this.registerLink = page.getByTestId("register-link"); + + this.passwordInput = page.getByTestId("password-input"); + this.usernameInput = page.getByTestId("username-input"); + } + + async goto() { + await this.page.goto("/login"); + } +} diff --git a/e2e/fixtures/registration-page.ts b/e2e/fixtures/registration-page.ts new file mode 100644 index 00000000..64a407bf --- /dev/null +++ b/e2e/fixtures/registration-page.ts @@ -0,0 +1,28 @@ +import { Locator, Page } from "@playwright/test"; +import { BasePage } from "./base/page"; + +export class RegistrationPage extends BasePage { + registerButton: Locator; + registrationForm: Locator; + + loginLink: Locator; + + displayNameInput: Locator; + passwordConfirmInput: Locator; + passwordInput: Locator; + usernameInput: Locator; + + constructor(page: Page) { + super(page); + + this.registerButton = page.getByTestId("register-button"); + this.registrationForm = page.getByTestId("registration-form"); + + this.loginLink = page.getByTestId("login-link"); + + this.displayNameInput = page.getByTestId("display-name-input"); + this.passwordConfirmInput = page.getByTestId("password-confirm-input"); + this.passwordInput = page.getByTestId("password-input"); + this.usernameInput = page.getByTestId("username-input"); + } +} diff --git a/e2e/index.ts b/e2e/index.ts new file mode 100644 index 00000000..106985f2 --- /dev/null +++ b/e2e/index.ts @@ -0,0 +1,2 @@ +export { test } from "./fixtures"; +export { expect } from "@playwright/test"; From b8d7bd57c8359070a9abbfe960fd728a93e3d641 Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Sun, 21 Apr 2024 17:16:50 -0600 Subject: [PATCH 22/79] feat(e2e): add default environment variables --- e2e/.env.example | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 e2e/.env.example diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 00000000..ba53eac8 --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,2 @@ +TEST_USERNAME=test +TEST_PASSWORD=password \ No newline at end of file From 7c14cf7bf17cf70e647bd814e84e10640c088288 Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Sun, 21 Apr 2024 17:17:27 -0600 Subject: [PATCH 23/79] feat(e2e): add default test setup, update playwright config --- e2e/tests/global/setup.dashboard.ts | 19 ++++++++++++++++++ e2e/tests/global/setup.public.ts | 8 ++++++++ playwright.config.ts | 31 +++++++++++++++++++++-------- 3 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 e2e/tests/global/setup.dashboard.ts create mode 100644 e2e/tests/global/setup.public.ts diff --git a/e2e/tests/global/setup.dashboard.ts b/e2e/tests/global/setup.dashboard.ts new file mode 100644 index 00000000..e0bd4bb3 --- /dev/null +++ b/e2e/tests/global/setup.dashboard.ts @@ -0,0 +1,19 @@ +import { seedUser } from "@/e2e/data/user"; +import { test as setup } from "../../index"; +import { STORAGE_STATE } from "../../../playwright.config"; + +setup("Setup the default user", async ({ page, dashboardPage, loginPage }) => { + const username = process.env["TEST_USERNAME"] || ""; + const password = process.env["TEST_PASSWORD"] || ""; + await seedUser(username, password); + + await loginPage.goto(); + await loginPage.usernameInput.fill(username); + await loginPage.passwordInput.fill(password); + await loginPage.submitLoginButton.click(); + await dashboardPage.container.waitFor({ state: "visible" }); + + await page.context().storageState({ + path: STORAGE_STATE, + }); +}); diff --git a/e2e/tests/global/setup.public.ts b/e2e/tests/global/setup.public.ts new file mode 100644 index 00000000..42ec8fea --- /dev/null +++ b/e2e/tests/global/setup.public.ts @@ -0,0 +1,8 @@ +import { seedUser } from "@/e2e/data/user"; +import { test as setup } from "../../index"; + +setup("Setup the default user", async () => { + const username = process.env["TEST_USERNAME"] || ""; + const password = process.env["TEST_PASSWORD"] || ""; + await seedUser(username, password); +}); diff --git a/playwright.config.ts b/playwright.config.ts index a2811c03..b90f7d74 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,11 +1,8 @@ import { defineConfig, devices } from "@playwright/test"; +import path from "path"; +import "dotenv/config.js"; -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); - +export const STORAGE_STATE = path.join(__dirname, "playwright/.auth/user.json"); /** * See https://playwright.dev/docs/test-configuration. */ @@ -24,7 +21,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', + baseURL: "http://127.0.0.1:3000", /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", @@ -33,10 +30,27 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ { - name: "chromium", + name: "setup dashboard", + testMatch: /global\/setup\.dashboard\.ts/, + }, + { + name: "setup public", + testMatch: /global\/setup\.public\.ts/, + }, + { + name: "chromium dashboard", + dependencies: ["setup dashboard"], + testMatch: "dashboard/*.spec.ts", + use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE }, + }, + { + name: "chromium public", + dependencies: ["setup public"], + testMatch: "public/*.spec.ts", use: { ...devices["Desktop Chrome"] }, }, + /* { name: "firefox", use: { ...devices["Desktop Firefox"] }, @@ -46,6 +60,7 @@ export default defineConfig({ name: "webkit", use: { ...devices["Desktop Safari"] }, }, + */ /* Test against mobile viewports. */ // { From 489ad14c3bc29638345c492458dbf90669bda921 Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Sun, 21 Apr 2024 17:17:47 -0600 Subject: [PATCH 24/79] fix: add additional playwright folders to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 906d2a6f..77f0d918 100644 --- a/.gitignore +++ b/.gitignore @@ -42,8 +42,10 @@ prisma/dev.db # tests /tests /test-results/ +/blob-report/ /playwright-report/ /playwright/.cache/ +/playwright/.auth/ # docker pgdata From 163bf6a0cc985b21706efee3166d5769c390d7eb Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Sun, 21 Apr 2024 17:18:05 -0600 Subject: [PATCH 25/79] test(e2e): add login tests --- e2e/tests/public/login.spec.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 e2e/tests/public/login.spec.ts diff --git a/e2e/tests/public/login.spec.ts b/e2e/tests/public/login.spec.ts new file mode 100644 index 00000000..d39b34f9 --- /dev/null +++ b/e2e/tests/public/login.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "../../index" + +test("Logging in without credentials displays an error", async ({ loginPage }) => { + await loginPage.submitLoginButton.click() + const toast = await loginPage.getLatestToast() + await expect(toast.locator).toBeVisible() + await expect(toast.locator).toHaveAttribute("data-type", "error") +}) + +test("Logging in with an erroneous password displays an error", async ({ loginPage }) => { + await loginPage.usernameInput.fill(process.env['TEST_USERNAME'] || "") + await loginPage.passwordInput.fill("NOT_MY_PASSWORD_DNE_ERROR") + await loginPage.submitLoginButton.click() + const toast = await loginPage.getLatestToast() + await expect(toast.locator).toBeVisible() + await expect(toast.locator).toHaveAttribute("data-type", "error") +}) + +test("Logging in without valid credentials displays an error", async ({ loginPage }) => { + await loginPage.submitLoginButton.click() + const toast = await loginPage.getLatestToast() + await expect(toast.locator).toBeVisible() + await expect(toast.locator).toHaveAttribute("data-type", "error") +}) + +test("Logging in with a valid username and password works as expected", async ({ page, loginPage, dashboardPage }) => { + await loginPage.usernameInput.fill(process.env['TEST_USERNAME'] || "") + await loginPage.passwordInput.fill(process.env['TEST_PASSWORD'] || "") + await loginPage.submitLoginButton.click() + await expect(loginPage.loginForm).not.toBeVisible() + await expect(dashboardPage.container).toBeVisible() +}) \ No newline at end of file From f37a4b9c9e49a352d99902dfc96703ac83c5445b Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Sun, 21 Apr 2024 19:21:30 -0400 Subject: [PATCH 26/79] replace maskable logo --- public/logo_maskable.png | Bin 31305 -> 88987 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/logo_maskable.png b/public/logo_maskable.png index 307583a895adecdef26b544beb69f8d8330c000f..bfc2c96f241df3db552d6c08132f158ffcf06b6c 100644 GIT binary patch literal 88987 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 Qc5JUJ7ws>Q&UrrhKP$Bk(f|Me literal 31305 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 Q6#xJL07*qoM6N<$g6?8<BLDyZ From 04547e1bdfc3dc240e66fcdc0733cea00e6cc259 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Mon, 22 Apr 2024 08:19:21 -0400 Subject: [PATCH 27/79] update playwright --- .gitignore | 6 +++++- package.json | 4 ++-- yarn.lock | 33 ++++++++++++++++----------------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 77f0d918..21611740 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,8 @@ prisma/dev.db # docker pgdata -certificates \ No newline at end of file +certificates +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/package.json b/package.json index a08ae19d..8e424247 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "next-auth": "^4.22.1", "node-fetch": "^2.7.0", "nodemailer": "^6.9.3", - "playwright": "^1.35.1", + "playwright": "^1.43.1", "react": "18.2.0", "react-colorful": "^5.6.1", "react-dom": "18.2.0", @@ -68,7 +68,7 @@ "zustand": "^4.3.8" }, "devDependencies": { - "@playwright/test": "^1.35.1", + "@playwright/test": "^1.43.1", "@types/bcrypt": "^5.0.0", "@types/dompurify": "^3.0.4", "@types/jsdom": "^21.1.3", diff --git a/yarn.lock b/yarn.lock index d016ae93..9616e333 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1279,15 +1279,12 @@ tiny-glob "^0.2.9" tslib "^2.4.0" -"@playwright/test@^1.35.1": - version "1.35.1" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.35.1.tgz#a596b61e15b980716696f149cc7a2002f003580c" - integrity sha512-b5YoFe6J9exsMYg0pQAobNDR85T1nLumUYgUTtKm4d21iX2L7WqKq9dW8NGJ+2vX0etZd+Y7UeuqsxDXm9+5ZA== +"@playwright/test@^1.43.1": + version "1.43.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.43.1.tgz#16728a59eb8ce0f60472f98d8886d6cab0fa3e42" + integrity sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA== dependencies: - "@types/node" "*" - playwright-core "1.35.1" - optionalDependencies: - fsevents "2.3.2" + playwright "1.43.1" "@prisma/client@^4.16.2": version "4.16.2" @@ -4920,17 +4917,19 @@ pixelmatch@^4.0.2: dependencies: pngjs "^3.0.0" -playwright-core@1.35.1: - version "1.35.1" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.35.1.tgz#52c1e6ffaa6a8c29de1a5bdf8cce0ce290ffb81d" - integrity sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg== +playwright-core@1.43.1: + version "1.43.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.43.1.tgz#0eafef9994c69c02a1a3825a4343e56c99c03b02" + integrity sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg== -playwright@^1.35.1: - version "1.35.1" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.35.1.tgz#f991d0c76ae517d4a0023d9428b09d19d5e87128" - integrity sha512-NbwBeGJLu5m7VGM0+xtlmLAH9VUfWwYOhUi/lSEDyGg46r1CA9RWlvoc5yywxR9AzQb0mOCm7bWtOXV7/w43ZA== +playwright@1.43.1, playwright@^1.43.1: + version "1.43.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.43.1.tgz#8ad08984ac66c9ef3d0db035be54dd7ec9f1c7d9" + integrity sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA== dependencies: - playwright-core "1.35.1" + playwright-core "1.43.1" + optionalDependencies: + fsevents "2.3.2" pngjs@^3.0.0, pngjs@^3.3.3: version "3.4.0" From 7856e76b15c1b859cfb2e3f9cf12dd8a5f4b5352 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Mon, 22 Apr 2024 18:00:59 -0400 Subject: [PATCH 28/79] basic user listing --- .env.sample | 1 + lib/api/controllers/users/getUsers.ts | 22 ++++++++ pages/admin.tsx | 77 +++++++++++++++++++++++++++ pages/api/v1/users/index.ts | 8 +++ types/enviornment.d.ts | 3 +- 5 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 lib/api/controllers/users/getUsers.ts create mode 100644 pages/admin.tsx diff --git a/.env.sample b/.env.sample index 0e692b0d..a5796351 100644 --- a/.env.sample +++ b/.env.sample @@ -22,6 +22,7 @@ BROWSER_TIMEOUT= IGNORE_UNAUTHORIZED_CA= IGNORE_HTTPS_ERRORS= IGNORE_URL_SIZE_LIMIT= +ADMINISTRATOR= # AWS S3 Settings SPACES_KEY= diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts new file mode 100644 index 00000000..dd2b1686 --- /dev/null +++ b/lib/api/controllers/users/getUsers.ts @@ -0,0 +1,22 @@ +import { prisma } from "@/lib/api/db"; + +export default async function getUsers() { + // Get all users + const users = await prisma.user.findMany({ + select: { + id: true, + username: true, + email: true, + emailVerified: true, + subscriptions: { + select: { + active: true, + }, + }, + createdAt: true, + updatedAt: true, + }, + }); + + return { response: users, status: 200 }; +} diff --git a/pages/admin.tsx b/pages/admin.tsx new file mode 100644 index 00000000..6624fe33 --- /dev/null +++ b/pages/admin.tsx @@ -0,0 +1,77 @@ +import { User as U } from "@prisma/client"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +interface User extends U { + subscriptions: { + active: boolean; + }; +} + +export default function Admin() { + const [users, setUsers] = useState<User[]>(); + + useEffect(() => { + // fetch users + fetch("/api/v1/users") + .then((res) => res.json()) + .then((data) => setUsers(data.response)); + }, []); + + return ( + <div className="max-w-5xl mx-auto mt-5 px-5"> + <div className="gap-2 inline-flex items-center"> + <Link + href="/dashboard" + className="text-neutral btn btn-square btn-sm btn-ghost" + > + <i className="bi-chevron-left text-xl"></i> + </Link> + <p className="capitalize text-3xl font-thin inline"> + User Administration + </p> + </div> + + <div className="divider my-3"></div> + + {users && users.length > 0 ? ( + <div className="overflow-x-auto whitespace-nowrap w-full"> + <table className="table table-zebra w-full"> + <thead> + <tr> + <th></th> + <th>Username</th> + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + <th>Email</th> + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && ( + <th>Subscribed</th> + )} + <th>Created At</th> + <th>Updated At</th> + </tr> + </thead> + <tbody> + {users.map((user, index) => ( + <tr key={user.id}> + <td>{index + 1}</td> + <td>{user.username}</td> + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + <td>{user.email}</td> + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && ( + <td>{user.subscriptions.active ? "Yes" : "No"}</td> + )} + <td>{new Date(user.createdAt).toLocaleString()}</td> + <td>{new Date(user.updatedAt).toLocaleString()}</td> + </tr> + ))} + </tbody> + </table> + </div> + ) : ( + <p>No users found.</p> + )} + </div> + ); +} diff --git a/pages/api/v1/users/index.ts b/pages/api/v1/users/index.ts index 3af7bf8e..cce6b4ea 100644 --- a/pages/api/v1/users/index.ts +++ b/pages/api/v1/users/index.ts @@ -1,9 +1,17 @@ import type { NextApiRequest, NextApiResponse } from "next"; import postUser from "@/lib/api/controllers/users/postUser"; +import getUsers from "@/lib/api/controllers/users/getUsers"; +import verifyUser from "@/lib/api/verifyUser"; export default async function users(req: NextApiRequest, res: NextApiResponse) { if (req.method === "POST") { const response = await postUser(req, res); return response; + } else if (req.method === "GET") { + const user = await verifyUser({ req, res }); + if (!user || process.env.ADMINISTRATOR !== user.username) return; + + const response = await getUsers(); + return res.status(response.status).json({ response: response.response }); } } diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index e8c5a7c6..5a94472f 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -14,6 +14,7 @@ declare global { ARCHIVE_TAKE_COUNT?: string; IGNORE_UNAUTHORIZED_CA?: string; IGNORE_URL_SIZE_LIMIT?: string; + ADMINISTRATOR?: string; SPACES_KEY?: string; SPACES_SECRET?: string; @@ -418,4 +419,4 @@ declare global { } } -export { }; +export {}; From d37b25c5a2b1fea07a2ff7af23efa2db7449bcaf Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Mon, 22 Apr 2024 22:01:06 -0600 Subject: [PATCH 29/79] feat: add github workflow for e2e tests --- .github/workflows/playwright-tests.yml | 95 ++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 96 insertions(+) create mode 100644 .github/workflows/playwright-tests.yml diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml new file mode 100644 index 00000000..f8890585 --- /dev/null +++ b/.github/workflows/playwright-tests.yml @@ -0,0 +1,95 @@ +name: Linkwarden Playwright Tests + +on: + push: + branches: + - main + - qacomet/** + pull_request: + workflow_dispatch: + +env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: password + PGDATABASE: postgres + + TEST_POSTGRES_USER: test_linkwarden_user + TEST_POSTGRES_PASSWORD: password + TEST_POSTGRES_DATABASE: test_linkwarden_db + TEST_POSTGRES_DATABASE_TEMPLATE: test_linkwarden_db_template + TEST_POSTGRES_HOST: localhost + TEST_POSTGREST_PORT: 5432 + PRODUCTION_POSTGRES_DATABASE: linkwarden_db + + NEXTAUTH_SECRET: very_sensitive_secret + NEXTAUTH_URL: http://localhost:3000/api/v1/auth + + # Manual installation database settings + DATABASE_URL: postgresql://test_linkwarden_user:password@localhost:5432/test_linkwarden_db + + # Docker installation database settings + POSTGRES_PASSWORD: password + + TEST_USERNAME: test-user + TEST_PASSWORD: password + +jobs: + playwright-test-runner: + timeout-minutes: 20 + runs-on: + - ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v2 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: "18" + + - name: Initialize PostgreSQL + run: | + echo "Initializing Databases" + psql -h localhost -U postgres -d postgres -c "CREATE USER ${{ env.TEST_POSTGRES_USER }} WITH PASSWORD '${{ env.TEST_POSTGRES_PASSWORD }}';" + psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};" + + - name: Install packages + run: yarn install -y + + - name: Install playwright + run: yarn playwright install --with-deps + + - name: Setup project + run: | + yarn prisma generate + yarn build + yarn prisma migrate deploy + + - name: Start linkwarden server and worker + run: yarn start & + + - name: Run Tests + run: yarn e2e + + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: test-results + retention-days: 30 diff --git a/package.json b/package.json index 8e424247..55b71eaa 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --", "build": "next build", "lint": "next lint", + "e2e": "playwright test e2e", "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"" }, "dependencies": { From 1a96ca32f99c41876f110a5078373c966388f5c8 Mon Sep 17 00:00:00 2001 From: Matthew Jacobs <codingmatty@gmail.com> Date: Wed, 24 Apr 2024 00:56:00 +0000 Subject: [PATCH 30/79] add link actions to readable view --- .../LinkViews/LinkComponents/LinkActions.tsx | 12 ++++++++--- components/ReadableView.tsx | 21 +++++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/components/LinkViews/LinkComponents/LinkActions.tsx b/components/LinkViews/LinkComponents/LinkActions.tsx index d809dfc8..fcd47507 100644 --- a/components/LinkViews/LinkComponents/LinkActions.tsx +++ b/components/LinkViews/LinkComponents/LinkActions.tsx @@ -18,6 +18,7 @@ type Props = { position?: string; toggleShowInfo?: () => void; linkInfo?: boolean; + alignToTop?: boolean; flipDropdown?: boolean; }; @@ -26,6 +27,7 @@ export default function LinkActions({ toggleShowInfo, position, linkInfo, + alignToTop, flipDropdown, }: Props) { const permissions = usePermissions(link.collection.id as number); @@ -67,9 +69,9 @@ export default function LinkActions({ return ( <> <div - className={`dropdown dropdown-left dropdown-end absolute ${ + className={`dropdown dropdown-left absolute ${ position || "top-3 right-3" - } z-20`} + } ${alignToTop ? "" : "dropdown-end"} z-20`} > <div tabIndex={0} @@ -79,7 +81,11 @@ export default function LinkActions({ > <i title="More" className="bi-three-dots text-xl" /> </div> - <ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 translate-y-10"> + <ul + className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 ${ + alignToTop ? "" : "translate-y-10" + }`} + > <li> <div role="button" diff --git a/components/ReadableView.tsx b/components/ReadableView.tsx index 9c7ce176..977ca2a0 100644 --- a/components/ReadableView.tsx +++ b/components/ReadableView.tsx @@ -4,6 +4,7 @@ import isValidUrl from "@/lib/shared/isValidUrl"; import useLinkStore from "@/store/links"; import { ArchivedFormat, + CollectionIncludingMembersAndLinkCount, LinkIncludingShortenedCollectionAndTags, } from "@/types/global"; import ColorThief, { RGBColor } from "colorthief"; @@ -11,7 +12,9 @@ import DOMPurify from "dompurify"; import Image from "next/image"; import Link from "next/link"; 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 useCollectionStore from "@/store/collections"; type LinkContent = { title: string; @@ -41,6 +44,13 @@ export default function ReadableView({ link }: Props) { const router = useRouter(); const { links, getLink } = useLinkStore(); + const { collections } = useCollectionStore(); + + const collection = useMemo(() => { + return collections.find( + (e) => e.id === link.collection.id + ) as CollectionIncludingMembersAndLinkCount; + }, [collections, link]); useEffect(() => { const fetchLinkContent = async () => { @@ -131,7 +141,7 @@ export default function ReadableView({ link }: Props) { <div className={`flex flex-col max-w-screen-md h-full mx-auto py-5`}> <div id="link-banner" - className="link-banner bg-opacity-10 border-neutral-content p-3 border mb-3" + className="link-banner relative bg-opacity-10 border-neutral-content p-3 border mb-3" > <div id="link-banner-inner" className="link-banner-inner"></div> @@ -226,6 +236,13 @@ export default function ReadableView({ link }: Props) { {link?.name ? <p>{unescapeString(link?.description)}</p> : undefined} </div> + + <LinkActions + link={link} + collection={collection} + position="top-3 right-3" + alignToTop + /> </div> <div className="flex flex-col gap-5 h-full"> From 2b04bcb1df93978582f068f0339223e2b664c24c Mon Sep 17 00:00:00 2001 From: Isaac Wise <iamisaacwise2006@gmail.com> Date: Tue, 23 Apr 2024 20:48:15 -0500 Subject: [PATCH 31/79] Added Masonry View --- components/LinkViews/Layouts/MasonryView.tsx | 40 +++++++++++++ components/LinkViews/LinkCard.tsx | 61 +++++++++++--------- components/ViewDropdown.tsx | 28 +++++---- package.json | 1 + pages/collections/[id].tsx | 18 +++--- pages/dashboard.tsx | 2 + pages/links/index.tsx | 13 ++--- pages/links/pinned.tsx | 13 ++--- pages/public/collections/[id].tsx | 7 ++- pages/search.tsx | 2 + pages/tags/[id].tsx | 18 +++--- types/global.ts | 1 + yarn.lock | 5 ++ 13 files changed, 134 insertions(+), 75 deletions(-) create mode 100644 components/LinkViews/Layouts/MasonryView.tsx diff --git a/components/LinkViews/Layouts/MasonryView.tsx b/components/LinkViews/Layouts/MasonryView.tsx new file mode 100644 index 00000000..7cd707c5 --- /dev/null +++ b/components/LinkViews/Layouts/MasonryView.tsx @@ -0,0 +1,40 @@ + +import LinkCard from "@/components/LinkViews/LinkCard"; +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import { GridLoader } from "react-spinners"; +import Masonry from 'react-masonry-css' + +export default function MasonryView({ + links, + editMode, + isLoading, +}: { + links: LinkIncludingShortenedCollectionAndTags[]; + editMode?: boolean; + isLoading?: boolean; +}) { + return ( + <Masonry breakpointCols={4} columnClassName="!w-full flex flex-col gap-5" className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> + {links.map((e, i) => { + return ( + <LinkCard + key={i} + link={e} + count={i} + flipDropdown={i === links.length - 1} + editMode={editMode} + /> + ); + })} + + {isLoading && links.length > 0 && ( + <GridLoader + color="oklch(var(--p))" + loading={true} + size={20} + className="fixed top-5 right-5 opacity-50 z-30" + /> + )} + </Masonry> + ); +} diff --git a/components/LinkViews/LinkCard.tsx b/components/LinkViews/LinkCard.tsx index 92994422..5c3417dc 100644 --- a/components/LinkViews/LinkCard.tsx +++ b/components/LinkViews/LinkCard.tsx @@ -30,6 +30,7 @@ type Props = { }; export default function LinkCard({ link, flipDropdown, editMode }: Props) { + const viewMode = localStorage.getItem("viewMode") || "card"; const { collections } = useCollectionStore(); const { account } = useAccountStore(); @@ -121,8 +122,8 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { ? handleCheckboxClick(link) : editMode ? toast.error( - "You don't have permission to edit or delete this item." - ) + "You don't have permission to edit or delete this item." + ) : undefined } > @@ -132,32 +133,36 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { !editMode && window.open(generateLinkHref(link, account), "_blank") } > - <div className="relative rounded-t-2xl h-40 overflow-hidden"> - {previewAvailable(link) ? ( - <Image - src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`} - width={1280} - height={720} - alt="" - className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105" - style={{ filter: "blur(2px)" }} - draggable="false" - onError={(e) => { - const target = e.target as HTMLElement; - target.style.display = "none"; - }} - /> - ) : link.preview === "unavailable" ? ( - <div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div> - ) : ( - <div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div> - )} - <div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> - <LinkIcon link={link} /> - </div> - </div> + {viewMode === 'masonry' && !(previewAvailable(link)) ? null : ( + <> + <div className="relative rounded-t-2xl h-40 overflow-hidden"> + {previewAvailable(link) ? ( + <Image + src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`} + width={1280} + height={720} + alt="" + className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105" + style={{ filter: "blur(2px)" }} + draggable="false" + onError={(e) => { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + ) : link.preview === "unavailable" ? ( + <div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div> + ) : ( + <div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div> + )} + <div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> + <LinkIcon link={link} /> + </div> + </div> - <hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" /> + <hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" /> + </> + )} <div className="p-3 mt-1"> <p className="truncate w-full pr-8 text-primary"> @@ -229,7 +234,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { <LinkActions link={link} collection={collection} - position="top-[10.75rem] right-3" + position={!(previewAvailable(link)) && viewMode === 'masonry' ? "top-[.75rem] right-3" : "top-[10.75rem] right-3"} toggleShowInfo={() => setShowInfo(!showInfo)} linkInfo={showInfo} flipDropdown={flipDropdown} diff --git a/components/ViewDropdown.tsx b/components/ViewDropdown.tsx index 5ab821ee..9e5a1890 100644 --- a/components/ViewDropdown.tsx +++ b/components/ViewDropdown.tsx @@ -26,22 +26,30 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) { <div className="p-1 flex flex-row gap-1 border border-neutral-content rounded-[0.625rem]"> <button onClick={(e) => onChangeViewMode(e, ViewMode.Card)} - className={`btn btn-square btn-sm btn-ghost ${ - viewMode == ViewMode.Card - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} + className={`btn btn-square btn-sm btn-ghost ${viewMode == ViewMode.Card + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} > <i className="bi-grid w-4 h-4 text-neutral"></i> </button> + <button + onClick={(e) => onChangeViewMode(e, ViewMode.Masonry)} + className={`btn btn-square btn-sm btn-ghost ${viewMode == ViewMode.Masonry + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + <i className="bi bi-columns-gap w-4 h-4 text-neutral"></i> + </button> + <button onClick={(e) => onChangeViewMode(e, ViewMode.List)} - className={`btn btn-square btn-sm btn-ghost ${ - viewMode == ViewMode.List - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} + className={`btn btn-square btn-sm btn-ghost ${viewMode == ViewMode.List + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} > <i className="bi bi-view-stacked w-4 h-4 text-neutral"></i> </button> diff --git a/package.json b/package.json index a08ae19d..cd6c826f 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "react-dom": "18.2.0", "react-hot-toast": "^2.4.1", "react-image-file-resizer": "^0.4.8", + "react-masonry-css": "^1.0.16", "react-select": "^5.7.4", "react-spinners": "^0.13.8", "socks-proxy-agent": "^8.0.2", diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index 92456a79..3a23940a 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -28,6 +28,7 @@ import NewCollectionModal from "@/components/ModalContent/NewCollectionModal"; import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; import toast from "react-hot-toast"; import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; +import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; export default function Index() { const { settings } = useLocalSettingsStore(); @@ -110,6 +111,7 @@ export default function Index() { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, + [ViewMode.Masonry]: MasonryView, }; // @ts-ignore @@ -125,8 +127,7 @@ export default function Index() { const bulkDeleteLinks = async () => { const load = toast.loading( - `Deleting ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" + `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" }...` ); @@ -138,8 +139,7 @@ export default function Index() { response.ok && toast.success( - `Deleted ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" + `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" }!` ); }; @@ -149,9 +149,8 @@ export default function Index() { <div className="h-[60rem] p-5 flex gap-3 flex-col" style={{ - backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${ - settings.theme === "dark" ? "#262626" : "#f3f4f6" - } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, + backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${settings.theme === "dark" ? "#262626" : "#f3f4f6" + } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, }} > {activeCollection && ( @@ -326,11 +325,10 @@ export default function Index() { setEditMode(!editMode); setSelectedLinks([]); }} - className={`btn btn-square btn-sm btn-ghost ${ - editMode + className={`btn btn-square btn-sm btn-ghost ${editMode ? "bg-primary/20 hover:bg-primary/20" : "hover:bg-neutral/20" - }`} + }`} > <i className="bi-pencil-fill text-neutral text-xl"></i> </div> diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 6fabf33a..04882f1e 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -16,6 +16,7 @@ import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import ViewDropdown from "@/components/ViewDropdown"; import { dropdownTriggerer } from "@/lib/client/utils"; +import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; // import GridView from "@/components/LinkViews/Layouts/GridView"; export default function Dashboard() { @@ -102,6 +103,7 @@ export default function Dashboard() { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, + [ViewMode.Masonry]: MasonryView, }; // @ts-ignore diff --git a/pages/links/index.tsx b/pages/links/index.tsx index b6b67c39..949f6be0 100644 --- a/pages/links/index.tsx +++ b/pages/links/index.tsx @@ -15,6 +15,7 @@ import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; // import GridView from "@/components/LinkViews/Layouts/GridView"; import { useRouter } from "next/router"; +import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; export default function Links() { const { links, selectedLinks, deleteLinksById, setSelectedLinks } = @@ -51,8 +52,7 @@ export default function Links() { const bulkDeleteLinks = async () => { const load = toast.loading( - `Deleting ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" + `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" }...` ); @@ -64,8 +64,7 @@ export default function Links() { response.ok && toast.success( - `Deleted ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" + `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" }!` ); }; @@ -74,6 +73,7 @@ export default function Links() { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, + [ViewMode.Masonry]: MasonryView, }; // @ts-ignore @@ -97,11 +97,10 @@ export default function Links() { setEditMode(!editMode); setSelectedLinks([]); }} - className={`btn btn-square btn-sm btn-ghost ${ - editMode + className={`btn btn-square btn-sm btn-ghost ${editMode ? "bg-primary/20 hover:bg-primary/20" : "hover:bg-neutral/20" - }`} + }`} > <i className="bi-pencil-fill text-neutral text-xl"></i> </div> diff --git a/pages/links/pinned.tsx b/pages/links/pinned.tsx index ecdec4ea..5e53886c 100644 --- a/pages/links/pinned.tsx +++ b/pages/links/pinned.tsx @@ -14,6 +14,7 @@ import useCollectivePermissions from "@/hooks/useCollectivePermissions"; import toast from "react-hot-toast"; // import GridView from "@/components/LinkViews/Layouts/GridView"; import { useRouter } from "next/router"; +import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; export default function PinnedLinks() { const { links, selectedLinks, deleteLinksById, setSelectedLinks } = @@ -49,8 +50,7 @@ export default function PinnedLinks() { const bulkDeleteLinks = async () => { const load = toast.loading( - `Deleting ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" + `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" }...` ); @@ -62,8 +62,7 @@ export default function PinnedLinks() { response.ok && toast.success( - `Deleted ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" + `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" }!` ); }; @@ -72,6 +71,7 @@ export default function PinnedLinks() { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, + [ViewMode.Masonry]: MasonryView, }; // @ts-ignore @@ -94,11 +94,10 @@ export default function PinnedLinks() { setEditMode(!editMode); setSelectedLinks([]); }} - className={`btn btn-square btn-sm btn-ghost ${ - editMode + className={`btn btn-square btn-sm btn-ghost ${editMode ? "bg-primary/20 hover:bg-primary/20" : "hover:bg-neutral/20" - }`} + }`} > <i className="bi-pencil-fill text-neutral text-xl"></i> </div> diff --git a/pages/public/collections/[id].tsx b/pages/public/collections/[id].tsx index d9180863..c72f0a5f 100644 --- a/pages/public/collections/[id].tsx +++ b/pages/public/collections/[id].tsx @@ -24,6 +24,7 @@ import EditCollectionSharingModal from "@/components/ModalContent/EditCollection import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; +import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; // import GridView from "@/components/LinkViews/Layouts/GridView"; const cardVariants: Variants = { @@ -109,6 +110,7 @@ export default function PublicCollections() { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, + [ViewMode.Masonry]: MasonryView, }; // @ts-ignore @@ -118,9 +120,8 @@ export default function PublicCollections() { <div className="h-96" style={{ - backgroundImage: `linear-gradient(${collection?.color}30 10%, ${ - settings.theme === "dark" ? "#262626" : "#f3f4f6" - } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, + backgroundImage: `linear-gradient(${collection?.color}30 10%, ${settings.theme === "dark" ? "#262626" : "#f3f4f6" + } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, }} > {collection ? ( diff --git a/pages/search.tsx b/pages/search.tsx index f89c799e..3e4ce491 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -12,6 +12,7 @@ import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import PageHeader from "@/components/PageHeader"; import { GridLoader, PropagateLoader } from "react-spinners"; +import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; export default function Search() { const { links } = useLinkStore(); @@ -49,6 +50,7 @@ export default function Search() { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, + [ViewMode.Masonry]: MasonryView, }; // @ts-ignore diff --git a/pages/tags/[id].tsx b/pages/tags/[id].tsx index 34d172f2..1b286c8e 100644 --- a/pages/tags/[id].tsx +++ b/pages/tags/[id].tsx @@ -15,6 +15,7 @@ import { dropdownTriggerer } from "@/lib/client/utils"; import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; import useCollectivePermissions from "@/hooks/useCollectivePermissions"; +import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; export default function Index() { const router = useRouter(); @@ -124,8 +125,7 @@ export default function Index() { const bulkDeleteLinks = async () => { const load = toast.loading( - `Deleting ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" + `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" }...` ); @@ -137,8 +137,7 @@ export default function Index() { response.ok && toast.success( - `Deleted ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" + `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" }!` ); }; @@ -151,6 +150,7 @@ export default function Index() { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, + [ViewMode.Masonry]: MasonryView, }; // @ts-ignore @@ -197,11 +197,10 @@ export default function Index() { </p> <div className="relative"> <div - className={`dropdown dropdown-bottom font-normal ${ - activeTag?.name.length && activeTag?.name.length > 8 + className={`dropdown dropdown-bottom font-normal ${activeTag?.name.length && activeTag?.name.length > 8 ? "dropdown-end" : "" - }`} + }`} > <div tabIndex={0} @@ -253,11 +252,10 @@ export default function Index() { setEditMode(!editMode); setSelectedLinks([]); }} - className={`btn btn-square btn-sm btn-ghost ${ - editMode + className={`btn btn-square btn-sm btn-ghost ${editMode ? "bg-primary/20 hover:bg-primary/20" : "hover:bg-neutral/20" - }`} + }`} > <i className="bi-pencil-fill text-neutral text-xl"></i> </div> diff --git a/types/global.ts b/types/global.ts index b347659a..cc171f25 100644 --- a/types/global.ts +++ b/types/global.ts @@ -68,6 +68,7 @@ export enum ViewMode { Card = "card", Grid = "grid", List = "list", + Masonry = "masonry", } export enum Sort { diff --git a/yarn.lock b/yarn.lock index d016ae93..14c49ca3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5165,6 +5165,11 @@ react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-masonry-css@^1.0.16: + version "1.0.16" + resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz#72b28b4ae3484e250534700860597553a10f1a2c" + integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ== + react-redux@^7.0.3: version "7.2.9" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" From 4f6368fcbf36716c235731356dab469599aa9d8e Mon Sep 17 00:00:00 2001 From: Isaac Wise <iamisaacwise2006@gmail.com> Date: Tue, 23 Apr 2024 20:53:33 -0500 Subject: [PATCH 32/79] Format & Lint --- components/LinkViews/Layouts/MasonryView.tsx | 65 ++++++++++---------- components/LinkViews/LinkCard.tsx | 12 ++-- components/ViewDropdown.tsx | 27 ++++---- pages/api/v1/auth/[...nextauth].ts | 64 ++++++++++--------- pages/api/v1/logins/index.ts | 2 +- pages/collections/[id].tsx | 16 +++-- pages/links/index.tsx | 11 ++-- pages/links/pinned.tsx | 11 ++-- pages/tags/[id].tsx | 16 +++-- types/enviornment.d.ts | 2 +- 10 files changed, 124 insertions(+), 102 deletions(-) diff --git a/components/LinkViews/Layouts/MasonryView.tsx b/components/LinkViews/Layouts/MasonryView.tsx index 7cd707c5..743dcd4c 100644 --- a/components/LinkViews/Layouts/MasonryView.tsx +++ b/components/LinkViews/Layouts/MasonryView.tsx @@ -1,40 +1,43 @@ - import LinkCard from "@/components/LinkViews/LinkCard"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { GridLoader } from "react-spinners"; -import Masonry from 'react-masonry-css' +import Masonry from "react-masonry-css"; export default function MasonryView({ - links, - editMode, - isLoading, + links, + editMode, + isLoading, }: { - links: LinkIncludingShortenedCollectionAndTags[]; - editMode?: boolean; - isLoading?: boolean; + links: LinkIncludingShortenedCollectionAndTags[]; + editMode?: boolean; + isLoading?: boolean; }) { - return ( - <Masonry breakpointCols={4} columnClassName="!w-full flex flex-col gap-5" className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> - {links.map((e, i) => { - return ( - <LinkCard - key={i} - link={e} - count={i} - flipDropdown={i === links.length - 1} - editMode={editMode} - /> - ); - })} + return ( + <Masonry + breakpointCols={4} + columnClassName="!w-full flex flex-col gap-5" + className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5" + > + {links.map((e, i) => { + return ( + <LinkCard + key={i} + link={e} + count={i} + flipDropdown={i === links.length - 1} + editMode={editMode} + /> + ); + })} - {isLoading && links.length > 0 && ( - <GridLoader - color="oklch(var(--p))" - loading={true} - size={20} - className="fixed top-5 right-5 opacity-50 z-30" - /> - )} - </Masonry> - ); + {isLoading && links.length > 0 && ( + <GridLoader + color="oklch(var(--p))" + loading={true} + size={20} + className="fixed top-5 right-5 opacity-50 z-30" + /> + )} + </Masonry> + ); } diff --git a/components/LinkViews/LinkCard.tsx b/components/LinkViews/LinkCard.tsx index 5c3417dc..0ab4eee1 100644 --- a/components/LinkViews/LinkCard.tsx +++ b/components/LinkViews/LinkCard.tsx @@ -122,8 +122,8 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { ? handleCheckboxClick(link) : editMode ? toast.error( - "You don't have permission to edit or delete this item." - ) + "You don't have permission to edit or delete this item." + ) : undefined } > @@ -133,7 +133,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { !editMode && window.open(generateLinkHref(link, account), "_blank") } > - {viewMode === 'masonry' && !(previewAvailable(link)) ? null : ( + {viewMode === "masonry" && !previewAvailable(link) ? null : ( <> <div className="relative rounded-t-2xl h-40 overflow-hidden"> {previewAvailable(link) ? ( @@ -234,7 +234,11 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { <LinkActions link={link} collection={collection} - position={!(previewAvailable(link)) && viewMode === 'masonry' ? "top-[.75rem] right-3" : "top-[10.75rem] right-3"} + position={ + !previewAvailable(link) && viewMode === "masonry" + ? "top-[.75rem] right-3" + : "top-[10.75rem] right-3" + } toggleShowInfo={() => setShowInfo(!showInfo)} linkInfo={showInfo} flipDropdown={flipDropdown} diff --git a/components/ViewDropdown.tsx b/components/ViewDropdown.tsx index 9e5a1890..ec4fb8c8 100644 --- a/components/ViewDropdown.tsx +++ b/components/ViewDropdown.tsx @@ -26,30 +26,33 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) { <div className="p-1 flex flex-row gap-1 border border-neutral-content rounded-[0.625rem]"> <button onClick={(e) => onChangeViewMode(e, ViewMode.Card)} - className={`btn btn-square btn-sm btn-ghost ${viewMode == ViewMode.Card - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} + className={`btn btn-square btn-sm btn-ghost ${ + viewMode == ViewMode.Card + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} > <i className="bi-grid w-4 h-4 text-neutral"></i> </button> <button onClick={(e) => onChangeViewMode(e, ViewMode.Masonry)} - className={`btn btn-square btn-sm btn-ghost ${viewMode == ViewMode.Masonry - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} + className={`btn btn-square btn-sm btn-ghost ${ + viewMode == ViewMode.Masonry + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} > <i className="bi bi-columns-gap w-4 h-4 text-neutral"></i> </button> <button onClick={(e) => onChangeViewMode(e, ViewMode.List)} - className={`btn btn-square btn-sm btn-ghost ${viewMode == ViewMode.List - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} + className={`btn btn-square btn-sm btn-ghost ${ + viewMode == ViewMode.List + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} > <i className="bi bi-view-stacked w-4 h-4 text-neutral"></i> </button> diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index 5935d834..c8ca51f3 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -98,19 +98,19 @@ if ( const user = await prisma.user.findFirst({ where: emailEnabled ? { - OR: [ - { - username: username.toLowerCase(), - }, - { - email: username?.toLowerCase(), - }, - ], - emailVerified: { not: null }, - } + OR: [ + { + username: username.toLowerCase(), + }, + { + email: username?.toLowerCase(), + }, + ], + emailVerified: { not: null }, + } : { - username: username.toLowerCase(), - }, + username: username.toLowerCase(), + }, }); let passwordMatches: boolean = false; @@ -242,27 +242,25 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") { // Authelia if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") { - providers.push( - { - id: "authelia", - name: "Authelia", - type: "oauth", - clientId: process.env.AUTHELIA_CLIENT_ID!, - clientSecret: process.env.AUTHELIA_CLIENT_SECRET!, - wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!, - authorization: { params: { scope: "openid email profile" } }, - idToken: true, - checks: ["pkce", "state"], - profile(profile) { - return { - id: profile.sub, - name: profile.name, - email: profile.email, - username: profile.preferred_username, - } - }, - } - ); + providers.push({ + id: "authelia", + name: "Authelia", + type: "oauth", + clientId: process.env.AUTHELIA_CLIENT_ID!, + clientSecret: process.env.AUTHELIA_CLIENT_SECRET!, + wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!, + authorization: { params: { scope: "openid email profile" } }, + idToken: true, + checks: ["pkce", "state"], + profile(profile) { + return { + id: profile.sub, + name: profile.name, + email: profile.email, + username: profile.preferred_username, + }; + }, + }); const _linkAccount = adapter.linkAccount; adapter.linkAccount = (account) => { diff --git a/pages/api/v1/logins/index.ts b/pages/api/v1/logins/index.ts index bdf65889..d2ac875f 100644 --- a/pages/api/v1/logins/index.ts +++ b/pages/api/v1/logins/index.ts @@ -401,7 +401,7 @@ export function getLogins() { return { credentialsEnabled: process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" || - process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined + process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined ? "true" : "false", emailEnabled: diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index 3a23940a..e1ba2b8d 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -127,7 +127,8 @@ export default function Index() { const bulkDeleteLinks = async () => { const load = toast.loading( - `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }...` ); @@ -139,7 +140,8 @@ export default function Index() { response.ok && toast.success( - `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }!` ); }; @@ -149,8 +151,9 @@ export default function Index() { <div className="h-[60rem] p-5 flex gap-3 flex-col" style={{ - backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${settings.theme === "dark" ? "#262626" : "#f3f4f6" - } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, + backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${ + settings.theme === "dark" ? "#262626" : "#f3f4f6" + } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, }} > {activeCollection && ( @@ -325,10 +328,11 @@ export default function Index() { setEditMode(!editMode); setSelectedLinks([]); }} - className={`btn btn-square btn-sm btn-ghost ${editMode + className={`btn btn-square btn-sm btn-ghost ${ + editMode ? "bg-primary/20 hover:bg-primary/20" : "hover:bg-neutral/20" - }`} + }`} > <i className="bi-pencil-fill text-neutral text-xl"></i> </div> diff --git a/pages/links/index.tsx b/pages/links/index.tsx index 949f6be0..2b1e67b2 100644 --- a/pages/links/index.tsx +++ b/pages/links/index.tsx @@ -52,7 +52,8 @@ export default function Links() { const bulkDeleteLinks = async () => { const load = toast.loading( - `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }...` ); @@ -64,7 +65,8 @@ export default function Links() { response.ok && toast.success( - `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }!` ); }; @@ -97,10 +99,11 @@ export default function Links() { setEditMode(!editMode); setSelectedLinks([]); }} - className={`btn btn-square btn-sm btn-ghost ${editMode + className={`btn btn-square btn-sm btn-ghost ${ + editMode ? "bg-primary/20 hover:bg-primary/20" : "hover:bg-neutral/20" - }`} + }`} > <i className="bi-pencil-fill text-neutral text-xl"></i> </div> diff --git a/pages/links/pinned.tsx b/pages/links/pinned.tsx index 5e53886c..f2018448 100644 --- a/pages/links/pinned.tsx +++ b/pages/links/pinned.tsx @@ -50,7 +50,8 @@ export default function PinnedLinks() { const bulkDeleteLinks = async () => { const load = toast.loading( - `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }...` ); @@ -62,7 +63,8 @@ export default function PinnedLinks() { response.ok && toast.success( - `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }!` ); }; @@ -94,10 +96,11 @@ export default function PinnedLinks() { setEditMode(!editMode); setSelectedLinks([]); }} - className={`btn btn-square btn-sm btn-ghost ${editMode + className={`btn btn-square btn-sm btn-ghost ${ + editMode ? "bg-primary/20 hover:bg-primary/20" : "hover:bg-neutral/20" - }`} + }`} > <i className="bi-pencil-fill text-neutral text-xl"></i> </div> diff --git a/pages/tags/[id].tsx b/pages/tags/[id].tsx index 1b286c8e..3c8b7c4b 100644 --- a/pages/tags/[id].tsx +++ b/pages/tags/[id].tsx @@ -125,7 +125,8 @@ export default function Index() { const bulkDeleteLinks = async () => { const load = toast.loading( - `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }...` ); @@ -137,7 +138,8 @@ export default function Index() { response.ok && toast.success( - `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }!` ); }; @@ -197,10 +199,11 @@ export default function Index() { </p> <div className="relative"> <div - className={`dropdown dropdown-bottom font-normal ${activeTag?.name.length && activeTag?.name.length > 8 + className={`dropdown dropdown-bottom font-normal ${ + activeTag?.name.length && activeTag?.name.length > 8 ? "dropdown-end" : "" - }`} + }`} > <div tabIndex={0} @@ -252,10 +255,11 @@ export default function Index() { setEditMode(!editMode); setSelectedLinks([]); }} - className={`btn btn-square btn-sm btn-ghost ${editMode + className={`btn btn-square btn-sm btn-ghost ${ + editMode ? "bg-primary/20 hover:bg-primary/20" : "hover:bg-neutral/20" - }`} + }`} > <i className="bi-pencil-fill text-neutral text-xl"></i> </div> diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index e8c5a7c6..3dccc63f 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -418,4 +418,4 @@ declare global { } } -export { }; +export {}; From ca076b1be8c096bd41a0a86b32b5659fe43ab599 Mon Sep 17 00:00:00 2001 From: Isaac Wise <iamisaacwise2006@gmail.com> Date: Tue, 23 Apr 2024 21:19:18 -0500 Subject: [PATCH 33/79] Added bulk edit/delete to search page --- pages/search.tsx | 137 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 3 deletions(-) diff --git a/pages/search.tsx b/pages/search.tsx index f89c799e..1f540c4a 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -11,10 +11,14 @@ import CardView from "@/components/LinkViews/Layouts/CardView"; // import GridView from "@/components/LinkViews/Layouts/GridView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import PageHeader from "@/components/PageHeader"; -import { GridLoader, PropagateLoader } from "react-spinners"; +import { GridLoader } from "react-spinners"; +import useCollectivePermissions from "@/hooks/useCollectivePermissions"; +import toast from "react-hot-toast"; +import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; +import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; export default function Search() { - const { links } = useLinkStore(); + const { links, selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore(); const router = useRouter(); @@ -29,8 +33,48 @@ export default function Search() { const [viewMode, setViewMode] = useState<string>( localStorage.getItem("viewMode") || ViewMode.Card ); + const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + const [editMode, setEditMode] = useState(false); + + useEffect(() => { + if (editMode) return setEditMode(false); + }, [router]); + + const collectivePermissions = useCollectivePermissions( + selectedLinks.map((link) => link.collectionId as number) + ); + + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + }!` + ); + }; + const { isLoading } = useLinks({ sort: sortBy, searchQueryString: decodeURIComponent(router.query.q as string), @@ -62,6 +106,21 @@ export default function Search() { <div className="flex gap-3 items-center justify-end"> <div className="flex gap-2 items-center mt-2"> + {links.length > 0 && ( + <div + role="button" + onClick={() => { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + <i className="bi-pencil-fill text-neutral text-xl"></i> + </div> + )} <FilterSearchDropdown searchFilter={searchFilter} setSearchFilter={setSearchFilter} @@ -72,6 +131,64 @@ export default function Search() { </div> </div> + {editMode && links.length > 0 && ( + <div className="w-full flex justify-between items-center min-h-[32px]"> + {links.length > 0 && ( + <div className="flex gap-3 ml-3"> + <input + type="checkbox" + className="checkbox checkbox-primary" + onChange={() => handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + <span> + {selectedLinks.length}{" "} + {selectedLinks.length === 1 ? "link" : "links"} selected + </span> + ) : ( + <span>Nothing selected</span> + )} + </div> + )} + <div className="flex gap-3"> + <button + onClick={() => setBulkEditLinksModal(true)} + className="btn btn-sm btn-accent text-white w-fit ml-auto" + disabled={ + selectedLinks.length === 0 || + !( + collectivePermissions === true || + collectivePermissions?.canUpdate + ) + } + > + Edit + </button> + <button + onClick={(e) => { + (document?.activeElement as HTMLElement)?.blur(); + e.shiftKey + ? bulkDeleteLinks() + : setBulkDeleteLinksModal(true); + }} + className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto" + disabled={ + selectedLinks.length === 0 || + !( + collectivePermissions === true || + collectivePermissions?.canDelete + ) + } + > + Delete + </button> + </div> + </div> + )} + {!isLoading && !links[0] ? ( <p> Nothing found.{" "} @@ -80,7 +197,7 @@ export default function Search() { </span> </p> ) : links[0] ? ( - <LinkComponent links={links} isLoading={isLoading} /> + <LinkComponent editMode={editMode} links={links} isLoading={isLoading} /> ) : ( isLoading && ( <GridLoader @@ -92,6 +209,20 @@ export default function Search() { ) )} </div> + {bulkDeleteLinksModal && ( + <BulkDeleteLinksModal + onClose={() => { + setBulkDeleteLinksModal(false); + }} + /> + )} + {bulkEditLinksModal && ( + <BulkEditLinksModal + onClose={() => { + setBulkEditLinksModal(false); + }} + /> + )} </MainLayout> ); } From 154d0d5fb6ea84f724ea222a1988b8853e2a8638 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Wed, 24 Apr 2024 09:16:34 -0400 Subject: [PATCH 34/79] add search to user admin --- lib/api/controllers/users/getUsers.ts | 1 - pages/admin.tsx | 145 ++++++++++++++++++-------- 2 files changed, 99 insertions(+), 47 deletions(-) diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts index dd2b1686..496efcf3 100644 --- a/lib/api/controllers/users/getUsers.ts +++ b/lib/api/controllers/users/getUsers.ts @@ -14,7 +14,6 @@ export default async function getUsers() { }, }, createdAt: true, - updatedAt: true, }, }); diff --git a/pages/admin.tsx b/pages/admin.tsx index 6624fe33..be634f82 100644 --- a/pages/admin.tsx +++ b/pages/admin.tsx @@ -11,6 +11,9 @@ interface User extends U { export default function Admin() { const [users, setUsers] = useState<User[]>(); + const [searchQuery, setSearchQuery] = useState(""); + const [filteredUsers, setFilteredUsers] = useState<User[]>(); + useEffect(() => { // fetch users fetch("/api/v1/users") @@ -19,59 +22,109 @@ export default function Admin() { }, []); return ( - <div className="max-w-5xl mx-auto mt-5 px-5"> - <div className="gap-2 inline-flex items-center"> - <Link - href="/dashboard" - className="text-neutral btn btn-square btn-sm btn-ghost" - > - <i className="bi-chevron-left text-xl"></i> - </Link> - <p className="capitalize text-3xl font-thin inline"> - User Administration - </p> + <div className="max-w-6xl mx-auto p-5"> + <div className="flex sm:flex-row flex-col justify-between gap-2"> + <div className="gap-2 inline-flex items-center"> + <Link + href="/dashboard" + className="text-neutral btn btn-square btn-sm btn-ghost" + > + <i className="bi-chevron-left text-xl"></i> + </Link> + <p className="capitalize sm:text-3xl text-2xl font-thin inline"> + User Administration + </p> + </div> + + <div className="flex items-center relative justify-between gap-2"> + <div> + <label + htmlFor="search-box" + className="inline-flex items-center w-fit absolute left-1 pointer-events-none rounded-md p-1 text-primary" + > + <i className="bi-search"></i> + </label> + + <input + id="search-box" + type="text" + placeholder={"Search for Users"} + value={searchQuery} + onChange={(e) => { + setSearchQuery(e.target.value); + + if (users) { + setFilteredUsers( + users.filter((user) => + JSON.stringify(user) + .toLowerCase() + .includes(e.target.value.toLowerCase()) + ) + ); + } + }} + className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-[15rem] md:max-w-full duration-200 outline-none" + /> + </div> + + <div className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative"> + <i className="bi-plus text-3xl absolute"></i> + </div> + </div> </div> <div className="divider my-3"></div> - {users && users.length > 0 ? ( - <div className="overflow-x-auto whitespace-nowrap w-full"> - <table className="table table-zebra w-full"> - <thead> - <tr> - <th></th> - <th>Username</th> - {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( - <th>Email</th> - )} - {process.env.NEXT_PUBLIC_STRIPE === "true" && ( - <th>Subscribed</th> - )} - <th>Created At</th> - <th>Updated At</th> - </tr> - </thead> - <tbody> - {users.map((user, index) => ( - <tr key={user.id}> - <td>{index + 1}</td> - <td>{user.username}</td> - {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( - <td>{user.email}</td> - )} - {process.env.NEXT_PUBLIC_STRIPE === "true" && ( - <td>{user.subscriptions.active ? "Yes" : "No"}</td> - )} - <td>{new Date(user.createdAt).toLocaleString()}</td> - <td>{new Date(user.updatedAt).toLocaleString()}</td> - </tr> - ))} - </tbody> - </table> - </div> + {filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? ( + UserLising(filteredUsers) + ) : searchQuery !== "" ? ( + <p>No users found with the given search query.</p> + ) : users && users.length > 0 ? ( + UserLising(users) ) : ( <p>No users found.</p> )} </div> ); } + +const UserLising = (users: User[]) => { + return ( + <div className="overflow-x-auto whitespace-nowrap w-full"> + <table className="table table-zebra w-full"> + <thead> + <tr> + <th></th> + <th>Username</th> + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + <th>Email</th> + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && <th>Subscribed</th>} + <th>Created At</th> + <th></th> + </tr> + </thead> + <tbody> + {users.map((user, index) => ( + <tr key={user.id}> + <td className="rounded-tl">{index + 1}</td> + <td>{user.username}</td> + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + <td>{user.email}</td> + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && ( + <td>{JSON.stringify(user.subscriptions.active)}</td> + )} + <td>{new Date(user.createdAt).toLocaleString()}</td> + <td> + <button className="btn btn-sm btn-ghost"> + <i className="bi bi-trash"></i> + </button> + </td> + </tr> + ))} + </tbody> + </table> + </div> + ); +}; From d181d5db20f9e477e8cab05162c5a86eaf11a068 Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Wed, 24 Apr 2024 13:44:39 -0600 Subject: [PATCH 35/79] fix: update to apply prettier on e2e/tests/public/ --- .prettierignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index 3742dae5..eb2aa384 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,6 @@ node_modules .next -public +/public *.lock *.log From 1c55ec8d9775faae5f34d6f885b582e090e868b1 Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Wed, 24 Apr 2024 13:45:30 -0600 Subject: [PATCH 36/79] feat(e2e): update github workflow to use matrix with playwright tags, cache workflow setup steps --- .github/workflows/playwright-tests.yml | 18 ++++++- e2e/tests/public/login.spec.ts | 74 ++++++++++++++++---------- 2 files changed, 62 insertions(+), 30 deletions(-) diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index f8890585..0f903424 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -37,9 +37,12 @@ env: jobs: playwright-test-runner: + strategy: + matrix: + test_case: ['@login'] timeout-minutes: 20 runs-on: - - ubuntu-latest + - ubuntu-22.04 services: postgres: image: postgres:16-alpine @@ -62,6 +65,7 @@ jobs: uses: actions/setup-node@v3 with: node-version: "18" + cache: 'yarn' - name: Initialize PostgreSQL run: | @@ -72,7 +76,17 @@ jobs: - name: Install packages run: yarn install -y + - name: Cache playwright browsers + id: cache-playwright + uses: actions/cache@v4 + with: + path: ~/.cache/ + key: ${{ runner.os }}-playwright-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install playwright + if: steps.cache-playwright.outputs.cache-hit != 'true' run: yarn playwright install --with-deps - name: Setup project @@ -85,7 +99,7 @@ jobs: run: yarn start & - name: Run Tests - run: yarn e2e + run: npx playwright test --grep ${{ matrix.test_case }} - uses: actions/upload-artifact@v3 if: always() diff --git a/e2e/tests/public/login.spec.ts b/e2e/tests/public/login.spec.ts index d39b34f9..5faca173 100644 --- a/e2e/tests/public/login.spec.ts +++ b/e2e/tests/public/login.spec.ts @@ -1,32 +1,50 @@ -import { expect, test } from "../../index" +import { expect, test } from "../../index"; -test("Logging in without credentials displays an error", async ({ loginPage }) => { - await loginPage.submitLoginButton.click() - const toast = await loginPage.getLatestToast() - await expect(toast.locator).toBeVisible() - await expect(toast.locator).toHaveAttribute("data-type", "error") -}) +test.describe( + "Login test suite", + { + tag: "@login", + }, + async () => { + test("Logging in without credentials displays an error", async ({ + loginPage, + }) => { + await loginPage.submitLoginButton.click(); + const toast = await loginPage.getLatestToast(); + await expect(toast.locator).toBeVisible(); + await expect(toast.locator).toHaveAttribute("data-type", "error"); + }); -test("Logging in with an erroneous password displays an error", async ({ loginPage }) => { - await loginPage.usernameInput.fill(process.env['TEST_USERNAME'] || "") - await loginPage.passwordInput.fill("NOT_MY_PASSWORD_DNE_ERROR") - await loginPage.submitLoginButton.click() - const toast = await loginPage.getLatestToast() - await expect(toast.locator).toBeVisible() - await expect(toast.locator).toHaveAttribute("data-type", "error") -}) + test("Logging in with an erroneous password displays an error", async ({ + loginPage, + }) => { + await loginPage.usernameInput.fill(process.env["TEST_USERNAME"] || ""); + await loginPage.passwordInput.fill("NOT_MY_PASSWORD_DNE_ERROR"); + await loginPage.submitLoginButton.click(); + const toast = await loginPage.getLatestToast(); + await expect(toast.locator).toBeVisible(); + await expect(toast.locator).toHaveAttribute("data-type", "error"); + }); -test("Logging in without valid credentials displays an error", async ({ loginPage }) => { - await loginPage.submitLoginButton.click() - const toast = await loginPage.getLatestToast() - await expect(toast.locator).toBeVisible() - await expect(toast.locator).toHaveAttribute("data-type", "error") -}) + test("Logging in without valid credentials displays an error", async ({ + loginPage, + }) => { + await loginPage.submitLoginButton.click(); + const toast = await loginPage.getLatestToast(); + await expect(toast.locator).toBeVisible(); + await expect(toast.locator).toHaveAttribute("data-type", "error"); + }); -test("Logging in with a valid username and password works as expected", async ({ page, loginPage, dashboardPage }) => { - await loginPage.usernameInput.fill(process.env['TEST_USERNAME'] || "") - await loginPage.passwordInput.fill(process.env['TEST_PASSWORD'] || "") - await loginPage.submitLoginButton.click() - await expect(loginPage.loginForm).not.toBeVisible() - await expect(dashboardPage.container).toBeVisible() -}) \ No newline at end of file + test("Logging in with a valid username and password works as expected", async ({ + page, + loginPage, + dashboardPage, + }) => { + await loginPage.usernameInput.fill(process.env["TEST_USERNAME"] || ""); + await loginPage.passwordInput.fill(process.env["TEST_PASSWORD"] || ""); + await loginPage.submitLoginButton.click(); + await expect(loginPage.loginForm).not.toBeVisible(); + await expect(dashboardPage.container).toBeVisible(); + }); + } +); From 464d2f920d6a68dd706d4bb38bdef539be605881 Mon Sep 17 00:00:00 2001 From: QAComet <lucas@qacomet.com> Date: Wed, 24 Apr 2024 14:00:51 -0600 Subject: [PATCH 37/79] feat(e2e-workflow): cache apt packages --- .github/workflows/playwright-tests.yml | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 0f903424..2d226af1 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -76,6 +76,40 @@ jobs: - name: Install packages run: yarn install -y + - name: Cache playwright dependencies + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: | + ffmpeg fonts-freefont-ttf fonts-ipafont-gothic fonts-tlwg-loma-otf + fonts-unifont fonts-wqy-zenhei gstreamer1.0-libav gstreamer1.0-plugins-bad + gstreamer1.0-plugins-base gstreamer1.0-plugins-good libaa1 libass9 + libasyncns0 libavc1394-0 libavcodec58 libavdevice58 libavfilter7 + libavformat58 libavutil56 libbluray2 libbs2b0 libcaca0 libcdio-cdda2 + libcdio-paranoia2 libcdio19 libcdparanoia0 libchromaprint1 libcodec2-1.0 + libdc1394-25 libdca0 libdecor-0-0 libdv4 libdvdnav4 libdvdread8 libegl-mesa0 + libegl1 libevdev2 libevent-2.1-7 libfaad2 libffi7 libflac8 libflite1 + libfluidsynth3 libfreeaptx0 libgles2 libgme0 libgsm1 libgssdp-1.2-0 + libgstreamer-gl1.0-0 libgstreamer-plugins-bad1.0-0 + libgstreamer-plugins-base1.0-0 libgstreamer-plugins-good1.0-0 libgupnp-1.2-1 + libgupnp-igd-1.0-4 libharfbuzz-icu0 libhyphen0 libiec61883-0 + libinstpatch-1.0-2 libjack-jackd2-0 libkate1 libldacbt-enc2 liblilv-0-0 + libltc11 libmanette-0.2-0 libmfx1 libmjpegutils-2.1-0 libmodplug1 + libmp3lame0 libmpcdec6 libmpeg2encpp-2.1-0 libmpg123-0 libmplex2-2.1-0 + libmysofa1 libnice10 libnotify4 libopenal-data libopenal1 libopengl0 + libopenh264-6 libopenmpt0 libopenni2-0 libopus0 liborc-0.4-0 + libpocketsphinx3 libpostproc55 libpulse0 libqrencode4 libraw1394-11 + librubberband2 libsamplerate0 libsbc1 libsdl2-2.0-0 libserd-0-0 libshine3 + libshout3 libsndfile1 libsndio7.0 libsord-0-0 libsoundtouch1 libsoup-3.0-0 + libsoup-3.0-common libsoxr0 libspandsp2 libspeex1 libsphinxbase3 + libsratom-0-0 libsrt1.4-gnutls libsrtp2-1 libssh-gcrypt-4 libswresample3 + libswscale5 libtag1v5 libtag1v5-vanilla libtheora0 libtwolame0 libudfread0 + libv4l-0 libv4lconvert0 libva-drm2 libva-x11-2 libva2 libvdpau1 + libvidstab1.1 libvisual-0.4-0 libvo-aacenc0 libvo-amrwbenc0 libvorbisenc2 + libvpx7 libwavpack1 libwebrtc-audio-processing1 libwildmidi2 libwoff1 + libx264-163 libxcb-shape0 libxv1 libxvidcore4 libzbar0 libzimg2 + libzvbi-common libzvbi0 libzxingcore1 ocl-icd-libopencl1 timgm6mb-soundfont + xfonts-cyrillic xfonts-encodings xfonts-scalable xfonts-utils + - name: Cache playwright browsers id: cache-playwright uses: actions/cache@v4 From d66019bfea9d8ec86d91b7a73196f2188e3a9d5e Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Thu, 25 Apr 2024 23:56:36 -0400 Subject: [PATCH 38/79] bug fixed --- components/LinkViews/Layouts/MasonryView.tsx | 21 +++++++++++++++++--- components/MobileNavigation.tsx | 4 ++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/components/LinkViews/Layouts/MasonryView.tsx b/components/LinkViews/Layouts/MasonryView.tsx index 743dcd4c..1caca9f3 100644 --- a/components/LinkViews/Layouts/MasonryView.tsx +++ b/components/LinkViews/Layouts/MasonryView.tsx @@ -2,6 +2,9 @@ import LinkCard from "@/components/LinkViews/LinkCard"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { GridLoader } from "react-spinners"; import Masonry from "react-masonry-css"; +import resolveConfig from "tailwindcss/resolveConfig"; +import tailwindConfig from "../../../tailwind.config.js"; +import { useMemo } from "react"; export default function MasonryView({ links, @@ -12,11 +15,23 @@ export default function MasonryView({ editMode?: boolean; isLoading?: boolean; }) { + const fullConfig = resolveConfig(tailwindConfig as any); + + const breakpointColumnsObj = useMemo(() => { + return { + default: 4, + 1900: 3, + [fullConfig.theme.screens.xl]: 2, + [fullConfig.theme.screens.sm]: 1, + }; + }, []); + return ( <Masonry - breakpointCols={4} - columnClassName="!w-full flex flex-col gap-5" - className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5" + breakpointCols={breakpointColumnsObj} + columnClassName="flex flex-col gap-5 !w-full" + // className="grid gap-5 grid-cols-3" + className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 pb-5" > {links.map((e, i) => { return ( diff --git a/components/MobileNavigation.tsx b/components/MobileNavigation.tsx index 02fa08ab..e5cbbf7f 100644 --- a/components/MobileNavigation.tsx +++ b/components/MobileNavigation.tsx @@ -1,4 +1,4 @@ -import { dropdownTriggerer, isIphone } from "@/lib/client/utils"; +import { dropdownTriggerer, isIphone, isPWA } from "@/lib/client/utils"; import React from "react"; import { useState } from "react"; import NewLinkModal from "./ModalContent/NewLinkModal"; @@ -20,7 +20,7 @@ export default function MobileNavigation({}: Props) { > <div className={`w-full flex bg-base-100 ${ - isIphone() ? "pb-5" : "" + isIphone() && isPWA() ? "pb-5" : "" } border-solid border-t-neutral-content border-t`} > <MobileNavigationButton href={`/dashboard`} icon={"bi-house"} /> From 30ef557f43a027c0d345c5a5f80f44f33ba5d648 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Fri, 26 Apr 2024 12:18:31 -0400 Subject: [PATCH 39/79] small improvements --- components/LinkViews/Layouts/GridView.tsx | 16 -- components/LinkViews/Layouts/MasonryView.tsx | 5 +- components/LinkViews/LinkCard.tsx | 60 ++--- components/LinkViews/LinkGrid.tsx | 111 --------- components/LinkViews/LinkMasonry.tsx | 244 +++++++++++++++++++ pages/collections/[id].tsx | 2 - pages/dashboard.tsx | 3 +- pages/links/index.tsx | 2 - pages/links/pinned.tsx | 2 - pages/public/collections/[id].tsx | 2 - pages/search.tsx | 26 +- pages/tags/[id].tsx | 2 - 12 files changed, 289 insertions(+), 186 deletions(-) delete mode 100644 components/LinkViews/Layouts/GridView.tsx delete mode 100644 components/LinkViews/LinkGrid.tsx create mode 100644 components/LinkViews/LinkMasonry.tsx diff --git a/components/LinkViews/Layouts/GridView.tsx b/components/LinkViews/Layouts/GridView.tsx deleted file mode 100644 index 005ba28a..00000000 --- a/components/LinkViews/Layouts/GridView.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import LinkGrid from "@/components/LinkViews/LinkGrid"; -import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; - -export default function GridView({ - links, -}: { - links: LinkIncludingShortenedCollectionAndTags[]; -}) { - return ( - <div className="grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5"> - {links.map((e, i) => { - return <LinkGrid link={e} count={i} key={i} />; - })} - </div> - ); -} diff --git a/components/LinkViews/Layouts/MasonryView.tsx b/components/LinkViews/Layouts/MasonryView.tsx index 1caca9f3..9a06a0e5 100644 --- a/components/LinkViews/Layouts/MasonryView.tsx +++ b/components/LinkViews/Layouts/MasonryView.tsx @@ -1,4 +1,4 @@ -import LinkCard from "@/components/LinkViews/LinkCard"; +import LinkMasonry from "@/components/LinkViews/LinkMasonry"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { GridLoader } from "react-spinners"; import Masonry from "react-masonry-css"; @@ -30,12 +30,11 @@ export default function MasonryView({ <Masonry breakpointCols={breakpointColumnsObj} columnClassName="flex flex-col gap-5 !w-full" - // className="grid gap-5 grid-cols-3" className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 pb-5" > {links.map((e, i) => { return ( - <LinkCard + <LinkMasonry key={i} link={e} count={i} diff --git a/components/LinkViews/LinkCard.tsx b/components/LinkViews/LinkCard.tsx index 0ab4eee1..a6f6b484 100644 --- a/components/LinkViews/LinkCard.tsx +++ b/components/LinkViews/LinkCard.tsx @@ -133,36 +133,32 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { !editMode && window.open(generateLinkHref(link, account), "_blank") } > - {viewMode === "masonry" && !previewAvailable(link) ? null : ( - <> - <div className="relative rounded-t-2xl h-40 overflow-hidden"> - {previewAvailable(link) ? ( - <Image - src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`} - width={1280} - height={720} - alt="" - className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105" - style={{ filter: "blur(2px)" }} - draggable="false" - onError={(e) => { - const target = e.target as HTMLElement; - target.style.display = "none"; - }} - /> - ) : link.preview === "unavailable" ? ( - <div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div> - ) : ( - <div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div> - )} - <div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> - <LinkIcon link={link} /> - </div> - </div> + <div className="relative rounded-t-2xl h-40 overflow-hidden"> + {previewAvailable(link) ? ( + <Image + src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`} + width={1280} + height={720} + alt="" + className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105" + style={{ filter: "blur(2px)" }} + draggable="false" + onError={(e) => { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + ) : link.preview === "unavailable" ? ( + <div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div> + ) : ( + <div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div> + )} + <div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> + <LinkIcon link={link} /> + </div> + </div> - <hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" /> - </> - )} + <hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" /> <div className="p-3 mt-1"> <p className="truncate w-full pr-8 text-primary"> @@ -234,11 +230,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { <LinkActions link={link} collection={collection} - position={ - !previewAvailable(link) && viewMode === "masonry" - ? "top-[.75rem] right-3" - : "top-[10.75rem] right-3" - } + position="top-[10.75rem] right-3" toggleShowInfo={() => setShowInfo(!showInfo)} linkInfo={showInfo} flipDropdown={flipDropdown} diff --git a/components/LinkViews/LinkGrid.tsx b/components/LinkViews/LinkGrid.tsx deleted file mode 100644 index be4d6edc..00000000 --- a/components/LinkViews/LinkGrid.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { - CollectionIncludingMembersAndLinkCount, - LinkIncludingShortenedCollectionAndTags, -} from "@/types/global"; -import { useEffect, useState } from "react"; -import useLinkStore from "@/store/links"; -import useCollectionStore from "@/store/collections"; -import unescapeString from "@/lib/client/unescapeString"; -import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; -import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; -import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; -import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon"; -import Link from "next/link"; - -type Props = { - link: LinkIncludingShortenedCollectionAndTags; - count: number; - className?: string; -}; - -export default function LinkGrid({ link }: Props) { - const { collections } = useCollectionStore(); - - const { links } = useLinkStore(); - - let shortendURL; - - try { - shortendURL = new URL(link.url || "").host.toLowerCase(); - } catch (error) { - console.log(error); - } - - const [collection, setCollection] = - useState<CollectionIncludingMembersAndLinkCount>( - collections.find( - (e) => e.id === link.collection.id - ) as CollectionIncludingMembersAndLinkCount - ); - - useEffect(() => { - setCollection( - collections.find( - (e) => e.id === link.collection.id - ) as CollectionIncludingMembersAndLinkCount - ); - }, [collections, links]); - - return ( - <div className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative p-3"> - <div - onClick={() => link.url && window.open(link.url || "", "_blank")} - className="cursor-pointer" - > - <LinkIcon link={link} width="w-12 mb-3" /> - <p className="truncate w-full"> - {unescapeString(link.name || link.description) || link.url} - </p> - - <div className="mt-1 flex flex-col text-xs text-neutral"> - <div className="flex items-center gap-2"> - <LinkCollection link={link} collection={collection} /> - · - {link.url ? ( - <div - onClick={(e) => { - e.preventDefault(); - window.open(link.url || "", "_blank"); - }} - className="flex items-center hover:opacity-60 cursor-pointer duration-100" - > - <p className="truncate">{shortendURL}</p> - </div> - ) : ( - <div className="badge badge-primary badge-sm my-1"> - {link.type} - </div> - )} - </div> - <LinkDate link={link} /> - </div> - <p className="truncate">{unescapeString(link.description)}</p> - {link.tags[0] ? ( - <div className="flex gap-3 items-center flex-wrap mt-2 truncate relative"> - <div className="flex gap-1 items-center flex-wrap"> - {link.tags.map((e, i) => ( - <Link - href={"/tags/" + e.id} - key={i} - onClick={(e) => { - e.stopPropagation(); - }} - className="btn btn-xs btn-ghost truncate max-w-[19rem]" - > - #{e.name} - </Link> - ))} - </div> - </div> - ) : undefined} - </div> - - <LinkActions - toggleShowInfo={() => {}} - linkInfo={false} - link={link} - collection={collection} - /> - </div> - ); -} diff --git a/components/LinkViews/LinkMasonry.tsx b/components/LinkViews/LinkMasonry.tsx new file mode 100644 index 00000000..62c6cfbe --- /dev/null +++ b/components/LinkViews/LinkMasonry.tsx @@ -0,0 +1,244 @@ +import { + ArchivedFormat, + CollectionIncludingMembersAndLinkCount, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; +import { useEffect, useRef, useState } from "react"; +import useLinkStore from "@/store/links"; +import useCollectionStore from "@/store/collections"; +import unescapeString from "@/lib/client/unescapeString"; +import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; +import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; +import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; +import Image from "next/image"; +import { previewAvailable } from "@/lib/shared/getArchiveValidity"; +import Link from "next/link"; +import LinkIcon from "./LinkComponents/LinkIcon"; +import useOnScreen from "@/hooks/useOnScreen"; +import { generateLinkHref } from "@/lib/client/generateLinkHref"; +import useAccountStore from "@/store/account"; +import usePermissions from "@/hooks/usePermissions"; +import toast from "react-hot-toast"; +import LinkTypeBadge from "./LinkComponents/LinkTypeBadge"; + +type Props = { + link: LinkIncludingShortenedCollectionAndTags; + count: number; + className?: string; + flipDropdown?: boolean; + editMode?: boolean; +}; + +export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { + const viewMode = localStorage.getItem("viewMode") || "card"; + const { collections } = useCollectionStore(); + const { account } = useAccountStore(); + + const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore(); + + useEffect(() => { + if (!editMode) { + setSelectedLinks([]); + } + }, [editMode]); + + const handleCheckboxClick = ( + link: LinkIncludingShortenedCollectionAndTags + ) => { + if (selectedLinks.includes(link)) { + setSelectedLinks(selectedLinks.filter((e) => e !== link)); + } else { + setSelectedLinks([...selectedLinks, link]); + } + }; + + let shortendURL; + + try { + if (link.url) { + shortendURL = new URL(link.url).host.toLowerCase(); + } + } catch (error) { + console.log(error); + } + + const [collection, setCollection] = + useState<CollectionIncludingMembersAndLinkCount>( + collections.find( + (e) => e.id === link.collection.id + ) as CollectionIncludingMembersAndLinkCount + ); + + useEffect(() => { + setCollection( + collections.find( + (e) => e.id === link.collection.id + ) as CollectionIncludingMembersAndLinkCount + ); + }, [collections, links]); + + const ref = useRef<HTMLDivElement>(null); + const isVisible = useOnScreen(ref); + const permissions = usePermissions(collection?.id as number); + + useEffect(() => { + let interval: any; + + if ( + isVisible && + !link.preview?.startsWith("archives") && + link.preview !== "unavailable" + ) { + interval = setInterval(async () => { + getLink(link.id as number); + }, 5000); + } + + return () => { + if (interval) { + clearInterval(interval); + } + }; + }, [isVisible, link.preview]); + + const [showInfo, setShowInfo] = useState(false); + + const selectedStyle = selectedLinks.some( + (selectedLink) => selectedLink.id === link.id + ) + ? "border-primary bg-base-300" + : "border-neutral-content"; + + const selectable = + editMode && + (permissions === true || permissions?.canCreate || permissions?.canDelete); + + return ( + <div + ref={ref} + className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`} + onClick={() => + selectable + ? handleCheckboxClick(link) + : editMode + ? toast.error( + "You don't have permission to edit or delete this item." + ) + : undefined + } + > + <div + className="rounded-2xl cursor-pointer" + onClick={() => + !editMode && window.open(generateLinkHref(link, account), "_blank") + } + > + <div className="relative rounded-t-2xl overflow-hidden"> + {previewAvailable(link) ? ( + <Image + src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`} + width={1280} + height={720} + alt="" + className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105" + style={{ filter: "blur(2px)" }} + draggable="false" + onError={(e) => { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + ) : link.preview === "unavailable" ? null : ( + <div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div> + )} + <div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> + <LinkIcon link={link} /> + </div> + </div> + + {link.preview !== "unavailable" && ( + <hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" /> + )} + + <div className="p-3 mt-1"> + <p className="truncate w-full pr-8 text-primary"> + {unescapeString(link.name || link.description) || link.url} + </p> + + <LinkTypeBadge link={link} /> + </div> + + <hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> + + <div className="flex justify-between text-xs text-neutral px-3 pb-1"> + <div className="cursor-pointer w-fit"> + {collection && ( + <LinkCollection link={link} collection={collection} /> + )} + </div> + <LinkDate link={link} /> + </div> + </div> + + {showInfo && ( + <div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto"> + <div + onClick={() => setShowInfo(!showInfo)} + className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10" + > + <i className="bi-x text-neutral text-2xl"></i> + </div> + <p className="text-neutral text-lg font-semibold">Description</p> + + <hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" /> + <p> + {link.description ? ( + unescapeString(link.description) + ) : ( + <span className="text-neutral text-sm"> + No description provided. + </span> + )} + </p> + {link.tags[0] && ( + <> + <p className="text-neutral text-lg mt-3 font-semibold">Tags</p> + + <hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" /> + + <div className="flex gap-3 items-center flex-wrap mt-2 truncate relative"> + <div className="flex gap-1 items-center flex-wrap"> + {link.tags.map((e, i) => ( + <Link + href={"/tags/" + e.id} + key={i} + onClick={(e) => { + e.stopPropagation(); + }} + className="btn btn-xs btn-ghost truncate max-w-[19rem]" + > + #{e.name} + </Link> + ))} + </div> + </div> + </> + )} + </div> + )} + + <LinkActions + link={link} + collection={collection} + position={ + link.preview !== "unavailable" + ? "top-[10.75rem] right-3" + : "top-[.75rem] right-3" + } + toggleShowInfo={() => setShowInfo(!showInfo)} + linkInfo={showInfo} + flipDropdown={flipDropdown} + /> + </div> + ); +} diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index e1ba2b8d..046c8556 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -21,7 +21,6 @@ import EditCollectionSharingModal from "@/components/ModalContent/EditCollection import DeleteCollectionModal from "@/components/ModalContent/DeleteCollectionModal"; import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; -// import GridView from "@/components/LinkViews/Layouts/GridView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import { dropdownTriggerer } from "@/lib/client/utils"; import NewCollectionModal from "@/components/ModalContent/NewCollectionModal"; @@ -109,7 +108,6 @@ export default function Index() { const linkView = { [ViewMode.Card]: CardView, - // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, [ViewMode.Masonry]: MasonryView, }; diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 04882f1e..c940b65d 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -17,7 +17,6 @@ import ListView from "@/components/LinkViews/Layouts/ListView"; import ViewDropdown from "@/components/ViewDropdown"; import { dropdownTriggerer } from "@/lib/client/utils"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; -// import GridView from "@/components/LinkViews/Layouts/GridView"; export default function Dashboard() { const { collections } = useCollectionStore(); @@ -101,7 +100,7 @@ export default function Dashboard() { const linkView = { [ViewMode.Card]: CardView, - // [ViewMode.Grid]: GridView, + // [ViewMode.Grid]: , [ViewMode.List]: ListView, [ViewMode.Masonry]: MasonryView, }; diff --git a/pages/links/index.tsx b/pages/links/index.tsx index 2b1e67b2..d617d15d 100644 --- a/pages/links/index.tsx +++ b/pages/links/index.tsx @@ -13,7 +13,6 @@ import useCollectivePermissions from "@/hooks/useCollectivePermissions"; import toast from "react-hot-toast"; import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; -// import GridView from "@/components/LinkViews/Layouts/GridView"; import { useRouter } from "next/router"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; @@ -73,7 +72,6 @@ export default function Links() { const linkView = { [ViewMode.Card]: CardView, - // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, [ViewMode.Masonry]: MasonryView, }; diff --git a/pages/links/pinned.tsx b/pages/links/pinned.tsx index f2018448..a56af063 100644 --- a/pages/links/pinned.tsx +++ b/pages/links/pinned.tsx @@ -12,7 +12,6 @@ import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; import useCollectivePermissions from "@/hooks/useCollectivePermissions"; import toast from "react-hot-toast"; -// import GridView from "@/components/LinkViews/Layouts/GridView"; import { useRouter } from "next/router"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; @@ -71,7 +70,6 @@ export default function PinnedLinks() { const linkView = { [ViewMode.Card]: CardView, - // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, [ViewMode.Masonry]: MasonryView, }; diff --git a/pages/public/collections/[id].tsx b/pages/public/collections/[id].tsx index c72f0a5f..05574a7f 100644 --- a/pages/public/collections/[id].tsx +++ b/pages/public/collections/[id].tsx @@ -25,7 +25,6 @@ import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; -// import GridView from "@/components/LinkViews/Layouts/GridView"; const cardVariants: Variants = { offscreen: { @@ -108,7 +107,6 @@ export default function PublicCollections() { const linkView = { [ViewMode.Card]: CardView, - // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, [ViewMode.Masonry]: MasonryView, }; diff --git a/pages/search.tsx b/pages/search.tsx index 9114f416..27e23617 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -8,7 +8,6 @@ import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; -// import GridView from "@/components/LinkViews/Layouts/GridView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import PageHeader from "@/components/PageHeader"; import { GridLoader } from "react-spinners"; @@ -19,7 +18,8 @@ import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; export default function Search() { - const { links, selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore(); + const { links, selectedLinks, setSelectedLinks, deleteLinksById } = + useLinkStore(); const router = useRouter(); @@ -59,7 +59,8 @@ export default function Search() { const bulkDeleteLinks = async () => { const load = toast.loading( - `Deleting ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }...` ); @@ -71,7 +72,8 @@ export default function Search() { response.ok && toast.success( - `Deleted ${selectedLinks.length} Link${selectedLinks.length > 1 ? "s" : "" + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" }!` ); }; @@ -92,7 +94,6 @@ export default function Search() { const linkView = { [ViewMode.Card]: CardView, - // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, [ViewMode.Masonry]: MasonryView, }; @@ -115,10 +116,11 @@ export default function Search() { setEditMode(!editMode); setSelectedLinks([]); }} - className={`btn btn-square btn-sm btn-ghost ${editMode - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} > <i className="bi-pencil-fill text-neutral text-xl"></i> </div> @@ -199,7 +201,11 @@ export default function Search() { </span> </p> ) : links[0] ? ( - <LinkComponent editMode={editMode} links={links} isLoading={isLoading} /> + <LinkComponent + editMode={editMode} + links={links} + isLoading={isLoading} + /> ) : ( isLoading && ( <GridLoader diff --git a/pages/tags/[id].tsx b/pages/tags/[id].tsx index 3c8b7c4b..1623c8f4 100644 --- a/pages/tags/[id].tsx +++ b/pages/tags/[id].tsx @@ -9,7 +9,6 @@ import useLinks from "@/hooks/useLinks"; import { toast } from "react-hot-toast"; import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; -// import GridView from "@/components/LinkViews/Layouts/GridView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import { dropdownTriggerer } from "@/lib/client/utils"; import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; @@ -150,7 +149,6 @@ export default function Index() { const linkView = { [ViewMode.Card]: CardView, - // [ViewMode.Grid]: GridView, [ViewMode.List]: ListView, [ViewMode.Masonry]: MasonryView, }; From 6f4759d928c427c2b9db9324c1561eb764e15f53 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Sat, 27 Apr 2024 11:03:08 -0400 Subject: [PATCH 40/79] added tags and description directly inside masonry view --- components/LinkViews/LinkMasonry.tsx | 31 +++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/components/LinkViews/LinkMasonry.tsx b/components/LinkViews/LinkMasonry.tsx index 62c6cfbe..5e886a81 100644 --- a/components/LinkViews/LinkMasonry.tsx +++ b/components/LinkViews/LinkMasonry.tsx @@ -160,12 +160,33 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { <hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" /> )} - <div className="p-3 mt-1"> - <p className="truncate w-full pr-8 text-primary"> - {unescapeString(link.name || link.description) || link.url} - </p> + <div className="p-3 mt-1 flex flex-col gap-2"> + <div className="w-full pr-8"> + <p className="text-primary">{unescapeString(link.name)}</p> - <LinkTypeBadge link={link} /> + <LinkTypeBadge link={link} /> + </div> + + {link.description && ( + <p className="text-sm">{unescapeString(link.description)}</p> + )} + + {link.tags[0] && ( + <div className="flex gap-1 items-center flex-wrap"> + {link.tags.map((e, i) => ( + <Link + href={"/tags/" + e.id} + key={i} + onClick={(e) => { + e.stopPropagation(); + }} + className="btn btn-xs btn-ghost truncate max-w-[19rem]" + > + #{e.name} + </Link> + ))} + </div> + )} </div> <hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> From b4ea7dcd8e5cd5fc3dcd38fd16fb8a1c1853f455 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Sat, 27 Apr 2024 12:23:33 -0400 Subject: [PATCH 41/79] improved masonry look --- components/LinkViews/Layouts/MasonryView.tsx | 11 ++-- .../LinkViews/LinkComponents/LinkDate.tsx | 2 +- .../LinkViews/LinkComponents/LinkIcon.tsx | 52 ++++++++++++++----- .../LinkComponents/LinkTypeBadge.tsx | 4 +- components/LinkViews/LinkList.tsx | 3 +- components/LinkViews/LinkMasonry.tsx | 24 +++++---- 6 files changed, 61 insertions(+), 35 deletions(-) diff --git a/components/LinkViews/Layouts/MasonryView.tsx b/components/LinkViews/Layouts/MasonryView.tsx index 9a06a0e5..bceaf337 100644 --- a/components/LinkViews/Layouts/MasonryView.tsx +++ b/components/LinkViews/Layouts/MasonryView.tsx @@ -19,10 +19,11 @@ export default function MasonryView({ const breakpointColumnsObj = useMemo(() => { return { - default: 4, - 1900: 3, - [fullConfig.theme.screens.xl]: 2, - [fullConfig.theme.screens.sm]: 1, + default: 5, + 1900: 4, + 1500: 3, + 880: 2, + 550: 1, }; }, []); @@ -30,7 +31,7 @@ export default function MasonryView({ <Masonry breakpointCols={breakpointColumnsObj} columnClassName="flex flex-col gap-5 !w-full" - className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 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) => { return ( diff --git a/components/LinkViews/LinkComponents/LinkDate.tsx b/components/LinkViews/LinkComponents/LinkDate.tsx index 7bed676a..cdd6838a 100644 --- a/components/LinkViews/LinkComponents/LinkDate.tsx +++ b/components/LinkViews/LinkComponents/LinkDate.tsx @@ -15,7 +15,7 @@ export default function LinkDate({ }); return ( - <div className="flex items-center gap-1 text-neutral"> + <div className="flex items-center gap-1 text-neutral min-w-fit"> <i className="bi-calendar3 text-lg"></i> <p>{formattedDate}</p> </div> diff --git a/components/LinkViews/LinkComponents/LinkIcon.tsx b/components/LinkViews/LinkComponents/LinkIcon.tsx index 3076a1f4..055b432d 100644 --- a/components/LinkViews/LinkComponents/LinkIcon.tsx +++ b/components/LinkViews/LinkComponents/LinkIcon.tsx @@ -5,23 +5,35 @@ import React from "react"; export default function LinkIcon({ link, - width, className, + size, }: { link: LinkIncludingShortenedCollectionAndTags; - width?: string; className?: string; + size?: "small" | "medium"; }) { + let iconClasses: string = + "bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10 " + + (className || ""); + + let dimension; + + switch (size) { + case "small": + dimension = " w-8 h-8"; + break; + case "medium": + dimension = " w-12 h-12"; + break; + default: + size = "medium"; + dimension = " w-12 h-12"; + break; + } + const url = isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined; - const iconClasses: string = - "bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" + - " " + - (width || "w-12") + - " " + - (className || ""); - const [showFavicon, setShowFavicon] = React.useState<boolean>(true); return ( @@ -33,23 +45,29 @@ export default function LinkIcon({ width={64} height={64} alt="" - className={iconClasses} + className={iconClasses + dimension} draggable="false" onError={() => { setShowFavicon(false); }} /> ) : ( - <LinkPlaceholderIcon iconClasses={iconClasses} icon="bi-link-45deg" /> + <LinkPlaceholderIcon + iconClasses={iconClasses + dimension} + size={size} + icon="bi-link-45deg" + /> ) ) : link.type === "pdf" ? ( <LinkPlaceholderIcon - iconClasses={iconClasses} + iconClasses={iconClasses + dimension} + size={size} icon="bi-file-earmark-pdf" /> ) : link.type === "image" ? ( <LinkPlaceholderIcon - iconClasses={iconClasses} + iconClasses={iconClasses + dimension} + size={size} icon="bi-file-earmark-image" /> ) : undefined} @@ -59,13 +77,19 @@ export default function LinkIcon({ const LinkPlaceholderIcon = ({ iconClasses, + size, icon, }: { iconClasses: string; + size?: "small" | "medium"; icon: string; }) => { return ( - <div className={`text-4xl text-black aspect-square ${iconClasses}`}> + <div + className={`${ + size === "small" ? "text-2xl" : "text-4xl" + } text-black aspect-square ${iconClasses}`} + > <i className={`${icon} m-auto`}></i> </div> ); diff --git a/components/LinkViews/LinkComponents/LinkTypeBadge.tsx b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx index e491330a..b7563e46 100644 --- a/components/LinkViews/LinkComponents/LinkTypeBadge.tsx +++ b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx @@ -27,8 +27,8 @@ export default function LinkTypeBadge({ }} className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100" > - <i className="bi-link-45deg text-lg mt-[0.1rem] leading-none"></i> - <p className="text-sm truncate">{shortendURL}</p> + <i className="bi-link-45deg text-lg leading-none"></i> + <p className="text-xs truncate">{shortendURL}</p> </Link> ) : ( <div className="badge badge-primary badge-sm my-1 select-none"> diff --git a/components/LinkViews/LinkList.tsx b/components/LinkViews/LinkList.tsx index e5bd8bc4..1f13a5f7 100644 --- a/components/LinkViews/LinkList.tsx +++ b/components/LinkViews/LinkList.tsx @@ -125,8 +125,7 @@ export default function LinkCardCompact({ <div className="shrink-0"> <LinkIcon link={link} - width="sm:w-12 w-8" - className="mt-1 sm:mt-0" + className="mt-1 sm:mt-0 sm:w-12 w-8 sm:h-12 h-8 text-xl sm:text-4xl" /> </div> diff --git a/components/LinkViews/LinkMasonry.tsx b/components/LinkViews/LinkMasonry.tsx index 5e886a81..e3315a5c 100644 --- a/components/LinkViews/LinkMasonry.tsx +++ b/components/LinkViews/LinkMasonry.tsx @@ -141,7 +141,6 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { height={720} alt="" className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105" - style={{ filter: "blur(2px)" }} draggable="false" onError={(e) => { const target = e.target as HTMLElement; @@ -151,9 +150,6 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { ) : link.preview === "unavailable" ? null : ( <div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div> )} - <div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> - <LinkIcon link={link} /> - </div> </div> {link.preview !== "unavailable" && ( @@ -162,15 +158,21 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { <div className="p-3 mt-1 flex flex-col gap-2"> <div className="w-full pr-8"> - <p className="text-primary">{unescapeString(link.name)}</p> + <div + className={`rounded-t-2xl flex items-center justify-center shadow rounded-md float-left mr-2 ${ + link.type === "url" ? "" : "hidden" + }`} + > + <LinkIcon link={link} size="small" className="mt-1" /> + </div> + <p className="text-primary text-sm">{unescapeString(link.name)}</p> + {link.description && ( + <p className="text-sm">{unescapeString(link.description)}</p> + )} <LinkTypeBadge link={link} /> </div> - {link.description && ( - <p className="text-sm">{unescapeString(link.description)}</p> - )} - {link.tags[0] && ( <div className="flex gap-1 items-center flex-wrap"> {link.tags.map((e, i) => ( @@ -191,8 +193,8 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { <hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> - <div className="flex justify-between text-xs text-neutral px-3 pb-1"> - <div className="cursor-pointer w-fit"> + <div className="flex justify-between gap-1 text-xs text-neutral px-3 pb-1"> + <div className="cursor-pointer w-fit truncate"> {collection && ( <LinkCollection link={link} collection={collection} /> )} From 58d71a863bf419a5768ac33020c5d27f072516cb Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Wed, 1 May 2024 17:15:00 -0400 Subject: [PATCH 42/79] small improvement --- components/Modal.tsx | 41 ++++++++++++++++++++++++++--------------- pages/login.tsx | 9 ++++++--- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/components/Modal.tsx b/components/Modal.tsx index 1620b2cd..4691df77 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -6,9 +6,15 @@ type Props = { toggleModal: Function; children: ReactNode; className?: string; + dismissible?: boolean; }; -export default function Modal({ toggleModal, className, children }: Props) { +export default function Modal({ + toggleModal, + className, + children, + dismissible = true, +}: Props) { const [drawerIsOpen, setDrawerIsOpen] = React.useState(true); useEffect(() => { @@ -26,14 +32,17 @@ export default function Modal({ toggleModal, className, children }: Props) { return ( <Drawer.Root open={drawerIsOpen} - onClose={() => setTimeout(() => toggleModal(), 100)} + onClose={() => dismissible && setTimeout(() => toggleModal(), 100)} + dismissible={dismissible} > <Drawer.Portal> <Drawer.Overlay className="fixed inset-0 bg-black/40" /> - <ClickAwayHandler onClickOutside={() => setDrawerIsOpen(false)}> - <Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30"> + <ClickAwayHandler + onClickOutside={() => dismissible && setDrawerIsOpen(false)} + > + <Drawer.Content className="flex flex-col rounded-t-2xl min-h-max mt-24 fixed bottom-0 left-0 right-0 z-30"> <div - className="p-4 pb-32 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto" + className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto" data-testid="mobile-modal-container" > <div @@ -55,7 +64,7 @@ export default function Modal({ toggleModal, className, children }: Props) { data-testid="modal-outer" > <ClickAwayHandler - onClickOutside={toggleModal} + onClickOutside={() => dismissible && toggleModal()} className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${ className || "" }`} @@ -64,15 +73,17 @@ export default function Modal({ toggleModal, className, children }: Props) { className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible" data-testid="modal-container" > - <div - onClick={toggleModal as MouseEventHandler<HTMLDivElement>} - className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10" - > - <i - className="bi-x text-neutral text-2xl" - data-testid="close-modal-button" - ></i> - </div> + {dismissible && ( + <div + onClick={toggleModal as MouseEventHandler<HTMLDivElement>} + className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10" + > + <i + className="bi-x text-neutral text-2xl" + data-testid="close-modal-button" + ></i> + </div> + )} {children} </div> </ClickAwayHandler> diff --git a/pages/login.tsx b/pages/login.tsx index 13941d1f..1824afe3 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -69,7 +69,7 @@ export default function Login({ function displayLoginCredential() { if (availableLogins.credentialsEnabled === "true") { return ( - <div data-testid="login-form"> + <> <p className="text-3xl text-black dark:text-white text-center font-extralight"> Enter your credentials </p> @@ -127,7 +127,7 @@ export default function Login({ {availableLogins.buttonAuths.length > 0 ? ( <div className="divider my-1">OR</div> ) : undefined} - </div> + </> ); } } @@ -171,7 +171,10 @@ export default function Login({ return ( <CenteredForm text="Sign in to your account"> <form onSubmit={loginUser}> - <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700"> + <div + className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700" + data-testid="login-form" + > {displayLoginCredential()} {displayLoginExternalButton()} {displayRegistration()} From 08c2ff278fec1ed8af6ec524dc17072526c91d64 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Thu, 2 May 2024 09:17:56 -0400 Subject: [PATCH 43/79] delete user functionality --- components/ModalContent/DeleteUserModal.tsx | 51 ++++++++++++++++ .../users/userId/deleteUserById.ts | 12 +++- pages/admin.tsx | 44 ++++++++++--- pages/api/v1/users/[id].ts | 14 ++++- store/admin/users.ts | 61 +++++++++++++++++++ 5 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 components/ModalContent/DeleteUserModal.tsx create mode 100644 store/admin/users.ts diff --git a/components/ModalContent/DeleteUserModal.tsx b/components/ModalContent/DeleteUserModal.tsx new file mode 100644 index 00000000..97c1cffb --- /dev/null +++ b/components/ModalContent/DeleteUserModal.tsx @@ -0,0 +1,51 @@ +import toast from "react-hot-toast"; +import Modal from "../Modal"; +import useUserStore from "@/store/admin/users"; + +type Props = { + onClose: Function; + userId: number; +}; + +export default function DeleteUserModal({ onClose, userId }: Props) { + const { removeUser } = useUserStore(); + + const deleteUser = async () => { + const load = toast.loading("Deleting..."); + + const response = await removeUser(userId); + + toast.dismiss(load); + + response.ok && toast.success(`User Deleted.`); + + onClose(); + }; + + return ( + <Modal toggleModal={onClose}> + <p className="text-xl font-thin text-red-500">Delete User</p> + + <div className="divider mb-3 mt-1"></div> + + <div className="flex flex-col gap-3"> + <p>Are you sure you want to remove this user?</p> + + <div role="alert" className="alert alert-warning"> + <i className="bi-exclamation-triangle text-xl" /> + <span> + <b>Warning:</b> This action is irreversible! + </span> + </div> + + <button + className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`} + onClick={deleteUser} + > + <i className="bi-trash text-xl" /> + Delete, I know what I'm doing + </button> + </div> + </Modal> + ); +} diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index 976bd715..7e333445 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -10,7 +10,8 @@ const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET; export default async function deleteUserById( userId: number, - body: DeleteUserBody + body: DeleteUserBody, + isServerAdmin: boolean ) { // First, we retrieve the user from the database const user = await prisma.user.findUnique({ @@ -25,13 +26,13 @@ export default async function deleteUserById( } // Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration) - if (!keycloakEnabled && !authentikEnabled) { + if (!keycloakEnabled && !authentikEnabled && !isServerAdmin) { const isPasswordValid = bcrypt.compareSync( body.password, user.password as string ); - if (!isPasswordValid) { + if (!isPasswordValid && !isServerAdmin) { return { response: "Invalid credentials.", status: 401, // Unauthorized @@ -43,6 +44,11 @@ export default async function deleteUserById( await prisma .$transaction( async (prisma) => { + // Delete Access Tokens + await prisma.accessToken.deleteMany({ + where: { userId }, + }); + // Delete whitelisted users await prisma.whitelistedUser.deleteMany({ where: { userId }, diff --git a/pages/admin.tsx b/pages/admin.tsx index be634f82..3d5a0a31 100644 --- a/pages/admin.tsx +++ b/pages/admin.tsx @@ -1,6 +1,8 @@ +import DeleteUserModal from "@/components/ModalContent/DeleteUserModal"; +import useUserStore from "@/store/admin/users"; import { User as U } from "@prisma/client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; interface User extends U { subscriptions: { @@ -8,12 +10,22 @@ interface User extends U { }; } +type UserModal = { + isOpen: boolean; + userId: number | null; +}; + export default function Admin() { - const [users, setUsers] = useState<User[]>(); + const { users, setUsers } = useUserStore(); const [searchQuery, setSearchQuery] = useState(""); const [filteredUsers, setFilteredUsers] = useState<User[]>(); + const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({ + isOpen: false, + userId: null, + }); + useEffect(() => { // fetch users fetch("/api/v1/users") @@ -31,7 +43,7 @@ export default function Admin() { > <i className="bi-chevron-left text-xl"></i> </Link> - <p className="capitalize sm:text-3xl text-2xl font-thin inline"> + <p className="capitalize text-3xl font-thin inline"> User Administration </p> </div> @@ -76,11 +88,11 @@ export default function Admin() { <div className="divider my-3"></div> {filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? ( - UserLising(filteredUsers) + UserListing(filteredUsers, deleteUserModal, setDeleteUserModal) ) : searchQuery !== "" ? ( <p>No users found with the given search query.</p> ) : users && users.length > 0 ? ( - UserLising(users) + UserListing(users, deleteUserModal, setDeleteUserModal) ) : ( <p>No users found.</p> )} @@ -88,7 +100,11 @@ export default function Admin() { ); } -const UserLising = (users: User[]) => { +const UserListing = ( + users: User[], + deleteUserModal: UserModal, + setDeleteUserModal: Function +) => { return ( <div className="overflow-x-auto whitespace-nowrap w-full"> <table className="table table-zebra w-full"> @@ -106,7 +122,7 @@ const UserLising = (users: User[]) => { </thead> <tbody> {users.map((user, index) => ( - <tr key={user.id}> + <tr key={index}> <td className="rounded-tl">{index + 1}</td> <td>{user.username}</td> {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( @@ -117,7 +133,12 @@ const UserLising = (users: User[]) => { )} <td>{new Date(user.createdAt).toLocaleString()}</td> <td> - <button className="btn btn-sm btn-ghost"> + <button + className="btn btn-sm btn-ghost" + onClick={() => + setDeleteUserModal({ isOpen: true, userId: user.id }) + } + > <i className="bi bi-trash"></i> </button> </td> @@ -125,6 +146,13 @@ const UserLising = (users: User[]) => { ))} </tbody> </table> + + {deleteUserModal.isOpen && deleteUserModal.userId ? ( + <DeleteUserModal + onClose={() => setDeleteUserModal({ isOpen: false, userId: null })} + userId={deleteUserModal.userId} + /> + ) : null} </div> ); }; diff --git a/pages/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts index cf75e162..128b4262 100644 --- a/pages/api/v1/users/[id].ts +++ b/pages/api/v1/users/[id].ts @@ -16,9 +16,17 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { return null; } - const userId = token?.id; + const user = await prisma.user.findUnique({ + where: { + id: token?.id, + }, + }); - if (userId !== Number(req.query.id)) + const isServerAdmin = process.env.ADMINISTRATOR === user?.username; + + const userId = isServerAdmin ? Number(req.query.id) : token.id; + + if (userId !== Number(req.query.id) && !isServerAdmin) return res.status(401).json({ response: "Permission denied." }); if (req.method === "GET") { @@ -53,7 +61,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { const updated = await updateUserById(userId, req.body); return res.status(updated.status).json({ response: updated.response }); } else if (req.method === "DELETE") { - const updated = await deleteUserById(userId, req.body); + const updated = await deleteUserById(userId, req.body, isServerAdmin); return res.status(updated.status).json({ response: updated.response }); } } diff --git a/store/admin/users.ts b/store/admin/users.ts new file mode 100644 index 00000000..c5cb5af1 --- /dev/null +++ b/store/admin/users.ts @@ -0,0 +1,61 @@ +import { User as U } from "@prisma/client"; +import { create } from "zustand"; + +interface User extends U { + subscriptions: { + active: boolean; + }; +} + +type ResponseObject = { + ok: boolean; + data: object | string; +}; + +type UserStore = { + users: User[]; + setUsers: (users: User[]) => void; + addUser: () => Promise<ResponseObject>; + removeUser: (userId: number) => Promise<ResponseObject>; +}; + +const useUserStore = create<UserStore>((set) => ({ + users: [], + setUsers: async () => { + const response = await fetch("/api/v1/users"); + + const data = await response.json(); + + if (response.ok) set({ users: data.response }); + }, + addUser: async () => { + const response = await fetch("/api/v1/users", { + method: "POST", + }); + + const data = await response.json(); + + if (response.ok) + set((state) => ({ + users: [...state.users, data.response], + })); + + return { ok: response.ok, data: data.response }; + }, + removeUser: async (userId) => { + const response = await fetch(`/api/v1/users/${userId}`, { + method: "DELETE", + }); + + const data = await response.json(); + + if (response.ok) + set((state) => ({ + users: state.users.filter((user) => user.id !== userId), + })); + + return { ok: response.ok, data: data.response }; + }, +})); + +export default useUserStore; From 915d08a315f26b0959481bee9c4b84d4cbe80aea Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Fri, 3 May 2024 10:22:45 -0400 Subject: [PATCH 44/79] finalized administration panel --- components/ModalContent/NewUserModal.tsx | 133 ++++++++++++++++++ lib/api/controllers/users/postUser.ts | 89 +++++++++--- .../users/userId/deleteUserById.ts | 3 +- pages/admin.tsx | 40 ++++-- pages/api/v1/users/index.ts | 3 +- store/admin/users.ts | 11 +- 6 files changed, 245 insertions(+), 34 deletions(-) create mode 100644 components/ModalContent/NewUserModal.tsx diff --git a/components/ModalContent/NewUserModal.tsx b/components/ModalContent/NewUserModal.tsx new file mode 100644 index 00000000..27e1a15a --- /dev/null +++ b/components/ModalContent/NewUserModal.tsx @@ -0,0 +1,133 @@ +import toast from "react-hot-toast"; +import Modal from "../Modal"; +import useUserStore from "@/store/admin/users"; +import TextInput from "../TextInput"; +import { FormEvent, useState } from "react"; + +type Props = { + onClose: Function; +}; + +type FormData = { + name: string; + username?: string; + email?: string; + password: string; +}; + +const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; + +export default function NewUserModal({ onClose }: Props) { + const { addUser } = useUserStore(); + + const [form, setForm] = useState<FormData>({ + name: "", + username: "", + email: emailEnabled ? "" : undefined, + password: "", + }); + + const [submitLoader, setSubmitLoader] = useState(false); + + async function submit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + + if (!submitLoader) { + const checkFields = () => { + if (emailEnabled) { + return form.name !== "" && form.email !== "" && form.password !== ""; + } else { + return ( + form.name !== "" && form.username !== "" && form.password !== "" + ); + } + }; + + if (checkFields()) { + if (form.password.length < 8) + return toast.error("Passwords must be at least 8 characters."); + + setSubmitLoader(true); + + const load = toast.loading("Creating Account..."); + + const response = await addUser(form); + + toast.dismiss(load); + setSubmitLoader(false); + + if (response.ok) { + toast.success("User Created!"); + onClose(); + } else { + toast.error(response.data as string); + } + } else { + toast.error("Please fill out all the fields."); + } + } + } + + return ( + <Modal toggleModal={onClose}> + <p className="text-xl font-thin">Create New User</p> + + <div className="divider mb-3 mt-1"></div> + + <form onSubmit={submit}> + <div className="grid sm:grid-cols-2 gap-3"> + <div> + <p className="mb-2">Display Name</p> + <TextInput + placeholder="Johnny" + className="bg-base-200" + onChange={(e) => setForm({ ...form, name: e.target.value })} + value={form.name} + /> + </div> + + {emailEnabled ? ( + <div> + <p className="mb-2">Username</p> + <TextInput + placeholder="john" + className="bg-base-200" + onChange={(e) => setForm({ ...form, username: e.target.value })} + value={form.username} + /> + </div> + ) : undefined} + + <div> + <p className="mb-2">Email</p> + <TextInput + placeholder="johnny@example.com" + className="bg-base-200" + onChange={(e) => setForm({ ...form, email: e.target.value })} + value={form.email} + /> + </div> + + <div> + <p className="mb-2">Password</p> + <TextInput + placeholder="••••••••••••••" + className="bg-base-200" + onChange={(e) => setForm({ ...form, password: e.target.value })} + value={form.password} + /> + </div> + </div> + + <div className="flex justify-between items-center mt-5"> + <button + className="btn btn-accent dark:border-violet-400 text-white ml-auto" + type="submit" + > + Create User + </button> + </div> + </form> + </Modal> + ); +} diff --git a/lib/api/controllers/users/postUser.ts b/lib/api/controllers/users/postUser.ts index f24738bb..84ab2e0f 100644 --- a/lib/api/controllers/users/postUser.ts +++ b/lib/api/controllers/users/postUser.ts @@ -1,9 +1,11 @@ import { prisma } from "@/lib/api/db"; import type { NextApiRequest, NextApiResponse } from "next"; import bcrypt from "bcrypt"; +import verifyUser from "../../verifyUser"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; +const stripeEnabled = process.env.STRIPE_SECRET_KEY ? true : false; interface Data { response: string | object; @@ -20,7 +22,15 @@ export default async function postUser( req: NextApiRequest, res: NextApiResponse<Data> ) { - if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") { + let isServerAdmin = false; + + const user = await verifyUser({ req, res }); + if (process.env.ADMINISTRATOR === user?.username) isServerAdmin = true; + + if ( + process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && + !isServerAdmin + ) { return res.status(400).json({ response: "Registration is disabled." }); } @@ -57,13 +67,16 @@ export default async function postUser( }); const checkIfUserExists = await prisma.user.findFirst({ - where: emailEnabled - ? { + where: { + OR: [ + { email: body.email?.toLowerCase().trim(), - } - : { + }, + { username: (body.username as string).toLowerCase().trim(), }, + ], + }, }); if (!checkIfUserExists) { @@ -71,21 +84,63 @@ export default async function postUser( const hashedPassword = bcrypt.hashSync(body.password, saltRounds); - await prisma.user.create({ - data: { - name: body.name, - username: emailEnabled - ? undefined - : (body.username as string).toLowerCase().trim(), - email: emailEnabled ? body.email?.toLowerCase().trim() : undefined, - password: hashedPassword, - }, - }); + // Subscription dates + const currentPeriodStart = new Date(); + const currentPeriodEnd = new Date(); + currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years... - return res.status(201).json({ response: "User successfully created." }); + if (isServerAdmin) { + const user = await prisma.user.create({ + data: { + name: body.name, + username: (body.username as string).toLowerCase().trim(), + email: emailEnabled ? body.email?.toLowerCase().trim() : undefined, + password: hashedPassword, + emailVerified: new Date(), + subscriptions: stripeEnabled + ? { + create: { + stripeSubscriptionId: + "fake_sub_" + Math.round(Math.random() * 10000000000000), + active: true, + currentPeriodStart, + currentPeriodEnd, + }, + } + : undefined, + }, + select: { + id: true, + username: true, + email: true, + emailVerified: true, + subscriptions: { + select: { + active: true, + }, + }, + createdAt: true, + }, + }); + + return res.status(201).json({ response: user }); + } else { + await prisma.user.create({ + data: { + name: body.name, + username: emailEnabled + ? undefined + : (body.username as string).toLowerCase().trim(), + email: emailEnabled ? body.email?.toLowerCase().trim() : undefined, + password: hashedPassword, + }, + }); + + return res.status(201).json({ response: "User successfully created." }); + } } else if (checkIfUserExists) { return res.status(400).json({ - response: `${emailEnabled ? "Email" : "Username"} already exists.`, + response: `Email or Username already exists.`, }); } } diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index 7e333445..9c01dcd0 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -11,7 +11,7 @@ const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET; export default async function deleteUserById( userId: number, body: DeleteUserBody, - isServerAdmin: boolean + isServerAdmin?: boolean ) { // First, we retrieve the user from the database const user = await prisma.user.findUnique({ @@ -93,6 +93,7 @@ export default async function deleteUserById( await prisma.subscription.delete({ where: { userId }, }); + // .catch((err) => console.log(err)); await prisma.usersAndCollections.deleteMany({ where: { diff --git a/pages/admin.tsx b/pages/admin.tsx index 3d5a0a31..d7b9fdaa 100644 --- a/pages/admin.tsx +++ b/pages/admin.tsx @@ -1,4 +1,5 @@ import DeleteUserModal from "@/components/ModalContent/DeleteUserModal"; +import NewUserModal from "@/components/ModalContent/NewUserModal"; import useUserStore from "@/store/admin/users"; import { User as U } from "@prisma/client"; import Link from "next/link"; @@ -26,11 +27,10 @@ export default function Admin() { userId: null, }); + const [newUserModal, setNewUserModal] = useState(false); + useEffect(() => { - // fetch users - fetch("/api/v1/users") - .then((res) => res.json()) - .then((data) => setUsers(data.response)); + setUsers(); }, []); return ( @@ -79,7 +79,10 @@ export default function Admin() { /> </div> - <div className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative"> + <div + onClick={() => setNewUserModal(true)} + className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative" + > <i className="bi-plus text-3xl absolute"></i> </div> </div> @@ -96,6 +99,10 @@ export default function Admin() { ) : ( <p>No users found.</p> )} + + {newUserModal ? ( + <NewUserModal onClose={() => setNewUserModal(false)} /> + ) : null} </div> ); } @@ -107,7 +114,7 @@ const UserListing = ( ) => { return ( <div className="overflow-x-auto whitespace-nowrap w-full"> - <table className="table table-zebra w-full"> + <table className="table w-full"> <thead> <tr> <th></th> @@ -122,19 +129,28 @@ const UserListing = ( </thead> <tbody> {users.map((user, index) => ( - <tr key={index}> - <td className="rounded-tl">{index + 1}</td> - <td>{user.username}</td> + <tr + key={index} + className="group hover:bg-neutral-content hover:bg-opacity-30 duration-100" + > + <td className="text-primary">{index + 1}</td> + <td>{user.username ? user.username : <b>N/A</b>}</td> {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( <td>{user.email}</td> )} {process.env.NEXT_PUBLIC_STRIPE === "true" && ( - <td>{JSON.stringify(user.subscriptions.active)}</td> + <td> + {user.subscriptions?.active ? ( + JSON.stringify(user.subscriptions?.active) + ) : ( + <b>N/A</b> + )} + </td> )} <td>{new Date(user.createdAt).toLocaleString()}</td> - <td> + <td className="relative"> <button - className="btn btn-sm btn-ghost" + className="btn btn-sm btn-ghost duration-100 hidden group-hover:block absolute z-20 right-[0.35rem] top-[0.35rem]" onClick={() => setDeleteUserModal({ isOpen: true, userId: user.id }) } diff --git a/pages/api/v1/users/index.ts b/pages/api/v1/users/index.ts index cce6b4ea..a4768fa6 100644 --- a/pages/api/v1/users/index.ts +++ b/pages/api/v1/users/index.ts @@ -9,7 +9,8 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { return response; } else if (req.method === "GET") { const user = await verifyUser({ req, res }); - if (!user || process.env.ADMINISTRATOR !== user.username) return; + if (!user || process.env.ADMINISTRATOR !== user.username) + return res.status(401).json({ response: "Unauthorized..." }); const response = await getUsers(); return res.status(response.status).json({ response: response.response }); diff --git a/store/admin/users.ts b/store/admin/users.ts index c5cb5af1..6925611d 100644 --- a/store/admin/users.ts +++ b/store/admin/users.ts @@ -14,8 +14,8 @@ type ResponseObject = { type UserStore = { users: User[]; - setUsers: (users: User[]) => void; - addUser: () => Promise<ResponseObject>; + setUsers: () => void; + addUser: (body: Partial<U>) => Promise<ResponseObject>; removeUser: (userId: number) => Promise<ResponseObject>; }; @@ -27,10 +27,15 @@ const useUserStore = create<UserStore>((set) => ({ const data = await response.json(); if (response.ok) set({ users: data.response }); + else if (response.status === 401) window.location.href = "/dashboard"; }, - addUser: async () => { + addUser: async (body) => { const response = await fetch("/api/v1/users", { method: "POST", + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + }, }); const data = await response.json(); From 80ad01a2d09c280d971f4f6ac1cb955265bdaf6f Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Fri, 3 May 2024 10:51:11 -0400 Subject: [PATCH 45/79] minor fix --- components/ModalContent/DeleteUserModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ModalContent/DeleteUserModal.tsx b/components/ModalContent/DeleteUserModal.tsx index 97c1cffb..cf1d3b98 100644 --- a/components/ModalContent/DeleteUserModal.tsx +++ b/components/ModalContent/DeleteUserModal.tsx @@ -43,7 +43,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) { onClick={deleteUser} > <i className="bi-trash text-xl" /> - Delete, I know what I'm doing + Delete, I know what I'm doing </button> </div> </Modal> From 2dd49ff844ab23d33d70d59fe2dcef2f0d2f0ab8 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Fri, 3 May 2024 17:08:58 -0400 Subject: [PATCH 46/79] minor improvement --- components/ModalContent/NewUserModal.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/ModalContent/NewUserModal.tsx b/components/ModalContent/NewUserModal.tsx index 27e1a15a..cf506cfe 100644 --- a/components/ModalContent/NewUserModal.tsx +++ b/components/ModalContent/NewUserModal.tsx @@ -119,6 +119,14 @@ export default function NewUserModal({ onClose }: Props) { </div> </div> + <div role="note" className="alert alert-note mt-5"> + <i className="bi-exclamation-triangle text-xl" /> + <span> + <b>Note:</b> Please make sure you inform the user that they need to + change their password. + </span> + </div> + <div className="flex justify-between items-center mt-5"> <button className="btn btn-accent dark:border-violet-400 text-white ml-auto" From 861f8e55f43c496c125cd03432a03312ecc5eab8 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Tue, 7 May 2024 16:59:00 -0400 Subject: [PATCH 47/79] bug fixed + add support for google profile pics --- components/CollectionListing.tsx | 6 ++- components/ProfilePhoto.tsx | 2 +- lib/api/controllers/users/postUser.ts | 54 ++++++++----------- .../users/userId/deleteUserById.ts | 6 ++- lib/api/isServerAdmin.ts | 44 +++++++++++++++ next.config.js | 9 ++++ pages/api/v1/auth/[...nextauth].ts | 14 +++++ pages/api/v1/users/index.ts | 2 +- 8 files changed, 100 insertions(+), 37 deletions(-) create mode 100644 lib/api/isServerAdmin.ts diff --git a/components/CollectionListing.tsx b/components/CollectionListing.tsx index 171117be..18d36f34 100644 --- a/components/CollectionListing.tsx +++ b/components/CollectionListing.tsx @@ -201,7 +201,11 @@ const CollectionListing = () => { }; if (!tree) { - return <></>; + return ( + <p className="text-neutral text-xs font-semibold truncate w-full px-2 mt-5 mb-8"> + You Have No Collections... + </p> + ); } else return ( <Tree diff --git a/components/ProfilePhoto.tsx b/components/ProfilePhoto.tsx index 340fa76c..e34b0ce8 100644 --- a/components/ProfilePhoto.tsx +++ b/components/ProfilePhoto.tsx @@ -19,7 +19,7 @@ export default function ProfilePhoto({ const [image, setImage] = useState(""); useEffect(() => { - if (src && !src?.includes("base64")) + if (src && !src?.includes("base64") && !src.startsWith("http")) setImage(`/api/v1/${src.replace("uploads/", "").replace(".jpg", "")}`); else if (!src) setImage(""); else { diff --git a/lib/api/controllers/users/postUser.ts b/lib/api/controllers/users/postUser.ts index 84ab2e0f..6a85e5f1 100644 --- a/lib/api/controllers/users/postUser.ts +++ b/lib/api/controllers/users/postUser.ts @@ -1,7 +1,7 @@ import { prisma } from "@/lib/api/db"; import type { NextApiRequest, NextApiResponse } from "next"; import bcrypt from "bcrypt"; -import verifyUser from "../../verifyUser"; +import isServerAdmin from "../../isServerAdmin"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; @@ -9,6 +9,7 @@ const stripeEnabled = process.env.STRIPE_SECRET_KEY ? true : false; interface Data { response: string | object; + status: number; } interface User { @@ -20,18 +21,12 @@ interface User { export default async function postUser( req: NextApiRequest, - res: NextApiResponse<Data> -) { - let isServerAdmin = false; + res: NextApiResponse +): Promise<Data> { + let isAdmin = await isServerAdmin({ req }); - const user = await verifyUser({ req, res }); - if (process.env.ADMINISTRATOR === user?.username) isServerAdmin = true; - - if ( - process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && - !isServerAdmin - ) { - return res.status(400).json({ response: "Registration is disabled." }); + if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && !isAdmin) { + return { response: "Registration is disabled.", status: 400 }; } const body: User = req.body; @@ -41,39 +36,36 @@ export default async function postUser( : !body.username || !body.password || !body.name; if (!body.password || body.password.length < 8) - return res - .status(400) - .json({ response: "Password must be at least 8 characters." }); + return { response: "Password must be at least 8 characters.", status: 400 }; if (checkHasEmptyFields) - return res - .status(400) - .json({ response: "Please fill out all the fields." }); + return { response: "Please fill out all the fields.", status: 400 }; // Check email (if enabled) const checkEmail = /^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; if (emailEnabled && !checkEmail.test(body.email?.toLowerCase() || "")) - return res.status(400).json({ - response: "Please enter a valid email.", - }); + return { response: "Please enter a valid email.", status: 400 }; // Check username (if email was disabled) const checkUsername = RegExp("^[a-z0-9_-]{3,31}$"); if (!emailEnabled && !checkUsername.test(body.username?.toLowerCase() || "")) - return res.status(400).json({ + return { response: "Username has to be between 3-30 characters, no spaces and special characters are allowed.", - }); + status: 400, + }; const checkIfUserExists = await prisma.user.findFirst({ where: { OR: [ { - email: body.email?.toLowerCase().trim(), + email: body.email ? body.email.toLowerCase().trim() : undefined, }, { - username: (body.username as string).toLowerCase().trim(), + username: body.username + ? body.username.toLowerCase().trim() + : undefined, }, ], }, @@ -89,7 +81,7 @@ export default async function postUser( const currentPeriodEnd = new Date(); currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years... - if (isServerAdmin) { + if (isAdmin) { const user = await prisma.user.create({ data: { name: body.name, @@ -123,7 +115,7 @@ export default async function postUser( }, }); - return res.status(201).json({ response: user }); + return { response: user, status: 201 }; } else { await prisma.user.create({ data: { @@ -136,11 +128,9 @@ export default async function postUser( }, }); - return res.status(201).json({ response: "User successfully created." }); + return { response: "User successfully created.", status: 201 }; } - } else if (checkIfUserExists) { - return res.status(400).json({ - response: `Email or Username already exists.`, - }); + } else { + return { response: "Email or Username already exists.", status: 400 }; } } diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index 9c01dcd0..19dce145 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -25,8 +25,10 @@ export default async function deleteUserById( }; } - // Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration) - if (!keycloakEnabled && !authentikEnabled && !isServerAdmin) { + // Then, we check if the provided password matches the one stored in the database (disabled in SSO/OAuth integrations) + if (user.password && !isServerAdmin) { + console.log("isServerAdmin", isServerAdmin); + console.log("isServerAdmin", body.password); const isPasswordValid = bcrypt.compareSync( body.password, user.password as string diff --git a/lib/api/isServerAdmin.ts b/lib/api/isServerAdmin.ts new file mode 100644 index 00000000..e58c329c --- /dev/null +++ b/lib/api/isServerAdmin.ts @@ -0,0 +1,44 @@ +import { NextApiRequest } from "next"; +import { getToken } from "next-auth/jwt"; +import { prisma } from "./db"; + +type Props = { + req: NextApiRequest; +}; + +export default async function isServerAdmin({ req }: Props): Promise<boolean> { + const token = await getToken({ req }); + const userId = token?.id; + + if (!userId) { + return false; + } + + if (token.exp < Date.now() / 1000) { + return false; + } + + // check if token is revoked + const revoked = await prisma.accessToken.findFirst({ + where: { + token: token.jti, + revoked: true, + }, + }); + + if (revoked) { + return false; + } + + const findUser = await prisma.user.findFirst({ + where: { + id: userId, + }, + }); + + if (findUser?.username === process.env.ADMINISTRATOR) { + return true; + } else { + return false; + } +} diff --git a/next.config.js b/next.config.js index ebb9b54b..665803ec 100644 --- a/next.config.js +++ b/next.config.js @@ -4,7 +4,16 @@ const { version } = require("./package.json"); const nextConfig = { reactStrictMode: true, images: { + // For fetching the favicons domains: ["t2.gstatic.com"], + + // For profile pictures (Google OAuth) + remotePatterns: [ + { + hostname: "*.googleusercontent.com", + }, + ], + minimumCacheTTL: 10, }, env: { diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index c8ca51f3..c1d4c1da 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -1103,6 +1103,20 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { }, callbacks: { async signIn({ user, account, profile, email, credentials }) { + // console.log( + // "User sign in attempt...", + // "User", + // user, + // "Account", + // account, + // "Profile", + // profile, + // "Email", + // email, + // "Credentials", + // credentials + // ); + if (account?.provider !== "credentials") { // registration via SSO can be separately disabled const existingUser = await prisma.account.findFirst({ diff --git a/pages/api/v1/users/index.ts b/pages/api/v1/users/index.ts index a4768fa6..c0d4f7be 100644 --- a/pages/api/v1/users/index.ts +++ b/pages/api/v1/users/index.ts @@ -6,7 +6,7 @@ import verifyUser from "@/lib/api/verifyUser"; export default async function users(req: NextApiRequest, res: NextApiResponse) { if (req.method === "POST") { const response = await postUser(req, res); - return response; + return res.status(response.status).json({ response: response.response }); } else if (req.method === "GET") { const user = await verifyUser({ req, res }); if (!user || process.env.ADMINISTRATOR !== user.username) From 65b29830f06fd00f4a52fbb07aaa8082837a5253 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Sun, 12 May 2024 22:28:34 -0400 Subject: [PATCH 48/79] enable modifying profile settings for SSO users --- .../users/userId/deleteUserById.ts | 22 +- .../users/userId/updateUserById.ts | 237 ++++++++---------- lib/api/verifyUser.ts | 8 +- pages/api/v1/auth/[...nextauth].ts | 2 +- 4 files changed, 117 insertions(+), 152 deletions(-) diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index 19dce145..87c09347 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -25,16 +25,20 @@ export default async function deleteUserById( }; } - // Then, we check if the provided password matches the one stored in the database (disabled in SSO/OAuth integrations) - if (user.password && !isServerAdmin) { - console.log("isServerAdmin", isServerAdmin); - console.log("isServerAdmin", body.password); - const isPasswordValid = bcrypt.compareSync( - body.password, - user.password as string - ); + if (!isServerAdmin) { + if (user.password) { + const isPasswordValid = bcrypt.compareSync( + body.password, + user.password as string + ); - if (!isPasswordValid && !isServerAdmin) { + if (!isPasswordValid && !isServerAdmin) { + return { + response: "Invalid credentials.", + status: 401, // Unauthorized + }; + } + } else { return { response: "Invalid credentials.", status: 401, // Unauthorized diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index f2b5e91e..782cc3a6 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -24,144 +24,106 @@ export default async function updateUserById( }, }); - if (ssoUser) { - // deny changes to SSO-defined properties - if (data.email !== user?.email) { + if (emailEnabled && !data.email) + return { + response: "Email invalid.", + status: 400, + }; + else if (!data.username) + return { + response: "Username invalid.", + status: 400, + }; + if (data.newPassword && data.newPassword?.length < 8) + return { + response: "Password must be at least 8 characters.", + status: 400, + }; + // 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({ + where: { + id: { not: userId }, + OR: emailEnabled + ? [ + { + username: data.username.toLowerCase(), + }, + { + email: data.email?.toLowerCase(), + }, + ] + : [ + { + username: data.username.toLowerCase(), + }, + ], + }, + }); + + if (userIsTaken) { + if (data.email?.toLowerCase().trim() === userIsTaken.email?.trim()) return { - response: "SSO users cannot change their email.", + response: "Email is taken.", status: 400, }; + else if ( + data.username?.toLowerCase().trim() === userIsTaken.username?.trim() + ) + return { + response: "Username is taken.", + status: 400, + }; + + return { + response: "Username/Email is taken.", + status: 400, + }; + } + + // Avatar Settings + + if ( + data.image?.startsWith("data:image/jpeg;base64") && + data.image.length < 1572864 + ) { + try { + const base64Data = data.image.replace(/^data:image\/jpeg;base64,/, ""); + + createFolder({ filePath: `uploads/avatar` }); + + await createFile({ + filePath: `uploads/avatar/${userId}.jpg`, + data: base64Data, + isBase64: true, + }); + } catch (err) { + console.log("Error saving image:", err); } - if (data.newPassword) { - return { - response: "SSO Users cannot change their password.", - status: 400, - }; - } - if (data.name !== user?.name) { - return { - response: "SSO Users cannot change their name.", - status: 400, - }; - } - if (data.username !== user?.username) { - return { - response: "SSO Users cannot change their username.", - status: 400, - }; - } - if (data.image?.startsWith("data:image/jpeg;base64")) { - return { - response: "SSO Users cannot change their avatar.", - status: 400, - }; - } - } else { - // verify only for non-SSO users - // SSO users cannot change their email, password, name, username, or avatar - if (emailEnabled && !data.email) - return { - response: "Email invalid.", - status: 400, - }; - else if (!data.username) - return { - response: "Username invalid.", - status: 400, - }; - if (data.newPassword && data.newPassword?.length < 8) - return { - response: "Password must be at least 8 characters.", - status: 400, - }; - // 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({ - where: { - id: { not: userId }, - OR: emailEnabled - ? [ - { - username: data.username.toLowerCase(), - }, - { - email: data.email?.toLowerCase(), - }, - ] - : [ - { - username: data.username.toLowerCase(), - }, - ], - }, - }); - - if (userIsTaken) { - if (data.email?.toLowerCase().trim() === userIsTaken.email?.trim()) - return { - response: "Email is taken.", - status: 400, - }; - else if ( - data.username?.toLowerCase().trim() === userIsTaken.username?.trim() - ) - return { - response: "Username is taken.", - status: 400, - }; - - return { - response: "Username/Email is taken.", - status: 400, - }; - } - - // Avatar Settings - - if (data.image?.startsWith("data:image/jpeg;base64")) { - if (data.image.length < 1572864) { - try { - const base64Data = data.image.replace( - /^data:image\/jpeg;base64,/, - "" - ); - - createFolder({ filePath: `uploads/avatar` }); - - await createFile({ - filePath: `uploads/avatar/${userId}.jpg`, - data: base64Data, - isBase64: true, - }); - } catch (err) { - console.log("Error saving image:", err); - } - } else { - console.log("A file larger than 1.5MB was uploaded."); - return { - response: "A file larger than 1.5MB was uploaded.", - status: 400, - }; - } - } else if (data.image == "") { - removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); - } + } else if (data.image?.length && data.image?.length >= 1572864) { + console.log("A file larger than 1.5MB was uploaded."); + return { + response: "A file larger than 1.5MB was uploaded.", + status: 400, + }; + } else if (data.image == "") { + removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); } const previousEmail = ( @@ -182,7 +144,12 @@ export default async function updateUserById( username: data.username?.toLowerCase().trim(), email: data.email?.toLowerCase().trim(), isPrivate: data.isPrivate, - image: data.image ? `uploads/avatar/${userId}.jpg` : "", + image: + data.image && data.image.startsWith("http") + ? data.image + : data.image + ? `uploads/avatar/${userId}.jpg` + : "", collectionOrder: data.collectionOrder.filter( (value, index, self) => self.indexOf(value) === index ), diff --git a/lib/api/verifyUser.ts b/lib/api/verifyUser.ts index 6e5fdf59..d77a74f0 100644 --- a/lib/api/verifyUser.ts +++ b/lib/api/verifyUser.ts @@ -32,19 +32,13 @@ export default async function verifyUser({ subscriptions: true, }, }); - const ssoUser = await prisma.account.findFirst({ - where: { - userId: userId, - }, - }); if (!user) { res.status(404).json({ response: "User not found." }); return null; } - if (!user.username && !ssoUser) { - // SSO users don't need a username + if (!user.username) { res.status(401).json({ response: "Username not found.", }); diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index c1d4c1da..79b6f08f 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -119,7 +119,7 @@ if ( passwordMatches = bcrypt.compareSync(password, user.password); } - if (passwordMatches) { + if (passwordMatches && user?.password) { return { id: user?.id }; } else return null as any; }, From 341154e928d772df5337686ed46a250e61fc404d Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Mon, 13 May 2024 00:27:29 -0400 Subject: [PATCH 49/79] auto assign username upon registration --- components/ModalContent/NewUserModal.tsx | 21 ++++-- layouts/AuthRedirect.tsx | 13 ---- lib/api/controllers/users/postUser.ts | 9 ++- pages/api/v1/auth/[...nextauth].ts | 1 - pages/choose-username.tsx | 93 ------------------------ 5 files changed, 20 insertions(+), 117 deletions(-) delete mode 100644 pages/choose-username.tsx diff --git a/components/ModalContent/NewUserModal.tsx b/components/ModalContent/NewUserModal.tsx index cf506cfe..2e4290fa 100644 --- a/components/ModalContent/NewUserModal.tsx +++ b/components/ModalContent/NewUserModal.tsx @@ -88,23 +88,28 @@ export default function NewUserModal({ onClose }: Props) { {emailEnabled ? ( <div> - <p className="mb-2">Username</p> + <p className="mb-2">Email</p> <TextInput - placeholder="john" + placeholder="johnny@example.com" className="bg-base-200" - onChange={(e) => setForm({ ...form, username: e.target.value })} - value={form.username} + onChange={(e) => setForm({ ...form, email: e.target.value })} + value={form.email} /> </div> ) : undefined} <div> - <p className="mb-2">Email</p> + <p className="mb-2"> + Username{" "} + {emailEnabled && ( + <span className="text-xs text-neutral">(Optional)</span> + )} + </p> <TextInput - placeholder="johnny@example.com" + placeholder="john" className="bg-base-200" - onChange={(e) => setForm({ ...form, email: e.target.value })} - value={form.email} + onChange={(e) => setForm({ ...form, username: e.target.value })} + value={form.username} /> </div> diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index da1f6fc3..9c35f6ee 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -32,19 +32,6 @@ export default function AuthRedirect({ children }: Props) { router.push("/subscribe").then(() => { setRedirect(false); }); - } - // Redirect to "/choose-username" if user is authenticated and is either a subscriber OR subscription is undefiend, and doesn't have a username - else if ( - emailEnabled && - status === "authenticated" && - account.subscription?.active && - stripeEnabled && - account.id && - !account.username - ) { - router.push("/choose-username").then(() => { - setRedirect(false); - }); } else if ( status === "authenticated" && account.id && diff --git a/lib/api/controllers/users/postUser.ts b/lib/api/controllers/users/postUser.ts index 6a85e5f1..464760ed 100644 --- a/lib/api/controllers/users/postUser.ts +++ b/lib/api/controllers/users/postUser.ts @@ -72,6 +72,9 @@ export default async function postUser( }); if (!checkIfUserExists) { + const autoGeneratedUsername = + "user" + Math.round(Math.random() * 1000000000); + const saltRounds = 10; const hashedPassword = bcrypt.hashSync(body.password, saltRounds); @@ -85,7 +88,9 @@ export default async function postUser( const user = await prisma.user.create({ data: { name: body.name, - username: (body.username as string).toLowerCase().trim(), + username: emailEnabled + ? autoGeneratedUsername + : (body.username as string).toLowerCase().trim(), email: emailEnabled ? body.email?.toLowerCase().trim() : undefined, password: hashedPassword, emailVerified: new Date(), @@ -121,7 +126,7 @@ export default async function postUser( data: { name: body.name, username: emailEnabled - ? undefined + ? autoGeneratedUsername : (body.username as string).toLowerCase().trim(), email: emailEnabled ? body.email?.toLowerCase().trim() : undefined, password: hashedPassword, diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index 79b6f08f..c59a38a6 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -1,7 +1,6 @@ import { prisma } from "@/lib/api/db"; import NextAuth from "next-auth/next"; import CredentialsProvider from "next-auth/providers/credentials"; -import { AuthOptions } from "next-auth"; import bcrypt from "bcrypt"; import EmailProvider from "next-auth/providers/email"; import { PrismaAdapter } from "@auth/prisma-adapter"; diff --git a/pages/choose-username.tsx b/pages/choose-username.tsx deleted file mode 100644 index e1e6b691..00000000 --- a/pages/choose-username.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import SubmitButton from "@/components/SubmitButton"; -import { signOut } from "next-auth/react"; -import { FormEvent, useState } from "react"; -import { toast } from "react-hot-toast"; -import { useSession } from "next-auth/react"; -import useAccountStore from "@/store/account"; -import CenteredForm from "@/layouts/CenteredForm"; -import TextInput from "@/components/TextInput"; -import AccentSubmitButton from "@/components/AccentSubmitButton"; - -export default function ChooseUsername() { - const [submitLoader, setSubmitLoader] = useState(false); - const [inputedUsername, setInputedUsername] = useState(""); - - const { data, status, update } = useSession(); - - const { updateAccount, account } = useAccountStore(); - - async function submitUsername(event: FormEvent<HTMLFormElement>) { - event.preventDefault(); - - setSubmitLoader(true); - - const redirectionToast = toast.loading("Applying..."); - - const response = await updateAccount({ - ...account, - username: inputedUsername, - }); - - if (response.ok) { - toast.success("Username Applied!"); - - update({ - id: data?.user.id, - }); - } else toast.error(response.data as string); - toast.dismiss(redirectionToast); - setSubmitLoader(false); - } - - return ( - <CenteredForm> - <form onSubmit={submitUsername}> - <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content"> - <p className="text-3xl text-center font-extralight"> - Choose a Username - </p> - - <div className="divider my-0"></div> - - <div> - <p className="text-sm w-fit font-semibold mb-1">Username</p> - - <TextInput - autoFocus - placeholder="john" - value={inputedUsername} - className="bg-base-100" - onChange={(e) => setInputedUsername(e.target.value)} - /> - </div> - <div> - <p className="text-md text-neutral mt-1"> - Feel free to reach out to us at{" "} - <a - className="font-semibold underline" - href="mailto:support@linkwarden.app" - > - support@linkwarden.app - </a>{" "} - in case of any issues. - </p> - </div> - - <AccentSubmitButton - type="submit" - label="Complete Registration" - className="mt-2 w-full" - loading={submitLoader} - /> - - <div - onClick={() => signOut()} - className="w-fit mx-auto cursor-pointer text-neutral font-semibold " - > - Sign Out - </div> - </div> - </form> - </CenteredForm> - ); -} From 7442799836f89233c82364b8b7dfe9a7c9401f14 Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Tue, 14 May 2024 12:14:22 -0400 Subject: [PATCH 50/79] minor fix --- components/LinkViews/LinkCard.tsx | 2 +- components/ReadableView.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/LinkViews/LinkCard.tsx b/components/LinkViews/LinkCard.tsx index d93904a1..8f11cea2 100644 --- a/components/LinkViews/LinkCard.tsx +++ b/components/LinkViews/LinkCard.tsx @@ -208,7 +208,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { </span> )} </p> - {link.tags[0] && ( + {link.tags && link.tags[0] && ( <> <p className="text-neutral text-lg mt-3 font-semibold">Tags</p> diff --git a/components/ReadableView.tsx b/components/ReadableView.tsx index 977ca2a0..539ec8e8 100644 --- a/components/ReadableView.tsx +++ b/components/ReadableView.tsx @@ -43,7 +43,7 @@ export default function ReadableView({ link }: Props) { const router = useRouter(); - const { links, getLink } = useLinkStore(); + const { getLink } = useLinkStore(); const { collections } = useCollectionStore(); const collection = useMemo(() => { @@ -138,7 +138,7 @@ export default function ReadableView({ link }: Props) { }, [colorPalette]); return ( - <div className={`flex flex-col max-w-screen-md h-full mx-auto py-5`}> + <div className={`flex flex-col max-w-screen-md h-full mx-auto p-5`}> <div id="link-banner" className="link-banner relative bg-opacity-10 border-neutral-content p-3 border mb-3" @@ -174,7 +174,7 @@ export default function ReadableView({ link }: Props) { /> )} <div className="flex flex-col"> - <p className="text-xl"> + <p className="text-xl pr-10"> {unescapeString( link?.name || link?.description || link?.url || "" )} @@ -212,7 +212,7 @@ export default function ReadableView({ link }: Props) { {link?.collection.name} </p> </Link> - {link?.tags.map((e, i) => ( + {link?.tags?.map((e, i) => ( <Link key={i} href={`/tags/${e.id}`} className="z-10"> <p title={e.name} From f68ca100a12a1e161bafee2e95b25894e2870e1b Mon Sep 17 00:00:00 2001 From: daniel31x13 <daniel31x13@gmail.com> Date: Thu, 16 May 2024 15:02:22 -0400 Subject: [PATCH 51/79] refactored email verification --- .env.sample | 1 + .../EmailChangeVerificationModal.tsx | 67 +++ .../users/userId/updateUserById.ts | 63 +-- lib/api/sendChangeEmailVerificationRequest.ts | 54 +++ lib/api/sendVerificationRequest.ts | 77 +--- lib/api/transporter.ts | 8 + package.json | 1 + pages/api/v1/auth/verify-email.ts | 116 +++++ pages/auth/verify-email.tsx | 33 ++ pages/settings/account.tsx | 120 ++--- .../migration.sql | 2 + prisma/schema.prisma | 1 + templates/verifyEmail.html | 413 +++++++++++++++++ templates/verifyEmailChange.html | 424 ++++++++++++++++++ types/enviornment.d.ts | 3 +- yarn.lock | 37 ++ 16 files changed, 1285 insertions(+), 135 deletions(-) create mode 100644 components/ModalContent/EmailChangeVerificationModal.tsx create mode 100644 lib/api/sendChangeEmailVerificationRequest.ts create mode 100644 lib/api/transporter.ts create mode 100644 pages/api/v1/auth/verify-email.ts create mode 100644 pages/auth/verify-email.tsx create mode 100644 prisma/migrations/20240515084924_add_unverified_new_email_field_to_user_table/migration.sql create mode 100644 templates/verifyEmail.html create mode 100644 templates/verifyEmailChange.html diff --git a/.env.sample b/.env.sample index a5796351..30bc0447 100644 --- a/.env.sample +++ b/.env.sample @@ -36,6 +36,7 @@ SPACES_FORCE_PATH_STYLE= NEXT_PUBLIC_EMAIL_PROVIDER= EMAIL_FROM= EMAIL_SERVER= +BASE_URL= # Proxy settings PROXY= diff --git a/components/ModalContent/EmailChangeVerificationModal.tsx b/components/ModalContent/EmailChangeVerificationModal.tsx new file mode 100644 index 00000000..15560d94 --- /dev/null +++ b/components/ModalContent/EmailChangeVerificationModal.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import TextInput from "@/components/TextInput"; +import Modal from "../Modal"; + +type Props = { + onClose: Function; + onSubmit: Function; + oldEmail: string; + newEmail: string; +}; + +export default function EmailChangeVerificationModal({ + onClose, + onSubmit, + oldEmail, + newEmail, +}: Props) { + const [password, setPassword] = useState(""); + + return ( + <Modal toggleModal={onClose}> + <p className="text-xl font-thin">Confirm Password</p> + + <div className="divider mb-3 mt-1"></div> + + <div className="flex flex-col gap-5"> + <p> + Please confirm your password before changing your email address.{" "} + {process.env.NEXT_PUBLIC_STRIPE === "true" + ? "Updating this field will change your billing email on Stripe as well." + : undefined} + </p> + + <div> + <p>Old Email</p> + <p className="text-neutral">{oldEmail}</p> + </div> + + <div> + <p>New Email</p> + <p className="text-neutral">{newEmail}</p> + </div> + + <div className="w-full"> + <p className="mb-2">Password</p> + <TextInput + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder="••••••••••••••" + className="bg-base-200" + type="password" + autoFocus + /> + </div> + + <div className="flex justify-end items-center"> + <button + className="btn btn-accent dark:border-violet-400 text-white" + onClick={() => onSubmit(password)} + > + Confirm + </button> + </div> + </div> + </Modal> + ); +} diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index 782cc3a6..cbf8a152 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -3,8 +3,8 @@ import { AccountSettings } from "@/types/global"; import bcrypt from "bcrypt"; import removeFile from "@/lib/api/storage/removeFile"; import createFile from "@/lib/api/storage/createFile"; -import updateCustomerEmail from "@/lib/api/updateCustomerEmail"; import createFolder from "@/lib/api/storage/createFolder"; +import sendChangeEmailVerificationRequest from "@/lib/api/sendChangeEmailVerificationRequest"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; @@ -13,17 +13,6 @@ export default async function updateUserById( userId: number, data: AccountSettings ) { - const ssoUser = await prisma.account.findFirst({ - where: { - userId: userId, - }, - }); - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - }); - if (emailEnabled && !data.email) return { response: "Email invalid.", @@ -39,6 +28,7 @@ export default async function updateUserById( response: "Password must be at least 8 characters.", status: 400, }; + // Check email (if enabled) const checkEmail = /^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; @@ -126,11 +116,42 @@ export default async function updateUserById( removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); } - const previousEmail = ( - await prisma.user.findUnique({ where: { id: userId } }) - )?.email; + // Email Settings - // Other settings + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true, password: true }, + }); + + if (user && user.email && data.email && data.email !== user.email) { + if (!data.password) { + return { + response: "Invalid password.", + status: 400, + }; + } + + // Verify password + if (!user.password) { + return { + response: "User has no password.", + status: 400, + }; + } + + const passwordMatch = bcrypt.compareSync(data.password, user.password); + + if (!passwordMatch) { + return { + response: "Password is incorrect.", + status: 400, + }; + } + + sendChangeEmailVerificationRequest(user.email, data.email, data.name); + } + + // Other settings / Apply changes const saltRounds = 10; const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds); @@ -142,7 +163,6 @@ export default async function updateUserById( data: { name: data.name, username: data.username?.toLowerCase().trim(), - email: data.email?.toLowerCase().trim(), isPrivate: data.isPrivate, image: data.image && data.image.startsWith("http") @@ -211,15 +231,6 @@ export default async function updateUserById( }); } - const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; - - if (STRIPE_SECRET_KEY && emailEnabled && previousEmail !== data.email) - await updateCustomerEmail( - STRIPE_SECRET_KEY, - previousEmail as string, - data.email as string - ); - const response: Omit<AccountSettings, "password"> = { ...userInfo, whitelistedUsers: newWhitelistedUsernames, diff --git a/lib/api/sendChangeEmailVerificationRequest.ts b/lib/api/sendChangeEmailVerificationRequest.ts new file mode 100644 index 00000000..85b09c97 --- /dev/null +++ b/lib/api/sendChangeEmailVerificationRequest.ts @@ -0,0 +1,54 @@ +import { randomBytes } from "crypto"; +import { prisma } from "./db"; +import transporter from "./transporter"; +import Handlebars from "handlebars"; +import { readFileSync } from "fs"; +import path from "path"; + +export default async function sendChangeEmailVerificationRequest( + oldEmail: string, + newEmail: string, + user: string +) { + const token = randomBytes(32).toString("hex"); + + await prisma.$transaction(async () => { + await prisma.verificationToken.create({ + data: { + identifier: oldEmail?.toLowerCase(), + token, + expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day + }, + }); + await prisma.user.update({ + where: { + email: oldEmail?.toLowerCase(), + }, + data: { + unverifiedNewEmail: newEmail?.toLowerCase(), + }, + }); + }); + + const emailsDir = path.resolve(process.cwd(), "templates"); + + const templateFile = readFileSync( + path.join(emailsDir, "verifyEmailChange.html"), + "utf8" + ); + + const emailTemplate = Handlebars.compile(templateFile); + + transporter.sendMail({ + from: process.env.EMAIL_FROM, + to: newEmail, + subject: "Verify your new Linkwarden email address", + html: emailTemplate({ + user, + baseUrl: process.env.BASE_URL, + oldEmail, + newEmail, + verifyUrl: `${process.env.BASE_URL}/auth/verify-email?token=${token}`, + }), + }); +} diff --git a/lib/api/sendVerificationRequest.ts b/lib/api/sendVerificationRequest.ts index 5951e9f3..730df46e 100644 --- a/lib/api/sendVerificationRequest.ts +++ b/lib/api/sendVerificationRequest.ts @@ -1,19 +1,33 @@ -import { Theme } from "next-auth"; +import { readFileSync } from "fs"; import { SendVerificationRequestParams } from "next-auth/providers"; -import { createTransport } from "nodemailer"; +import path from "path"; +import Handlebars from "handlebars"; +import transporter from "./transporter"; export default async function sendVerificationRequest( params: SendVerificationRequestParams ) { - const { identifier, url, provider, theme } = params; + const emailsDir = path.resolve(process.cwd(), "templates"); + + const templateFile = readFileSync( + path.join(emailsDir, "verifyEmail.html"), + "utf8" + ); + + const emailTemplate = Handlebars.compile(templateFile); + + const { identifier, url, provider, token } = params; const { host } = new URL(url); - const transport = createTransport(provider.server); - const result = await transport.sendMail({ + const result = await transporter.sendMail({ to: identifier, from: provider.from, - subject: `Sign in to ${host}`, + subject: `Please verify your email address`, text: text({ url, host }), - html: html({ url, host, theme }), + html: emailTemplate({ + url: `${ + process.env.NEXTAUTH_URL + }/callback/email?token=${token}&email=${encodeURIComponent(identifier)}`, + }), }); const failed = result.rejected.concat(result.pending).filter(Boolean); if (failed.length) { @@ -21,55 +35,6 @@ export default async function sendVerificationRequest( } } -function html(params: { url: string; host: string; theme: Theme }) { - const { url, host, theme } = params; - - const escapedHost = host.replace(/\./g, "​."); - - const brandColor = theme.brandColor || "#0029cf"; - const color = { - background: "#f9f9f9", - text: "#444", - mainBackground: "#fff", - buttonBackground: brandColor, - buttonBorder: brandColor, - buttonText: theme.buttonText || "#fff", - }; - - return ` -<body style="background: ${color.background};"> - <table width="100%" border="0" cellspacing="20" cellpadding="0" - style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;"> - <tr> - <td align="center" - style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};"> - Sign in to <strong>${escapedHost}</strong> - </td> - </tr> - <tr> - <td align="center" style="padding: 20px 0;"> - <table border="0" cellspacing="0" cellpadding="0"> - <tr> - <td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"> - <a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;"> - Sign in - </a> - </td> - </tr> - </table> - </td> - </tr> - <tr> - <td align="center" - style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};"> - If you did not request this email you can safely ignore it. - </td> - </tr> - </table> -</body> -`; -} - /** 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`; diff --git a/lib/api/transporter.ts b/lib/api/transporter.ts new file mode 100644 index 00000000..f07dd9c1 --- /dev/null +++ b/lib/api/transporter.ts @@ -0,0 +1,8 @@ +import { createTransport } from "nodemailer"; + +export default createTransport({ + url: process.env.EMAIL_SERVER, + auth: { + user: process.env.EMAIL_FROM, + }, +}); diff --git a/package.json b/package.json index a41987e4..13d0fd9f 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "eslint-config-next": "13.4.9", "formidable": "^3.5.1", "framer-motion": "^10.16.4", + "handlebars": "^4.7.8", "himalaya": "^1.1.0", "jimp": "^0.22.10", "jsdom": "^22.1.0", diff --git a/pages/api/v1/auth/verify-email.ts b/pages/api/v1/auth/verify-email.ts new file mode 100644 index 00000000..1205fe7f --- /dev/null +++ b/pages/api/v1/auth/verify-email.ts @@ -0,0 +1,116 @@ +import { prisma } from "@/lib/api/db"; +import updateCustomerEmail from "@/lib/api/updateCustomerEmail"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function verifyEmail( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "POST") { + const token = req.query.token; + + if (!token || typeof token !== "string") { + return res.status(400).json({ + response: "Invalid token.", + }); + } + + // Check token in db + const verifyToken = await prisma.verificationToken.findFirst({ + where: { + token, + expires: { + gte: new Date(), + }, + }, + }); + + const oldEmail = verifyToken?.identifier; + + if (!oldEmail) { + return res.status(400).json({ + response: "Invalid token.", + }); + } + + // Ensure email isn't in use + const findNewEmail = await prisma.user.findFirst({ + where: { + email: oldEmail, + }, + select: { + unverifiedNewEmail: true, + }, + }); + + const newEmail = findNewEmail?.unverifiedNewEmail; + + if (!newEmail) { + return res.status(400).json({ + response: "No unverified emails found.", + }); + } + + const emailInUse = await prisma.user.findFirst({ + where: { + email: newEmail, + }, + select: { + email: true, + }, + }); + + console.log(emailInUse); + + if (emailInUse) { + return res.status(400).json({ + response: "Email is already in use.", + }); + } + + // Remove SSO provider + await prisma.account.deleteMany({ + where: { + user: { + email: oldEmail, + }, + }, + }); + + // Update email in db + await prisma.user.update({ + where: { + email: oldEmail, + }, + data: { + email: newEmail.toLowerCase().trim(), + unverifiedNewEmail: null, + }, + }); + + // Apply to Stripe + const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + + if (STRIPE_SECRET_KEY) + await updateCustomerEmail(STRIPE_SECRET_KEY, oldEmail, newEmail); + + // Clean up existing tokens + await prisma.verificationToken.delete({ + where: { + token, + }, + }); + + await prisma.verificationToken.deleteMany({ + where: { + identifier: oldEmail, + }, + }); + + return res.status(200).json({ + response: token, + }); + } +} + +// http://localhost:3000/api/v1/auth/verify-email?token=67b3d33491be1ba7b9ab60a8cb8caec5f248b72c0e890aafec979c0d33899279 diff --git a/pages/auth/verify-email.tsx b/pages/auth/verify-email.tsx new file mode 100644 index 00000000..ce4d9670 --- /dev/null +++ b/pages/auth/verify-email.tsx @@ -0,0 +1,33 @@ +import { useRouter } from "next/router"; +import { useEffect } from "react"; +import toast from "react-hot-toast"; + +const VerifyEmail = () => { + const router = useRouter(); + + useEffect(() => { + const token = router.query.token; + + if (!token || typeof token !== "string") { + router.push("/login"); + } + + // Verify token + + fetch(`/api/v1/auth/verify-email?token=${token}`, { + method: "POST", + }).then((res) => { + if (res.ok) { + toast.success("Email verified. You can now login."); + } else { + toast.error("Invalid token."); + } + }); + + console.log(token); + }, []); + + return <div>Verify email...</div>; +}; + +export default VerifyEmail; diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index 8d3134ec..05fe14c1 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -12,9 +12,13 @@ import { MigrationFormat, MigrationRequest } from "@/types/global"; import Link from "next/link"; import Checkbox from "@/components/Checkbox"; import { dropdownTriggerer } from "@/lib/client/utils"; +import EmailChangeVerificationModal from "@/components/ModalContent/EmailChangeVerificationModal"; + +const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; export default function Account() { - const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; + const [emailChangeVerificationModal, setEmailChangeVerificationModal] = + useState(false); const [submitLoader, setSubmitLoader] = useState(false); @@ -30,6 +34,7 @@ export default function Account() { username: "", email: "", emailVerified: null, + password: undefined, image: "", isPrivate: true, // @ts-ignore @@ -68,19 +73,29 @@ export default function Account() { } }; - const submit = async () => { + const submit = async (password?: string) => { setSubmitLoader(true); const load = toast.loading("Applying..."); const response = await updateAccount({ ...user, + // @ts-ignore + password: password ? password : undefined, }); toast.dismiss(load); if (response.ok) { - toast.success("Settings Applied!"); + const emailChanged = account.email !== user.email; + + if (emailChanged) { + toast.success("Settings Applied!"); + toast.success( + "Email change request sent. Please verify the new email address." + ); + setEmailChangeVerificationModal(false); + } else toast.success("Settings Applied!"); } else toast.error(response.data as string); setSubmitLoader(false); }; @@ -177,12 +192,6 @@ export default function Account() { {emailEnabled ? ( <div> <p className="mb-2">Email</p> - {user.email !== account.email && - process.env.NEXT_PUBLIC_STRIPE === "true" ? ( - <p className="text-neutral mb-2 text-sm"> - Updating this field will change your billing email as well - </p> - ) : undefined} <TextInput value={user.email || ""} className="bg-base-200" @@ -230,6 +239,47 @@ export default function Account() { </div> </div> + <div className="sm:-mt-3"> + <Checkbox + label="Make profile private" + state={user.isPrivate} + onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })} + /> + + <p className="text-neutral text-sm"> + This will limit who can find and add you to new Collections. + </p> + + {user.isPrivate && ( + <div className="pl-5"> + <p className="mt-2">Whitelisted Users</p> + <p className="text-neutral text-sm mb-3"> + Please provide the Username of the users you wish to grant + visibility to your profile. Separated by comma. + </p> + <textarea + className="w-full resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary" + placeholder="Your profile is hidden from everyone right now..." + value={whitelistedUsersTextbox} + onChange={(e) => setWhiteListedUsersTextbox(e.target.value)} + /> + </div> + )} + </div> + + <SubmitButton + onClick={() => { + if (account.email !== user.email) { + setEmailChangeVerificationModal(true); + } else { + submit(); + } + }} + loading={submitLoader} + label="Save Changes" + className="mt-2 w-full sm:w-fit" + /> + <div> <div className="flex items-center gap-2 w-full rounded-md h-8"> <p className="truncate w-full pr-7 text-3xl font-thin"> @@ -310,49 +360,6 @@ export default function Account() { </div> </div> - <div> - <div className="flex items-center gap-2 w-full rounded-md h-8"> - <p className="truncate w-full pr-7 text-3xl font-thin"> - Profile Visibility - </p> - </div> - - <div className="divider my-3"></div> - - <Checkbox - label="Make profile private" - state={user.isPrivate} - onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })} - /> - - <p className="text-neutral text-sm"> - This will limit who can find and add you to new Collections. - </p> - - {user.isPrivate && ( - <div className="pl-5"> - <p className="mt-2">Whitelisted Users</p> - <p className="text-neutral text-sm mb-3"> - Please provide the Username of the users you wish to grant - visibility to your profile. Separated by comma. - </p> - <textarea - className="w-full resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary" - placeholder="Your profile is hidden from everyone right now..." - value={whitelistedUsersTextbox} - onChange={(e) => setWhiteListedUsersTextbox(e.target.value)} - /> - </div> - )} - </div> - - <SubmitButton - onClick={submit} - loading={submitLoader} - label="Save Changes" - className="mt-2 w-full sm:w-fit" - /> - <div> <div className="flex items-center gap-2 w-full rounded-md h-8"> <p className="text-red-500 dark:text-red-500 truncate w-full pr-7 text-3xl font-thin"> @@ -380,6 +387,15 @@ export default function Account() { <p className="text-center w-full">Delete Your Account</p> </Link> </div> + + {emailChangeVerificationModal ? ( + <EmailChangeVerificationModal + onClose={() => setEmailChangeVerificationModal(false)} + onSubmit={submit} + oldEmail={account.email || ""} + newEmail={user.email || ""} + /> + ) : undefined} </SettingsLayout> ); } diff --git a/prisma/migrations/20240515084924_add_unverified_new_email_field_to_user_table/migration.sql b/prisma/migrations/20240515084924_add_unverified_new_email_field_to_user_table/migration.sql new file mode 100644 index 00000000..3f4b05f6 --- /dev/null +++ b/prisma/migrations/20240515084924_add_unverified_new_email_field_to_user_table/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "unverifiedNewEmail" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 26d6dc90..6413faf5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { username String? @unique email String? @unique emailVerified DateTime? + unverifiedNewEmail String? image String? accounts Account[] password String? diff --git a/templates/verifyEmail.html b/templates/verifyEmail.html new file mode 100644 index 00000000..14e62ea5 --- /dev/null +++ b/templates/verifyEmail.html @@ -0,0 +1,413 @@ +<!doctype html> +<html lang="en"> + <head> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <title>Email + + + + + + + + + + + + diff --git a/templates/verifyEmailChange.html b/templates/verifyEmailChange.html new file mode 100644 index 00000000..284b8377 --- /dev/null +++ b/templates/verifyEmailChange.html @@ -0,0 +1,424 @@ + + + + + + Email + + + + + + + + + + + + diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index 5a94472f..32bc29da 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -30,13 +30,14 @@ declare global { EMAIL_FROM?: string; EMAIL_SERVER?: string; + BASE_URL?: string; // Used for email and stripe + NEXT_PUBLIC_STRIPE?: string; STRIPE_SECRET_KEY?: string; MONTHLY_PRICE_ID?: string; YEARLY_PRICE_ID?: string; NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL?: string; NEXT_PUBLIC_TRIAL_PERIOD_DAYS?: string; - BASE_URL?: string; // Proxy settings PROXY?: string; diff --git a/yarn.lock b/yarn.lock index ece2559f..d6ebd45b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3709,6 +3709,18 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +handlebars@^4.7.8: + version "4.7.8" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" + integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + har-schema@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" @@ -4438,6 +4450,11 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minimist@^1.2.5: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + minipass@^3.0.0: version "3.3.6" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" @@ -4508,6 +4525,11 @@ ndarray@^1.0.13: iota-array "^1.0.0" is-buffer "^1.0.2" +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + next-auth@^4.22.1: version "4.22.1" resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.22.1.tgz#1ea5084e38867966dc6492a71c6729c8f5cfa96b" @@ -5532,6 +5554,11 @@ source-map@^0.5.7: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + spawn-command@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e" @@ -5982,6 +6009,11 @@ typescript@4.9.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== +uglify-js@^3.1.4: + version "3.17.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" + integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== + unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -6218,6 +6250,11 @@ wide-align@^1.1.2: dependencies: string-width "^1.0.2 || 2 || 3 || 4" +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" From 142af9b5c0d7737778e17e8d45093d66a07e05d4 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Thu, 16 May 2024 15:50:43 -0400 Subject: [PATCH 52/79] small improvements --- .../ModalContent/EmailChangeVerificationModal.tsx | 12 +++++++++--- lib/api/controllers/users/userId/updateUserById.ts | 8 ++++++-- pages/auth/verify-email.tsx | 8 ++++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/components/ModalContent/EmailChangeVerificationModal.tsx b/components/ModalContent/EmailChangeVerificationModal.tsx index 15560d94..20fe0ef2 100644 --- a/components/ModalContent/EmailChangeVerificationModal.tsx +++ b/components/ModalContent/EmailChangeVerificationModal.tsx @@ -26,11 +26,17 @@ export default function EmailChangeVerificationModal({

    Please confirm your password before changing your email address.{" "} - {process.env.NEXT_PUBLIC_STRIPE === "true" - ? "Updating this field will change your billing email on Stripe as well." - : undefined} + {process.env.NEXT_PUBLIC_STRIPE === "true" && + "Updating this field will change your billing email on Stripe as well."}

    + {process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true" && ( +

    + If you change your email address, any existing Google SSO + connections will be removed. +

    + )} +

    Old Email

    {oldEmail}

    diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index cbf8a152..c5016316 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -148,7 +148,11 @@ export default async function updateUserById( }; } - sendChangeEmailVerificationRequest(user.email, data.email, data.name); + sendChangeEmailVerificationRequest( + user.email, + data.email, + data.name.trim() + ); } // Other settings / Apply changes @@ -161,7 +165,7 @@ export default async function updateUserById( id: userId, }, data: { - name: data.name, + name: data.name.trim(), username: data.username?.toLowerCase().trim(), isPrivate: data.isPrivate, image: diff --git a/pages/auth/verify-email.tsx b/pages/auth/verify-email.tsx index ce4d9670..db3e9206 100644 --- a/pages/auth/verify-email.tsx +++ b/pages/auth/verify-email.tsx @@ -1,3 +1,4 @@ +import { signOut } from "next-auth/react"; import { useRouter } from "next/router"; import { useEffect } from "react"; import toast from "react-hot-toast"; @@ -18,7 +19,10 @@ const VerifyEmail = () => { method: "POST", }).then((res) => { if (res.ok) { - toast.success("Email verified. You can now login."); + toast.success("Email verified. Signing out.."); + setTimeout(() => { + signOut(); + }, 3000); } else { toast.error("Invalid token."); } @@ -27,7 +31,7 @@ const VerifyEmail = () => { console.log(token); }, []); - return
    Verify email...
    ; + return <>; }; export default VerifyEmail; From 90efec3c6e2aa85d60671fc254fabbd699121d22 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Thu, 16 May 2024 15:52:09 -0400 Subject: [PATCH 53/79] removed unnecessary comment --- pages/api/v1/auth/verify-email.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/pages/api/v1/auth/verify-email.ts b/pages/api/v1/auth/verify-email.ts index 1205fe7f..5fe5eabb 100644 --- a/pages/api/v1/auth/verify-email.ts +++ b/pages/api/v1/auth/verify-email.ts @@ -112,5 +112,3 @@ export default async function verifyEmail( }); } } - -// http://localhost:3000/api/v1/auth/verify-email?token=67b3d33491be1ba7b9ab60a8cb8caec5f248b72c0e890aafec979c0d33899279 From f0621dac2e2a089ff66afdd94793dd2b5f4c932d Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Fri, 17 May 2024 03:15:18 -0400 Subject: [PATCH 54/79] small improvement --- lib/api/sendChangeEmailVerificationRequest.ts | 5 ++++- lib/api/sendVerificationRequest.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/api/sendChangeEmailVerificationRequest.ts b/lib/api/sendChangeEmailVerificationRequest.ts index 85b09c97..475ef8a6 100644 --- a/lib/api/sendChangeEmailVerificationRequest.ts +++ b/lib/api/sendChangeEmailVerificationRequest.ts @@ -40,7 +40,10 @@ export default async function sendChangeEmailVerificationRequest( const emailTemplate = Handlebars.compile(templateFile); transporter.sendMail({ - from: process.env.EMAIL_FROM, + from: { + name: "Linkwarden", + address: process.env.EMAIL_FROM as string, + }, to: newEmail, subject: "Verify your new Linkwarden email address", html: emailTemplate({ diff --git a/lib/api/sendVerificationRequest.ts b/lib/api/sendVerificationRequest.ts index 730df46e..98e0277e 100644 --- a/lib/api/sendVerificationRequest.ts +++ b/lib/api/sendVerificationRequest.ts @@ -20,7 +20,10 @@ export default async function sendVerificationRequest( const { host } = new URL(url); const result = await transporter.sendMail({ to: identifier, - from: provider.from, + from: { + name: "Linkwarden", + address: provider.from as string, + }, subject: `Please verify your email address`, text: text({ url, host }), html: emailTemplate({ From 78fa417f0612a23d0d1c9d2351584311060ebd12 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 18 May 2024 12:16:00 -0400 Subject: [PATCH 55/79] minor change --- pages/api/v1/auth/verify-email.ts | 2 +- prisma/schema.prisma | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pages/api/v1/auth/verify-email.ts b/pages/api/v1/auth/verify-email.ts index 5fe5eabb..2621c1e0 100644 --- a/pages/api/v1/auth/verify-email.ts +++ b/pages/api/v1/auth/verify-email.ts @@ -20,7 +20,7 @@ export default async function verifyEmail( where: { token, expires: { - gte: new Date(), + gt: new Date(), }, }, }); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6413faf5..1cec0fb4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,6 +79,14 @@ model VerificationToken { @@unique([identifier, token]) } +model PasswordResetToken { + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Collection { id Int @id @default(autoincrement()) name String From 27061ada43e1d2e7c75f2a47381f5c75d795c739 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 18 May 2024 12:18:43 -0400 Subject: [PATCH 56/79] add PasswordResetToken table --- .../migration.sql | 11 +++++++++++ prisma/schema.prisma | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20240518161814_added_password_reset_token_table/migration.sql diff --git a/prisma/migrations/20240518161814_added_password_reset_token_table/migration.sql b/prisma/migrations/20240518161814_added_password_reset_token_table/migration.sql new file mode 100644 index 00000000..80b7264c --- /dev/null +++ b/prisma/migrations/20240518161814_added_password_reset_token_table/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "PasswordResetToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1cec0fb4..bd4e9824 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -84,7 +84,7 @@ model PasswordResetToken { token String @unique expires DateTime createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + updatedAt DateTime @default(now()) @updatedAt } model Collection { From 73dda21573eafd6ae4f00d761cfedda1d4fc72e9 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 18 May 2024 23:57:00 -0400 Subject: [PATCH 57/79] password reset functionality [WIP] --- lib/api/sendPasswordResetRequest.ts | 44 +++ pages/api/v1/auth/forgot-password.ts | 52 ++++ pages/api/v1/auth/reset-password.ts | 80 ++++++ pages/auth/reset-password.tsx | 116 ++++++++ pages/forgot.tsx | 93 ++++--- templates/passwordReset.html | 388 +++++++++++++++++++++++++++ 6 files changed, 737 insertions(+), 36 deletions(-) create mode 100644 lib/api/sendPasswordResetRequest.ts create mode 100644 pages/api/v1/auth/forgot-password.ts create mode 100644 pages/api/v1/auth/reset-password.ts create mode 100644 pages/auth/reset-password.tsx create mode 100644 templates/passwordReset.html diff --git a/lib/api/sendPasswordResetRequest.ts b/lib/api/sendPasswordResetRequest.ts new file mode 100644 index 00000000..ce29e1ba --- /dev/null +++ b/lib/api/sendPasswordResetRequest.ts @@ -0,0 +1,44 @@ +import { randomBytes } from "crypto"; +import { prisma } from "./db"; +import transporter from "./transporter"; +import Handlebars from "handlebars"; +import { readFileSync } from "fs"; +import path from "path"; + +export default async function sendPasswordResetRequest( + email: string, + user: string +) { + const token = randomBytes(32).toString("hex"); + + await prisma.passwordResetToken.create({ + data: { + identifier: email?.toLowerCase(), + token, + expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day + }, + }); + + const emailsDir = path.resolve(process.cwd(), "templates"); + + const templateFile = readFileSync( + path.join(emailsDir, "passwordReset.html"), + "utf8" + ); + + const emailTemplate = Handlebars.compile(templateFile); + + transporter.sendMail({ + from: { + name: "Linkwarden", + address: process.env.EMAIL_FROM as string, + }, + to: email, + subject: "Verify your new Linkwarden email address", + html: emailTemplate({ + user, + baseUrl: process.env.BASE_URL, + url: `${process.env.BASE_URL}/auth/password-reset?token=${token}`, + }), + }); +} diff --git a/pages/api/v1/auth/forgot-password.ts b/pages/api/v1/auth/forgot-password.ts new file mode 100644 index 00000000..0672914c --- /dev/null +++ b/pages/api/v1/auth/forgot-password.ts @@ -0,0 +1,52 @@ +import { prisma } from "@/lib/api/db"; +import sendPasswordResetRequest from "@/lib/api/sendPasswordResetRequest"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function forgotPassword( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "POST") { + const email = req.body.email; + + if (!email) { + return res.status(400).json({ + response: "Invalid email.", + }); + } + + const recentPasswordRequestsCount = await prisma.passwordResetToken.count({ + where: { + identifier: email, + createdAt: { + gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes + }, + }, + }); + + // Rate limit password reset requests + if (recentPasswordRequestsCount >= 3) { + return res.status(400).json({ + response: "Too many requests. Please try again later.", + }); + } + + const user = await prisma.user.findFirst({ + where: { + email, + }, + }); + + if (!user || !user.email) { + return res.status(400).json({ + response: "Invalid email.", + }); + } + + sendPasswordResetRequest(user.email, user.name); + + return res.status(200).json({ + response: "Password reset email sent.", + }); + } +} diff --git a/pages/api/v1/auth/reset-password.ts b/pages/api/v1/auth/reset-password.ts new file mode 100644 index 00000000..06ebb799 --- /dev/null +++ b/pages/api/v1/auth/reset-password.ts @@ -0,0 +1,80 @@ +import { prisma } from "@/lib/api/db"; +import type { NextApiRequest, NextApiResponse } from "next"; +import bcrypt from "bcrypt"; + +export default async function resetPassword( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "POST") { + const token = req.body.token; + const password = req.body.password; + + if (!password || password.length < 8) { + return res.status(400).json({ + response: "Password must be at least 8 characters.", + }); + } + + if (!token || typeof token !== "string") { + return res.status(400).json({ + response: "Invalid token.", + }); + } + + // Hashed password + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + // Check token in db + const verifyToken = await prisma.passwordResetToken.findFirst({ + where: { + token, + expires: { + gt: new Date(), + }, + }, + }); + + if (!verifyToken) { + return res.status(400).json({ + response: "Invalid token.", + }); + } + + const email = verifyToken.identifier; + + // Update password + await prisma.user.update({ + where: { + email, + }, + data: { + password: hashedPassword, + }, + }); + + await prisma.passwordResetToken.update({ + where: { + token, + }, + data: { + expires: new Date(), + }, + }); + + // Delete tokens older than 5 minutes + await prisma.passwordResetToken.deleteMany({ + where: { + identifier: email, + createdAt: { + lt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes + }, + }, + }); + + return res.status(200).json({ + response: "Password reset successfully.", + }); + } +} diff --git a/pages/auth/reset-password.tsx b/pages/auth/reset-password.tsx new file mode 100644 index 00000000..4615ecd9 --- /dev/null +++ b/pages/auth/reset-password.tsx @@ -0,0 +1,116 @@ +import AccentSubmitButton from "@/components/AccentSubmitButton"; +import TextInput from "@/components/TextInput"; +import CenteredForm from "@/layouts/CenteredForm"; +import Link from "next/link"; +import { FormEvent, useState } from "react"; +import { toast } from "react-hot-toast"; + +interface FormData { + password: string; + passwordConfirmation: string; +} + +export default function ResetPassword() { + const [submitLoader, setSubmitLoader] = useState(false); + + const [form, setForm] = useState({ + password: "", + passwordConfirmation: "", + }); + + const [isEmailSent, setIsEmailSent] = useState(false); + + async function submitRequest() { + const response = await fetch("/api/v1/auth/forgot-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(form), + }); + + const data = await response.json(); + + if (response.ok) { + toast.success(data.response); + setIsEmailSent(true); + } else { + toast.error(data.response); + } + } + + async function sendConfirmation(event: FormEvent) { + event.preventDefault(); + + if (form.password !== "") { + setSubmitLoader(true); + + const load = toast.loading("Sending password recovery link..."); + + await submitRequest(); + + toast.dismiss(load); + + setSubmitLoader(false); + } else { + toast.error("Please fill out all the fields."); + } + } + + return ( + +
    +
    +

    + {isEmailSent ? "Email Sent!" : "Forgot Password?"} +

    + +
    + + {!isEmailSent ? ( + <> +
    +

    + Enter your email so we can send you a link to create a new + password. +

    +
    +
    +

    Email

    + + + setForm({ ...form, password: e.target.value }) + } + /> +
    + + + + ) : ( +

    + Check your email for a link to reset your password. If it doesn’t + appear within a few minutes, check your spam folder. +

    + )} + +
    + + Go back + +
    +
    +
    +
    + ); +} diff --git a/pages/forgot.tsx b/pages/forgot.tsx index d2773d94..f216776d 100644 --- a/pages/forgot.tsx +++ b/pages/forgot.tsx @@ -1,7 +1,6 @@ import AccentSubmitButton from "@/components/AccentSubmitButton"; import TextInput from "@/components/TextInput"; import CenteredForm from "@/layouts/CenteredForm"; -import { signIn } from "next-auth/react"; import Link from "next/link"; import { FormEvent, useState } from "react"; import { toast } from "react-hot-toast"; @@ -17,24 +16,40 @@ export default function Forgot() { email: "", }); + const [isEmailSent, setIsEmailSent] = useState(false); + + async function submitRequest() { + const response = await fetch("/api/v1/auth/forgot-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(form), + }); + + const data = await response.json(); + + if (response.ok) { + toast.success(data.response); + setIsEmailSent(true); + } else { + toast.error(data.response); + } + } + async function sendConfirmation(event: FormEvent) { event.preventDefault(); if (form.email !== "") { setSubmitLoader(true); - const load = toast.loading("Sending login link..."); + const load = toast.loading("Sending password recovery link..."); - await signIn("email", { - email: form.email, - callbackUrl: "/", - }); + await submitRequest(); toast.dismiss(load); setSubmitLoader(false); - - toast.success("Login link sent."); } else { toast.error("Please fill out all the fields."); } @@ -45,40 +60,46 @@ export default function Forgot() {

    - Password Recovery + {isEmailSent ? "Email Sent!" : "Forgot Password?"}

    -
    -

    - Enter your email so we can send you a link to recover your - account. Make sure to change your password in the profile settings - afterwards. -

    -

    - You wont get logged in if you haven't created an account yet. -

    -
    -
    -

    Email

    + {!isEmailSent ? ( + <> +
    +

    + Enter your email so we can send you a link to create a new + password. +

    +
    +
    +

    Email

    - setForm({ ...form, email: e.target.value })} - /> -
    + setForm({ ...form, email: e.target.value })} + /> +
    + + + + ) : ( +

    + Check your email for a link to reset your password. If it doesn’t + appear within a few minutes, check your spam folder. +

    + )} -
    Go back diff --git a/templates/passwordReset.html b/templates/passwordReset.html new file mode 100644 index 00000000..d3b6f5e1 --- /dev/null +++ b/templates/passwordReset.html @@ -0,0 +1,388 @@ + + + + + + Email + + + + + + + + + + + + From 329019b34e652bc3220859d0fddee1702e72b98e Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 20 May 2024 19:23:11 -0400 Subject: [PATCH 58/79] finalized password reset + code refactoring --- hooks/useInitialData.tsx | 2 + layouts/AuthRedirect.tsx | 97 +++++++++++++++-------------- lib/api/sendPasswordResetRequest.ts | 4 +- lib/api/sendVerificationRequest.ts | 20 ++++-- pages/_app.tsx | 12 ++++ pages/api/v1/auth/[...nextauth].ts | 68 ++++++++++++++++++-- pages/api/v1/auth/reset-password.ts | 2 +- pages/auth/reset-password.tsx | 85 +++++++++++++------------ pages/confirmation.tsx | 44 ++++++++++--- pages/forgot.tsx | 8 +-- pages/login.tsx | 6 +- pages/register.tsx | 9 ++- 12 files changed, 239 insertions(+), 118 deletions(-) diff --git a/hooks/useInitialData.tsx b/hooks/useInitialData.tsx index 775f8c5f..4b0dd17e 100644 --- a/hooks/useInitialData.tsx +++ b/hooks/useInitialData.tsx @@ -29,4 +29,6 @@ export default function useInitialData() { // setLinks(); } }, [account]); + + return status; } diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index 9c35f6ee..7f88b459 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -1,8 +1,7 @@ -import { ReactNode } from "react"; +import { ReactNode, useEffect, useState } from "react"; +import { useRouter } from "next/router"; import { useSession } from "next-auth/react"; import Loader from "../components/Loader"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; import useInitialData from "@/hooks/useInitialData"; import useAccountStore from "@/store/account"; @@ -10,62 +9,68 @@ interface Props { children: ReactNode; } +const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; +const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; + export default function AuthRedirect({ children }: Props) { const router = useRouter(); - const { status, data } = useSession(); - const [redirect, setRedirect] = useState(true); + const { status } = useSession(); + const [shouldRenderChildren, setShouldRenderChildren] = useState(false); const { account } = useAccountStore(); - const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; - const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; - useInitialData(); useEffect(() => { - if (!router.pathname.startsWith("/public")) { - if ( - status === "authenticated" && - account.id && - !account.subscription?.active && - stripeEnabled - ) { - router.push("/subscribe").then(() => { - setRedirect(false); - }); + const isLoggedIn = status === "authenticated"; + const isUnauthenticated = status === "unauthenticated"; + const isPublicPage = router.pathname.startsWith("/public"); + const hasInactiveSubscription = + account.id && !account.subscription?.active && stripeEnabled; + const routes = [ + { path: "/login", isProtected: false }, + { path: "/register", isProtected: false }, + { path: "/confirmation", isProtected: false }, + { path: "/forgot", isProtected: false }, + { path: "/auth/reset-password", isProtected: false }, + { path: "/", isProtected: false }, + { path: "/subscribe", isProtected: true }, + { path: "/dashboard", isProtected: true }, + { path: "/settings", isProtected: true }, + { path: "/collections", isProtected: true }, + { path: "/links", isProtected: true }, + { path: "/tags", isProtected: true }, + ]; + + if (isPublicPage) { + setShouldRenderChildren(true); + } else { + if (isLoggedIn && hasInactiveSubscription) { + redirectTo("/subscribe"); } else if ( - status === "authenticated" && - account.id && - (router.pathname === "/login" || - router.pathname === "/register" || - router.pathname === "/confirmation" || - router.pathname === "/subscribe" || - router.pathname === "/choose-username" || - router.pathname === "/forgot" || - router.pathname === "/") + isLoggedIn && + !routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected) ) { - router.push("/dashboard").then(() => { - setRedirect(false); - }); + redirectTo("/dashboard"); } else if ( - status === "unauthenticated" && - !( - router.pathname === "/login" || - router.pathname === "/register" || - router.pathname === "/confirmation" || - router.pathname === "/forgot" + isUnauthenticated && + !routes.some( + (e) => router.pathname.startsWith(e.path) && !e.isProtected ) ) { - router.push("/login").then(() => { - setRedirect(false); - }); - } else if (status === "loading") setRedirect(true); - else setRedirect(false); - } else { - setRedirect(false); + redirectTo("/login"); + } else { + setShouldRenderChildren(true); + } } }, [status, account, router.pathname]); - if (status !== "loading" && !redirect) return <>{children}; - else return <>; - // return <>{children}; + function redirectTo(destination: string) { + router.push(destination).then(() => setShouldRenderChildren(true)); + } + + if (status !== "loading" && shouldRenderChildren) { + return <>{children}; + } else { + return <>; + } } diff --git a/lib/api/sendPasswordResetRequest.ts b/lib/api/sendPasswordResetRequest.ts index ce29e1ba..b94ccef8 100644 --- a/lib/api/sendPasswordResetRequest.ts +++ b/lib/api/sendPasswordResetRequest.ts @@ -34,11 +34,11 @@ export default async function sendPasswordResetRequest( address: process.env.EMAIL_FROM as string, }, to: email, - subject: "Verify your new Linkwarden email address", + subject: "Linkwarden: Reset password instructions", html: emailTemplate({ user, baseUrl: process.env.BASE_URL, - url: `${process.env.BASE_URL}/auth/password-reset?token=${token}`, + url: `${process.env.BASE_URL}/auth/reset-password?token=${token}`, }), }); } diff --git a/lib/api/sendVerificationRequest.ts b/lib/api/sendVerificationRequest.ts index 98e0277e..eb19a889 100644 --- a/lib/api/sendVerificationRequest.ts +++ b/lib/api/sendVerificationRequest.ts @@ -1,12 +1,21 @@ import { readFileSync } from "fs"; -import { SendVerificationRequestParams } from "next-auth/providers"; import path from "path"; import Handlebars from "handlebars"; import transporter from "./transporter"; -export default async function sendVerificationRequest( - params: SendVerificationRequestParams -) { +type Params = { + identifier: string; + url: string; + from: string; + token: string; +}; + +export default async function sendVerificationRequest({ + identifier, + url, + from, + token, +}: Params) { const emailsDir = path.resolve(process.cwd(), "templates"); const templateFile = readFileSync( @@ -16,13 +25,12 @@ export default async function sendVerificationRequest( const emailTemplate = Handlebars.compile(templateFile); - const { identifier, url, provider, token } = params; const { host } = new URL(url); const result = await transporter.sendMail({ to: identifier, from: { name: "Linkwarden", - address: provider.from as string, + address: from as string, }, subject: `Please verify your email address`, text: text({ url, host }), diff --git a/pages/_app.tsx b/pages/_app.tsx index 0ece19db..9c6b5277 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -9,6 +9,7 @@ import toast from "react-hot-toast"; import { Toaster, ToastBar } from "react-hot-toast"; import { Session } from "next-auth"; import { isPWA } from "@/lib/client/utils"; +import useInitialData from "@/hooks/useInitialData"; export default function App({ Component, @@ -55,6 +56,7 @@ export default function App({ + {/* */} + {/* */} ); } + +// function GetData({ children }: { children: React.ReactNode }) { +// const status = useInitialData(); +// return typeof window !== "undefined" && status !== "loading" ? ( +// children +// ) : ( +// <> +// ); +// } diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index c59a38a6..a67bdf19 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -65,6 +65,7 @@ import ZohoProvider from "next-auth/providers/zoho"; import ZoomProvider from "next-auth/providers/zoom"; import * as process from "process"; import type { NextApiRequest, NextApiResponse } from "next"; +import { randomBytes } from "crypto"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; @@ -105,13 +106,54 @@ if ( email: username?.toLowerCase(), }, ], - emailVerified: { not: null }, } : { username: username.toLowerCase(), }, }); + if (!user) throw Error("Invalid credentials."); + else if (!user?.emailVerified && emailEnabled) { + const identifier = user?.email as string; + const token = randomBytes(32).toString("hex"); + const url = `${ + process.env.NEXTAUTH_URL + }/callback/email?token=${token}&email=${encodeURIComponent( + identifier + )}`; + const from = process.env.EMAIL_FROM as string; + + 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."); + + sendVerificationRequest({ + identifier, + url, + from, + token, + }); + + await prisma.verificationToken.create({ + data: { + identifier, + token, + expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day + }, + }); + + throw Error("Email not verified. Verification email sent."); + } + let passwordMatches: boolean = false; if (user?.password) { @@ -120,7 +162,7 @@ if ( if (passwordMatches && user?.password) { return { id: user?.id }; - } else return null as any; + } else throw Error("Invalid credentials."); }, }) ); @@ -132,8 +174,26 @@ if (emailEnabled) { server: process.env.EMAIL_SERVER, from: process.env.EMAIL_FROM, maxAge: 1200, - sendVerificationRequest(params) { - sendVerificationRequest(params); + async sendVerificationRequest({ identifier, url, provider, token }) { + 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."); + + sendVerificationRequest({ + identifier, + url, + from: provider.from as string, + token, + }); }, }) ); diff --git a/pages/api/v1/auth/reset-password.ts b/pages/api/v1/auth/reset-password.ts index 06ebb799..14287f36 100644 --- a/pages/api/v1/auth/reset-password.ts +++ b/pages/api/v1/auth/reset-password.ts @@ -74,7 +74,7 @@ export default async function resetPassword( }); return res.status(200).json({ - response: "Password reset successfully.", + response: "Password has been reset successfully.", }); } } diff --git a/pages/auth/reset-password.tsx b/pages/auth/reset-password.tsx index 4615ecd9..138d6ffe 100644 --- a/pages/auth/reset-password.tsx +++ b/pages/auth/reset-password.tsx @@ -2,52 +2,56 @@ import AccentSubmitButton from "@/components/AccentSubmitButton"; import TextInput from "@/components/TextInput"; import CenteredForm from "@/layouts/CenteredForm"; import Link from "next/link"; +import { useRouter } from "next/router"; import { FormEvent, useState } from "react"; import { toast } from "react-hot-toast"; interface FormData { password: string; - passwordConfirmation: string; + token: string; } export default function ResetPassword() { const [submitLoader, setSubmitLoader] = useState(false); + const router = useRouter(); + const [form, setForm] = useState({ password: "", - passwordConfirmation: "", + token: router.query.token as string, }); - const [isEmailSent, setIsEmailSent] = useState(false); + const [requestSent, setRequestSent] = useState(false); - async function submitRequest() { - const response = await fetch("/api/v1/auth/forgot-password", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(form), - }); - - const data = await response.json(); - - if (response.ok) { - toast.success(data.response); - setIsEmailSent(true); - } else { - toast.error(data.response); - } - } - - async function sendConfirmation(event: FormEvent) { + async function submit(event: FormEvent) { event.preventDefault(); - if (form.password !== "") { + if ( + form.password !== "" && + form.token !== "" && + !requestSent && + !submitLoader + ) { setSubmitLoader(true); const load = toast.loading("Sending password recovery link..."); - await submitRequest(); + const response = await fetch("/api/v1/auth/reset-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(form), + }); + + const data = await response.json(); + + if (response.ok) { + toast.success(data.response); + setRequestSent(true); + } else { + toast.error(data.response); + } toast.dismiss(load); @@ -59,15 +63,15 @@ export default function ResetPassword() { return ( - +

    - {isEmailSent ? "Email Sent!" : "Forgot Password?"} + {requestSent ? "Password Updated!" : "Reset Password"}

    - {!isEmailSent ? ( + {!requestSent ? ( <>

    @@ -76,12 +80,12 @@ export default function ResetPassword() {

    -

    Email

    +

    New Password

    @@ -92,23 +96,22 @@ export default function ResetPassword() { ) : ( -

    - Check your email for a link to reset your password. If it doesn’t - appear within a few minutes, check your spam folder. -

    - )} + <> +

    Your password has been successfully updated.

    -
    - - Go back - -
    +
    + + Back to Login + +
    + + )}
    diff --git a/pages/confirmation.tsx b/pages/confirmation.tsx index 1fee8d25..f8f99b6d 100644 --- a/pages/confirmation.tsx +++ b/pages/confirmation.tsx @@ -1,8 +1,33 @@ import CenteredForm from "@/layouts/CenteredForm"; +import { signIn } from "next-auth/react"; import Link from "next/link"; -import React from "react"; +import { useRouter } from "next/router"; +import React, { useState } from "react"; +import toast from "react-hot-toast"; export default function EmailConfirmaion() { + const router = useRouter(); + + const [submitLoader, setSubmitLoader] = useState(false); + + const resend = async () => { + setSubmitLoader(true); + + const load = toast.loading("Authenticating..."); + + const res = await signIn("email", { + email: decodeURIComponent(router.query.email as string), + callbackUrl: "/", + redirect: false, + }); + + toast.dismiss(load); + + setSubmitLoader(false); + + toast.success("Verification email sent."); + }; + return (
    @@ -12,15 +37,16 @@ export default function EmailConfirmaion() {
    -

    A sign in link has been sent to your email address.

    - -

    - Didn't see the email? Check your spam folder or visit the{" "} - - Password Recovery - {" "} - page to resend the link. +

    + A sign in link has been sent to your email address. If you don't see + the email, check your spam folder.

    + +
    +
    + Resend Email +
    +
    ); diff --git a/pages/forgot.tsx b/pages/forgot.tsx index f216776d..70dc8af3 100644 --- a/pages/forgot.tsx +++ b/pages/forgot.tsx @@ -94,15 +94,15 @@ export default function Forgot() { /> ) : ( -

    +

    Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.

    )} -
    - - Go back +
    + + Back to Login
    diff --git a/pages/login.tsx b/pages/login.tsx index 1824afe3..90d26d1d 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -47,7 +47,7 @@ export default function Login({ setSubmitLoader(false); if (!res?.ok) { - toast.error("Invalid login."); + toast.error(res?.error || "Invalid credentials."); } } else { toast.error("Please fill out all the fields."); @@ -108,7 +108,7 @@ export default function Login({
    Forgot Password? @@ -158,7 +158,7 @@ export default function Login({

    New here?

    Sign Up diff --git a/pages/register.tsx b/pages/register.tsx index eb5c6025..c3d63b15 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -76,12 +76,17 @@ export default function Register() { setSubmitLoader(false); if (response.ok) { - if (form.email && emailEnabled) + if (form.email && emailEnabled) { await signIn("email", { email: form.email, callbackUrl: "/", + redirect: false, }); - else if (!emailEnabled) router.push("/login"); + + router.push( + "/confirmation?email=" + encodeURIComponent(form.email) + ); + } else if (!emailEnabled) router.push("/login"); toast.success("User Created!"); } else { From 0fd10396f44cb2d487a433556fe6adff38111319 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 21 May 2024 07:08:08 -0400 Subject: [PATCH 59/79] Refactor password update functionality --- .../users/userId/updateUserById.ts | 36 ++++++++++++++++--- pages/settings/password.tsx | 30 ++++++++-------- types/global.ts | 1 + 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index c5016316..2b035284 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -23,11 +23,6 @@ export default async function updateUserById( response: "Username invalid.", status: 400, }; - if (data.newPassword && data.newPassword?.length < 8) - return { - response: "Password must be at least 8 characters.", - status: 400, - }; // Check email (if enabled) const checkEmail = @@ -155,6 +150,37 @@ export default async function updateUserById( ); } + // Password Settings + + if (data.newPassword || data.oldPassword) { + if (!data.oldPassword || !data.newPassword) + return { + response: "Please fill out all the fields.", + status: 400, + }; + else if (!user?.password) + return { + response: + "User has no password. Please reset your password from the forgot password page.", + status: 400, + }; + else if (!bcrypt.compareSync(data.oldPassword, user.password)) + return { + response: "Old password is incorrect.", + status: 400, + }; + else if (data.newPassword?.length < 8) + return { + response: "Password must be at least 8 characters.", + status: 400, + }; + else if (data.newPassword === data.oldPassword) + return { + response: "New password must be different from the old password.", + status: 400, + }; + } + // Other settings / Apply changes const saltRounds = 10; diff --git a/pages/settings/password.tsx b/pages/settings/password.tsx index ee8af3bc..fba4b5ab 100644 --- a/pages/settings/password.tsx +++ b/pages/settings/password.tsx @@ -6,21 +6,18 @@ import { toast } from "react-hot-toast"; import TextInput from "@/components/TextInput"; export default function Password() { - const [newPassword, setNewPassword1] = useState(""); - const [newPassword2, setNewPassword2] = useState(""); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); const [submitLoader, setSubmitLoader] = useState(false); const { account, updateAccount } = useAccountStore(); const submit = async () => { - if (newPassword == "" || newPassword2 == "") { + if (newPassword == "" || oldPassword == "") { return toast.error("Please fill all the fields."); } - - if (newPassword !== newPassword2) - return toast.error("Passwords do not match."); - else if (newPassword.length < 8) + if (newPassword.length < 8) return toast.error("Passwords must be at least 8 characters."); setSubmitLoader(true); @@ -30,14 +27,15 @@ export default function Password() { const response = await updateAccount({ ...account, newPassword, + oldPassword, }); toast.dismiss(load); if (response.ok) { toast.success("Settings Applied!"); - setNewPassword1(""); - setNewPassword2(""); + setNewPassword(""); + setOldPassword(""); } else toast.error(response.data as string); setSubmitLoader(false); @@ -54,22 +52,22 @@ export default function Password() { should be at least 8 characters.

    -

    New Password

    +

    Old Password

    setNewPassword1(e.target.value)} + onChange={(e) => setOldPassword(e.target.value)} placeholder="••••••••••••••" type="password" /> -

    Confirm New Password

    +

    New Password

    setNewPassword2(e.target.value)} + onChange={(e) => setNewPassword(e.target.value)} placeholder="••••••••••••••" type="password" /> @@ -78,7 +76,7 @@ export default function Password() { onClick={submit} loading={submitLoader} label="Save Changes" - className="mt-2 w-full sm:w-fit" + className="mt-3 w-full sm:w-fit" />
    diff --git a/types/global.ts b/types/global.ts index cc171f25..3e842239 100644 --- a/types/global.ts +++ b/types/global.ts @@ -50,6 +50,7 @@ export interface TagIncludingLinkCount extends Tag { export interface AccountSettings extends User { newPassword?: string; + oldPassword?: string; whitelistedUsers: string[]; subscription?: { active?: boolean; From 811628a952dd97667b005b8f7001809ff66afea9 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 21 May 2024 08:00:44 -0400 Subject: [PATCH 60/79] Fix deleteUserById response message for users without password --- lib/api/controllers/users/userId/deleteUserById.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index 87c09347..fd36ca91 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -5,9 +5,6 @@ import Stripe from "stripe"; import { DeleteUserBody } from "@/types/global"; import removeFile from "@/lib/api/storage/removeFile"; -const keycloakEnabled = process.env.KEYCLOAK_CLIENT_SECRET; -const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET; - export default async function deleteUserById( userId: number, body: DeleteUserBody, @@ -40,7 +37,8 @@ export default async function deleteUserById( } } else { return { - response: "Invalid credentials.", + response: + "User has no password. Please reset your password from the forgot password page.", status: 401, // Unauthorized }; } From a498f3a10d9bc78910e5cf926974d9033e170b00 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Wed, 22 May 2024 20:56:56 -0400 Subject: [PATCH 61/79] refactor code to improve readability and maintainability + redesigned announcement bar --- .vscode/settings.json | 7 +- components/AccentSubmitButton.tsx | 32 --------- components/Announcement.tsx | 35 ++++++++++ components/AnnouncementBar.tsx | 33 --------- components/InstallApp.tsx | 51 ++++++++++++++ components/Navbar.tsx | 2 +- components/ui/Button.tsx | 60 +++++++++++++++++ components/{ => ui}/ToggleDarkMode.tsx | 0 layouts/AuthRedirect.tsx | 7 +- layouts/MainLayout.tsx | 33 ++++----- .../users/userId/updateUserById.ts | 3 +- lib/client/utils.ts | 4 ++ package.json | 5 +- pages/api/v1/auth/[...nextauth].ts | 17 +---- pages/auth/reset-password.tsx | 11 +-- pages/forgot.tsx | 11 +-- pages/login.tsx | 36 +++++----- pages/public/collections/[id].tsx | 7 +- pages/register.tsx | 67 +++++++++++++++++-- pages/subscribe.tsx | 12 ++-- styles/globals.css | 26 ------- yarn.lock | 31 +++++++++ 22 files changed, 319 insertions(+), 171 deletions(-) delete mode 100644 components/AccentSubmitButton.tsx create mode 100644 components/Announcement.tsx delete mode 100644 components/AnnouncementBar.tsx create mode 100644 components/InstallApp.tsx create mode 100644 components/ui/Button.tsx rename components/{ => ui}/ToggleDarkMode.tsx (100%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0967ef42..b4464832 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,6 @@ -{} +{ + "tailwindCSS.experimental.classRegex": [ + ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ] +} diff --git a/components/AccentSubmitButton.tsx b/components/AccentSubmitButton.tsx deleted file mode 100644 index 930c1a61..00000000 --- a/components/AccentSubmitButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -type Props = { - onClick?: Function; - label: string; - loading?: boolean; - className?: string; - type?: "button" | "submit" | "reset" | undefined; - "data-testid"?: string; -}; - -export default function AccentSubmitButton({ - onClick, - label, - loading, - className, - type, - "data-testid": dataTestId, -}: Props) { - return ( - - ); -} diff --git a/components/Announcement.tsx b/components/Announcement.tsx new file mode 100644 index 00000000..46961790 --- /dev/null +++ b/components/Announcement.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import React, { MouseEventHandler } from "react"; + +type Props = { + toggleAnnouncementBar: MouseEventHandler; +}; + +export default function AnnouncementBar({ toggleAnnouncementBar }: Props) { + const announcementId = localStorage.getItem("announcementId"); + + return ( +
    +
    + +

    + See what's new in{" "} + + Linkwarden {announcementId} + + ! +

    + +
    +
    + ); +} diff --git a/components/AnnouncementBar.tsx b/components/AnnouncementBar.tsx deleted file mode 100644 index 57b7e921..00000000 --- a/components/AnnouncementBar.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Link from "next/link"; -import React, { MouseEventHandler } from "react"; - -type Props = { - toggleAnnouncementBar: MouseEventHandler; -}; - -export default function AnnouncementBar({ toggleAnnouncementBar }: Props) { - return ( -
    -
    -
    - 🎉️ See what's new in{" "} - - Linkwarden v2.5 - - ! 🥳️ -
    - - -
    -
    - ); -} diff --git a/components/InstallApp.tsx b/components/InstallApp.tsx new file mode 100644 index 00000000..b8bcf843 --- /dev/null +++ b/components/InstallApp.tsx @@ -0,0 +1,51 @@ +import { isPWA } from "@/lib/client/utils"; +import React, { useState } from "react"; + +type Props = {}; + +const InstallApp = (props: Props) => { + const [isOpen, setIsOpen] = useState(true); + + return isOpen && !isPWA() ? ( +
    +
    + + + + + +

    + Install Linkwarden to your home screen for a faster access and + enhanced experience.{" "} + + Learn more + +

    + +
    +
    + ) : ( + <> + ); +}; + +export default InstallApp; diff --git a/components/Navbar.tsx b/components/Navbar.tsx index f826f9de..344793b1 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -4,7 +4,7 @@ import Sidebar from "@/components/Sidebar"; import { useRouter } from "next/router"; import SearchBar from "@/components/SearchBar"; import useWindowDimensions from "@/hooks/useWindowDimensions"; -import ToggleDarkMode from "./ToggleDarkMode"; +import ToggleDarkMode from "./ui/ToggleDarkMode"; import NewLinkModal from "./ModalContent/NewLinkModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal"; import UploadFileModal from "./ModalContent/UploadFileModal"; diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx new file mode 100644 index 00000000..2494abbd --- /dev/null +++ b/components/ui/Button.tsx @@ -0,0 +1,60 @@ +import { cn } from "@/lib/client/utils"; + +import { cva, type VariantProps } from "class-variance-authority"; + +const buttonVariants = cva( + "select-none relative duration-200 rounded-lg text-center w-fit flex justify-center items-center gap-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + intent: { + accent: + "bg-accent text-white hover:bg-accent/80 border border-violet-400", + primary: "bg-primary text-primary-content hover:bg-primary/80", + secondary: + "bg-neutral-content text-secondary-foreground hover:bg-neutral-content/80 border border-neutral/30", + destructive: "bg-error text-white hover:bg-error/80", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-content", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + small: "h-9 px-3", + medium: "h-10 px-4 py-2", + full: "px-4 py-2 w-full", + icon: "h-10 w-10", + }, + loading: { + true: "cursor-wait", + }, + }, + defaultVariants: { + intent: "primary", + size: "medium", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button: React.FC = ({ + className, + intent, + size, + children, + disabled, + loading = false, + ...props +}) => ( + +); + +export default Button; diff --git a/components/ToggleDarkMode.tsx b/components/ui/ToggleDarkMode.tsx similarity index 100% rename from components/ToggleDarkMode.tsx rename to components/ui/ToggleDarkMode.tsx diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index 7f88b459..18c9a8af 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -9,7 +9,6 @@ interface Props { children: ReactNode; } -const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; export default function AuthRedirect({ children }: Props) { @@ -26,6 +25,8 @@ export default function AuthRedirect({ children }: Props) { const isPublicPage = router.pathname.startsWith("/public"); const hasInactiveSubscription = account.id && !account.subscription?.active && stripeEnabled; + + // There are better ways of doing this... but this one works for now const routes = [ { path: "/login", isProtected: false }, { path: "/register", isProtected: false }, @@ -53,9 +54,7 @@ export default function AuthRedirect({ children }: Props) { redirectTo("/dashboard"); } else if ( isUnauthenticated && - !routes.some( - (e) => router.pathname.startsWith(e.path) && !e.isProtected - ) + routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected) ) { redirectTo("/login"); } else { diff --git a/layouts/MainLayout.tsx b/layouts/MainLayout.tsx index a1178a50..2c606e54 100644 --- a/layouts/MainLayout.tsx +++ b/layouts/MainLayout.tsx @@ -1,5 +1,5 @@ import Navbar from "@/components/Navbar"; -import AnnouncementBar from "@/components/AnnouncementBar"; +import Announcement from "@/components/Announcement"; import Sidebar from "@/components/Sidebar"; import { ReactNode, useEffect, useState } from "react"; import getLatestVersion from "@/lib/client/getLatestVersion"; @@ -33,27 +33,20 @@ export default function MainLayout({ children }: Props) { }; return ( - <> +
    {showAnnouncement ? ( - + ) : undefined} - -
    -
    - -
    - -
    - - {children} -
    +
    +
    - + +
    + + {children} +
    +
    ); } diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index 2b035284..2c2d81f4 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -129,7 +129,8 @@ export default async function updateUserById( // Verify password if (!user.password) { return { - response: "User has no password.", + response: + "User has no password. Please reset your password from the forgot password page.", status: 400, }; } diff --git a/lib/client/utils.ts b/lib/client/utils.ts index 7d139c55..e0679333 100644 --- a/lib/client/utils.ts +++ b/lib/client/utils.ts @@ -18,3 +18,7 @@ export function dropdownTriggerer(e: any) { }, 0); } } + +import clsx, { ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; +export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes)); diff --git a/package.json b/package.json index 13d0fd9f..8222cb2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkwarden", - "version": "v2.5.4", + "version": "v2.6.0", "main": "index.js", "repository": "https://github.com/linkwarden/linkwarden.git", "author": "Daniel31X13 ", @@ -36,6 +36,8 @@ "axios": "^1.5.1", "bcrypt": "^5.1.0", "bootstrap-icons": "^1.11.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", "colorthief": "^2.4.0", "concurrently": "^8.2.2", "crypto-js": "^4.2.0", @@ -67,6 +69,7 @@ "react-spinners": "^0.13.8", "socks-proxy-agent": "^8.0.2", "stripe": "^12.13.0", + "tailwind-merge": "^2.3.0", "vaul": "^0.8.8", "zustand": "^4.3.8" }, diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index a67bdf19..91fe7a79 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -608,6 +608,9 @@ if (process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true") { GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + httpOptions: { + timeout: 10000, + }, }) ); @@ -1162,20 +1165,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) { }, callbacks: { async signIn({ user, account, profile, email, credentials }) { - // console.log( - // "User sign in attempt...", - // "User", - // user, - // "Account", - // account, - // "Profile", - // profile, - // "Email", - // email, - // "Credentials", - // credentials - // ); - if (account?.provider !== "credentials") { // registration via SSO can be separately disabled const existingUser = await prisma.account.findFirst({ diff --git a/pages/auth/reset-password.tsx b/pages/auth/reset-password.tsx index 138d6ffe..44d6abfa 100644 --- a/pages/auth/reset-password.tsx +++ b/pages/auth/reset-password.tsx @@ -1,4 +1,4 @@ -import AccentSubmitButton from "@/components/AccentSubmitButton"; +import AccentSubmitButton from "@/components/ui/Button"; import TextInput from "@/components/TextInput"; import CenteredForm from "@/layouts/CenteredForm"; import Link from "next/link"; @@ -96,10 +96,13 @@ export default function ResetPassword() { + > + Update Password + ) : ( <> diff --git a/pages/forgot.tsx b/pages/forgot.tsx index 70dc8af3..ff2ce9c2 100644 --- a/pages/forgot.tsx +++ b/pages/forgot.tsx @@ -1,4 +1,4 @@ -import AccentSubmitButton from "@/components/AccentSubmitButton"; +import AccentSubmitButton from "@/components/ui/Button"; import TextInput from "@/components/TextInput"; import CenteredForm from "@/layouts/CenteredForm"; import Link from "next/link"; @@ -88,10 +88,13 @@ export default function Forgot() { + > + Send Login Link + ) : (

    diff --git a/pages/login.tsx b/pages/login.tsx index 90d26d1d..e92cbde0 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -1,4 +1,4 @@ -import AccentSubmitButton from "@/components/AccentSubmitButton"; +import AccentSubmitButton from "@/components/ui/Button"; import TextInput from "@/components/TextInput"; import CenteredForm from "@/layouts/CenteredForm"; import { signIn } from "next-auth/react"; @@ -7,6 +7,7 @@ import React, { useState, FormEvent } from "react"; import { toast } from "react-hot-toast"; import { getLogins } from "./api/v1/logins"; import { InferGetServerSidePropsType } from "next"; +import InstallApp from "@/components/InstallApp"; interface FormData { username: string; @@ -118,33 +119,42 @@ export default function Login({

    + > + Login + {availableLogins.buttonAuths.length > 0 ? ( -
    OR
    +
    Or continue with
    ) : undefined} ); } } + function displayLoginExternalButton() { const Buttons: any = []; availableLogins.buttonAuths.forEach((value, index) => { Buttons.push( - {index !== 0 ?
    OR
    : undefined} + {index !== 0 ?
    Or
    : undefined} loginUserButton(value.method)} - label={`Sign in with ${value.name}`} - className=" w-full text-center" + size="full" + intent="secondary" loading={submitLoader} - /> + > + {value.name.toLowerCase() === "google" || + value.name.toLowerCase() === "apple" ? ( + + ) : undefined} + {value.name} +
    ); }); @@ -178,15 +188,9 @@ export default function Login({ {displayLoginCredential()} {displayLoginExternalButton()} {displayRegistration()} - - You can install Linkwarden onto your device -
    + ); } diff --git a/pages/public/collections/[id].tsx b/pages/public/collections/[id].tsx index 05574a7f..289c4c40 100644 --- a/pages/public/collections/[id].tsx +++ b/pages/public/collections/[id].tsx @@ -12,7 +12,7 @@ import Head from "next/head"; import useLinks from "@/hooks/useLinks"; import useLinkStore from "@/store/links"; import ProfilePhoto from "@/components/ProfilePhoto"; -import ToggleDarkMode from "@/components/ToggleDarkMode"; +import ToggleDarkMode from "@/components/ui/ToggleDarkMode"; import getPublicUserData from "@/lib/client/getPublicUserData"; import Image from "next/image"; import Link from "next/link"; @@ -118,8 +118,9 @@ export default function PublicCollections() {
    {collection ? ( diff --git a/pages/register.tsx b/pages/register.tsx index c3d63b15..e0c53043 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -1,11 +1,13 @@ import Link from "next/link"; -import { useState, FormEvent } from "react"; +import React, { useState, FormEvent } from "react"; import { toast } from "react-hot-toast"; import { signIn } from "next-auth/react"; import { useRouter } from "next/router"; import CenteredForm from "@/layouts/CenteredForm"; import TextInput from "@/components/TextInput"; -import AccentSubmitButton from "@/components/AccentSubmitButton"; +import AccentSubmitButton from "@/components/ui/Button"; +import { getLogins } from "./api/v1/logins"; +import { InferGetServerSidePropsType } from "next"; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; @@ -17,7 +19,14 @@ type FormData = { passwordConfirmation: string; }; -export default function Register() { +export const getServerSideProps = () => { + const availableLogins = getLogins(); + return { props: { availableLogins } }; +}; + +export default function Register({ + availableLogins, +}: InferGetServerSidePropsType) { const [submitLoader, setSubmitLoader] = useState(false); const router = useRouter(); @@ -98,6 +107,44 @@ export default function Register() { } } + async function loginUserButton(method: string) { + setSubmitLoader(true); + + const load = toast.loading("Authenticating..."); + + const res = await signIn(method, {}); + + toast.dismiss(load); + + setSubmitLoader(false); + } + + function displayLoginExternalButton() { + const Buttons: any = []; + availableLogins.buttonAuths.forEach((value, index) => { + Buttons.push( + + {index !== 0 ?
    Or
    : undefined} + + loginUserButton(value.method)} + size="full" + intent="secondary" + loading={submitLoader} + > + {value.name.toLowerCase() === "google" || + value.name.toLowerCase() === "apple" ? ( + + ) : undefined} + {value.name} + +
    + ); + }); + return Buttons; + } + return ( + > + Sign Up + + + {availableLogins.buttonAuths.length > 0 ? ( +
    Or continue with
    + ) : undefined} + + {displayLoginExternalButton()}

    Already have an account?

    Yearly

    -
    +
    25% Off
    @@ -98,11 +98,13 @@ export default function Subscribe() { + > + Complete Subscription! +
    signOut()} diff --git a/styles/globals.css b/styles/globals.css index 256106b8..1f97b6e2 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -255,32 +255,6 @@ border-radius: 8px; } -.rainbow { - background: linear-gradient( - 45deg, - #ff00004b, - #ff99004b, - #33cc334b, - #0099cc4b, - #9900cc4b, - #ff33cc4b - ); - background-size: 400% 400%; - animation: rainbow 30s linear infinite; -} - -@keyframes rainbow { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - .custom-file-input::file-selector-button { cursor: pointer; } diff --git a/yarn.lock b/yarn.lock index d6ebd45b..fea71549 100644 --- a/yarn.lock +++ b/yarn.lock @@ -666,6 +666,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.24.1": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.5.tgz#230946857c053a36ccc66e1dd03b17dd0c4ed02c" + integrity sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/types@^7.18.6": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.0.tgz#1da00d89c2f18b226c9207d96edbeb79316a1819" @@ -2551,6 +2558,13 @@ chownr@^2.0.0: resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== +class-variance-authority@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.0.tgz#1c3134d634d80271b1837452b06d821915954522" + integrity sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A== + dependencies: + clsx "2.0.0" + client-only@0.0.1, client-only@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" @@ -2565,6 +2579,16 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" +clsx@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" + integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== + +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -5751,6 +5775,13 @@ synckit@^0.8.4: "@pkgr/utils" "^2.3.1" tslib "^2.5.0" +tailwind-merge@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.3.0.tgz#27d2134fd00a1f77eca22bcaafdd67055917d286" + integrity sha512-vkYrLpIP+lgR0tQCG6AP7zZXCTLc1Lnv/CCRT3BqJ9CZ3ui2++GPaGb1x/ILsINIMSYqqvrpqjUFsMNLlW99EA== + dependencies: + "@babel/runtime" "^7.24.1" + tailwindcss@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf" From d262041f3368d8dd97b57e089b71d9fcf8d87572 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Fri, 24 May 2024 17:12:47 -0400 Subject: [PATCH 62/79] refactor code to improve readability and maintainability + redesigned announcement bar --- .../{Announcement.tsx => AnnouncementBar.tsx} | 0 components/DashboardItem.tsx | 6 +- components/LinkViews/Layouts/CardView.tsx | 2 +- components/LinkViews/LinkCard.tsx | 96 ++++++++++--------- .../LinkComponents/LinkTypeBadge.tsx | 6 +- components/LinkViews/LinkMasonry.tsx | 43 ++++----- components/ModalContent/DeleteLinkModal.tsx | 8 +- components/ModalContent/DeleteUserModal.tsx | 8 +- .../EmailChangeVerificationModal.tsx | 11 +-- components/ModalContent/NewTokenModal.tsx | 8 +- components/Navbar.tsx | 2 +- components/{ui => }/ToggleDarkMode.tsx | 0 components/ui/Button.tsx | 5 +- layouts/AuthRedirect.tsx | 2 + layouts/MainLayout.tsx | 2 +- .../users/userId/deleteUserById.ts | 9 +- pages/dashboard.tsx | 38 ++++---- pages/public/collections/[id].tsx | 2 +- pages/settings/account.tsx | 93 ++++++++++-------- pages/settings/billing.tsx | 6 +- pages/settings/delete.tsx | 19 ++-- 21 files changed, 191 insertions(+), 175 deletions(-) rename components/{Announcement.tsx => AnnouncementBar.tsx} (100%) rename components/{ui => }/ToggleDarkMode.tsx (100%) diff --git a/components/Announcement.tsx b/components/AnnouncementBar.tsx similarity index 100% rename from components/Announcement.tsx rename to components/AnnouncementBar.tsx diff --git a/components/DashboardItem.tsx b/components/DashboardItem.tsx index 60a5fe45..337fc367 100644 --- a/components/DashboardItem.tsx +++ b/components/DashboardItem.tsx @@ -9,12 +9,12 @@ export default function dashboardItem({ }) { return (
    -
    - +
    +

    {name}

    -

    {value}

    +

    {value}

    ); diff --git a/components/LinkViews/Layouts/CardView.tsx b/components/LinkViews/Layouts/CardView.tsx index 892e3ff6..45ea8f86 100644 --- a/components/LinkViews/Layouts/CardView.tsx +++ b/components/LinkViews/Layouts/CardView.tsx @@ -13,7 +13,7 @@ export default function CardView({ isLoading?: boolean; }) { return ( -
    +
    {links.map((e, i) => { return (
    !editMode && window.open(generateLinkHref(link, account), "_blank") } > -
    - {previewAvailable(link) ? ( - { - const target = e.target as HTMLElement; - target.style.display = "none"; - }} - /> - ) : link.preview === "unavailable" ? ( -
    - ) : ( -
    - )} -
    - -
    -
    - -
    - -
    -

    - {unescapeString(link.name || link.description) || link.url} -

    - - -
    - -
    - -
    -
    - {collection && ( - +
    +
    + {previewAvailable(link) ? ( + { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + ) : link.preview === "unavailable" ? ( +
    + ) : ( +
    + )} + {link.type !== "image" && ( +
    + +
    )}
    - + +
    +
    + +
    +
    +

    + {unescapeString(link.name)} +

    + + +
    + +
    +
    + +
    +
    + {collection && ( + + )} +
    + +
    +
    diff --git a/components/LinkViews/LinkComponents/LinkTypeBadge.tsx b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx index b7563e46..0088bae9 100644 --- a/components/LinkViews/LinkComponents/LinkTypeBadge.tsx +++ b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx @@ -25,14 +25,12 @@ export default function LinkTypeBadge({ onClick={(e) => { e.stopPropagation(); }} - className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100" + className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100" >

    {shortendURL}

    ) : ( -
    - {link.type} -
    +
    {link.type}
    ); } diff --git a/components/LinkViews/LinkMasonry.tsx b/components/LinkViews/LinkMasonry.tsx index e3315a5c..165c43f7 100644 --- a/components/LinkViews/LinkMasonry.tsx +++ b/components/LinkViews/LinkMasonry.tsx @@ -30,7 +30,6 @@ type Props = { }; export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { - const viewMode = localStorage.getItem("viewMode") || "card"; const { collections } = useCollectionStore(); const { account } = useAccountStore(); @@ -141,6 +140,9 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { height={720} alt="" className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105" + style={ + link.type !== "image" ? { filter: "blur(1px)" } : undefined + } draggable="false" onError={(e) => { const target = e.target as HTMLElement; @@ -150,28 +152,29 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { ) : link.preview === "unavailable" ? null : (
    )} + {link.type !== "image" && ( +
    + +
    + )}
    {link.preview !== "unavailable" && (
    )} -
    -
    -
    - -
    -

    {unescapeString(link.name)}

    +
    +

    + {unescapeString(link.name)} +

    - {link.description && ( -

    {unescapeString(link.description)}

    - )} - -
    + + + {link.description && ( +

    + {unescapeString(link.description)} +

    + )} {link.tags[0] && (
    @@ -193,12 +196,8 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
    -
    -
    - {collection && ( - - )} -
    +
    + {collection && }
    diff --git a/components/ModalContent/DeleteLinkModal.tsx b/components/ModalContent/DeleteLinkModal.tsx index 1a3a4764..b884dab7 100644 --- a/components/ModalContent/DeleteLinkModal.tsx +++ b/components/ModalContent/DeleteLinkModal.tsx @@ -4,6 +4,7 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import toast from "react-hot-toast"; import Modal from "../Modal"; import { useRouter } from "next/router"; +import Button from "../ui/Button"; type Props = { onClose: Function; @@ -59,13 +60,10 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) { 'Delete' to bypass this confirmation in the future.

    - +
    ); diff --git a/components/ModalContent/DeleteUserModal.tsx b/components/ModalContent/DeleteUserModal.tsx index cf1d3b98..3c4c1a5e 100644 --- a/components/ModalContent/DeleteUserModal.tsx +++ b/components/ModalContent/DeleteUserModal.tsx @@ -1,6 +1,7 @@ import toast from "react-hot-toast"; import Modal from "../Modal"; import useUserStore from "@/store/admin/users"; +import Button from "../ui/Button"; type Props = { onClose: Function; @@ -38,13 +39,10 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
    - +
    ); diff --git a/components/ModalContent/EmailChangeVerificationModal.tsx b/components/ModalContent/EmailChangeVerificationModal.tsx index 20fe0ef2..595b06f8 100644 --- a/components/ModalContent/EmailChangeVerificationModal.tsx +++ b/components/ModalContent/EmailChangeVerificationModal.tsx @@ -30,12 +30,11 @@ export default function EmailChangeVerificationModal({ "Updating this field will change your billing email on Stripe as well."}

    - {process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true" && ( -

    - If you change your email address, any existing Google SSO - connections will be removed. -

    - )} +

    + If you change your email address, any existing{" "} + {process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true" && "Google"} SSO + connections will be removed. +

    Old Email

    diff --git a/components/ModalContent/NewTokenModal.tsx b/components/ModalContent/NewTokenModal.tsx index 2092eaae..a8be992c 100644 --- a/components/ModalContent/NewTokenModal.tsx +++ b/components/ModalContent/NewTokenModal.tsx @@ -5,6 +5,7 @@ import toast from "react-hot-toast"; import Modal from "../Modal"; import useTokenStore from "@/store/tokens"; import { dropdownTriggerer } from "@/lib/client/utils"; +import Button from "../ui/Button"; type Props = { onClose: Function; @@ -90,18 +91,19 @@ export default function NewTokenModal({ onClose }: Props) {

    Expires in

    -
    {token.expires === TokenExpiry.sevenDays && "7 Days"} {token.expires === TokenExpiry.oneMonth && "30 Days"} {token.expires === TokenExpiry.twoMonths && "60 Days"} {token.expires === TokenExpiry.threeMonths && "90 Days"} {token.expires === TokenExpiry.never && "No Expiration"} -
    +
    -
    -
    - +
    + -
    +
    - + -
    +
    - -
    +
    diff --git a/pages/public/collections/[id].tsx b/pages/public/collections/[id].tsx index 289c4c40..836d57a2 100644 --- a/pages/public/collections/[id].tsx +++ b/pages/public/collections/[id].tsx @@ -12,7 +12,7 @@ import Head from "next/head"; import useLinks from "@/hooks/useLinks"; import useLinkStore from "@/store/links"; import ProfilePhoto from "@/components/ProfilePhoto"; -import ToggleDarkMode from "@/components/ui/ToggleDarkMode"; +import ToggleDarkMode from "@/components/ToggleDarkMode"; import getPublicUserData from "@/lib/client/getPublicUserData"; import Image from "next/image"; import Link from "next/link"; diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index 05fe14c1..83181710 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -13,6 +13,7 @@ import Link from "next/link"; import Checkbox from "@/components/Checkbox"; import { dropdownTriggerer } from "@/lib/client/utils"; import EmailChangeVerificationModal from "@/components/ModalContent/EmailChangeVerificationModal"; +import Button from "@/components/ui/Button"; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; @@ -203,37 +204,56 @@ export default function Account() {

    Profile Photo

    -
    +
    - {user.image && ( -
    - setUser({ - ...user, - image: "", - }) - } - className="absolute top-1 left-1 btn btn-xs btn-circle btn-neutral btn-outline bg-base-100" + +
    +
    - )} -
    - + + Edit + +
      +
    • + +
    • + {user.image && ( +
    • +
      + setUser({ + ...user, + image: "", + }) + } + > + Remove Photo +
      +
    • + )} +
    @@ -293,16 +313,18 @@ export default function Account() {

    Import your data from other platforms.

    -
    -

    Import From

    -
    + Import From + +
    diff --git a/pages/settings/billing.tsx b/pages/settings/billing.tsx index 139c74b2..c130fb3b 100644 --- a/pages/settings/billing.tsx +++ b/pages/settings/billing.tsx @@ -21,6 +21,7 @@ export default function Billing() { Billing Portal @@ -30,10 +31,7 @@ export default function Billing() {

    If you still need help or encountered any issues, feel free to reach out to us at:{" "} - + support@linkwarden.app

    diff --git a/pages/settings/delete.tsx b/pages/settings/delete.tsx index 0c789327..a2ee3e64 100644 --- a/pages/settings/delete.tsx +++ b/pages/settings/delete.tsx @@ -4,6 +4,7 @@ import TextInput from "@/components/TextInput"; import CenteredForm from "@/layouts/CenteredForm"; import { signOut, useSession } from "next-auth/react"; import Link from "next/link"; +import Button from "@/components/ui/Button"; const keycloakEnabled = process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === "true"; const authentikEnabled = process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true"; @@ -135,20 +136,14 @@ export default function Delete() { ) : undefined} - +
    ); From f310cd79adf78185956a4b70c5f06b30be14e49a Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Fri, 24 May 2024 19:13:04 -0400 Subject: [PATCH 63/79] refactor components --- .../{AnnouncementBar.tsx => Announcement.tsx} | 2 +- .../LinkComponents/LinkTypeBadge.tsx | 2 +- components/LinkViews/LinkList.tsx | 21 +++--- .../ModalContent/BulkDeleteLinksModal.tsx | 8 +-- .../ModalContent/DeleteCollectionModal.tsx | 14 ++-- components/ui/Button.tsx | 1 + layouts/MainLayout.tsx | 2 +- .../controllers/dashboard/getDashboardData.ts | 4 +- pages/collections/[id].tsx | 2 +- pages/dashboard.tsx | 64 ++++++++++--------- pages/links/index.tsx | 2 +- pages/links/pinned.tsx | 14 +++- pages/search.tsx | 2 +- pages/tags/[id].tsx | 2 +- 14 files changed, 74 insertions(+), 66 deletions(-) rename components/{AnnouncementBar.tsx => Announcement.tsx} (93%) diff --git a/components/AnnouncementBar.tsx b/components/Announcement.tsx similarity index 93% rename from components/AnnouncementBar.tsx rename to components/Announcement.tsx index 46961790..8fd42fed 100644 --- a/components/AnnouncementBar.tsx +++ b/components/Announcement.tsx @@ -5,7 +5,7 @@ type Props = { toggleAnnouncementBar: MouseEventHandler; }; -export default function AnnouncementBar({ toggleAnnouncementBar }: Props) { +export default function Announcement({ toggleAnnouncementBar }: Props) { const announcementId = localStorage.getItem("announcementId"); return ( diff --git a/components/LinkViews/LinkComponents/LinkTypeBadge.tsx b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx index 0088bae9..5a47c683 100644 --- a/components/LinkViews/LinkComponents/LinkTypeBadge.tsx +++ b/components/LinkViews/LinkComponents/LinkTypeBadge.tsx @@ -25,7 +25,7 @@ export default function LinkTypeBadge({ onClick={(e) => { e.stopPropagation(); }} - className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100" + className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100 max-w-full w-fit" >

    {shortendURL}

    diff --git a/components/LinkViews/LinkList.tsx b/components/LinkViews/LinkList.tsx index 1f13a5f7..10b008d3 100644 --- a/components/LinkViews/LinkList.tsx +++ b/components/LinkViews/LinkList.tsx @@ -91,7 +91,7 @@ export default function LinkCardCompact({
    selectable ? handleCheckboxClick(link) @@ -117,29 +117,32 @@ export default function LinkCardCompact({ /> )} */}
    !editMode && window.open(generateLinkHref(link, account), "_blank") } >
    - +

    - {unescapeString(link.name || link.description) || link.url} + {link.name ? ( + unescapeString(link.name) + ) : ( +

    + +
    + )}

    -
    +
    {collection ? ( ) : undefined} - + {link.name && }
    diff --git a/components/ModalContent/BulkDeleteLinksModal.tsx b/components/ModalContent/BulkDeleteLinksModal.tsx index 6de26cdc..28c565cc 100644 --- a/components/ModalContent/BulkDeleteLinksModal.tsx +++ b/components/ModalContent/BulkDeleteLinksModal.tsx @@ -2,6 +2,7 @@ import React from "react"; import useLinkStore from "@/store/links"; import toast from "react-hot-toast"; import Modal from "../Modal"; +import Button from "../ui/Button"; type Props = { onClose: Function; @@ -62,13 +63,10 @@ export default function BulkDeleteLinksModal({ onClose }: Props) { 'Delete' to bypass this confirmation in the future.

    - +
    ); diff --git a/components/ModalContent/DeleteCollectionModal.tsx b/components/ModalContent/DeleteCollectionModal.tsx index 5407f687..65485532 100644 --- a/components/ModalContent/DeleteCollectionModal.tsx +++ b/components/ModalContent/DeleteCollectionModal.tsx @@ -6,6 +6,7 @@ import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { useRouter } from "next/router"; import usePermissions from "@/hooks/usePermissions"; import Modal from "../Modal"; +import Button from "../ui/Button"; type Props = { onClose: Function; @@ -96,20 +97,15 @@ export default function DeleteCollectionModal({

    Click the button below to leave the current collection.

    )} - +
    ); diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index 728511a6..65a38172 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -22,6 +22,7 @@ const buttonVariants = cva( size: { small: "h-7 px-2", medium: "h-10 px-4 py-2", + large: "h-12 px-7 py-2", full: "px-4 py-2 w-full", icon: "h-10 w-10", }, diff --git a/layouts/MainLayout.tsx b/layouts/MainLayout.tsx index dd1ea525..2c606e54 100644 --- a/layouts/MainLayout.tsx +++ b/layouts/MainLayout.tsx @@ -1,5 +1,5 @@ import Navbar from "@/components/Navbar"; -import Announcement from "@/components/AnnouncementBar"; +import Announcement from "@/components/Announcement"; import Sidebar from "@/components/Sidebar"; import { ReactNode, useEffect, useState } from "react"; import getLatestVersion from "@/lib/client/getLatestVersion"; diff --git a/lib/api/controllers/dashboard/getDashboardData.ts b/lib/api/controllers/dashboard/getDashboardData.ts index b4dfbb09..3b0f7517 100644 --- a/lib/api/controllers/dashboard/getDashboardData.ts +++ b/lib/api/controllers/dashboard/getDashboardData.ts @@ -14,7 +14,7 @@ export default async function getDashboardData( else if (query.sort === Sort.DescriptionZA) order = { description: "desc" }; const pinnedLinks = await prisma.link.findMany({ - take: 8, + take: 10, where: { AND: [ { @@ -46,7 +46,7 @@ export default async function getDashboardData( }); const recentlyAddedLinks = await prisma.link.findMany({ - take: 8, + take: 10, where: { collection: { OR: [ diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index 046c8556..10a7d2d5 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -380,7 +380,7 @@ export default function Index() { ? bulkDeleteLinks() : setBulkDeleteLinksModal(true); }} - className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto" + className="btn btn-sm bg-red-500 hover:bg-red-400 text-white w-fit ml-auto" disabled={ selectedLinks.length === 0 || !(permissions === true || permissions?.canDelete) diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 318cebf8..1131ba19 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -41,12 +41,14 @@ export default function Dashboard() { const handleNumberOfLinksToShow = () => { if (window.innerWidth > 1900) { + setShowLinks(10); + } else if (window.innerWidth > 1500) { setShowLinks(8); - } else if (window.innerWidth > 1280) { + } else if (window.innerWidth > 880) { setShowLinks(6); - } else if (window.innerWidth > 650) { + } else if (window.innerWidth > 550) { setShowLinks(4); - } else setShowLinks(3); + } else setShowLinks(2); }; const { width } = useWindowDimensions(); @@ -120,28 +122,30 @@ export default function Dashboard() {
    -
    - +
    +
    + -
    +
    - + -
    +
    - + +
    @@ -162,7 +166,7 @@ export default function Dashboard() {
    {links[0] ? ( @@ -170,10 +174,7 @@ export default function Dashboard() {
    ) : ( -
    +

    View Your Recently Added Links Here!

    @@ -189,8 +190,8 @@ export default function Dashboard() { }} className="inline-flex items-center gap-2 text-sm btn btn-accent dark:border-violet-400 text-white" > - - + + Add New Link
    @@ -200,7 +201,7 @@ export default function Dashboard() { tabIndex={0} role="button" onMouseDown={dropdownTriggerer} - className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral" + className="inline-flex items-center gap-2 text-sm btn bg-neutral-content text-secondary-foreground hover:bg-neutral-content/80 border border-neutral/30 hover:border hover:border-neutral/30" id="import-dropdown" > @@ -286,12 +287,13 @@ export default function Dashboard() { ) : (
    +

    Pin Your Favorite Links Here!

    -

    +

    You can Pin your favorite Links by clicking on the three dots on each Link and clicking{" "} Pin to Dashboard. diff --git a/pages/links/index.tsx b/pages/links/index.tsx index d617d15d..e64ccb44 100644 --- a/pages/links/index.tsx +++ b/pages/links/index.tsx @@ -154,7 +154,7 @@ export default function Links() { ? bulkDeleteLinks() : setBulkDeleteLinksModal(true); }} - className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto" + className="btn btn-sm bg-red-500 hover:bg-red-400 text-white w-fit ml-auto" disabled={ selectedLinks.length === 0 || !( diff --git a/pages/links/pinned.tsx b/pages/links/pinned.tsx index a56af063..a6315736 100644 --- a/pages/links/pinned.tsx +++ b/pages/links/pinned.tsx @@ -151,7 +151,7 @@ export default function PinnedLinks() { ? bulkDeleteLinks() : setBulkDeleteLinksModal(true); }} - className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto" + className="btn btn-sm bg-red-500 hover:bg-red-400 text-white w-fit ml-auto" disabled={ selectedLinks.length === 0 || !( @@ -171,12 +171,20 @@ export default function PinnedLinks() { ) : (

    + + +

    Pin Your Favorite Links Here!

    -

    +

    You can Pin your favorite Links by clicking on the three dots on each Link and clicking{" "} Pin to Dashboard. diff --git a/pages/search.tsx b/pages/search.tsx index 27e23617..67d9893c 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -178,7 +178,7 @@ export default function Search() { ? bulkDeleteLinks() : setBulkDeleteLinksModal(true); }} - className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto" + className="btn btn-sm bg-red-500 hover:bg-red-400 text-white w-fit ml-auto" disabled={ selectedLinks.length === 0 || !( diff --git a/pages/tags/[id].tsx b/pages/tags/[id].tsx index 1623c8f4..fbe68357 100644 --- a/pages/tags/[id].tsx +++ b/pages/tags/[id].tsx @@ -308,7 +308,7 @@ export default function Index() { ? bulkDeleteLinks() : setBulkDeleteLinksModal(true); }} - className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto" + className="btn btn-sm bg-red-500 hover:bg-red-400 text-white w-fit ml-auto" disabled={ selectedLinks.length === 0 || !( From fc66dac933549c0f41e62d3f0d1609d772b59c7c Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Fri, 24 May 2024 19:15:33 -0400 Subject: [PATCH 64/79] small change --- components/ModalContent/RevokeTokenModal.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/components/ModalContent/RevokeTokenModal.tsx b/components/ModalContent/RevokeTokenModal.tsx index 9aef2d92..7f105415 100644 --- a/components/ModalContent/RevokeTokenModal.tsx +++ b/components/ModalContent/RevokeTokenModal.tsx @@ -5,6 +5,7 @@ import Modal from "../Modal"; import { useRouter } from "next/router"; import { AccessToken } from "@prisma/client"; import useTokenStore from "@/store/tokens"; +import Button from "../ui/Button"; type Props = { onClose: Function; @@ -49,13 +50,10 @@ export default function DeleteTokenModal({ onClose, activeToken }: Props) { using it.

    - +
    ); From cb50de96a3f5143b8a14bad3bdb571b7679c64a2 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Fri, 24 May 2024 19:47:45 -0400 Subject: [PATCH 65/79] minor fix --- components/LinkViews/LinkCard.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/components/LinkViews/LinkCard.tsx b/components/LinkViews/LinkCard.tsx index fa6b6c64..6385a9ab 100644 --- a/components/LinkViews/LinkCard.tsx +++ b/components/LinkViews/LinkCard.tsx @@ -162,8 +162,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
    )}
    - -
    +
    @@ -191,7 +190,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
    {showInfo && ( -
    +
    setShowInfo(!showInfo)} className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10" @@ -200,7 +199,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {

    Description

    -
    +

    {link.description ? ( unescapeString(link.description) @@ -214,7 +213,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) { <>

    Tags

    -
    +
    From bcb6aea119cadbcc478cdffdcf406281efb24b73 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Fri, 24 May 2024 20:41:31 -0400 Subject: [PATCH 66/79] names should be auto generated instead of descriptions + add default value to name field --- components/ModalContent/NewLinkModal.tsx | 4 +- lib/api/controllers/links/postLink.ts | 17 ++--- .../migration.sql | 2 + prisma/schema.prisma | 2 +- scripts/migration/descriptionToName.js | 70 +++++++++++++++++++ 5 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20240525002215_add_default_value_to_link_name/migration.sql create mode 100644 scripts/migration/descriptionToName.js diff --git a/components/ModalContent/NewLinkModal.tsx b/components/ModalContent/NewLinkModal.tsx index 46c9ffe9..756f0e4f 100644 --- a/components/ModalContent/NewLinkModal.tsx +++ b/components/ModalContent/NewLinkModal.tsx @@ -155,7 +155,7 @@ export default function NewLinkModal({ onClose }: Props) { setLink({ ...link, name: e.target.value })} - placeholder="e.g. Example Link" + placeholder="Will be auto generated if left empty." className="bg-base-200" />
    @@ -177,7 +177,7 @@ export default function NewLinkModal({ onClose }: Props) { onChange={(e) => setLink({ ...link, description: e.target.value }) } - placeholder="Will be auto generated if nothing is provided." + placeholder="Notes, thoughts, etc." 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" />
    diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index 4c42f6cc..7f77aeb3 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -160,12 +160,13 @@ export default async function postLink( link.collection.name = link.collection.name.trim(); - const description = - link.description && link.description !== "" - ? link.description - : link.url - ? await getTitle(link.url) - : undefined; + const title = + !(link.name && link.name !== "") && link.url + ? await getTitle(link.url) + : ""; + + const name = + link.name && link.name !== "" ? link.name : link.url ? title : ""; const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined; @@ -184,8 +185,8 @@ export default async function postLink( const newLink = await prisma.link.create({ data: { url: link.url?.trim().replace(/\/+$/, "") || null, - name: link.name, - description, + name, + description: link.description, type: linkType, collection: { connect: { diff --git a/prisma/migrations/20240525002215_add_default_value_to_link_name/migration.sql b/prisma/migrations/20240525002215_add_default_value_to_link_name/migration.sql new file mode 100644 index 00000000..b91c845e --- /dev/null +++ b/prisma/migrations/20240525002215_add_default_value_to_link_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Link" ALTER COLUMN "name" SET DEFAULT ''; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bd4e9824..6bc32004 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -123,7 +123,7 @@ model UsersAndCollections { model Link { id Int @id @default(autoincrement()) - name String + name String @default("") type String @default("url") description String @default("") pinnedBy User[] diff --git a/scripts/migration/descriptionToName.js b/scripts/migration/descriptionToName.js new file mode 100644 index 00000000..16b85cc3 --- /dev/null +++ b/scripts/migration/descriptionToName.js @@ -0,0 +1,70 @@ +// [Optional, but recommended] + +// We decided that the "name" field should be the auto-generated field instead of the "description" field, so we need to +// move the data from the "description" field to the "name" field for links that have an empty name. + +// This script is meant to be run only once. + +const { PrismaClient } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function main() { + console.log("Starting..."); + + const count = await prisma.link.count({ + where: { + name: "", + description: { + not: "", + }, + }, + }); + + console.log( + `Applying the changes to ${count} ${ + count == 1 ? "link" : "links" + } in 10 seconds...` + ); + + await new Promise((resolve) => setTimeout(resolve, 10000)); + + console.log("Applying the changes..."); + + const links = await prisma.link.findMany({ + where: { + name: "", + description: { + not: "", + }, + }, + select: { + id: true, + description: true, + }, + }); + + for (const link of links) { + await prisma.link.update({ + where: { + id: link.id, + }, + data: { + name: link.description, + description: "", + }, + }); + } + + console.log("Done!"); +} + +main() + .catch((e) => { + throw e; + }) + .finally(async () => { + await prisma.$disconnect(); + }); + +// Run the script with `node scripts/migration/descriptionToName.js` From 75d91fbac7f5c1e5f25110ad4cde534347112882 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Fri, 24 May 2024 20:42:27 -0400 Subject: [PATCH 67/79] minor change --- scripts/migration/descriptionToName.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/migration/descriptionToName.js b/scripts/migration/descriptionToName.js index 16b85cc3..435e59ff 100644 --- a/scripts/migration/descriptionToName.js +++ b/scripts/migration/descriptionToName.js @@ -5,6 +5,8 @@ // This script is meant to be run only once. +// Run the script with `node scripts/migration/descriptionToName.js` + const { PrismaClient } = require("@prisma/client"); const prisma = new PrismaClient(); @@ -66,5 +68,3 @@ main() .finally(async () => { await prisma.$disconnect(); }); - -// Run the script with `node scripts/migration/descriptionToName.js` From b0ea14737f9d0304f90ab687ba092ff64b1ec691 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Sat, 25 May 2024 18:26:24 -0400 Subject: [PATCH 68/79] added new import format --- .../migration/importFromLinkwarden.ts | 2 +- .../migration/importFromWallabag.ts | 115 ++++++++++++++++++ pages/api/v1/migration/index.ts | 6 +- pages/settings/account.tsx | 20 +++ types/global.ts | 1 + 5 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 lib/api/controllers/migration/importFromWallabag.ts diff --git a/lib/api/controllers/migration/importFromLinkwarden.ts b/lib/api/controllers/migration/importFromLinkwarden.ts index 3f91f19d..fb486cb0 100644 --- a/lib/api/controllers/migration/importFromLinkwarden.ts +++ b/lib/api/controllers/migration/importFromLinkwarden.ts @@ -54,7 +54,7 @@ export default async function importFromLinkwarden( // Import Links for (const link of e.links) { - const newLink = await prisma.link.create({ + await prisma.link.create({ data: { url: link.url, name: link.name, diff --git a/lib/api/controllers/migration/importFromWallabag.ts b/lib/api/controllers/migration/importFromWallabag.ts new file mode 100644 index 00000000..6f9f4046 --- /dev/null +++ b/lib/api/controllers/migration/importFromWallabag.ts @@ -0,0 +1,115 @@ +import { prisma } from "@/lib/api/db"; +import { Backup } from "@/types/global"; +import createFolder from "@/lib/api/storage/createFolder"; + +const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; + +type WallabagBackup = { + is_archived: number; + is_starred: number; + tags: String[]; + is_public: boolean; + id: number; + title: string; + url: string; + content: string; + created_at: Date; + updated_at: Date; + published_by: string[]; + starred_at: Date; + annotations: any[]; + mimetype: string; + language: string; + reading_time: number; + domain_name: string; + preview_picture: string; + http_status: string; + headers: Record; +}[]; + +export default async function importFromWallabag( + userId: number, + rawData: string +) { + const data: WallabagBackup = JSON.parse(rawData); + + const backup = data.filter((e) => e.url); + + let totalImports = backup.length; + + const numberOfLinksTheUserHas = await prisma.link.count({ + where: { + collection: { + ownerId: userId, + }, + }, + }); + + if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER) + return { + response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`, + status: 400, + }; + + await prisma + .$transaction( + async () => { + const newCollection = await prisma.collection.create({ + data: { + owner: { + connect: { + id: userId, + }, + }, + name: "Imports", + }, + }); + + createFolder({ filePath: `archives/${newCollection.id}` }); + + for (const link of backup) { + await prisma.link.create({ + data: { + pinnedBy: link.is_starred + ? { connect: { id: userId } } + : undefined, + url: link.url, + name: link.title || "", + textContent: link.content || "", + importDate: link.created_at || null, + collection: { + connect: { + id: newCollection.id, + }, + }, + tags: + link.tags && link.tags[0] + ? { + connectOrCreate: link.tags.map((tag) => ({ + where: { + name_ownerId: { + name: tag.trim(), + ownerId: userId, + }, + }, + create: { + name: tag.trim(), + owner: { + connect: { + id: userId, + }, + }, + }, + })), + } + : undefined, + }, + }); + } + }, + { timeout: 30000 } + ) + .catch((err) => console.log(err)); + + return { response: "Success.", status: 200 }; +} diff --git a/pages/api/v1/migration/index.ts b/pages/api/v1/migration/index.ts index bfba4532..0bf3d767 100644 --- a/pages/api/v1/migration/index.ts +++ b/pages/api/v1/migration/index.ts @@ -4,6 +4,7 @@ import importFromHTMLFile from "@/lib/api/controllers/migration/importFromHTMLFi import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden"; import { MigrationFormat, MigrationRequest } from "@/types/global"; import verifyUser from "@/lib/api/verifyUser"; +import importFromWallabag from "@/lib/api/controllers/migration/importFromWallabag"; export const config = { api: { @@ -32,9 +33,10 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { let data; if (request.format === MigrationFormat.htmlFile) data = await importFromHTMLFile(user.id, request.data); - - if (request.format === MigrationFormat.linkwarden) + else if (request.format === MigrationFormat.linkwarden) data = await importFromLinkwarden(user.id, request.data); + else if (request.format === MigrationFormat.wallabag) + data = await importFromWallabag(user.id, request.data); if (data) return res.status(data.status).json({ response: data.response }); } diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index 83181710..50202852 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -366,6 +366,26 @@ export default function Account() { />
  • +
  • + +
  • diff --git a/types/global.ts b/types/global.ts index 3e842239..fbdaea2a 100644 --- a/types/global.ts +++ b/types/global.ts @@ -116,6 +116,7 @@ export type MigrationRequest = { export enum MigrationFormat { linkwarden, htmlFile, + wallabag, } export enum Plan { From 17cdb7efa433bc19723613a16a40d41b5f201add Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Mon, 27 May 2024 17:42:29 -0400 Subject: [PATCH 69/79] initial commit for i18n --- next-i18next.config.js | 7 ++++ next.config.js | 2 ++ package.json | 3 ++ pages/_app.tsx | 8 +++-- pages/admin.tsx | 62 +++++++++++++++++++++++++++++++++-- public/locales/de/common.json | 3 ++ public/locales/en/common.json | 3 ++ yarn.lock | 57 +++++++++++++++++++++++++++++++- 8 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 next-i18next.config.js create mode 100644 public/locales/de/common.json create mode 100644 public/locales/en/common.json diff --git a/next-i18next.config.js b/next-i18next.config.js new file mode 100644 index 00000000..02de282b --- /dev/null +++ b/next-i18next.config.js @@ -0,0 +1,7 @@ +/** @type {import('next-i18next').UserConfig} */ +module.exports = { + i18n: { + defaultLocale: "en", + locales: ["en", "de"], + }, +}; diff --git a/next.config.js b/next.config.js index 665803ec..79d2d0ff 100644 --- a/next.config.js +++ b/next.config.js @@ -1,7 +1,9 @@ /** @type {import('next').NextConfig} */ const { version } = require("./package.json"); +const { i18n } = require("./next-i18next.config"); const nextConfig = { + i18n, reactStrictMode: true, images: { // For fetching the favicons diff --git a/package.json b/package.json index 8222cb2a..0045802c 100644 --- a/package.json +++ b/package.json @@ -50,12 +50,14 @@ "framer-motion": "^10.16.4", "handlebars": "^4.7.8", "himalaya": "^1.1.0", + "i18next": "^23.11.5", "jimp": "^0.22.10", "jsdom": "^22.1.0", "lottie-web": "^5.12.2", "micro": "^10.0.1", "next": "13.4.12", "next-auth": "^4.22.1", + "next-i18next": "^15.3.0", "node-fetch": "^2.7.0", "nodemailer": "^6.9.3", "playwright": "^1.43.1", @@ -63,6 +65,7 @@ "react-colorful": "^5.6.1", "react-dom": "18.2.0", "react-hot-toast": "^2.4.1", + "react-i18next": "^14.1.2", "react-image-file-resizer": "^0.4.8", "react-masonry-css": "^1.0.16", "react-select": "^5.7.4", diff --git a/pages/_app.tsx b/pages/_app.tsx index 9c6b5277..0cb79ffe 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -9,9 +9,11 @@ import toast from "react-hot-toast"; import { Toaster, ToastBar } from "react-hot-toast"; import { Session } from "next-auth"; import { isPWA } from "@/lib/client/utils"; -import useInitialData from "@/hooks/useInitialData"; +// import useInitialData from "@/hooks/useInitialData"; +import { appWithTranslation } from "next-i18next"; +import nextI18nextConfig from "../next-i18next.config"; -export default function App({ +function App({ Component, pageProps, }: AppProps<{ @@ -96,6 +98,8 @@ export default function App({ ); } +export default appWithTranslation(App); + // function GetData({ children }: { children: React.ReactNode }) { // const status = useInitialData(); // return typeof window !== "undefined" && status !== "loading" ? ( diff --git a/pages/admin.tsx b/pages/admin.tsx index d7b9fdaa..35e14ed9 100644 --- a/pages/admin.tsx +++ b/pages/admin.tsx @@ -3,7 +3,12 @@ import NewUserModal from "@/components/ModalContent/NewUserModal"; import useUserStore from "@/store/admin/users"; import { User as U } from "@prisma/client"; import Link from "next/link"; -import { Fragment, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "next-i18next"; +import { GetServerSideProps } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { useRouter } from "next/router"; +import { i18n } from "next-i18next.config"; interface User extends U { subscriptions: { @@ -17,6 +22,10 @@ type UserModal = { }; export default function Admin() { + const { t } = useTranslation(); + + const router = useRouter(); + const { users, setUsers } = useUserStore(); const [searchQuery, setSearchQuery] = useState(""); @@ -30,6 +39,7 @@ export default function Admin() { const [newUserModal, setNewUserModal] = useState(false); useEffect(() => { + console.log(router); setUsers(); }, []); @@ -44,7 +54,7 @@ export default function Admin() {

    - User Administration + {t("user_administration")}

    @@ -172,3 +182,51 @@ const UserListing = ( ); }; + +// Take this into a separate file, it's for logged out users +// For logged in users, we'll use their preferred language from the database (default: en) +export const getServerSideProps: GetServerSideProps = async (ctx) => { + console.log("CONTEXT", ctx); + + const acceptLanguageHeader = ctx.req.headers["accept-language"]; + const availableLanguages = i18n.locales; + + console.log("ACCEPT LANGUAGE", acceptLanguageHeader); + console.log("AVAILABLE LANGUAGES", availableLanguages); + + // Parse the accept-language header to get an array of languages + const acceptedLanguages = acceptLanguageHeader + ?.split(",") + .map((lang) => lang.split(";")[0]); + + console.log(acceptedLanguages); + + // Find the best match between the accepted languages and available languages + let bestMatch = acceptedLanguages?.find((lang) => + availableLanguages.includes(lang) + ); + + console.log(bestMatch); + + // If no direct match, find the best partial match + if (!bestMatch) { + acceptedLanguages?.some((acceptedLang) => { + const partialMatch = availableLanguages.find((lang) => + lang.startsWith(acceptedLang) + ); + if (partialMatch) { + bestMatch = partialMatch; + return true; + } + return false; + }); + } + + console.log("BEST MATCH", bestMatch); + + return { + props: { + ...(await serverSideTranslations(bestMatch ?? "en", ["common"])), + }, + }; +}; diff --git a/public/locales/de/common.json b/public/locales/de/common.json new file mode 100644 index 00000000..b4cce72e --- /dev/null +++ b/public/locales/de/common.json @@ -0,0 +1,3 @@ +{ + "user_administration": "TEST, TEST, TEST" +} \ No newline at end of file diff --git a/public/locales/en/common.json b/public/locales/en/common.json new file mode 100644 index 00000000..ff33803a --- /dev/null +++ b/public/locales/en/common.json @@ -0,0 +1,3 @@ +{ + "user_administration": "User Administration" +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index fea71549..55074b61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -666,6 +666,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9": + version "7.24.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.6.tgz#5b76eb89ad45e2e4a0a8db54c456251469a3358e" + integrity sha512-Ja18XcETdEl5mzzACGd+DKgaGJzPTCow7EglgwTmHdwokzDFYh/MHua6lU6DV/hjF2IaOJ4oX2nqnjG7RElKOw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.24.1": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.5.tgz#230946857c053a36ccc66e1dd03b17dd0c4ed02c" @@ -1960,7 +1967,7 @@ "@types/minimatch" "*" "@types/node" "*" -"@types/hoist-non-react-statics@^3.3.0": +"@types/hoist-non-react-statics@^3.3.0", "@types/hoist-non-react-statics@^3.3.4": version "3.3.5" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg== @@ -2683,6 +2690,11 @@ core-js@^2.6.12: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== +core-js@^3: + version "3.37.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.37.1.tgz#d21751ddb756518ac5a00e4d66499df981a62db9" + integrity sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw== + core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -3833,6 +3845,13 @@ html-encoding-sniffer@^3.0.0: dependencies: whatwg-encoding "^2.0.0" +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + http-errors@1.7.3: version "1.7.3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" @@ -3870,6 +3889,18 @@ https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: agent-base "6" debug "4" +i18next-fs-backend@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-2.3.1.tgz#0c7d2459ff4a039e2b3228131809fbc0e74ff1a8" + integrity sha512-tvfXskmG/9o+TJ5Fxu54sSO5OkY6d+uMn+K6JiUGLJrwxAVfer+8V3nU8jq3ts9Pe5lXJv4b1N7foIjJ8Iy2Gg== + +i18next@^23.11.5: + version "23.11.5" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.11.5.tgz#d71eb717a7e65498d87d0594f2664237f9e361ef" + integrity sha512-41pvpVbW9rhZPk5xjCX2TPJi2861LEig/YRhUkY+1FQ2IQPS0bKUDYnEqY8XPPbB48h1uIwLnP9iiEfuSl20CA== + dependencies: + "@babel/runtime" "^7.23.2" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -4569,6 +4600,17 @@ next-auth@^4.22.1: preact-render-to-string "^5.1.19" uuid "^8.3.2" +next-i18next@^15.3.0: + version "15.3.0" + resolved "https://registry.yarnpkg.com/next-i18next/-/next-i18next-15.3.0.tgz#b4530c80573854d00f95229af405e1e5beedbf18" + integrity sha512-bq7Cc9XJFcmGOCLnyEtHaeJ3+JJNsI/8Pkj9BaHAnhm4sZ9vNNC4ZsaqYnlRZ7VH5ypSo73fEqLK935jLsmCvQ== + dependencies: + "@babel/runtime" "^7.23.2" + "@types/hoist-non-react-statics" "^3.3.4" + core-js "^3" + hoist-non-react-statics "^3.3.2" + i18next-fs-backend "^2.3.1" + next@13.4.12: version "13.4.12" resolved "https://registry.yarnpkg.com/next/-/next-13.4.12.tgz#809b21ea0aabbe88ced53252c88c4a5bd5af95df" @@ -5195,6 +5237,14 @@ react-hot-toast@^2.4.1: dependencies: goober "^2.1.10" +react-i18next@^14.1.2: + version "14.1.2" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-14.1.2.tgz#cd57a755f25a32a5fcc3dbe546cf3cc62b4f3ebd" + integrity sha512-FSIcJy6oauJbGEXfhUgVeLzvWBhIBIS+/9c6Lj4niwKZyGaGb4V4vUbATXSlsHJDXXB+ociNxqFNiFuV1gmoqg== + dependencies: + "@babel/runtime" "^7.23.9" + html-parse-stringify "^3.0.1" + react-image-file-resizer@^0.4.8: version "0.4.8" resolved "https://registry.yarnpkg.com/react-image-file-resizer/-/react-image-file-resizer-0.4.8.tgz#85f4ae4469fd2867d961568af660ef403d7a79af" @@ -6176,6 +6226,11 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + w3c-xmlserializer@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" From deb6ed7ec8fb24140c7a4502449adf7a4a364113 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 28 May 2024 15:55:19 -0400 Subject: [PATCH 70/79] code refactoring + add locale field to user table --- lib/client/getServerSideProps.ts | 64 +++++++++++++++++++ pages/admin.tsx | 56 +--------------- .../migration.sql | 2 + prisma/schema.prisma | 5 +- 4 files changed, 71 insertions(+), 56 deletions(-) create mode 100644 lib/client/getServerSideProps.ts create mode 100644 prisma/migrations/20240528194553_added_locale_field_for_users/migration.sql diff --git a/lib/client/getServerSideProps.ts b/lib/client/getServerSideProps.ts new file mode 100644 index 00000000..5f4027c0 --- /dev/null +++ b/lib/client/getServerSideProps.ts @@ -0,0 +1,64 @@ +import { GetServerSideProps } from "next"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { i18n } from "next-i18next.config"; +import { getToken } from "next-auth/jwt"; +import { prisma } from "../api/db"; + +// Keep this in a separate file, it's for logged out users +// For logged in users, we'll use their preferred language from the database (default: en) +const getServerSideProps: GetServerSideProps = async (ctx) => { + const acceptLanguageHeader = ctx.req.headers["accept-language"]; + const availableLanguages = i18n.locales; + + // Check if it's a logged in user + // If it is, get the user's preferred language from the database + const token = await getToken({ req: ctx.req }); + + if (token) { + const user = await prisma.user.findUnique({ + where: { + id: token.id, + }, + }); + + if (user) { + return { + props: { + ...(await serverSideTranslations(user.locale, ["common"])), + }, + }; + } + } + + // Parse the accept-language header to get an array of languages + const acceptedLanguages = acceptLanguageHeader + ?.split(",") + .map((lang) => lang.split(";")[0]); + + // Find the best match between the accepted languages and available languages + let bestMatch = acceptedLanguages?.find((lang) => + availableLanguages.includes(lang) + ); + + // If no direct match, find the best partial match + if (!bestMatch) { + acceptedLanguages?.some((acceptedLang) => { + const partialMatch = availableLanguages.find((lang) => + lang.startsWith(acceptedLang) + ); + if (partialMatch) { + bestMatch = partialMatch; + return true; + } + return false; + }); + } + + return { + props: { + ...(await serverSideTranslations(bestMatch ?? "en", ["common"])), + }, + }; +}; + +export default getServerSideProps; diff --git a/pages/admin.tsx b/pages/admin.tsx index 35e14ed9..3341fb8a 100644 --- a/pages/admin.tsx +++ b/pages/admin.tsx @@ -5,10 +5,7 @@ import { User as U } from "@prisma/client"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useTranslation } from "next-i18next"; -import { GetServerSideProps } from "next"; -import { serverSideTranslations } from "next-i18next/serverSideTranslations"; -import { useRouter } from "next/router"; -import { i18n } from "next-i18next.config"; +import getServerSideProps from "@/lib/client/getServerSideProps"; interface User extends U { subscriptions: { @@ -24,8 +21,6 @@ type UserModal = { export default function Admin() { const { t } = useTranslation(); - const router = useRouter(); - const { users, setUsers } = useUserStore(); const [searchQuery, setSearchQuery] = useState(""); @@ -39,7 +34,6 @@ export default function Admin() { const [newUserModal, setNewUserModal] = useState(false); useEffect(() => { - console.log(router); setUsers(); }, []); @@ -183,50 +177,4 @@ const UserListing = ( ); }; -// Take this into a separate file, it's for logged out users -// For logged in users, we'll use their preferred language from the database (default: en) -export const getServerSideProps: GetServerSideProps = async (ctx) => { - console.log("CONTEXT", ctx); - - const acceptLanguageHeader = ctx.req.headers["accept-language"]; - const availableLanguages = i18n.locales; - - console.log("ACCEPT LANGUAGE", acceptLanguageHeader); - console.log("AVAILABLE LANGUAGES", availableLanguages); - - // Parse the accept-language header to get an array of languages - const acceptedLanguages = acceptLanguageHeader - ?.split(",") - .map((lang) => lang.split(";")[0]); - - console.log(acceptedLanguages); - - // Find the best match between the accepted languages and available languages - let bestMatch = acceptedLanguages?.find((lang) => - availableLanguages.includes(lang) - ); - - console.log(bestMatch); - - // If no direct match, find the best partial match - if (!bestMatch) { - acceptedLanguages?.some((acceptedLang) => { - const partialMatch = availableLanguages.find((lang) => - lang.startsWith(acceptedLang) - ); - if (partialMatch) { - bestMatch = partialMatch; - return true; - } - return false; - }); - } - - console.log("BEST MATCH", bestMatch); - - return { - props: { - ...(await serverSideTranslations(bestMatch ?? "en", ["common"])), - }, - }; -}; +export { getServerSideProps }; diff --git a/prisma/migrations/20240528194553_added_locale_field_for_users/migration.sql b/prisma/migrations/20240528194553_added_locale_field_for_users/migration.sql new file mode 100644 index 00000000..6523faa9 --- /dev/null +++ b/prisma/migrations/20240528194553_added_locale_field_for_users/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'en'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6bc32004..b1af7174 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,13 +33,14 @@ model User { emailVerified DateTime? unverifiedNewEmail String? image String? - accounts Account[] password String? + locale String @default("en") + accounts Account[] collections Collection[] tags Tag[] pinnedLinks Link[] collectionsJoined UsersAndCollections[] - collectionOrder Int[] @default([]) + collectionOrder Int[] @default([]) whitelistedUsers WhitelistedUser[] accessTokens AccessToken[] subscriptions Subscription? From f921ecaa96cbf0e9e1679e2f18c01e345e6f00fd Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 28 May 2024 17:10:25 -0400 Subject: [PATCH 71/79] add language selection to the settings page --- .../users/userId/updateUserById.ts | 2 ++ lib/client/getServerSideProps.ts | 2 +- pages/settings/account.tsx | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index 2c2d81f4..b6e8bea5 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -5,6 +5,7 @@ import removeFile from "@/lib/api/storage/removeFile"; import createFile from "@/lib/api/storage/createFile"; import createFolder from "@/lib/api/storage/createFolder"; import sendChangeEmailVerificationRequest from "@/lib/api/sendChangeEmailVerificationRequest"; +import { i18n } from "next-i18next.config"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; @@ -204,6 +205,7 @@ export default async function updateUserById( collectionOrder: data.collectionOrder.filter( (value, index, self) => self.indexOf(value) === index ), + locale: i18n.locales.includes(data.locale) ? data.locale : "en", archiveAsScreenshot: data.archiveAsScreenshot, archiveAsPDF: data.archiveAsPDF, archiveAsWaybackMachine: data.archiveAsWaybackMachine, diff --git a/lib/client/getServerSideProps.ts b/lib/client/getServerSideProps.ts index 5f4027c0..44957d68 100644 --- a/lib/client/getServerSideProps.ts +++ b/lib/client/getServerSideProps.ts @@ -24,7 +24,7 @@ const getServerSideProps: GetServerSideProps = async (ctx) => { if (user) { return { props: { - ...(await serverSideTranslations(user.locale, ["common"])), + ...(await serverSideTranslations(user.locale ?? "en", ["common"])), }, }; } diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index 50202852..2d0c2b36 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -14,6 +14,7 @@ import Checkbox from "@/components/Checkbox"; import { dropdownTriggerer } from "@/lib/client/utils"; import EmailChangeVerificationModal from "@/components/ModalContent/EmailChangeVerificationModal"; import Button from "@/components/ui/Button"; +import { i18n } from "next-i18next.config"; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; @@ -200,6 +201,28 @@ export default function Account() { /> ) : undefined} + +
    +

    Language

    + +
    From adcc4e85ace8a29cae4dd9016d42d4395dd4375e Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 28 May 2024 17:12:10 -0400 Subject: [PATCH 72/79] remove comments --- lib/client/getServerSideProps.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/client/getServerSideProps.ts b/lib/client/getServerSideProps.ts index 44957d68..b208f3e7 100644 --- a/lib/client/getServerSideProps.ts +++ b/lib/client/getServerSideProps.ts @@ -4,14 +4,10 @@ import { i18n } from "next-i18next.config"; import { getToken } from "next-auth/jwt"; import { prisma } from "../api/db"; -// Keep this in a separate file, it's for logged out users -// For logged in users, we'll use their preferred language from the database (default: en) const getServerSideProps: GetServerSideProps = async (ctx) => { const acceptLanguageHeader = ctx.req.headers["accept-language"]; const availableLanguages = i18n.locales; - // Check if it's a logged in user - // If it is, get the user's preferred language from the database const token = await getToken({ req: ctx.req }); if (token) { @@ -30,17 +26,14 @@ const getServerSideProps: GetServerSideProps = async (ctx) => { } } - // Parse the accept-language header to get an array of languages const acceptedLanguages = acceptLanguageHeader ?.split(",") .map((lang) => lang.split(";")[0]); - // Find the best match between the accepted languages and available languages let bestMatch = acceptedLanguages?.find((lang) => availableLanguages.includes(lang) ); - // If no direct match, find the best partial match if (!bestMatch) { acceptedLanguages?.some((acceptedLang) => { const partialMatch = availableLanguages.find((lang) => From 83cd9f6a065768499b8f3ec91799ff21370819b4 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 28 May 2024 17:14:10 -0400 Subject: [PATCH 73/79] minor fix --- components/Announcement.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Announcement.tsx b/components/Announcement.tsx index 8fd42fed..caf1b73c 100644 --- a/components/Announcement.tsx +++ b/components/Announcement.tsx @@ -9,7 +9,7 @@ export default function Announcement({ toggleAnnouncementBar }: Props) { const announcementId = localStorage.getItem("announcementId"); return ( -
    +

    From 2c87459f352f2e49e930dde8d7e25a48033b8a3e Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 28 May 2024 17:16:27 -0400 Subject: [PATCH 74/79] remove experimental locales --- next-i18next.config.js | 2 +- public/locales/de/common.json | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 public/locales/de/common.json diff --git a/next-i18next.config.js b/next-i18next.config.js index 02de282b..d74375dd 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -2,6 +2,6 @@ module.exports = { i18n: { defaultLocale: "en", - locales: ["en", "de"], + locales: ["en"], }, }; diff --git a/public/locales/de/common.json b/public/locales/de/common.json deleted file mode 100644 index b4cce72e..00000000 --- a/public/locales/de/common.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "user_administration": "TEST, TEST, TEST" -} \ No newline at end of file From d261bd39ecabef02411baa3d2406ecf355cad795 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 4 Jun 2024 16:59:49 -0400 Subject: [PATCH 75/79] added internationalization to pages [WIP] --- components/LinkListOptions.tsx | 203 +++++++++++++++++ components/SortDropdown.tsx | 26 +-- components/UserListing.tsx | 87 ++++++++ layouts/AuthRedirect.tsx | 1 + layouts/CenteredForm.tsx | 2 +- .../controllers/links/bulk/deleteLinksById.ts | 1 - lib/client/addMemberToCollection.ts | 8 +- next-i18next.config.js | 1 + pages/admin.tsx | 77 +------ pages/api/v1/auth/[...nextauth].ts | 39 +--- pages/auth/reset-password.tsx | 33 ++- pages/auth/verify-email.tsx | 9 +- pages/collections/[id].tsx | 203 +++++------------ pages/collections/index.tsx | 19 +- pages/confirmation.tsx | 23 +- pages/dashboard.tsx | 70 ++++-- pages/forgot.tsx | 27 ++- pages/links/index.tsx | 163 ++------------ pages/links/pinned.tsx | 165 ++------------ pages/login.tsx | 120 ++++++++-- pages/public/collections/[id].tsx | 88 ++++---- pages/register.tsx | 203 +++++++++++------ pages/search.tsx | 176 ++------------- pages/settings/access-tokens.tsx | 23 +- pages/settings/account.tsx | 121 +++++----- pages/settings/billing.tsx | 16 +- pages/settings/delete.tsx | 97 ++++---- pages/settings/password.tsx | 38 ++-- pages/settings/preference.tsx | 97 ++++---- pages/subscribe.tsx | 70 ++++-- pages/tags/[id].tsx | 146 ++---------- public/locales/en/common.json | 210 +++++++++++++++++- 32 files changed, 1299 insertions(+), 1263 deletions(-) create mode 100644 components/LinkListOptions.tsx create mode 100644 components/UserListing.tsx diff --git a/components/LinkListOptions.tsx b/components/LinkListOptions.tsx new file mode 100644 index 00000000..880e91da --- /dev/null +++ b/components/LinkListOptions.tsx @@ -0,0 +1,203 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; +import FilterSearchDropdown from "./FilterSearchDropdown"; +import SortDropdown from "./SortDropdown"; +import ViewDropdown from "./ViewDropdown"; +import { TFunction } from "i18next"; +import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal"; +import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal"; +import toast from "react-hot-toast"; +import useCollectivePermissions from "@/hooks/useCollectivePermissions"; +import { useRouter } from "next/router"; +import useLinkStore from "@/store/links"; +import { Sort } from "@/types/global"; + +type Props = { + children: React.ReactNode; + t: TFunction<"translation", undefined>; + viewMode: string; + setViewMode: Dispatch>; + searchFilter?: { + name: boolean; + url: boolean; + description: boolean; + tags: boolean; + textContent: boolean; + }; + setSearchFilter?: (filter: { + name: boolean; + url: boolean; + description: boolean; + tags: boolean; + textContent: boolean; + }) => void; + sortBy: Sort; + setSortBy: Dispatch>; + editMode?: boolean; + setEditMode?: (mode: boolean) => void; +}; + +const LinkListOptions = ({ + children, + t, + viewMode, + setViewMode, + searchFilter, + setSearchFilter, + sortBy, + setSortBy, + editMode, + setEditMode, +}: Props) => { + const { links, selectedLinks, setSelectedLinks, deleteLinksById } = + useLinkStore(); + + const router = useRouter(); + + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + + useEffect(() => { + if (editMode && setEditMode) return setEditMode(false); + }, [router]); + + const collectivePermissions = useCollectivePermissions( + selectedLinks.map((link) => link.collectionId as number) + ); + + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading(t("deleting_selections")); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + selectedLinks.length === 1 + ? t("link_deleted") + : t("links_deleted", { count: selectedLinks.length }) + ); + }; + + return ( + <> +

    + {children} + +
    +
    + {links.length > 0 && editMode !== undefined && setEditMode && ( +
    { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
    + )} + {searchFilter && setSearchFilter && ( + + )} + + +
    +
    +
    + + {editMode && links.length > 0 && ( +
    + {links.length > 0 && ( +
    + handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length === 1 + ? t("link_selected") + : t("links_selected", { count: selectedLinks.length })} + + ) : ( + {t("nothing_selected")} + )} +
    + )} +
    + + +
    +
    + )} + + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} + + ); +}; + +export default LinkListOptions; diff --git a/components/SortDropdown.tsx b/components/SortDropdown.tsx index 81d44968..002dac50 100644 --- a/components/SortDropdown.tsx +++ b/components/SortDropdown.tsx @@ -1,13 +1,15 @@ import React, { Dispatch, SetStateAction } from "react"; import { Sort } from "@/types/global"; import { dropdownTriggerer } from "@/lib/client/utils"; +import { TFunction } from "i18next"; type Props = { sortBy: Sort; setSort: Dispatch>; + t: TFunction<"translation", undefined>; }; -export default function SortDropdown({ sortBy, setSort }: Props) { +export default function SortDropdown({ sortBy, setSort, t }: Props) { return (
    { - setSort(Sort.DateNewestFirst); - }} + onChange={() => setSort(Sort.DateNewestFirst)} /> - Date (Newest First) + {t("date_newest_first")}
  • @@ -48,11 +47,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) { type="radio" name="sort-radio" className="radio checked:bg-primary" - value="Date (Oldest First)" checked={sortBy === Sort.DateOldestFirst} onChange={() => setSort(Sort.DateOldestFirst)} /> - Date (Oldest First) + {t("date_oldest_first")}
  • @@ -65,11 +63,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) { type="radio" name="sort-radio" className="radio checked:bg-primary" - value="Name (A-Z)" checked={sortBy === Sort.NameAZ} onChange={() => setSort(Sort.NameAZ)} /> - Name (A-Z) + {t("name_az")}
  • @@ -82,11 +79,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) { type="radio" name="sort-radio" className="radio checked:bg-primary" - value="Name (Z-A)" checked={sortBy === Sort.NameZA} onChange={() => setSort(Sort.NameZA)} /> - Name (Z-A) + {t("name_za")}
  • @@ -99,11 +95,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) { type="radio" name="sort-radio" className="radio checked:bg-primary" - value="Description (A-Z)" checked={sortBy === Sort.DescriptionAZ} onChange={() => setSort(Sort.DescriptionAZ)} /> - Description (A-Z) + {t("description_az")}
  • @@ -116,11 +111,10 @@ export default function SortDropdown({ sortBy, setSort }: Props) { type="radio" name="sort-radio" className="radio checked:bg-primary" - value="Description (Z-A)" checked={sortBy === Sort.DescriptionZA} onChange={() => setSort(Sort.DescriptionZA)} /> - Description (Z-A) + {t("description_za")}
  • diff --git a/components/UserListing.tsx b/components/UserListing.tsx new file mode 100644 index 00000000..378ad985 --- /dev/null +++ b/components/UserListing.tsx @@ -0,0 +1,87 @@ +import DeleteUserModal from "@/components/ModalContent/DeleteUserModal"; +import { User as U } from "@prisma/client"; +import { TFunction } from "i18next"; + +interface User extends U { + subscriptions: { + active: boolean; + }; +} + +type UserModal = { + isOpen: boolean; + userId: number | null; +}; + +const UserListing = ( + users: User[], + deleteUserModal: UserModal, + setDeleteUserModal: Function, + t: TFunction<"translation", undefined> +) => { + return ( +
    + + + + + + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && ( + + )} + + + + + + {users.map((user, index) => ( + + + + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && ( + + )} + + + + ))} + +
    {t("username")}{t("email")}{t("subscribed")}{t("created_at")}
    {index + 1} + {user.username ? user.username : {t("not_available")}} + {user.email} + {user.subscriptions?.active ? ( + + ) : ( + + )} + {new Date(user.createdAt).toLocaleString()} + +
    + + {deleteUserModal.isOpen && deleteUserModal.userId ? ( + setDeleteUserModal({ isOpen: false, userId: null })} + userId={deleteUserModal.userId} + /> + ) : null} +
    + ); +}; + +export default UserListing; diff --git a/layouts/AuthRedirect.tsx b/layouts/AuthRedirect.tsx index ddc5d8a2..fd0f8bae 100644 --- a/layouts/AuthRedirect.tsx +++ b/layouts/AuthRedirect.tsx @@ -42,6 +42,7 @@ export default function AuthRedirect({ children }: Props) { { path: "/tags", isProtected: true }, { path: "/preserved", isProtected: true }, { path: "/admin", isProtected: true }, + { path: "/search", isProtected: true }, ]; if (isPublicPage) { diff --git a/layouts/CenteredForm.tsx b/layouts/CenteredForm.tsx index 5960ffbd..ac402b81 100644 --- a/layouts/CenteredForm.tsx +++ b/layouts/CenteredForm.tsx @@ -1,7 +1,7 @@ import useLocalSettingsStore from "@/store/localSettings"; import Image from "next/image"; import Link from "next/link"; -import React, { ReactNode, useEffect } from "react"; +import React, { ReactNode } from "react"; interface Props { text?: string; diff --git a/lib/api/controllers/links/bulk/deleteLinksById.ts b/lib/api/controllers/links/bulk/deleteLinksById.ts index 2db38969..a9c395b3 100644 --- a/lib/api/controllers/links/bulk/deleteLinksById.ts +++ b/lib/api/controllers/links/bulk/deleteLinksById.ts @@ -1,7 +1,6 @@ import { prisma } from "@/lib/api/db"; import { UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; -import removeFile from "@/lib/api/storage/removeFile"; import { removeFiles } from "@/lib/api/manageLinkFiles"; export default async function deleteLinksById( diff --git a/lib/client/addMemberToCollection.ts b/lib/client/addMemberToCollection.ts index 8d1e0b12..4e03720e 100644 --- a/lib/client/addMemberToCollection.ts +++ b/lib/client/addMemberToCollection.ts @@ -1,12 +1,14 @@ import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global"; import getPublicUserData from "./getPublicUserData"; import { toast } from "react-hot-toast"; +import { TFunction } from "i18next"; const addMemberToCollection = async ( ownerUsername: string, memberUsername: string, collection: CollectionIncludingMembersAndLinkCount, - setMember: (newMember: Member) => null | undefined + setMember: (newMember: Member) => null | undefined, + t: TFunction<"translation", undefined> ) => { const checkIfMemberAlreadyExists = collection.members.find((e) => { const username = (e.user.username || "").toLowerCase(); @@ -39,9 +41,9 @@ const addMemberToCollection = async ( }, }); } - } else if (checkIfMemberAlreadyExists) toast.error("User already exists."); + } else if (checkIfMemberAlreadyExists) toast.error(t("user_already_member")); else if (memberUsername.trim().toLowerCase() === ownerUsername.toLowerCase()) - toast.error("You are already the collection owner."); + toast.error(t("you_are_already_collection_owner")); }; export default addMemberToCollection; diff --git a/next-i18next.config.js b/next-i18next.config.js index d74375dd..a163e4c4 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -4,4 +4,5 @@ module.exports = { defaultLocale: "en", locales: ["en"], }, + reloadOnPrerender: process.env.NODE_ENV === "development", }; diff --git a/pages/admin.tsx b/pages/admin.tsx index 3341fb8a..a11affce 100644 --- a/pages/admin.tsx +++ b/pages/admin.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { useTranslation } from "next-i18next"; import getServerSideProps from "@/lib/client/getServerSideProps"; +import UserListing from "@/components/UserListing"; interface User extends U { subscriptions: { @@ -64,7 +65,7 @@ export default function Admin() { { setSearchQuery(e.target.value); @@ -95,13 +96,13 @@ export default function Admin() {
    {filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? ( - UserListing(filteredUsers, deleteUserModal, setDeleteUserModal) + UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t) ) : searchQuery !== "" ? ( -

    No users found with the given search query.

    +

    {t("no_user_found_in_search")}

    ) : users && users.length > 0 ? ( - UserListing(users, deleteUserModal, setDeleteUserModal) + UserListing(users, deleteUserModal, setDeleteUserModal, t) ) : ( -

    No users found.

    +

    {t("no_users_found")}

    )} {newUserModal ? ( @@ -111,70 +112,4 @@ export default function Admin() { ); } -const UserListing = ( - users: User[], - deleteUserModal: UserModal, - setDeleteUserModal: Function -) => { - return ( -
    - - - - - - {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( - - )} - {process.env.NEXT_PUBLIC_STRIPE === "true" && } - - - - - - {users.map((user, index) => ( - - - - {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( - - )} - {process.env.NEXT_PUBLIC_STRIPE === "true" && ( - - )} - - - - ))} - -
    UsernameEmailSubscribedCreated At
    {index + 1}{user.username ? user.username : N/A}{user.email} - {user.subscriptions?.active ? ( - JSON.stringify(user.subscriptions?.active) - ) : ( - N/A - )} - {new Date(user.createdAt).toLocaleString()} - -
    - - {deleteUserModal.isOpen && deleteUserModal.userId ? ( - setDeleteUserModal({ isOpen: false, userId: null })} - userId={deleteUserModal.userId} - /> - ) : null} -
    - ); -}; - export { getServerSideProps }; diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index 91fe7a79..5f98479e 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -114,44 +114,7 @@ if ( if (!user) throw Error("Invalid credentials."); else if (!user?.emailVerified && emailEnabled) { - const identifier = user?.email as string; - const token = randomBytes(32).toString("hex"); - const url = `${ - process.env.NEXTAUTH_URL - }/callback/email?token=${token}&email=${encodeURIComponent( - identifier - )}`; - const from = process.env.EMAIL_FROM as string; - - 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."); - - sendVerificationRequest({ - identifier, - url, - from, - token, - }); - - await prisma.verificationToken.create({ - data: { - identifier, - token, - expires: new Date(Date.now() + 24 * 3600 * 1000), // 1 day - }, - }); - - throw Error("Email not verified. Verification email sent."); + throw Error("Email not verified."); } let passwordMatches: boolean = false; diff --git a/pages/auth/reset-password.tsx b/pages/auth/reset-password.tsx index 44d6abfa..9d5301e4 100644 --- a/pages/auth/reset-password.tsx +++ b/pages/auth/reset-password.tsx @@ -5,6 +5,8 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { FormEvent, useState } from "react"; import { toast } from "react-hot-toast"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useTranslation } from "next-i18next"; interface FormData { password: string; @@ -12,8 +14,8 @@ interface FormData { } export default function ResetPassword() { + const { t } = useTranslation(); const [submitLoader, setSubmitLoader] = useState(false); - const router = useRouter(); const [form, setForm] = useState({ @@ -34,7 +36,7 @@ export default function ResetPassword() { ) { setSubmitLoader(true); - const load = toast.loading("Sending password recovery link..."); + const load = toast.loading(t("sending_password_recovery_link")); const response = await fetch("/api/v1/auth/reset-password", { method: "POST", @@ -46,6 +48,7 @@ export default function ResetPassword() { const data = await response.json(); + toast.dismiss(load); if (response.ok) { toast.success(data.response); setRequestSent(true); @@ -53,11 +56,9 @@ export default function ResetPassword() { toast.error(data.response); } - toast.dismiss(load); - setSubmitLoader(false); } else { - toast.error("Please fill out all the fields."); + toast.error(t("please_fill_all_fields")); } } @@ -66,22 +67,18 @@ export default function ResetPassword() {

    - {requestSent ? "Password Updated!" : "Reset Password"} + {requestSent ? t("password_updated") : t("reset_password")}

    {!requestSent ? ( <> +

    {t("enter_email_for_new_password")}

    -

    - Enter your email so we can send you a link to create a new - password. +

    + {t("new_password")}

    -
    -
    -

    New Password

    -
    - - Update Password + {t("update_password")} ) : ( <> -

    Your password has been successfully updated.

    - +

    {t("password_successfully_updated")}

    - Back to Login + {t("back_to_login")}
    @@ -120,3 +115,5 @@ export default function ResetPassword() { ); } + +export { getServerSideProps }; diff --git a/pages/auth/verify-email.tsx b/pages/auth/verify-email.tsx index db3e9206..7b8e3b10 100644 --- a/pages/auth/verify-email.tsx +++ b/pages/auth/verify-email.tsx @@ -2,11 +2,14 @@ import { signOut } from "next-auth/react"; import { useRouter } from "next/router"; import { useEffect } from "react"; import toast from "react-hot-toast"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useTranslation } from "next-i18next"; const VerifyEmail = () => { const router = useRouter(); useEffect(() => { + const { t } = useTranslation(); const token = router.query.token; if (!token || typeof token !== "string") { @@ -19,12 +22,12 @@ const VerifyEmail = () => { method: "POST", }).then((res) => { if (res.ok) { - toast.success("Email verified. Signing out.."); + toast.success(t("email_verified_signing_out")); setTimeout(() => { signOut(); }, 3000); } else { - toast.error("Invalid token."); + toast.error(t("invalid_token")); } }); @@ -35,3 +38,5 @@ const VerifyEmail = () => { }; export default VerifyEmail; + +export { getServerSideProps }; diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index 10a7d2d5..58d474d2 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -9,7 +9,6 @@ import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; import MainLayout from "@/layouts/MainLayout"; import ProfilePhoto from "@/components/ProfilePhoto"; -import SortDropdown from "@/components/SortDropdown"; import useLinks from "@/hooks/useLinks"; import usePermissions from "@/hooks/usePermissions"; import NoLinksFound from "@/components/NoLinksFound"; @@ -19,23 +18,22 @@ import getPublicUserData from "@/lib/client/getPublicUserData"; import EditCollectionModal from "@/components/ModalContent/EditCollectionModal"; import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal"; import DeleteCollectionModal from "@/components/ModalContent/DeleteCollectionModal"; -import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import { dropdownTriggerer } from "@/lib/client/utils"; import NewCollectionModal from "@/components/ModalContent/NewCollectionModal"; -import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; -import toast from "react-hot-toast"; -import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useTranslation } from "next-i18next"; +import LinkListOptions from "@/components/LinkListOptions"; export default function Index() { + const { t } = useTranslation(); const { settings } = useLocalSettingsStore(); const router = useRouter(); - const { links, selectedLinks, setSelectedLinks, deleteLinksById } = - useLinkStore(); + const { links } = useLinkStore(); const { collections } = useCollectionStore(); const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); @@ -84,9 +82,6 @@ export default function Index() { }; fetchOwner(); - - // When the collection changes, reset the selected links - setSelectedLinks([]); }, [activeCollection]); const [editCollectionModal, setEditCollectionModal] = useState(false); @@ -94,8 +89,6 @@ export default function Index() { const [editCollectionSharingModal, setEditCollectionSharingModal] = useState(false); const [deleteCollectionModal, setDeleteCollectionModal] = useState(false); - const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); - const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); const [editMode, setEditMode] = useState(false); useEffect(() => { @@ -115,35 +108,6 @@ export default function Index() { // @ts-ignore const LinkComponent = linkView[viewMode]; - const handleSelectAll = () => { - if (selectedLinks.length === links.length) { - setSelectedLinks([]); - } else { - setSelectedLinks(links.map((link) => link)); - } - }; - - const bulkDeleteLinks = async () => { - const load = toast.loading( - `Deleting ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }...` - ); - - const response = await deleteLinksById( - selectedLinks.map((link) => link.id as number) - ); - - toast.dismiss(load); - - response.ok && - toast.success( - `Deleted ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }!` - ); - }; - return (
    - Edit Collection Info + {t("edit_collection_info")}
    )} @@ -201,8 +165,8 @@ export default function Index() { }} > {permissions === true - ? "Share and Collaborate" - : "View Team"} + ? t("share_and_collaborate") + : t("view_team")}
    {permissions === true && ( @@ -215,7 +179,7 @@ export default function Index() { setNewCollectionModal(true); }} > - Create Sub-Collection + {t("create_subcollection")}
    )} @@ -229,8 +193,8 @@ export default function Index() { }} > {permissions === true - ? "Delete Collection" - : "Leave Collection"} + ? t("delete_collection") + : t("leave_collection")}
    @@ -272,11 +236,23 @@ export default function Index() {
    ) : null}
    -

    - By {collectionOwner.name} + +

    {activeCollection.members.length > 0 && - ` and ${activeCollection.members.length} others`} - . + activeCollection.members.length === 1 + ? t("by_author_and_other", { + author: collectionOwner.name, + count: activeCollection.members.length, + }) + : activeCollection.members.length > 0 && + activeCollection.members.length !== 1 + ? t("by_author_and_others", { + author: collectionOwner.name, + count: activeCollection.members.length, + }) + : t("by_author", { + author: collectionOwner.name, + })}

    @@ -313,84 +289,37 @@ export default function Index() {
    -
    -

    Showing {activeCollection?._count?.links} results

    -
    - {links.length > 0 && - (permissions === true || - permissions?.canUpdate || - permissions?.canDelete) && ( -
    { - setEditMode(!editMode); - setSelectedLinks([]); - }} - className={`btn btn-square btn-sm btn-ghost ${ - editMode - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} - > - -
    - )} - - -
    -
    - - {editMode && links.length > 0 && ( -
    - {links.length > 0 && ( -
    - handleSelectAll()} - checked={ - selectedLinks.length === links.length && links.length > 0 - } - /> - {selectedLinks.length > 0 ? ( - - {selectedLinks.length}{" "} - {selectedLinks.length === 1 ? "link" : "links"} selected - - ) : ( - Nothing selected - )} -
    - )} -
    - - -
    -
    - )} + +

    + {activeCollection?._count?.links === 1 + ? t("showing_count_result", { + count: activeCollection?._count?.links, + }) + : t("showing_count_results", { + count: activeCollection?._count?.links, + })} +

    +
    {links.some((e) => e.collectionId === Number(router.query.id)) ? ( )} - {bulkDeleteLinksModal && ( - { - setBulkDeleteLinksModal(false); - }} - /> - )} - {bulkEditLinksModal && ( - { - setBulkEditLinksModal(false); - }} - /> - )} )} ); } + +export { getServerSideProps }; diff --git a/pages/collections/index.tsx b/pages/collections/index.tsx index f5ed526c..642e3dd4 100644 --- a/pages/collections/index.tsx +++ b/pages/collections/index.tsx @@ -8,8 +8,11 @@ import { Sort } from "@/types/global"; import useSort from "@/hooks/useSort"; import NewCollectionModal from "@/components/ModalContent/NewCollectionModal"; import PageHeader from "@/components/PageHeader"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useTranslation } from "next-i18next"; export default function Collections() { + const { t } = useTranslation(); const { collections } = useCollectionStore(); const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); const [sortedCollections, setSortedCollections] = useState(collections); @@ -26,13 +29,13 @@ export default function Collections() {
    - +
    @@ -48,7 +51,9 @@ export default function Collections() { className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content p-5 bg-base-200 self-stretch min-h-[12rem] rounded-2xl cursor-pointer flex flex-col gap-4 justify-center items-center group btn" onClick={() => setNewCollectionModal(true)} > -

    New Collection

    +

    + {t("new_collection")} +

    @@ -57,8 +62,8 @@ export default function Collections() { <>
    @@ -77,3 +82,5 @@ export default function Collections() { ); } + +export { getServerSideProps }; diff --git a/pages/confirmation.tsx b/pages/confirmation.tsx index f8f99b6d..4e33a9cc 100644 --- a/pages/confirmation.tsx +++ b/pages/confirmation.tsx @@ -1,19 +1,25 @@ import CenteredForm from "@/layouts/CenteredForm"; import { signIn } from "next-auth/react"; -import Link from "next/link"; import { useRouter } from "next/router"; import React, { useState } from "react"; import toast from "react-hot-toast"; +import { useTranslation } from "next-i18next"; +import getServerSideProps from "@/lib/client/getServerSideProps"; export default function EmailConfirmaion() { const router = useRouter(); + const { t } = useTranslation(); + const [submitLoader, setSubmitLoader] = useState(false); const resend = async () => { + if (submitLoader) return; + else if (!router.query.email) return; + setSubmitLoader(true); - const load = toast.loading("Authenticating..."); + const load = toast.loading(t("authenticating")); const res = await signIn("email", { email: decodeURIComponent(router.query.email as string), @@ -25,29 +31,28 @@ export default function EmailConfirmaion() { setSubmitLoader(false); - toast.success("Verification email sent."); + toast.success(t("verification_email_sent")); }; return (

    - Please check your Email + {t("check_your_email")}

    -

    - A sign in link has been sent to your email address. If you don't see - the email, check your spam folder. -

    +

    {t("verification_email_sent_desc")}

    - Resend Email + {t("resend_email")}
    ); } + +export { getServerSideProps }; diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 1131ba19..3d1fdb6f 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -17,8 +17,11 @@ import ListView from "@/components/LinkViews/Layouts/ListView"; import ViewDropdown from "@/components/ViewDropdown"; import { dropdownTriggerer } from "@/lib/client/utils"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useTranslation } from "next-i18next"; export default function Dashboard() { + const { t } = useTranslation(); const { collections } = useCollectionStore(); const { links } = useLinkStore(); const { tags } = useTagStore(); @@ -117,7 +120,7 @@ export default function Dashboard() {
    @@ -125,7 +128,7 @@ export default function Dashboard() {
    @@ -133,7 +136,9 @@ export default function Dashboard() {
    @@ -141,7 +146,7 @@ export default function Dashboard() {
    @@ -152,15 +157,15 @@ export default function Dashboard() {
    - View All + {t("view_all")}
    @@ -176,11 +181,10 @@ export default function Dashboard() { ) : (

    - View Your Recently Added Links Here! + {t("view_added_links_here")}

    - This section will view your latest added Links across every - Collections you have access to. + {t("view_added_links_here_desc")}

    @@ -192,7 +196,7 @@ export default function Dashboard() { > - Add New Link + {t("add_link")}
    @@ -205,7 +209,7 @@ export default function Dashboard() { id="import-dropdown" > -

    Import From

    +

    {t("import_links")}

    • @@ -213,9 +217,9 @@ export default function Dashboard() { tabIndex={0} role="button" htmlFor="import-linkwarden-file" - title="JSON File" + title={t("from_linkwarden")} > - From Linkwarden + {t("from_linkwarden")} - From Bookmarks HTML file + {t("from_html")}
    • +
    • + +
    @@ -259,15 +283,15 @@ export default function Dashboard() {
    - View All + {t("view_all")} @@ -291,12 +315,10 @@ export default function Dashboard() { >

    - Pin Your Favorite Links Here! + {t("pin_favorite_links_here")}

    - You can Pin your favorite Links by clicking on the three dots on - each Link and clicking{" "} - Pin to Dashboard. + {t("pin_favorite_links_here_desc")}

    )} @@ -308,3 +330,5 @@ export default function Dashboard() { ); } + +export { getServerSideProps }; diff --git a/pages/forgot.tsx b/pages/forgot.tsx index ff2ce9c2..7b01d7a7 100644 --- a/pages/forgot.tsx +++ b/pages/forgot.tsx @@ -4,12 +4,15 @@ import CenteredForm from "@/layouts/CenteredForm"; import Link from "next/link"; import { FormEvent, useState } from "react"; import { toast } from "react-hot-toast"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useTranslation } from "next-i18next"; interface FormData { email: string; } export default function Forgot() { + const { t } = useTranslation(); const [submitLoader, setSubmitLoader] = useState(false); const [form, setForm] = useState({ @@ -43,7 +46,7 @@ export default function Forgot() { if (form.email !== "") { setSubmitLoader(true); - const load = toast.loading("Sending password recovery link..."); + const load = toast.loading(t("sending_password_link")); await submitRequest(); @@ -51,7 +54,7 @@ export default function Forgot() { setSubmitLoader(false); } else { - toast.error("Please fill out all the fields."); + toast.error(t("fill_all_fields")); } } @@ -60,7 +63,7 @@ export default function Forgot() {

    - {isEmailSent ? "Email Sent!" : "Forgot Password?"} + {isEmailSent ? t("email_sent") : t("forgot_password")}

    @@ -68,13 +71,10 @@ export default function Forgot() { {!isEmailSent ? ( <>
    -

    - Enter your email so we can send you a link to create a new - password. -

    +

    {t("password_email_prompt")}

    -

    Email

    +

    {t("email")}

    - Send Login Link + {t("send_reset_link")} ) : ( -

    - Check your email for a link to reset your password. If it doesn’t - appear within a few minutes, check your spam folder. -

    +

    {t("reset_email_sent_desc")}

    )}
    - Back to Login + {t("back_to_login")}
    @@ -113,3 +110,5 @@ export default function Forgot() { ); } + +export { getServerSideProps }; diff --git a/pages/links/index.tsx b/pages/links/index.tsx index e64ccb44..35c14889 100644 --- a/pages/links/index.tsx +++ b/pages/links/index.tsx @@ -1,24 +1,21 @@ import NoLinksFound from "@/components/NoLinksFound"; -import SortDropdown from "@/components/SortDropdown"; import useLinks from "@/hooks/useLinks"; import MainLayout from "@/layouts/MainLayout"; import useLinkStore from "@/store/links"; import React, { useEffect, useState } from "react"; import PageHeader from "@/components/PageHeader"; -import { Member, Sort, ViewMode } from "@/types/global"; -import ViewDropdown from "@/components/ViewDropdown"; +import { Sort, ViewMode } from "@/types/global"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; -import useCollectivePermissions from "@/hooks/useCollectivePermissions"; -import toast from "react-hot-toast"; -import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; -import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; import { useRouter } from "next/router"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; +import LinkListOptions from "@/components/LinkListOptions"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useTranslation } from "next-i18next"; export default function Links() { - const { links, selectedLinks, deleteLinksById, setSelectedLinks } = - useLinkStore(); + const { t } = useTranslation(); + const { links } = useLinkStore(); const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card @@ -27,49 +24,14 @@ export default function Links() { const router = useRouter(); - const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); - const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); const [editMode, setEditMode] = useState(false); useEffect(() => { if (editMode) return setEditMode(false); }, [router]); - const collectivePermissions = useCollectivePermissions( - selectedLinks.map((link) => link.collectionId as number) - ); - useLinks({ sort: sortBy }); - const handleSelectAll = () => { - if (selectedLinks.length === links.length) { - setSelectedLinks([]); - } else { - setSelectedLinks(links.map((link) => link)); - } - }; - - const bulkDeleteLinks = async () => { - const load = toast.loading( - `Deleting ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }...` - ); - - const response = await deleteLinksById( - selectedLinks.map((link) => link.id as number) - ); - - toast.dismiss(load); - - response.ok && - toast.success( - `Deleted ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }!` - ); - }; - const linkView = { [ViewMode.Card]: CardView, [ViewMode.List]: ListView, @@ -82,113 +44,30 @@ export default function Links() { return (
    -
    + - -
    - {links.length > 0 && ( -
    { - setEditMode(!editMode); - setSelectedLinks([]); - }} - className={`btn btn-square btn-sm btn-ghost ${ - editMode - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} - > - -
    - )} - - -
    -
    - - {editMode && links.length > 0 && ( -
    - {links.length > 0 && ( -
    - handleSelectAll()} - checked={ - selectedLinks.length === links.length && links.length > 0 - } - /> - {selectedLinks.length > 0 ? ( - - {selectedLinks.length}{" "} - {selectedLinks.length === 1 ? "link" : "links"} selected - - ) : ( - Nothing selected - )} -
    - )} -
    - - -
    -
    - )} + {links[0] ? ( ) : ( - + )}
    - {bulkDeleteLinksModal && ( - { - setBulkDeleteLinksModal(false); - }} - /> - )} - {bulkEditLinksModal && ( - { - setBulkEditLinksModal(false); - }} - /> - )}
    ); } + +export { getServerSideProps }; diff --git a/pages/links/pinned.tsx b/pages/links/pinned.tsx index a6315736..60d75d85 100644 --- a/pages/links/pinned.tsx +++ b/pages/links/pinned.tsx @@ -1,23 +1,21 @@ -import SortDropdown from "@/components/SortDropdown"; import useLinks from "@/hooks/useLinks"; import MainLayout from "@/layouts/MainLayout"; import useLinkStore from "@/store/links"; import React, { useEffect, useState } from "react"; import PageHeader from "@/components/PageHeader"; import { Sort, ViewMode } from "@/types/global"; -import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; -import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; -import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; -import useCollectivePermissions from "@/hooks/useCollectivePermissions"; -import toast from "react-hot-toast"; import { useRouter } from "next/router"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; +import { useTranslation } from "next-i18next"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import LinkListOptions from "@/components/LinkListOptions"; export default function PinnedLinks() { - const { links, selectedLinks, deleteLinksById, setSelectedLinks } = - useLinkStore(); + const { t } = useTranslation(); + + const { links } = useLinkStore(); const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card @@ -27,47 +25,12 @@ export default function PinnedLinks() { useLinks({ sort: sortBy, pinnedOnly: true }); const router = useRouter(); - const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); - const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); const [editMode, setEditMode] = useState(false); useEffect(() => { if (editMode) return setEditMode(false); }, [router]); - const collectivePermissions = useCollectivePermissions( - selectedLinks.map((link) => link.collectionId as number) - ); - - const handleSelectAll = () => { - if (selectedLinks.length === links.length) { - setSelectedLinks([]); - } else { - setSelectedLinks(links.map((link) => link)); - } - }; - - const bulkDeleteLinks = async () => { - const load = toast.loading( - `Deleting ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }...` - ); - - const response = await deleteLinksById( - selectedLinks.map((link) => link.id as number) - ); - - toast.dismiss(load); - - response.ok && - toast.success( - `Deleted ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }!` - ); - }; - const linkView = { [ViewMode.Card]: CardView, [ViewMode.List]: ListView, @@ -80,91 +43,21 @@ export default function PinnedLinks() { return (
    -
    + -
    - {!(links.length === 0) && ( -
    { - setEditMode(!editMode); - setSelectedLinks([]); - }} - className={`btn btn-square btn-sm btn-ghost ${ - editMode - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} - > - -
    - )} - - -
    -
    - - {editMode && links.length > 0 && ( -
    - {links.length > 0 && ( -
    - handleSelectAll()} - checked={ - selectedLinks.length === links.length && links.length > 0 - } - /> - {selectedLinks.length > 0 ? ( - - {selectedLinks.length}{" "} - {selectedLinks.length === 1 ? "link" : "links"} selected - - ) : ( - Nothing selected - )} -
    - )} -
    - - -
    -
    - )} + {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( @@ -182,30 +75,16 @@ export default function PinnedLinks() {

    - Pin Your Favorite Links Here! + {t("pin_favorite_links_here")}

    - You can Pin your favorite Links by clicking on the three dots on - each Link and clicking{" "} - Pin to Dashboard. + {t("pin_favorite_links_here_desc")}

    )}
    - {bulkDeleteLinksModal && ( - { - setBulkDeleteLinksModal(false); - }} - /> - )} - {bulkEditLinksModal && ( - { - setBulkEditLinksModal(false); - }} - /> - )} ); } + +export { getServerSideProps }; diff --git a/pages/login.tsx b/pages/login.tsx index e92cbde0..aa77e1bb 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -6,22 +6,27 @@ import Link from "next/link"; import React, { useState, FormEvent } from "react"; import { toast } from "react-hot-toast"; import { getLogins } from "./api/v1/logins"; -import { InferGetServerSidePropsType } from "next"; +import { GetServerSideProps, InferGetServerSidePropsType } from "next"; import InstallApp from "@/components/InstallApp"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { i18n } from "next-i18next.config"; +import { getToken } from "next-auth/jwt"; +import { prisma } from "@/lib/api/db"; +import { useTranslation } from "next-i18next"; +import { useRouter } from "next/router"; interface FormData { username: string; password: string; } -export const getServerSideProps = () => { - const availableLogins = getLogins(); - return { props: { availableLogins } }; -}; - export default function Login({ availableLogins, }: InferGetServerSidePropsType) { + const { t } = useTranslation(); + + const router = useRouter(); + const [submitLoader, setSubmitLoader] = useState(false); const [form, setForm] = useState({ @@ -35,7 +40,7 @@ export default function Login({ if (form.username !== "" && form.password !== "") { setSubmitLoader(true); - const load = toast.loading("Authenticating..."); + const load = toast.loading(t("authenticating")); const res = await signIn("credentials", { username: form.username, @@ -48,17 +53,29 @@ export default function Login({ setSubmitLoader(false); if (!res?.ok) { - toast.error(res?.error || "Invalid credentials."); + toast.error(res?.error || t("invalid_credentials")); + + if (res?.error === "Email not verified.") { + await signIn("email", { + email: form.username, + callbackUrl: "/", + redirect: false, + }); + + router.push( + `/confirmation?email=${encodeURIComponent(form.username)}` + ); + } } } else { - toast.error("Please fill out all the fields."); + toast.error(t("fill_all_fields")); } } async function loginUserButton(method: string) { setSubmitLoader(true); - const load = toast.loading("Authenticating..."); + const load = toast.loading(t("authenticating")); const res = await signIn(method, {}); @@ -72,15 +89,14 @@ export default function Login({ return ( <>

    - Enter your credentials + {t("enter_credentials")}


    - Username {availableLogins.emailEnabled === "true" - ? " or Email" - : undefined} + ? t("username_or_email") + : t("username")}

    - Password + {t("password")}

    - Forgot Password? + {t("forgot_password")}
    )} @@ -124,11 +140,11 @@ export default function Login({ data-testid="submit-login-button" loading={submitLoader} > - Login + {t("login")} {availableLogins.buttonAuths.length > 0 ? ( -
    Or continue with
    +
    {t("or_continue_with")}
    ) : undefined} ); @@ -137,11 +153,9 @@ export default function Login({ function displayLoginExternalButton() { const Buttons: any = []; - availableLogins.buttonAuths.forEach((value, index) => { + availableLogins.buttonAuths.forEach((value: any, index: any) => { Buttons.push( - {index !== 0 ?
    Or
    : undefined} - loginUserButton(value.method)} @@ -165,13 +179,15 @@ export default function Login({ if (availableLogins.registrationDisabled !== "true") { return (
    -

    New here?

    +

    + {t("new_here")} +

    - Sign Up + {t("sign_up")}
    ); @@ -179,7 +195,7 @@ export default function Login({ } return ( - +
    ); } + +const getServerSideProps: GetServerSideProps = async (ctx) => { + const availableLogins = getLogins(); + + const acceptLanguageHeader = ctx.req.headers["accept-language"]; + const availableLanguages = i18n.locales; + + const token = await getToken({ req: ctx.req }); + + if (token) { + const user = await prisma.user.findUnique({ + where: { + id: token.id, + }, + }); + + if (user) { + return { + props: { + availableLogins, + ...(await serverSideTranslations(user.locale ?? "en", ["common"])), + }, + }; + } + } + + const acceptedLanguages = acceptLanguageHeader + ?.split(",") + .map((lang) => lang.split(";")[0]); + + let bestMatch = acceptedLanguages?.find((lang) => + availableLanguages.includes(lang) + ); + + if (!bestMatch) { + acceptedLanguages?.some((acceptedLang) => { + const partialMatch = availableLanguages.find((lang) => + lang.startsWith(acceptedLang) + ); + if (partialMatch) { + bestMatch = partialMatch; + return true; + } + return false; + }); + } + + return { + props: { + availableLogins, + ...(await serverSideTranslations(bestMatch ?? "en", ["common"])), + }, + }; +}; + +export { getServerSideProps }; diff --git a/pages/public/collections/[id].tsx b/pages/public/collections/[id].tsx index 836d57a2..83775186 100644 --- a/pages/public/collections/[id].tsx +++ b/pages/public/collections/[id].tsx @@ -7,7 +7,6 @@ import { } from "@/types/global"; import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; -import { motion, Variants } from "framer-motion"; import Head from "next/head"; import useLinks from "@/hooks/useLinks"; import useLinkStore from "@/store/links"; @@ -16,35 +15,25 @@ import ToggleDarkMode from "@/components/ToggleDarkMode"; import getPublicUserData from "@/lib/client/getPublicUserData"; import Image from "next/image"; import Link from "next/link"; -import FilterSearchDropdown from "@/components/FilterSearchDropdown"; -import SortDropdown from "@/components/SortDropdown"; import useLocalSettingsStore from "@/store/localSettings"; import SearchBar from "@/components/SearchBar"; import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal"; -import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; - -const cardVariants: Variants = { - offscreen: { - y: 50, - opacity: 0, - }, - onscreen: { - y: 0, - opacity: 1, - transition: { - duration: 0.4, - }, - }, -}; +import { useTranslation } from "next-i18next"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import useCollectionStore from "@/store/collections"; +import LinkListOptions from "@/components/LinkListOptions"; export default function PublicCollections() { + const { t } = useTranslation(); const { links } = useLinkStore(); const { settings } = useLocalSettingsStore(); + const { collections } = useCollectionStore(); + const router = useRouter(); const [collectionOwner, setCollectionOwner] = useState({ @@ -85,7 +74,7 @@ export default function PublicCollections() { if (router.query.id) { getPublicCollectionData(Number(router.query.id), setCollection); } - }, []); + }, [collections]); useEffect(() => { const fetchOwner = async () => { @@ -147,7 +136,7 @@ export default function PublicCollections() { width={551} height={551} alt="Linkwarden" - title="Created with Linkwarden" + title={t("list_created_with_linkwarden")} className="h-8 w-fit mx-auto rounded" /> @@ -189,12 +178,22 @@ export default function PublicCollections() { ) : null}
    -

    - By {collectionOwner.name} - {collection.members.length > 0 - ? ` and ${collection.members.length} others` - : undefined} - . +

    + {collection.members.length > 0 && + collection.members.length === 1 + ? t("by_author_and_other", { + author: collectionOwner.name, + count: collection.members.length, + }) + : collection.members.length > 0 && + collection.members.length !== 1 + ? t("by_author_and_others", { + author: collectionOwner.name, + count: collection.members.length, + }) + : t("by_author", { + author: collectionOwner.name, + })}

    @@ -205,22 +204,27 @@ export default function PublicCollections() {
    -
    + - -
    - - - - - -
    -
    + {links[0] ? ( ) : ( -

    This collection is empty...

    +

    {t("collection_is_empty")}

    )} {/*

    @@ -254,3 +258,5 @@ export default function PublicCollections() { <> ); } + +export { getServerSideProps }; diff --git a/pages/register.tsx b/pages/register.tsx index e0c53043..3e0dc07e 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -7,7 +7,12 @@ import CenteredForm from "@/layouts/CenteredForm"; import TextInput from "@/components/TextInput"; import AccentSubmitButton from "@/components/ui/Button"; import { getLogins } from "./api/v1/logins"; -import { InferGetServerSidePropsType } from "next"; +import { GetServerSideProps, InferGetServerSidePropsType } from "next"; +import { getToken } from "next-auth/jwt"; +import { prisma } from "@/lib/api/db"; +import { serverSideTranslations } from "next-i18next/serverSideTranslations"; +import { i18n } from "next-i18next.config"; +import { Trans, useTranslation } from "next-i18next"; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; @@ -19,14 +24,10 @@ type FormData = { passwordConfirmation: string; }; -export const getServerSideProps = () => { - const availableLogins = getLogins(); - return { props: { availableLogins } }; -}; - export default function Register({ availableLogins, }: InferGetServerSidePropsType) { + const { t } = useTranslation(); const [submitLoader, setSubmitLoader] = useState(false); const router = useRouter(); @@ -62,14 +63,14 @@ export default function Register({ if (checkFields()) { if (form.password !== form.passwordConfirmation) - return toast.error("Passwords do not match."); + return toast.error(t("passwords_mismatch")); else if (form.password.length < 8) - return toast.error("Passwords must be at least 8 characters."); + return toast.error(t("password_too_short")); const { passwordConfirmation, ...request } = form; setSubmitLoader(true); - const load = toast.loading("Creating Account..."); + const load = toast.loading(t("creating_account")); const response = await fetch("/api/v1/users", { body: JSON.stringify(request), @@ -97,12 +98,12 @@ export default function Register({ ); } else if (!emailEnabled) router.push("/login"); - toast.success("User Created!"); + toast.success(t("account_created")); } else { toast.error(data.response); } } else { - toast.error("Please fill out all the fields."); + toast.error(t("fill_all_fields")); } } } @@ -110,7 +111,7 @@ export default function Register({ async function loginUserButton(method: string) { setSubmitLoader(true); - const load = toast.loading("Authenticating..."); + const load = toast.loading(t("authenticating")); const res = await signIn(method, {}); @@ -121,11 +122,9 @@ export default function Register({ function displayLoginExternalButton() { const Buttons: any = []; - availableLogins.buttonAuths.forEach((value, index) => { + availableLogins.buttonAuths.forEach((value: any, index: any) => { Buttons.push( - {index !== 0 ?

    Or
    : undefined} - loginUserButton(value.method)} @@ -149,31 +148,30 @@ export default function Register({ {process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" ? (
    -

    - Registration is disabled for this instance, please contact the admin - in case of any issues. -

    +

    {t("registration_disabled")}

    ) : (

    - Enter your details + {t("enter_details")}

    -

    Display Name

    +

    + {t("display_name")} +

    -

    Username

    +

    + {t("username")} +

    -

    Email

    +

    {t("email")}

    -

    Password

    +

    + {t("password")} +

    - Confirm Password + {t("confirm_password")}

    {process.env.NEXT_PUBLIC_STRIPE ? ( -
    -

    - By signing up, you agree to our{" "} - - Terms of Service - {" "} - and{" "} - - Privacy Policy - - . -

    -

    - Need help?{" "} - - Get in touch - - . +

    +

    + + Terms of Services + , + + Privacy Policy + , + ]} + />

    ) : undefined} @@ -288,23 +281,37 @@ export default function Register({ size="full" data-testid="register-button" > - Sign Up + {t("sign_up")} {availableLogins.buttonAuths.length > 0 ? ( -
    Or continue with
    +
    {t("or_continue_with")}
    ) : undefined} {displayLoginExternalButton()} -
    -

    Already have an account?

    - - Login - +
    +
    +

    {t("already_registered")}

    + + {t("login")} + +
    + {process.env.NEXT_PUBLIC_STRIPE ? ( +
    +

    {t("need_help")}

    + + {t("get_in_touch")} + +
    + ) : undefined}
    @@ -312,3 +319,59 @@ export default function Register({ ); } + +const getServerSideProps: GetServerSideProps = async (ctx) => { + const availableLogins = getLogins(); + + const acceptLanguageHeader = ctx.req.headers["accept-language"]; + const availableLanguages = i18n.locales; + + const token = await getToken({ req: ctx.req }); + + if (token) { + const user = await prisma.user.findUnique({ + where: { + id: token.id, + }, + }); + + if (user) { + return { + props: { + availableLogins, + ...(await serverSideTranslations(user.locale ?? "en", ["common"])), + }, + }; + } + } + + const acceptedLanguages = acceptLanguageHeader + ?.split(",") + .map((lang) => lang.split(";")[0]); + + let bestMatch = acceptedLanguages?.find((lang) => + availableLanguages.includes(lang) + ); + + if (!bestMatch) { + acceptedLanguages?.some((acceptedLang) => { + const partialMatch = availableLanguages.find((lang) => + lang.startsWith(acceptedLang) + ); + if (partialMatch) { + bestMatch = partialMatch; + return true; + } + return false; + }); + } + + return { + props: { + availableLogins, + ...(await serverSideTranslations(bestMatch ?? "en", ["common"])), + }, + }; +}; + +export { getServerSideProps }; diff --git a/pages/search.tsx b/pages/search.tsx index 67d9893c..d075cd89 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -1,25 +1,22 @@ -import FilterSearchDropdown from "@/components/FilterSearchDropdown"; -import SortDropdown from "@/components/SortDropdown"; import useLinks from "@/hooks/useLinks"; import MainLayout from "@/layouts/MainLayout"; import useLinkStore from "@/store/links"; import { Sort, ViewMode } from "@/types/global"; import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; -import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import PageHeader from "@/components/PageHeader"; import { GridLoader } from "react-spinners"; -import useCollectivePermissions from "@/hooks/useCollectivePermissions"; -import toast from "react-hot-toast"; -import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; -import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; import MasonryView from "@/components/LinkViews/Layouts/MasonryView"; +import LinkListOptions from "@/components/LinkListOptions"; +import getServerSideProps from "@/lib/client/getServerSideProps"; +import { useTranslation } from "next-i18next"; export default function Search() { - const { links, selectedLinks, setSelectedLinks, deleteLinksById } = - useLinkStore(); + const { t } = useTranslation(); + + const { links } = useLinkStore(); const router = useRouter(); @@ -37,47 +34,12 @@ export default function Search() { const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); - const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); - const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); const [editMode, setEditMode] = useState(false); useEffect(() => { if (editMode) return setEditMode(false); }, [router]); - const collectivePermissions = useCollectivePermissions( - selectedLinks.map((link) => link.collectionId as number) - ); - - const handleSelectAll = () => { - if (selectedLinks.length === links.length) { - setSelectedLinks([]); - } else { - setSelectedLinks(links.map((link) => link)); - } - }; - - const bulkDeleteLinks = async () => { - const load = toast.loading( - `Deleting ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }...` - ); - - const response = await deleteLinksById( - selectedLinks.map((link) => link.id as number) - ); - - toast.dismiss(load); - - response.ok && - toast.success( - `Deleted ${selectedLinks.length} Link${ - selectedLinks.length > 1 ? "s" : "" - }!` - ); - }; - const { isLoading } = useLinks({ sort: sortBy, searchQueryString: decodeURIComponent(router.query.q as string), @@ -88,10 +50,6 @@ export default function Search() { searchByTags: searchFilter.tags, }); - useEffect(() => { - console.log("isLoading", isLoading); - }, [isLoading]); - const linkView = { [ViewMode.Card]: CardView, [ViewMode.List]: ListView, @@ -104,102 +62,22 @@ export default function Search() { return (
    -
    + - -
    -
    - {links.length > 0 && ( -
    { - setEditMode(!editMode); - setSelectedLinks([]); - }} - className={`btn btn-square btn-sm btn-ghost ${ - editMode - ? "bg-primary/20 hover:bg-primary/20" - : "hover:bg-neutral/20" - }`} - > - -
    - )} - - - -
    -
    -
    - - {editMode && links.length > 0 && ( -
    - {links.length > 0 && ( -
    - handleSelectAll()} - checked={ - selectedLinks.length === links.length && links.length > 0 - } - /> - {selectedLinks.length > 0 ? ( - - {selectedLinks.length}{" "} - {selectedLinks.length === 1 ? "link" : "links"} selected - - ) : ( - Nothing selected - )} -
    - )} -
    - - -
    -
    - )} + {!isLoading && !links[0] ? ( -

    - Nothing found.{" "} - - ¯\_(ツ)_/¯ - -

    +

    {t("nothing_found")}

    ) : links[0] ? ( - {bulkDeleteLinksModal && ( - { - setBulkDeleteLinksModal(false); - }} - /> - )} - {bulkEditLinksModal && ( - { - setBulkEditLinksModal(false); - }} - /> - )} ); } + +export { getServerSideProps }; diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx index 1204ce8a..ef66f6dc 100644 --- a/pages/settings/access-tokens.tsx +++ b/pages/settings/access-tokens.tsx @@ -4,11 +4,14 @@ import NewTokenModal from "@/components/ModalContent/NewTokenModal"; import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal"; import { AccessToken } from "@prisma/client"; import useTokenStore from "@/store/tokens"; +import { useTranslation } from "next-i18next"; +import getServerSideProps from "@/lib/client/getServerSideProps"; export default function AccessTokens() { const [newTokenModal, setNewTokenModal] = useState(false); const [revokeTokenModal, setRevokeTokenModal] = useState(false); const [selectedToken, setSelectedToken] = useState(null); + const { t } = useTranslation(); const openRevokeModal = (token: AccessToken) => { setSelectedToken(token); @@ -27,15 +30,14 @@ export default function AccessTokens() { return ( -

    Access Tokens

    +

    + {t("access_tokens")} +

    -

    - Access Tokens can be used to access Linkwarden from other apps and - services without giving away your Username and Password. -

    +

    {t("access_tokens_description")}

    {tokens.length > 0 ? ( @@ -51,13 +53,12 @@ export default function AccessTokens() {
    - {/* head */} - - - + + + @@ -105,3 +106,5 @@ export default function AccessTokens() { ); } + +export { getServerSideProps }; diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index 2d0c2b36..cb464427 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -15,17 +15,16 @@ import { dropdownTriggerer } from "@/lib/client/utils"; import EmailChangeVerificationModal from "@/components/ModalContent/EmailChangeVerificationModal"; import Button from "@/components/ui/Button"; import { i18n } from "next-i18next.config"; +import { useTranslation } from "next-i18next"; +import getServerSideProps from "@/lib/client/getServerSideProps"; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; export default function Account() { const [emailChangeVerificationModal, setEmailChangeVerificationModal] = useState(false); - const [submitLoader, setSubmitLoader] = useState(false); - const { account, updateAccount } = useAccountStore(); - const [user, setUser] = useState( !objectIsEmpty(account) ? account @@ -45,6 +44,8 @@ export default function Account() { } as unknown as AccountSettings) ); + const { t } = useTranslation(); + function objectIsEmpty(obj: object) { return Object.keys(obj).length === 0; } @@ -68,17 +69,16 @@ export default function Account() { }; reader.readAsDataURL(resizedFile); } else { - toast.error("Please select a PNG or JPEG file thats less than 1MB."); + toast.error(t("image_upload_size_error")); } } else { - toast.error("Invalid file format."); + toast.error(t("image_upload_format_error")); } }; const submit = async (password?: string) => { setSubmitLoader(true); - - const load = toast.loading("Applying..."); + const load = toast.loading(t("applying_settings")); const response = await updateAccount({ ...user, @@ -91,56 +91,44 @@ export default function Account() { if (response.ok) { const emailChanged = account.email !== user.email; + toast.success(t("settings_applied")); if (emailChanged) { - toast.success("Settings Applied!"); - toast.success( - "Email change request sent. Please verify the new email address." - ); + toast.success(t("email_change_request")); setEmailChangeVerificationModal(false); - } else toast.success("Settings Applied!"); + } } else toast.error(response.data as string); setSubmitLoader(false); }; const importBookmarks = async (e: any, format: MigrationFormat) => { setSubmitLoader(true); - const file: File = e.target.files[0]; - if (file) { var reader = new FileReader(); reader.readAsText(file, "UTF-8"); reader.onload = async function (e) { - const load = toast.loading("Importing..."); - + const load = toast.loading(t("importing_bookmarks")); const request: string = e.target?.result as string; - - const body: MigrationRequest = { - format, - data: request, - }; - + const body: MigrationRequest = { format, data: request }; const response = await fetch("/api/v1/migration", { method: "POST", body: JSON.stringify(body), }); - const data = await response.json(); - toast.dismiss(load); - if (response.ok) { - toast.success("Imported the Bookmarks! Reloading the page..."); + toast.success(t("import_success")); setTimeout(() => { location.reload(); }, 2000); - } else toast.error(data.response as string); + } else { + toast.error(data.response as string); + } }; reader.onerror = function (e) { console.log("Error:", e); }; } - setSubmitLoader(false); }; @@ -158,16 +146,14 @@ export default function Account() { }, [whitelistedUsersTextbox]); const stringToArray = (str: string) => { - const stringWithoutSpaces = str?.replace(/\s+/g, ""); - - const wordsArray = stringWithoutSpaces?.split(","); - - return wordsArray; + return str?.replace(/\s+/g, "").split(","); }; return ( -

    Account Settings

    +

    + {t("accountSettings")} +

    @@ -175,7 +161,7 @@ export default function Account() {
    -

    Display Name

    +

    {t("display_name")}

    -

    Username

    +

    {t("username")}

    setUser({ ...user, username: e.target.value })} />
    - {emailEnabled ? (
    -

    Email

    +

    {t("email")}

    ) : undefined} -
    -

    Language

    +

    {t("language")}

    -

    Profile Photo

    +

    {t("profile_photo")}

    - Edit + {t("edit")}
    )} @@ -284,25 +269,22 @@ export default function Account() {
    setUser({ ...user, isPrivate: !user.isPrivate })} /> -

    - This will limit who can find and add you to new Collections. -

    +

    {t("profile_privacy_info")}

    {user.isPrivate && (
    -

    Whitelisted Users

    +

    {t("whitelisted_users")}

    - Please provide the Username of the users you wish to grant - visibility to your profile. Separated by comma. + {t("whitelisted_users_info")}

    NameCreatedExpires{t("name")}{t("created")}{t("expires")}