+5
-2
@@ -88,10 +88,13 @@ function App({
|
||||
{icon}
|
||||
<span data-testid="toast-message">{message}</span>
|
||||
{t.type !== "loading" && (
|
||||
<div
|
||||
<button
|
||||
className="btn btn-xs outline-none btn-circle btn-ghost"
|
||||
data-testid="close-toast-button"
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
></div>
|
||||
>
|
||||
<i className="bi bi-x"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
+4
-3
@@ -6,7 +6,6 @@ import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import UserListing from "@/components/UserListing";
|
||||
import { useUsers } from "@/hooks/store/admin/users";
|
||||
import Divider from "@/components/ui/Divider";
|
||||
|
||||
interface User extends U {
|
||||
subscriptions: {
|
||||
@@ -89,7 +88,7 @@ export default function Admin() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider className="my-3" />
|
||||
<div className="divider my-3"></div>
|
||||
|
||||
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
||||
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
|
||||
@@ -101,7 +100,9 @@ export default function Admin() {
|
||||
<p>{t("no_users_found")}</p>
|
||||
)}
|
||||
|
||||
{newUserModal && <NewUserModal onClose={() => setNewUserModal(false)} />}
|
||||
{newUserModal ? (
|
||||
<NewUserModal onClose={() => setNewUserModal(false)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import fs from "fs";
|
||||
import verifyToken from "@/lib/api/verifyToken";
|
||||
import generatePreview from "@/lib/api/generatePreview";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import { UploadFileSchema } from "@/lib/shared/schemaValidation";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
@@ -106,6 +105,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
||||
response: "Collection is not accessible.",
|
||||
});
|
||||
|
||||
// await uploadHandler(linkId, )
|
||||
|
||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER || 30000);
|
||||
|
||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||
@@ -118,7 +119,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||
return res.status(400).json({
|
||||
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||
response:
|
||||
"Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.",
|
||||
});
|
||||
|
||||
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
|
||||
@@ -139,20 +141,6 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
||||
"image/jpeg",
|
||||
];
|
||||
|
||||
const dataValidation = UploadFileSchema.safeParse({
|
||||
id: Number(req.query.linkId),
|
||||
format: Number(req.query.format),
|
||||
file: files.file,
|
||||
});
|
||||
|
||||
if (!dataValidation.success) {
|
||||
return res.status(400).json({
|
||||
response: `Error: ${
|
||||
dataValidation.error.issues[0].message
|
||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
err ||
|
||||
!files.file ||
|
||||
@@ -178,12 +166,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
||||
where: { id: linkId },
|
||||
});
|
||||
|
||||
const { mimetype } = files.file[0];
|
||||
const isPDF = mimetype?.includes("pdf");
|
||||
const isImage = mimetype?.includes("image");
|
||||
|
||||
if (linkStillExists && isImage) {
|
||||
const collectionId = collectionPermissions.id;
|
||||
if (linkStillExists && files.file[0].mimetype?.includes("image")) {
|
||||
const collectionId = collectionPermissions.id as number;
|
||||
createFolder({
|
||||
filePath: `archives/preview/${collectionId}`,
|
||||
});
|
||||
@@ -200,11 +184,13 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
||||
await prisma.link.update({
|
||||
where: { id: linkId },
|
||||
data: {
|
||||
preview: isPDF ? "unavailable" : undefined,
|
||||
image: isImage
|
||||
preview: files.file[0].mimetype?.includes("pdf")
|
||||
? "unavailable"
|
||||
: undefined,
|
||||
image: files.file[0].mimetype?.includes("image")
|
||||
? `archives/${collectionPermissions.id}/${linkId + suffix}`
|
||||
: null,
|
||||
pdf: isPDF
|
||||
pdf: files.file[0].mimetype?.includes("pdf")
|
||||
? `archives/${collectionPermissions.id}/${linkId + suffix}`
|
||||
: null,
|
||||
lastPreserved: new Date().toISOString(),
|
||||
@@ -220,94 +206,4 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
||||
});
|
||||
});
|
||||
}
|
||||
// To update the link preview
|
||||
else if (req.method === "PUT") {
|
||||
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
||||
return res.status(400).json({
|
||||
response:
|
||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||
});
|
||||
|
||||
const user = await verifyUser({ req, res });
|
||||
if (!user) return;
|
||||
|
||||
const collectionPermissions = await getPermission({
|
||||
userId: user.id,
|
||||
linkId,
|
||||
});
|
||||
|
||||
if (!collectionPermissions)
|
||||
return res.status(400).json({
|
||||
response: "Collection is not accessible.",
|
||||
});
|
||||
|
||||
const memberHasAccess = collectionPermissions.members.some(
|
||||
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
|
||||
);
|
||||
|
||||
if (!(collectionPermissions.ownerId === user.id || memberHasAccess))
|
||||
return res.status(400).json({
|
||||
response: "Collection is not accessible.",
|
||||
});
|
||||
|
||||
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
|
||||
process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10
|
||||
);
|
||||
|
||||
const form = formidable({
|
||||
maxFields: 1,
|
||||
maxFiles: 1,
|
||||
maxFileSize: NEXT_PUBLIC_MAX_FILE_BUFFER * 1024 * 1024,
|
||||
});
|
||||
|
||||
form.parse(req, async (err, fields, files) => {
|
||||
const allowedMIMETypes = ["image/png", "image/jpg", "image/jpeg"];
|
||||
|
||||
if (
|
||||
err ||
|
||||
!files.file ||
|
||||
!files.file[0] ||
|
||||
!allowedMIMETypes.includes(files.file[0].mimetype || "")
|
||||
) {
|
||||
// Handle parsing error
|
||||
return res.status(400).json({
|
||||
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
|
||||
});
|
||||
} else {
|
||||
const fileBuffer = fs.readFileSync(files.file[0].filepath);
|
||||
|
||||
if (
|
||||
Buffer.byteLength(fileBuffer) >
|
||||
1024 * 1024 * Number(NEXT_PUBLIC_MAX_FILE_BUFFER)
|
||||
)
|
||||
return res.status(400).json({
|
||||
response: `Sorry, we couldn't process your file. Please ensure it's a PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
|
||||
});
|
||||
|
||||
const linkStillExists = await prisma.link.update({
|
||||
where: { id: linkId },
|
||||
data: {
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (linkStillExists) {
|
||||
const collectionId = collectionPermissions.id;
|
||||
createFolder({
|
||||
filePath: `archives/preview/${collectionId}`,
|
||||
});
|
||||
|
||||
await generatePreview(fileBuffer, collectionId, linkId);
|
||||
}
|
||||
|
||||
fs.unlinkSync(files.file[0].filepath);
|
||||
|
||||
if (linkStillExists)
|
||||
return res.status(200).json({
|
||||
response: linkStillExists,
|
||||
});
|
||||
else return res.status(400).json({ response: "Link not found." });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import sendInvitationRequest from "@/lib/api/sendInvitationRequest";
|
||||
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
|
||||
import updateSeats from "@/lib/api/stripe/updateSeats";
|
||||
import verifySubscription from "@/lib/api/stripe/verifySubscription";
|
||||
import verifySubscription from "@/lib/api/verifySubscription";
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import { User } from "@prisma/client";
|
||||
import bcrypt from "bcrypt";
|
||||
import { randomBytes } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Adapter } from "next-auth/adapters";
|
||||
import NextAuth from "next-auth/next";
|
||||
@@ -135,7 +133,6 @@ if (process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED !== "false") {
|
||||
if (emailEnabled) {
|
||||
providers.push(
|
||||
EmailProvider({
|
||||
id: "email",
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
@@ -160,56 +157,6 @@ if (emailEnabled) {
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
EmailProvider({
|
||||
id: "invite",
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
async sendVerificationRequest({ identifier, url, provider, token }) {
|
||||
const parentSubscriptionEmail = (
|
||||
await prisma.user.findFirst({
|
||||
where: {
|
||||
email: identifier,
|
||||
emailVerified: null,
|
||||
},
|
||||
include: {
|
||||
parentSubscription: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)?.parentSubscription?.user.email;
|
||||
|
||||
if (!parentSubscriptionEmail) throw Error("Invalid email.");
|
||||
|
||||
const recentVerificationRequestsCount =
|
||||
await prisma.verificationToken.count({
|
||||
where: {
|
||||
identifier,
|
||||
createdAt: {
|
||||
gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (recentVerificationRequestsCount >= 4)
|
||||
throw Error("Too many requests. Please try again later.");
|
||||
|
||||
sendInvitationRequest({
|
||||
parentSubscriptionEmail,
|
||||
identifier,
|
||||
url,
|
||||
from: provider.from as string,
|
||||
token,
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1232,52 +1179,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
callbacks: {
|
||||
async signIn({ user, account, profile, email, credentials }) {
|
||||
if (
|
||||
!(user as User).emailVerified &&
|
||||
!email?.verificationRequest
|
||||
// && (account?.provider === "email" || account?.provider === "google")
|
||||
) {
|
||||
// Email is being verified for the first time...
|
||||
console.log("Email is being verified for the first time...");
|
||||
|
||||
const parentSubscriptionId = (user as User).parentSubscriptionId;
|
||||
|
||||
if (parentSubscriptionId) {
|
||||
// Add seat request to Stripe
|
||||
const parentSubscription = await prisma.subscription.findFirst({
|
||||
where: {
|
||||
id: parentSubscriptionId,
|
||||
},
|
||||
});
|
||||
|
||||
// Count child users with verified email under a specific subscription, excluding the current user
|
||||
const verifiedChildUsersCount = await prisma.user.count({
|
||||
where: {
|
||||
parentSubscriptionId: parentSubscriptionId,
|
||||
id: {
|
||||
not: user.id as number,
|
||||
},
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
STRIPE_SECRET_KEY &&
|
||||
parentSubscription?.quantity &&
|
||||
verifiedChildUsersCount + 2 > // add current user and the admin
|
||||
parentSubscription.quantity
|
||||
) {
|
||||
// Add seat if the user count exceeds the subscription limit
|
||||
await updateSeats(
|
||||
parentSubscription.stripeSubscriptionId,
|
||||
verifiedChildUsersCount + 2
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (account?.provider !== "credentials") {
|
||||
// registration via SSO can be separately disabled
|
||||
const existingUser = await prisma.account.findFirst({
|
||||
@@ -1386,6 +1287,8 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.id;
|
||||
|
||||
console.log("session", session);
|
||||
|
||||
if (STRIPE_SECRET_KEY) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
@@ -1393,7 +1296,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
include: {
|
||||
subscriptions: true,
|
||||
parentSubscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import sendPasswordResetRequest from "@/lib/api/sendPasswordResetRequest";
|
||||
import { ForgotPasswordSchema } from "@/lib/shared/schemaValidation";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function forgotPassword(
|
||||
@@ -14,18 +13,14 @@ export default async function forgotPassword(
|
||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||
});
|
||||
|
||||
const dataValidation = ForgotPasswordSchema.safeParse(req.body);
|
||||
const email = req.body.email;
|
||||
|
||||
if (!dataValidation.success) {
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
response: `Error: ${
|
||||
dataValidation.error.issues[0].message
|
||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
||||
response: "Invalid email.",
|
||||
});
|
||||
}
|
||||
|
||||
const { email } = dataValidation.data;
|
||||
|
||||
const recentPasswordRequestsCount = await prisma.passwordResetToken.count({
|
||||
where: {
|
||||
identifier: email,
|
||||
@@ -50,11 +45,11 @@ export default async function forgotPassword(
|
||||
|
||||
if (!user || !user.email) {
|
||||
return res.status(400).json({
|
||||
response: "No user found with that email.",
|
||||
response: "Invalid email.",
|
||||
});
|
||||
}
|
||||
|
||||
sendPasswordResetRequest(user.email, user.name || "Linkwarden User");
|
||||
sendPasswordResetRequest(user.email, user.name);
|
||||
|
||||
return res.status(200).json({
|
||||
response: "Password reset email sent.",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import bcrypt from "bcrypt";
|
||||
import { ResetPasswordSchema } from "@/lib/shared/schemaValidation";
|
||||
|
||||
export default async function resetPassword(
|
||||
req: NextApiRequest,
|
||||
@@ -14,17 +13,20 @@ export default async function resetPassword(
|
||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||
});
|
||||
|
||||
const dataValidation = ResetPasswordSchema.safeParse(req.body);
|
||||
const token = req.body.token;
|
||||
const password = req.body.password;
|
||||
|
||||
if (!dataValidation.success) {
|
||||
if (!password || password.length < 8) {
|
||||
return res.status(400).json({
|
||||
response: `Error: ${
|
||||
dataValidation.error.issues[0].message
|
||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
||||
response: "Password must be at least 8 characters.",
|
||||
});
|
||||
}
|
||||
|
||||
const { token, password } = dataValidation.data;
|
||||
if (!token || typeof token !== "string") {
|
||||
return res.status(400).json({
|
||||
response: "Invalid token.",
|
||||
});
|
||||
}
|
||||
|
||||
// Hashed password
|
||||
const saltRounds = 10;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import updateCustomerEmail from "@/lib/api/stripe/updateCustomerEmail";
|
||||
import { VerifyEmailSchema } from "@/lib/shared/schemaValidation";
|
||||
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function verifyEmail(
|
||||
@@ -14,18 +13,14 @@ export default async function verifyEmail(
|
||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||
});
|
||||
|
||||
const dataValidation = VerifyEmailSchema.safeParse(req.query);
|
||||
const token = req.query.token;
|
||||
|
||||
if (!dataValidation.success) {
|
||||
if (!token || typeof token !== "string") {
|
||||
return res.status(400).json({
|
||||
response: `Error: ${
|
||||
dataValidation.error.issues[0].message
|
||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
||||
response: "Invalid token.",
|
||||
});
|
||||
}
|
||||
|
||||
const { token } = dataValidation.data;
|
||||
|
||||
// Check token in db
|
||||
const verifyToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -2,10 +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 { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import { moveFiles, removeFiles } from "@/lib/api/manageLinkFiles";
|
||||
import { Collection, Link } from "@prisma/client";
|
||||
import { removeFiles } from "@/lib/api/manageLinkFiles";
|
||||
|
||||
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
|
||||
|
||||
@@ -25,16 +23,7 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||
response: "Link not found.",
|
||||
});
|
||||
|
||||
const collectionIsAccessible = await getPermission({
|
||||
userId: user.id,
|
||||
collectionId: link.collectionId,
|
||||
});
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === user.id && e.canUpdate
|
||||
);
|
||||
|
||||
if (!(collectionIsAccessible?.ownerId === user.id || memberHasAccess))
|
||||
if (link.collection.ownerId !== user.id)
|
||||
return res.status(401).json({
|
||||
response: "Permission denied.",
|
||||
});
|
||||
@@ -65,20 +54,7 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||
response: "Invalid URL.",
|
||||
});
|
||||
|
||||
await prisma.link.update({
|
||||
where: {
|
||||
id: link.id,
|
||||
},
|
||||
data: {
|
||||
image: null,
|
||||
pdf: null,
|
||||
readable: null,
|
||||
monolith: null,
|
||||
preview: null,
|
||||
},
|
||||
});
|
||||
|
||||
await removeFiles(link.id, link.collection.id);
|
||||
await deleteArchivedFiles(link);
|
||||
|
||||
return res.status(200).json({
|
||||
response: "Link is being archived.",
|
||||
@@ -96,3 +72,20 @@ const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
|
||||
|
||||
return diffInMinutes;
|
||||
};
|
||||
|
||||
const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
|
||||
await prisma.link.update({
|
||||
where: {
|
||||
id: link.id,
|
||||
},
|
||||
data: {
|
||||
image: null,
|
||||
pdf: null,
|
||||
readable: null,
|
||||
monolith: null,
|
||||
preview: null,
|
||||
},
|
||||
});
|
||||
|
||||
await removeFiles(link.id, link.collection.id);
|
||||
};
|
||||
|
||||
@@ -60,7 +60,6 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||
req.body.removePreviousTags,
|
||||
req.body.newData
|
||||
);
|
||||
|
||||
return res.status(updated.status).json({
|
||||
response: updated.response,
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import getTags from "@/lib/api/controllers/tags/getTags";
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { LinkRequestQuery } from "@/types/global";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function collections(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method === "GET") {
|
||||
// Convert the type of the request query to "LinkRequestQuery"
|
||||
const convertedData: Omit<LinkRequestQuery, "tagId"> = {
|
||||
sort: Number(req.query.sort as string),
|
||||
collectionId: req.query.collectionId
|
||||
? Number(req.query.collectionId as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!convertedData.collectionId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ response: "Please choose a valid collection." });
|
||||
}
|
||||
|
||||
const collection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
id: convertedData.collectionId,
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
return res.status(404).json({ response: "Collection not found." });
|
||||
}
|
||||
|
||||
const tags = await getTags({
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
return res.status(tags.status).json({ response: tags.response });
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,12 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import verifyByCredentials from "@/lib/api/verifyByCredentials";
|
||||
import createSession from "@/lib/api/controllers/session/createSession";
|
||||
import { PostSessionSchema } from "@/lib/shared/schemaValidation";
|
||||
|
||||
export default async function session(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
const dataValidation = PostSessionSchema.safeParse(req.body);
|
||||
|
||||
if (!dataValidation.success) {
|
||||
return res.status(400).json({
|
||||
response: `Error: ${
|
||||
dataValidation.error.issues[0].message
|
||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
||||
});
|
||||
}
|
||||
|
||||
const { username, password, sessionName } = dataValidation.data;
|
||||
const { username, password, sessionName } = req.body;
|
||||
|
||||
const user = await verifyByCredentials({ username, password });
|
||||
|
||||
|
||||
@@ -9,11 +9,6 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const tagId = Number(req.query.id);
|
||||
|
||||
if (!tagId)
|
||||
return res.status(400).json({
|
||||
response: "Please choose a valid name for the tag.",
|
||||
});
|
||||
|
||||
if (req.method === "PUT") {
|
||||
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
||||
return res.status(400).json({
|
||||
|
||||
@@ -7,9 +7,7 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!user) return;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const tags = await getTags({
|
||||
userId: user.id,
|
||||
});
|
||||
const tags = await getTags(user.id);
|
||||
return res.status(tags.status).json({ response: tags.response });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function tokens(
|
||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||
});
|
||||
|
||||
const token = await postToken(req.body, user.id);
|
||||
const token = await postToken(JSON.parse(req.body), user.id);
|
||||
return res.status(token.status).json({ response: token.response });
|
||||
} else if (req.method === "GET") {
|
||||
const token = await getTokens(user.id);
|
||||
|
||||
@@ -3,7 +3,7 @@ import getUserById from "@/lib/api/controllers/users/userId/getUserById";
|
||||
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
|
||||
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import verifySubscription from "@/lib/api/stripe/verifySubscription";
|
||||
import verifySubscription from "@/lib/api/verifySubscription";
|
||||
import verifyToken from "@/lib/api/verifyToken";
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
@@ -11,12 +11,6 @@ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
const token = await verifyToken({ req });
|
||||
|
||||
const queryId = Number(req.query.id);
|
||||
|
||||
if (!queryId) {
|
||||
return res.status(400).json({ response: "Invalid request." });
|
||||
}
|
||||
|
||||
if (typeof token === "string") {
|
||||
res.status(401).json({ response: token });
|
||||
return null;
|
||||
@@ -30,12 +24,12 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const isServerAdmin = user?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
|
||||
|
||||
const userId = token.id;
|
||||
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") {
|
||||
if (userId !== queryId && !isServerAdmin)
|
||||
return res.status(401).json({ response: "Permission denied." });
|
||||
|
||||
const users = await getUserById(userId);
|
||||
return res.status(users.status).json({ response: users.response });
|
||||
}
|
||||
@@ -47,7 +41,6 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
include: {
|
||||
subscriptions: true,
|
||||
parentSubscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,9 +58,6 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
if (userId !== queryId && !isServerAdmin)
|
||||
return res.status(401).json({ response: "Permission denied." });
|
||||
|
||||
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
||||
return res.status(400).json({
|
||||
response:
|
||||
@@ -83,12 +73,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||
});
|
||||
|
||||
const updated = await deleteUserById(
|
||||
userId,
|
||||
req.body,
|
||||
isServerAdmin,
|
||||
queryId
|
||||
);
|
||||
const updated = await deleteUserById(userId, req.body, isServerAdmin);
|
||||
return res.status(updated.status).json({ response: updated.response });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,10 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
} else if (req.method === "GET") {
|
||||
const user = await verifyUser({ req, res });
|
||||
|
||||
if (!user) return res.status(401).json({ response: "Unauthorized..." });
|
||||
if (!user || user.id !== Number(process.env.NEXT_PUBLIC_ADMIN || 1))
|
||||
return res.status(401).json({ response: "Unauthorized..." });
|
||||
|
||||
const response = await getUsers(user);
|
||||
const response = await getUsers();
|
||||
return res.status(response.status).json({ response: response.response });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import handleSubscription from "@/lib/api/stripe/handleSubscription";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
const buffer = (req: NextApiRequest) => {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
resolve(Buffer.concat(chunks as any));
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
});
|
||||
};
|
||||
|
||||
export default async function webhook(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (process.env.NEXT_PUBLIC_DEMO === "true")
|
||||
return res.status(400).json({
|
||||
response:
|
||||
"This action is disabled because this is a read-only demo of Linkwarden.",
|
||||
});
|
||||
|
||||
// see if stripe is already initialized
|
||||
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET) {
|
||||
return res.status(400).json({
|
||||
response: "This action is disabled because Stripe is not initialized.",
|
||||
});
|
||||
}
|
||||
|
||||
let event = req.body;
|
||||
|
||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2022-11-15",
|
||||
});
|
||||
|
||||
const signature = req.headers["stripe-signature"] as any;
|
||||
|
||||
try {
|
||||
const body = await buffer(req);
|
||||
event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.status(400).send("Webhook signature verification failed.");
|
||||
}
|
||||
|
||||
// Handle the event based on its type
|
||||
const eventType = event.type;
|
||||
const data = event.data.object;
|
||||
|
||||
try {
|
||||
switch (eventType) {
|
||||
case "customer.subscription.created":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: data.status === "active" || data.status === "trialing",
|
||||
quantity: data?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
});
|
||||
break;
|
||||
|
||||
case "customer.subscription.updated":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: data.status === "active" || data.status === "trialing",
|
||||
quantity: data?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
});
|
||||
break;
|
||||
|
||||
case "customer.subscription.deleted":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: false,
|
||||
quantity: data?.lines?.data[0]?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
});
|
||||
break;
|
||||
|
||||
case "customer.subscription.cancelled":
|
||||
await handleSubscription({
|
||||
id: data.id,
|
||||
active: !(data.current_period_end * 1000 < Date.now()),
|
||||
quantity: data?.lines?.data[0]?.quantity ?? 1,
|
||||
periodStart: data.current_period_start,
|
||||
periodEnd: data.current_period_end,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event type ${eventType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling webhook event:", error);
|
||||
return res.status(500).send("Server Error");
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
response: "Done!",
|
||||
});
|
||||
}
|
||||
+18
-26
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
Sort,
|
||||
ViewMode,
|
||||
@@ -24,8 +23,6 @@ import { useCollections } from "@/hooks/store/collections";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useLinks } from "@/hooks/store/links";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import Icon from "@/components/Icon";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useTranslation();
|
||||
@@ -57,9 +54,15 @@ export default function Index() {
|
||||
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState<
|
||||
Partial<AccountSettings>
|
||||
>({});
|
||||
const [collectionOwner, setCollectionOwner] = useState({
|
||||
id: null as unknown as number,
|
||||
name: "",
|
||||
username: "",
|
||||
image: "",
|
||||
archiveAsScreenshot: undefined as unknown as boolean,
|
||||
archiveAsMonolith: undefined as unknown as boolean,
|
||||
archiveAsPDF: undefined as unknown as boolean,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
@@ -112,21 +115,10 @@ export default function Index() {
|
||||
{activeCollection && (
|
||||
<div className="flex gap-3 items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCollection.icon ? (
|
||||
<Icon
|
||||
icon={activeCollection.icon}
|
||||
size={45}
|
||||
weight={
|
||||
(activeCollection.iconWeight || "regular") as IconWeight
|
||||
}
|
||||
color={activeCollection.color}
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className="bi-folder-fill text-3xl"
|
||||
style={{ color: activeCollection.color }}
|
||||
></i>
|
||||
)}
|
||||
<i
|
||||
className="bi-folder-fill text-3xl drop-shadow"
|
||||
style={{ color: activeCollection?.color }}
|
||||
></i>
|
||||
|
||||
<p className="sm:text-3xl text-2xl capitalize w-full py-1 break-words hyphens-auto font-thin">
|
||||
{activeCollection?.name}
|
||||
@@ -215,14 +207,14 @@ export default function Index() {
|
||||
className="flex items-center btn px-2 btn-ghost rounded-full w-fit"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id && (
|
||||
{collectionOwner.id ? (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
)}
|
||||
) : undefined}
|
||||
{activeCollection.members
|
||||
.sort((a, b) => a.userId - b.userId)
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
@@ -234,13 +226,13 @@ export default function Index() {
|
||||
);
|
||||
})
|
||||
.slice(0, 3)}
|
||||
{activeCollection.members.length - 3 > 0 && (
|
||||
{activeCollection.members.length - 3 > 0 ? (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{activeCollection.members.length - 3}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral text-sm">
|
||||
|
||||
+10
-36
@@ -10,7 +10,6 @@ import PageHeader from "@/components/PageHeader";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@/hooks/store/collections";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
|
||||
export default function Collections() {
|
||||
const { t } = useTranslation();
|
||||
@@ -30,37 +29,12 @@ export default function Collections() {
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("collections")}
|
||||
description={t("collections_you_own")}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className={"dropdown dropdown-bottom font-normal"}>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||
>
|
||||
<i className={"bi-three-dots text-neutral text-2xl"}></i>
|
||||
</div>
|
||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("new_collection")}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("collections")}
|
||||
description={t("collections_you_own")}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<div className="relative mt-2">
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} />
|
||||
@@ -86,7 +60,7 @@ export default function Collections() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] && (
|
||||
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] ? (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
@@ -102,11 +76,11 @@ export default function Collections() {
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : undefined}
|
||||
</div>
|
||||
{newCollectionModal && (
|
||||
{newCollectionModal ? (
|
||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||
)}
|
||||
) : undefined}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+60
-61
@@ -1,6 +1,7 @@
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||
import React from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { MigrationFormat, MigrationRequest, ViewMode } from "@/types/global";
|
||||
@@ -15,20 +16,16 @@ import { useCollections } from "@/hooks/store/collections";
|
||||
import { useTags } from "@/hooks/store/tags";
|
||||
import { useDashboardData } from "@/hooks/store/dashboardData";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const { data: collections = [] } = useCollections();
|
||||
const {
|
||||
data: { links = [], numberOfPinnedLinks } = { links: [] },
|
||||
...dashboardData
|
||||
} = useDashboardData();
|
||||
const dashboardData = useDashboardData();
|
||||
const { data: tags = [] } = useTags();
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
|
||||
const { settings } = useLocalSettingsStore();
|
||||
const [showLinks, setShowLinks] = useState(3);
|
||||
|
||||
useEffect(() => {
|
||||
setNumberOfLinks(
|
||||
@@ -40,28 +37,29 @@ export default function Dashboard() {
|
||||
);
|
||||
}, [collections]);
|
||||
|
||||
const numberOfLinksToShow = useMemo(() => {
|
||||
const handleNumberOfLinksToShow = () => {
|
||||
if (window.innerWidth > 1900) {
|
||||
return 10;
|
||||
setShowLinks(10);
|
||||
} else if (window.innerWidth > 1500) {
|
||||
return 8;
|
||||
setShowLinks(8);
|
||||
} else if (window.innerWidth > 880) {
|
||||
return 6;
|
||||
setShowLinks(6);
|
||||
} else if (window.innerWidth > 550) {
|
||||
return 4;
|
||||
} else {
|
||||
return 2;
|
||||
}
|
||||
}, []);
|
||||
setShowLinks(4);
|
||||
} else setShowLinks(2);
|
||||
};
|
||||
|
||||
const importBookmarks = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
format: MigrationFormat
|
||||
) => {
|
||||
const file: File | null = e.target.files && e.target.files[0];
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
handleNumberOfLinksToShow();
|
||||
}, [width]);
|
||||
|
||||
const importBookmarks = async (e: any, format: MigrationFormat) => {
|
||||
const file: File = e.target.files[0];
|
||||
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
var reader = new FileReader();
|
||||
reader.readAsText(file, "UTF-8");
|
||||
reader.onload = async function (e) {
|
||||
const load = toast.loading("Importing...");
|
||||
@@ -112,30 +110,32 @@ export default function Dashboard() {
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</div>
|
||||
|
||||
<div className="xl:flex flex flex-col sm:grid grid-cols-2 gap-5 xl:flex-row xl:justify-evenly xl:w-full h-full rounded-2xl p-5 bg-base-200 border border-neutral-content">
|
||||
<DashboardItem
|
||||
name={numberOfLinks === 1 ? t("link") : t("links")}
|
||||
value={numberOfLinks}
|
||||
icon={"bi-link-45deg"}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex justify-evenly flex-col xl:flex-row xl:items-center gap-2 xl:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200">
|
||||
<DashboardItem
|
||||
name={numberOfLinks === 1 ? t("link") : t("links")}
|
||||
value={numberOfLinks}
|
||||
icon={"bi-link-45deg"}
|
||||
/>
|
||||
|
||||
<DashboardItem
|
||||
name={collections.length === 1 ? t("collection") : t("collections")}
|
||||
value={collections.length}
|
||||
icon={"bi-folder"}
|
||||
/>
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
|
||||
<DashboardItem
|
||||
name={tags.length === 1 ? t("tag") : t("tags")}
|
||||
value={tags.length}
|
||||
icon={"bi-hash"}
|
||||
/>
|
||||
<DashboardItem
|
||||
name={
|
||||
collections.length === 1 ? t("collection") : t("collections")
|
||||
}
|
||||
value={collections.length}
|
||||
icon={"bi-folder"}
|
||||
/>
|
||||
|
||||
<DashboardItem
|
||||
name={t("pinned")}
|
||||
value={numberOfPinnedLinks}
|
||||
icon={"bi-pin-angle"}
|
||||
/>
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
|
||||
<DashboardItem
|
||||
name={tags.length === 1 ? t("tag") : t("tags")}
|
||||
value={tags.length}
|
||||
icon={"bi-hash"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -157,7 +157,10 @@ export default function Dashboard() {
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: links || dashboardData.isLoading ? "0 1 auto" : "1 1 auto",
|
||||
flex:
|
||||
dashboardData.data || dashboardData.isLoading
|
||||
? "0 1 auto"
|
||||
: "1 1 auto",
|
||||
}}
|
||||
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
|
||||
>
|
||||
@@ -165,17 +168,16 @@ export default function Dashboard() {
|
||||
<div className="w-full">
|
||||
<Links
|
||||
layout={viewMode}
|
||||
placeholderCount={settings.columns || 1}
|
||||
placeholderCount={showLinks / 2}
|
||||
useData={dashboardData}
|
||||
/>
|
||||
</div>
|
||||
) : links && links[0] && !dashboardData.isLoading ? (
|
||||
) : dashboardData.data &&
|
||||
dashboardData.data[0] &&
|
||||
!dashboardData.isLoading ? (
|
||||
<div className="w-full">
|
||||
<Links
|
||||
links={links.slice(
|
||||
0,
|
||||
settings.columns ? settings.columns * 2 : numberOfLinksToShow
|
||||
)}
|
||||
links={dashboardData.data.slice(0, showLinks)}
|
||||
layout={viewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -308,21 +310,16 @@ export default function Dashboard() {
|
||||
<div className="w-full">
|
||||
<Links
|
||||
layout={viewMode}
|
||||
placeholderCount={settings.columns || 1}
|
||||
placeholderCount={showLinks / 2}
|
||||
useData={dashboardData}
|
||||
/>
|
||||
</div>
|
||||
) : links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
) : dashboardData.data?.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<div className="w-full">
|
||||
<Links
|
||||
links={links
|
||||
.filter((e: any) => e.pinnedBy && e.pinnedBy[0])
|
||||
.slice(
|
||||
0,
|
||||
settings.columns
|
||||
? settings.columns * 2
|
||||
: numberOfLinksToShow
|
||||
)}
|
||||
links={dashboardData.data
|
||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||
.slice(0, showLinks)}
|
||||
layout={viewMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -342,7 +339,9 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
||||
{newLinkModal ? (
|
||||
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||
) : undefined}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import LinkDetails from "@/components/LinkDetails";
|
||||
import { useGetLink } from "@/hooks/store/links";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
|
||||
const Index = () => {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
|
||||
useState;
|
||||
|
||||
const getLink = useGetLink();
|
||||
|
||||
useEffect(() => {
|
||||
getLink.mutate({ id: Number(id) });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{getLink.data ? (
|
||||
<LinkDetails
|
||||
activeLink={getLink.data}
|
||||
className="sm:max-w-xl sm:m-auto sm:p-5 w-full"
|
||||
standalone
|
||||
/>
|
||||
) : (
|
||||
<div className="max-w-xl p-5 m-auto w-full flex flex-col items-center gap-5">
|
||||
<div className="w-20 h-20 skeleton rounded-xl"></div>
|
||||
<div className="w-full h-10 skeleton rounded-xl"></div>
|
||||
<div className="w-full h-10 skeleton rounded-xl"></div>
|
||||
<div className="w-full h-10 skeleton rounded-xl"></div>
|
||||
<div className="w-full h-10 skeleton rounded-xl"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
|
||||
export { getServerSideProps };
|
||||
+5
-5
@@ -203,9 +203,9 @@ export default function Login({
|
||||
{t("login")}
|
||||
</Button>
|
||||
|
||||
{availableLogins.buttonAuths.length > 0 && (
|
||||
{availableLogins.buttonAuths.length > 0 ? (
|
||||
<div className="divider my-1">{t("or_continue_with")}</div>
|
||||
)}
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -224,9 +224,9 @@ export default function Login({
|
||||
loading={submitLoader}
|
||||
>
|
||||
{value.name.toLowerCase() === "google" ||
|
||||
(value.name.toLowerCase() === "apple" && (
|
||||
<i className={"bi-" + value.name.toLowerCase()}></i>
|
||||
))}
|
||||
value.name.toLowerCase() === "apple" ? (
|
||||
<i className={"bi-" + value.name.toLowerCase()}></i>
|
||||
) : undefined}
|
||||
{value.name}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import Button from "@/components/ui/Button";
|
||||
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";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||
|
||||
interface FormData {
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function MemberOnboarding() {
|
||||
const { t } = useTranslation();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState<FormData>({
|
||||
password: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
const { data: user = {} } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (form.password !== "" && form.name !== "" && !submitLoader) {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading(t("sending_password_recovery_link"));
|
||||
|
||||
await updateUser.mutateAsync(
|
||||
{
|
||||
...user,
|
||||
name: form.name,
|
||||
password: form.password,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
router.push("/dashboard");
|
||||
},
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("settings_applied"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.error(t("please_fill_all_fields"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CenteredForm>
|
||||
<form onSubmit={submit}>
|
||||
<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">
|
||||
{t("invitation_accepted")}
|
||||
</p>
|
||||
|
||||
<div className="divider my-0"></div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: "pre-line",
|
||||
}}
|
||||
>
|
||||
{t("invitation_desc", {
|
||||
owner: user?.parentSubscription?.user?.email,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="text-sm w-fit font-semibold mb-1">
|
||||
{t("display_name")}
|
||||
</p>
|
||||
<TextInput
|
||||
autoFocus
|
||||
placeholder="John Doe"
|
||||
value={form.name}
|
||||
className="bg-base-100"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm w-fit font-semibold mb-1">
|
||||
{t("new_password")}
|
||||
</p>
|
||||
<TextInput
|
||||
type="password"
|
||||
placeholder="••••••••••••••"
|
||||
value={form.password}
|
||||
className="bg-base-100"
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{process.env.NEXT_PUBLIC_STRIPE && (
|
||||
<div className="text-xs text-neutral mb-3">
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="sign_up_agreement"
|
||||
components={[
|
||||
<Link
|
||||
href="https://linkwarden.app/tos"
|
||||
className="font-semibold"
|
||||
data-testid="terms-of-service-link"
|
||||
key={0}
|
||||
/>,
|
||||
<Link
|
||||
href="https://linkwarden.app/privacy-policy"
|
||||
className="font-semibold"
|
||||
data-testid="privacy-policy-link"
|
||||
key={1}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
intent="accent"
|
||||
className="mt-2"
|
||||
size="full"
|
||||
loading={submitLoader}
|
||||
>
|
||||
{t("continue_to_dashboard")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CenteredForm>
|
||||
);
|
||||
}
|
||||
|
||||
export { getServerSideProps };
|
||||
@@ -20,7 +20,7 @@ export default function Index() {
|
||||
useEffect(() => {
|
||||
const fetchLink = async () => {
|
||||
if (router.query.id) {
|
||||
await getLink.mutateAsync({ id: Number(router.query.id) });
|
||||
await getLink.mutateAsync(Number(router.query.id));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+145
-218
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
import getPublicCollectionData from "@/lib/client/getPublicCollectionData";
|
||||
import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
Sort,
|
||||
ViewMode,
|
||||
@@ -22,7 +21,6 @@ import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import { usePublicLinks } from "@/hooks/store/publicLinks";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import { usePublicTags } from "@/hooks/store/publicTags";
|
||||
|
||||
export default function PublicCollections() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,35 +29,15 @@ export default function PublicCollections() {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState<
|
||||
Partial<AccountSettings>
|
||||
>({});
|
||||
|
||||
const handleTagSelection = (tag: string | undefined) => {
|
||||
if (tag) {
|
||||
Object.keys(searchFilter).forEach(
|
||||
(v) =>
|
||||
(searchFilter[
|
||||
v as keyof {
|
||||
name: boolean;
|
||||
url: boolean;
|
||||
description: boolean;
|
||||
tags: boolean;
|
||||
textContent: boolean;
|
||||
}
|
||||
] = false)
|
||||
);
|
||||
searchFilter.tags = true;
|
||||
return router.push(
|
||||
"/public/collections/" +
|
||||
router.query.id +
|
||||
"?q=" +
|
||||
encodeURIComponent(tag || "")
|
||||
);
|
||||
} else {
|
||||
return router.push("/public/collections/" + router.query.id);
|
||||
}
|
||||
};
|
||||
const [collectionOwner, setCollectionOwner] = useState({
|
||||
id: null as unknown as number,
|
||||
name: "",
|
||||
username: "",
|
||||
image: "",
|
||||
archiveAsScreenshot: undefined as unknown as boolean,
|
||||
archiveAsMonolith: undefined as unknown as boolean,
|
||||
archiveAsPDF: undefined as unknown as boolean,
|
||||
});
|
||||
|
||||
const [searchFilter, setSearchFilter] = useState({
|
||||
name: true,
|
||||
@@ -73,8 +51,6 @@ export default function PublicCollections() {
|
||||
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
|
||||
);
|
||||
|
||||
const { data: tags } = usePublicTags();
|
||||
|
||||
const { links, data } = usePublicLinks({
|
||||
sort: sortBy,
|
||||
searchQueryString: router.query.q
|
||||
@@ -86,8 +62,10 @@ export default function PublicCollections() {
|
||||
searchByTextContent: searchFilter.textContent,
|
||||
searchByTags: searchFilter.tags,
|
||||
});
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>();
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.id) {
|
||||
getPublicCollectionData(Number(router.query.id)).then((res) => {
|
||||
@@ -115,211 +93,160 @@ export default function PublicCollections() {
|
||||
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
|
||||
);
|
||||
|
||||
if (!collection) return <></>;
|
||||
else
|
||||
return (
|
||||
<div
|
||||
className="h-96"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
|
||||
settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
{collection && (
|
||||
<Head>
|
||||
<title>{collection.name} | Linkwarden</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${collection.name} | Linkwarden`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
)}
|
||||
<div className="lg:w-3/4 w-full mx-auto p-5 bg">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-4xl font-thin mb-2 capitalize mt-10">
|
||||
{collection.name}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center mt-8 min-w-fit">
|
||||
<ToggleDarkMode />
|
||||
return collection ? (
|
||||
<div
|
||||
className="h-96"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
|
||||
settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
{collection ? (
|
||||
<Head>
|
||||
<title>{collection.name} | Linkwarden</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${collection.name} | Linkwarden`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
) : undefined}
|
||||
<div className="lg:w-3/4 w-full mx-auto p-5 bg">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-4xl font-thin mb-2 capitalize mt-10">
|
||||
{collection.name}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center mt-8 min-w-fit">
|
||||
<ToggleDarkMode />
|
||||
|
||||
<Link href="https://linkwarden.app/" target="_blank">
|
||||
<Image
|
||||
src={`/icon.png`}
|
||||
width={551}
|
||||
height={551}
|
||||
alt="Linkwarden"
|
||||
title={t("list_created_with_linkwarden")}
|
||||
className="h-8 w-fit mx-auto rounded"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="https://linkwarden.app/" target="_blank">
|
||||
<Image
|
||||
src={`/icon.png`}
|
||||
width={551}
|
||||
height={551}
|
||||
alt="Linkwarden"
|
||||
title={t("list_created_with_linkwarden")}
|
||||
className="h-8 w-fit mx-auto rounded"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className={`min-w-[15rem]`}>
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
className="flex items-center btn px-2 btn-ghost rounded-full"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id && (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
)}
|
||||
{collection.members
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
key={i}
|
||||
src={e.user.image ? e.user.image : undefined}
|
||||
className="-ml-3"
|
||||
name={e.user.name}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.slice(0, 3)}
|
||||
{collection.members.length - 3 > 0 && (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{collection.members.length - 3}</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className={`min-w-[15rem]`}>
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
className="flex items-center btn px-2 btn-ghost rounded-full"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id ? (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
) : undefined}
|
||||
{collection.members
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
key={i}
|
||||
src={e.user.image ? e.user.image : undefined}
|
||||
className="-ml-3"
|
||||
name={e.user.name}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.slice(0, 3)}
|
||||
{collection.members.length - 3 > 0 ? (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{collection.members.length - 3}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral text-sm">
|
||||
{collection.members.length > 0 &&
|
||||
collection.members.length === 1
|
||||
? t("by_author_and_other", {
|
||||
<p className="text-neutral text-sm">
|
||||
{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,
|
||||
})
|
||||
: 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,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
: t("by_author", {
|
||||
author: collectionOwner.name,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5">{collection.description}</p>
|
||||
<p className="mt-5">{collection.description}</p>
|
||||
|
||||
<div className="divider mt-5 mb-0"></div>
|
||||
<div className="divider mt-5 mb-0"></div>
|
||||
|
||||
<div className="flex mb-5 mt-10 flex-col gap-5">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
searchFilter={searchFilter}
|
||||
setSearchFilter={setSearchFilter}
|
||||
>
|
||||
<SearchBar
|
||||
placeholder={
|
||||
collection._count?.links === 1
|
||||
? t("search_count_link", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
: t("search_count_links", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LinkListOptions>
|
||||
{tags && tags[0] && (
|
||||
<div className="flex gap-2 mt-2 mb-6 flex-wrap">
|
||||
<button
|
||||
className="max-w-full"
|
||||
onClick={() => handleTagSelection(undefined)}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
!router.query.q
|
||||
? "bg-primary/20"
|
||||
: "bg-neutral-content/20 hover:bg-neutral/20"
|
||||
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 rounded-md h-8`}
|
||||
>
|
||||
<i className="text-primary bi-hash text-2xl drop-shadow"></i>
|
||||
<p className="truncate pr-7">{t("all_links")}</p>
|
||||
<div className="text-neutral drop-shadow text-xs">
|
||||
{collection._count?.links}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{tags
|
||||
.map((t) => t.name)
|
||||
.filter((item, pos, self) => self.indexOf(item) === pos)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((e, i) => {
|
||||
const active = router.query.q === e;
|
||||
return (
|
||||
<button
|
||||
className="max-w-full"
|
||||
key={i}
|
||||
onClick={() => handleTagSelection(e)}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
active
|
||||
? "bg-primary/20"
|
||||
: "bg-neutral-content/20 hover:bg-neutral/20"
|
||||
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 rounded-md h-8`}
|
||||
>
|
||||
<i className="bi-hash text-2xl text-primary drop-shadow"></i>
|
||||
<p className="truncate pr-7">{e}</p>
|
||||
<div className="drop-shadow text-neutral text-xs">
|
||||
{tags.filter((t) => t.name === e)[0]._count.links}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Links
|
||||
links={
|
||||
links?.map((e, i) => {
|
||||
const linkWithCollectionData = {
|
||||
...e,
|
||||
collection: collection, // Append collection data
|
||||
};
|
||||
return linkWithCollectionData;
|
||||
}) as any
|
||||
<div className="flex mb-5 mt-10 flex-col gap-5">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
searchFilter={searchFilter}
|
||||
setSearchFilter={setSearchFilter}
|
||||
>
|
||||
<SearchBar
|
||||
placeholder={
|
||||
collection._count?.links === 1
|
||||
? t("search_count_link", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
: t("search_count_links", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<p>{t("nothing_found")}</p>
|
||||
)}
|
||||
</LinkListOptions>
|
||||
|
||||
{/* <p className="text-center text-neutral">
|
||||
<Links
|
||||
links={
|
||||
links?.map((e, i) => {
|
||||
const linkWithCollectionData = {
|
||||
...e,
|
||||
collection: collection, // Append collection data
|
||||
};
|
||||
return linkWithCollectionData;
|
||||
}) as any
|
||||
}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && <p>{t("nothing_found")}</p>}
|
||||
|
||||
{/* <p className="text-center text-neutral">
|
||||
List created with <span className="text-black">Linkwarden.</span>
|
||||
</p> */}
|
||||
</div>
|
||||
</div>
|
||||
{editCollectionSharingModal && (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={collection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{editCollectionSharingModal ? (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={collection}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import LinkDetails from "@/components/LinkDetails";
|
||||
import { useGetLink } from "@/hooks/store/links";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
|
||||
const Index = () => {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
|
||||
const getLink = useGetLink();
|
||||
|
||||
useEffect(() => {
|
||||
getLink.mutate({ id: Number(id) });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{getLink.data ? (
|
||||
<LinkDetails
|
||||
activeLink={getLink.data}
|
||||
className="sm:max-w-xl sm:m-auto sm:p-5 w-full"
|
||||
standalone
|
||||
/>
|
||||
) : (
|
||||
<div className="max-w-xl p-5 m-auto w-full flex flex-col items-center gap-5">
|
||||
<div className="w-20 h-20 skeleton rounded-xl"></div>
|
||||
<div className="w-full h-10 skeleton rounded-xl"></div>
|
||||
<div className="w-full h-10 skeleton rounded-xl"></div>
|
||||
<div className="w-full h-10 skeleton rounded-xl"></div>
|
||||
<div className="w-full h-10 skeleton rounded-xl"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
|
||||
export { getServerSideProps };
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
} from "@/types/global";
|
||||
import ReadableView from "@/components/ReadableView";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useGetLink } from "@/hooks/store/links";
|
||||
import { useGetLink, useLinks } from "@/hooks/store/links";
|
||||
|
||||
export default function Index() {
|
||||
const { links } = useLinks();
|
||||
const getLink = useGetLink();
|
||||
|
||||
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
|
||||
@@ -18,14 +19,18 @@ export default function Index() {
|
||||
useEffect(() => {
|
||||
const fetchLink = async () => {
|
||||
if (router.query.id) {
|
||||
const get = await getLink.mutateAsync({ id: Number(router.query.id) });
|
||||
setLink(get);
|
||||
await getLink.mutateAsync(Number(router.query.id));
|
||||
}
|
||||
};
|
||||
|
||||
fetchLink();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (links && links[0])
|
||||
setLink(links.find((e) => e.id === Number(router.query.id)));
|
||||
}, [links]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* <div className="fixed left-1/2 transform -translate-x-1/2 w-fit py-1 px-3 bg-base-200 border border-neutral-content rounded-md">
|
||||
@@ -34,12 +39,6 @@ export default function Index() {
|
||||
{link && Number(router.query.format) === ArchivedFormat.readability && (
|
||||
<ReadableView link={link} />
|
||||
)}
|
||||
{link && Number(router.query.format) === ArchivedFormat.monolith && (
|
||||
<iframe
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.monolith}`}
|
||||
className="w-full h-screen border-none"
|
||||
></iframe>
|
||||
)}
|
||||
{link && Number(router.query.format) === ArchivedFormat.pdf && (
|
||||
<iframe
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}`}
|
||||
|
||||
+11
-11
@@ -133,9 +133,9 @@ export default function Register({
|
||||
loading={submitLoader}
|
||||
>
|
||||
{value.name.toLowerCase() === "google" ||
|
||||
(value.name.toLowerCase() === "apple" && (
|
||||
<i className={"bi-" + value.name.toLowerCase()}></i>
|
||||
))}
|
||||
value.name.toLowerCase() === "apple" ? (
|
||||
<i className={"bi-" + value.name.toLowerCase()}></i>
|
||||
) : undefined}
|
||||
{value.name}
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
@@ -201,7 +201,7 @@ export default function Register({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emailEnabled && (
|
||||
{emailEnabled ? (
|
||||
<div>
|
||||
<p className="text-sm w-fit font-semibold mb-1">{t("email")}</p>
|
||||
|
||||
@@ -214,7 +214,7 @@ export default function Register({
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : undefined}
|
||||
|
||||
<div className="w-full">
|
||||
<p className="text-sm w-fit font-semibold mb-1">
|
||||
@@ -248,7 +248,7 @@ export default function Register({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{process.env.NEXT_PUBLIC_STRIPE && (
|
||||
{process.env.NEXT_PUBLIC_STRIPE ? (
|
||||
<div className="text-xs text-neutral mb-3">
|
||||
<p>
|
||||
<Trans
|
||||
@@ -270,7 +270,7 @@ export default function Register({
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
) : undefined}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -282,9 +282,9 @@ export default function Register({
|
||||
{t("sign_up")}
|
||||
</Button>
|
||||
|
||||
{availableLogins.buttonAuths.length > 0 && (
|
||||
{availableLogins.buttonAuths.length > 0 ? (
|
||||
<div className="divider my-1">{t("or_continue_with")}</div>
|
||||
)}
|
||||
) : undefined}
|
||||
|
||||
{displayLoginExternalButton()}
|
||||
<div>
|
||||
@@ -298,7 +298,7 @@ export default function Register({
|
||||
{t("login")}
|
||||
</Link>
|
||||
</div>
|
||||
{process.env.NEXT_PUBLIC_STRIPE && (
|
||||
{process.env.NEXT_PUBLIC_STRIPE ? (
|
||||
<div className="text-neutral text-center flex items-baseline gap-1 justify-center">
|
||||
<p>{t("need_help")}</p>
|
||||
<Link
|
||||
@@ -309,7 +309,7 @@ export default function Register({
|
||||
{t("get_in_touch")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function AccessTokens() {
|
||||
{t("new_token")}
|
||||
</button>
|
||||
|
||||
{tokens.length > 0 && (
|
||||
{tokens.length > 0 ? (
|
||||
<table className="table mt-2 overflow-x-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -67,18 +67,10 @@ export default function AccessTokens() {
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(token.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
{new Date(token.createdAt || "").toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(token.expires).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
{new Date(token.expires || "").toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@@ -93,12 +85,12 @@ export default function AccessTokens() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
) : undefined}
|
||||
</div>
|
||||
|
||||
{newTokenModal && (
|
||||
{newTokenModal ? (
|
||||
<NewTokenModal onClose={() => setNewTokenModal(false)} />
|
||||
)}
|
||||
) : undefined}
|
||||
{revokeTokenModal && selectedToken && (
|
||||
<RevokeTokenModal
|
||||
onClose={() => {
|
||||
|
||||
+13
-35
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, ChangeEvent } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { AccountSettings } from "@/types/global";
|
||||
import { toast } from "react-hot-toast";
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
@@ -17,7 +17,6 @@ import { i18n } from "next-i18next.config";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||
import { z } from "zod";
|
||||
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
|
||||
@@ -56,10 +55,8 @@ export default function Account() {
|
||||
if (!objectIsEmpty(account)) setUser({ ...account });
|
||||
}, [account]);
|
||||
|
||||
const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return toast.error(t("image_upload_no_file_error"));
|
||||
|
||||
const handleImageUpload = async (e: any) => {
|
||||
const file: File = e.target.files[0];
|
||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||
const allowedExtensions = ["png", "jpeg", "jpg"];
|
||||
if (allowedExtensions.includes(fileExtension as string)) {
|
||||
@@ -81,16 +78,6 @@ export default function Account() {
|
||||
};
|
||||
|
||||
const submit = async (password?: string) => {
|
||||
if (!/^[a-z0-9_-]{3,50}$/.test(user.username || "")) {
|
||||
return toast.error(t("username_invalid_guide"));
|
||||
}
|
||||
|
||||
const emailSchema = z.string().trim().email().toLowerCase();
|
||||
const emailValidation = emailSchema.safeParse(user.email || "");
|
||||
if (!emailValidation.success) {
|
||||
return toast.error(t("email_invalid"));
|
||||
}
|
||||
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading(t("applying_settings"));
|
||||
@@ -108,7 +95,6 @@ export default function Account() {
|
||||
}
|
||||
},
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
@@ -125,20 +111,12 @@ export default function Account() {
|
||||
}
|
||||
);
|
||||
|
||||
if (user.locale !== account.locale) {
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
const importBookmarks = async (
|
||||
e: ChangeEvent<HTMLInputElement>,
|
||||
format: MigrationFormat
|
||||
) => {
|
||||
const importBookmarks = async (e: any, format: MigrationFormat) => {
|
||||
setSubmitLoader(true);
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
const file: File = e.target.files[0];
|
||||
if (file) {
|
||||
var reader = new FileReader();
|
||||
reader.readAsText(file, "UTF-8");
|
||||
@@ -212,17 +190,16 @@ export default function Account() {
|
||||
onChange={(e) => setUser({ ...user, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{emailEnabled && (
|
||||
{emailEnabled ? (
|
||||
<div>
|
||||
<p className="mb-2">{t("email")}</p>
|
||||
<TextInput
|
||||
value={user.email || ""}
|
||||
type="email"
|
||||
className="bg-base-200"
|
||||
onChange={(e) => setUser({ ...user, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : undefined}
|
||||
<div>
|
||||
<p className="mb-2">{t("language")}</p>
|
||||
<select
|
||||
@@ -460,8 +437,9 @@ export default function Account() {
|
||||
|
||||
<p>
|
||||
{t("delete_account_warning")}
|
||||
{process.env.NEXT_PUBLIC_STRIPE &&
|
||||
" " + t("cancel_subscription_notice")}
|
||||
{process.env.NEXT_PUBLIC_STRIPE
|
||||
? " " + t("cancel_subscription_notice")
|
||||
: undefined}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -470,14 +448,14 @@ export default function Account() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{emailChangeVerificationModal && (
|
||||
{emailChangeVerificationModal ? (
|
||||
<EmailChangeVerificationModal
|
||||
onClose={() => setEmailChangeVerificationModal(false)}
|
||||
onSubmit={submit}
|
||||
oldEmail={account.email || ""}
|
||||
newEmail={user.email || ""}
|
||||
/>
|
||||
)}
|
||||
) : undefined}
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-226
@@ -1,57 +1,17 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useRouter } from "next/router";
|
||||
import InviteModal from "@/components/ModalContent/InviteModal";
|
||||
import { User as U } from "@prisma/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUsers } from "@/hooks/store/admin/users";
|
||||
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import clsx from "clsx";
|
||||
import { signIn } from "next-auth/react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface User extends U {
|
||||
subscriptions: {
|
||||
active: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type UserModal = {
|
||||
isOpen: boolean;
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
export default function Billing() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: account } = useUser();
|
||||
const { data: users = [] } = useUsers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!process.env.NEXT_PUBLIC_STRIPE || account.parentSubscriptionId)
|
||||
router.push("/settings/account");
|
||||
if (!process.env.NEXT_PUBLIC_STRIPE) router.push("/settings/profile");
|
||||
}, []);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (users.length > 0) {
|
||||
setFilteredUsers(users);
|
||||
}
|
||||
}, [users]);
|
||||
|
||||
const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({
|
||||
isOpen: false,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
@@ -80,190 +40,6 @@ export default function Billing() {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full rounded-md h-8 mt-5">
|
||||
<p className="truncate w-full pr-7 text-3xl font-thin">
|
||||
{t("manage_seats")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divider my-3"></div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mb-3 relative">
|
||||
<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={t("search_users")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
||||
if (users) {
|
||||
setFilteredUsers(
|
||||
users.filter((user: any) =>
|
||||
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 gap-3">
|
||||
<div
|
||||
onClick={() => setInviteModal(true)}
|
||||
className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 h-[2.15rem] relative"
|
||||
>
|
||||
<p>{t("invite_user")}</p>
|
||||
<i className="bi-plus text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md shadow border-neutral-content">
|
||||
<table className="table bg-base-300 rounded-md">
|
||||
<thead>
|
||||
<tr className="sm:table-row hidden border-b-neutral-content">
|
||||
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
||||
<th>{t("email")}</th>
|
||||
)}
|
||||
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
|
||||
<th>{t("status")}</th>
|
||||
)}
|
||||
<th>{t("date_added")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers?.map((user, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className={clsx(
|
||||
"group border-b-neutral-content duration-100 w-full relative flex flex-col sm:table-row",
|
||||
user.id !== account.id &&
|
||||
"hover:bg-neutral-content hover:bg-opacity-30"
|
||||
)}
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
||||
<td className="truncate max-w-full" title={user.email || ""}>
|
||||
<p className="sm:hidden block text-neutral text-xs font-bold mb-2">
|
||||
{t("email")}
|
||||
</p>
|
||||
<p>{user.email}</p>
|
||||
</td>
|
||||
)}
|
||||
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
|
||||
<td>
|
||||
<p className="sm:hidden block text-neutral text-xs font-bold mb-2">
|
||||
{t("status")}
|
||||
</p>
|
||||
{user.emailVerified ? (
|
||||
<p className="font-bold px-2 bg-green-600 text-white rounded-md w-fit">
|
||||
{t("active")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="font-bold px-2 bg-neutral-content rounded-md w-fit">
|
||||
{t("pending")}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<p className="sm:hidden block text-neutral text-xs font-bold mb-2">
|
||||
{t("date_added")}
|
||||
</p>
|
||||
<p className="whitespace-nowrap">
|
||||
{new Date(user.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</td>
|
||||
{user.id !== account.id && (
|
||||
<td>
|
||||
<div
|
||||
className={`dropdown dropdown-bottom font-normal dropdown-end absolute right-[0.35rem] top-[0.35rem]`}
|
||||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-ghost btn-sm btn-square duration-100"
|
||||
>
|
||||
<i
|
||||
className={"bi bi-three-dots text-lg text-neutral"}
|
||||
></i>
|
||||
</div>
|
||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
|
||||
{!user.emailVerified ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(
|
||||
document?.activeElement as HTMLElement
|
||||
)?.blur();
|
||||
signIn("invite", {
|
||||
email: user.email,
|
||||
callbackUrl: "/member-onboarding",
|
||||
redirect: false,
|
||||
}).then(() =>
|
||||
toast.success(t("resend_invite_success"))
|
||||
);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("resend_invite")}
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setDeleteUserModal({
|
||||
isOpen: true,
|
||||
userId: user.id,
|
||||
});
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("remove_user")}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-sm text-center font-bold mt-3">
|
||||
{t("seats_purchased", { count: account?.subscription?.quantity })}
|
||||
</p>
|
||||
{inviteModal && <InviteModal onClose={() => setInviteModal(false)} />}
|
||||
{deleteUserModal.isOpen && deleteUserModal.userId && (
|
||||
<DeleteUserModal
|
||||
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
|
||||
userId={deleteUserModal.userId}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import Link from "next/link";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export default function Delete() {
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -16,7 +15,6 @@ export default function Delete() {
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { data } = useSession();
|
||||
const { t } = useTranslation();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const submit = async () => {
|
||||
const body = {
|
||||
@@ -85,7 +83,7 @@ export default function Delete() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && (
|
||||
{process.env.NEXT_PUBLIC_STRIPE ? (
|
||||
<fieldset className="border rounded-md p-2 border-primary">
|
||||
<legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-primary">
|
||||
<b>{t("optional")}</b> <i>{t("feedback_help")}</i>
|
||||
@@ -125,7 +123,7 @@ export default function Delete() {
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
) : undefined}
|
||||
|
||||
<Button
|
||||
className="mx-auto"
|
||||
|
||||
@@ -34,7 +34,6 @@ export default function Password() {
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
@@ -48,6 +47,8 @@ export default function Password() {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -80,7 +80,6 @@ export default function Appearance() {
|
||||
{ ...user },
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
@@ -91,6 +90,8 @@ export default function Appearance() {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+8
-9
@@ -9,6 +9,8 @@ import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { Trans, useTranslation } from "next-i18next";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
|
||||
|
||||
export default function Subscribe() {
|
||||
const { t } = useTranslation();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
@@ -21,14 +23,13 @@ export default function Subscribe() {
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("user", user);
|
||||
if (
|
||||
session.status === "authenticated" &&
|
||||
user.id &&
|
||||
(user?.subscription?.active || user.parentSubscription?.active)
|
||||
)
|
||||
const hasInactiveSubscription =
|
||||
user.id && !user.subscription?.active && stripeEnabled;
|
||||
|
||||
if (session.status === "authenticated" && !hasInactiveSubscription) {
|
||||
router.push("/dashboard");
|
||||
}, [session.status, user]);
|
||||
}
|
||||
}, [session.status]);
|
||||
|
||||
async function submit() {
|
||||
setSubmitLoader(true);
|
||||
@@ -39,8 +40,6 @@ export default function Subscribe() {
|
||||
const data = await res.json();
|
||||
|
||||
router.push(data.response);
|
||||
|
||||
toast.dismiss(redirectionToast);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+26
-24
@@ -85,7 +85,6 @@ export default function Index() {
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
@@ -98,6 +97,7 @@ export default function Index() {
|
||||
);
|
||||
}
|
||||
|
||||
setSubmitLoader(false);
|
||||
setRenameTag(false);
|
||||
};
|
||||
|
||||
@@ -146,29 +146,31 @@ export default function Index() {
|
||||
<i className={"bi-hash text-primary text-3xl"} />
|
||||
|
||||
{renameTag ? (
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
className="sm:text-3xl text-2xl bg-transparent h-10 w-3/4 outline-none border-b border-b-neutral-content"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
onClick={() => submit()}
|
||||
id="expand-dropdown"
|
||||
className="btn btn-ghost btn-square btn-sm"
|
||||
>
|
||||
<i className={"bi-check2 text-neutral text-2xl"}></i>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => cancelUpdateTag()}
|
||||
id="expand-dropdown"
|
||||
className="btn btn-ghost btn-square btn-sm"
|
||||
>
|
||||
<i className={"bi-x text-neutral text-2xl"}></i>
|
||||
</div>
|
||||
</form>
|
||||
<>
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
className="sm:text-3xl text-2xl bg-transparent h-10 w-3/4 outline-none border-b border-b-neutral-content"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
onClick={() => submit()}
|
||||
id="expand-dropdown"
|
||||
className="btn btn-ghost btn-square btn-sm"
|
||||
>
|
||||
<i className={"bi-check text-neutral text-2xl"}></i>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => cancelUpdateTag()}
|
||||
id="expand-dropdown"
|
||||
className="btn btn-ghost btn-square btn-sm"
|
||||
>
|
||||
<i className={"bi-x text-neutral text-2xl"}></i>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="sm:text-3xl text-2xl capitalize">
|
||||
|
||||
Reference in New Issue
Block a user