Merge pull request #1416 from linkwarden/feat/improvements

Feat/improvements
This commit is contained in:
Daniel
2025-09-25 09:37:21 -04:00
committed by GitHub
34 changed files with 713 additions and 292 deletions

View File

@@ -35,9 +35,9 @@ export type LinkIncludingShortenedCollectionAndTags = {
};
export enum ArchivedFormat {
png,
jpeg,
pdf,
readability,
monolith,
png = 0,
jpeg = 1,
pdf = 2,
readability = 3,
monolith = 4,
}

View File

@@ -268,7 +268,7 @@ export default function DashboardLayoutDropdown() {
<TextInput
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="py-0"
className="py-0 bg-base-100"
placeholder={t("search")}
/>
</div>

View File

@@ -39,7 +39,7 @@ export function DashboardLinks({
}) {
return (
<div
className={`flex gap-5 overflow-x-auto overflow-y-hidden hide-scrollbar w-full min-h-72`}
className={`flex gap-5 overflow-x-auto overflow-y-hidden hide-scrollbar w-full min-h-fit`}
>
{isLoading ? (
<div className="flex flex-col gap-4 min-w-60 w-60">
@@ -198,7 +198,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
</div>
)}
<div className="flex flex-col justify-between h-full min-h-24">
<div className="flex flex-col justify-between h-full min-h-11">
<div className="p-3 flex flex-col justify-between h-full gap-2">
{show.name && (
<p className="line-clamp-2 w-full text-primary text-sm">

View File

@@ -34,8 +34,8 @@ export default function NoLinksFound({ text }: Props) {
}}
variant="accent"
>
<i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i>
<span className="group-hover:opacity-0 text-right w-full duration-100">
<i className="bi-plus-lg text-xl duration-100"></i>
<span className="group-hover:opacity-0 w-full duration-100">
{t("create_new_link")}
</span>
</Button>

View File

@@ -7,8 +7,26 @@ import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import { useTags } from "@linkwarden/router/tags";
import { TagListing } from "./TagListing";
import { Button } from "./ui/button";
import { useUser } from "@linkwarden/router/user";
import Image from "next/image";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export default function Sidebar({ className }: { className?: string }) {
export default function Sidebar({
className,
toggleSidebar,
sidebarIsCollapsed,
}: {
className?: string;
toggleSidebar?: () => void;
sidebarIsCollapsed?: boolean;
}) {
const { t } = useTranslation();
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
const storedValue = localStorage.getItem("tagDisclosure");
@@ -29,6 +47,8 @@ export default function Sidebar({ className }: { className?: string }) {
const router = useRouter();
const { data: user } = useUser();
useEffect(() => {
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
}, [tagDisclosure]);
@@ -47,105 +67,201 @@ export default function Sidebar({ className }: { className?: string }) {
return (
<div
id="sidebar"
className={`bg-base-200 h-full w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
className || ""
}`}
className={cn(
"bg-base-200 h-screen overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20",
className,
sidebarIsCollapsed ? "w-14" : "w-80"
)}
>
<div className="flex flex-col gap-1">
<SidebarHighlightLink
title={t("dashboard")}
href={`/dashboard`}
icon={"bi-house"}
active={active === `/dashboard`}
/>
<SidebarHighlightLink
title={t("links")}
href={`/links`}
icon={"bi-link-45deg"}
active={active === `/links`}
/>
<SidebarHighlightLink
title={t("pinned")}
href={`/links/pinned`}
icon={"bi-pin-angle"}
active={active === `/links/pinned`}
/>
<SidebarHighlightLink
title={t("collections")}
href={`/collections`}
icon={"bi-folder"}
active={active === `/collections`}
/>
<SidebarHighlightLink
title={t("tags")}
href={`/tags`}
icon={"bi-hash"}
active={active === `/tags`}
/>
<div
className={cn(
"flex flex-col",
sidebarIsCollapsed
? "my-auto h-full justify-between items-center gap-3"
: "gap-1"
)}
>
<div className="flex items-center justify-between mb-4">
{sidebarIsCollapsed ? (
<Image
src={"/icon.png"}
width={640}
height={136}
alt="Linkwarden Icon"
className="h-8 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
/>
) : user?.theme === "light" ? (
<Image
src={"/linkwarden_light.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
/>
) : (
<Image
src={"/linkwarden_dark.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
/>
)}
{!sidebarIsCollapsed && (
<div className="hidden lg:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={toggleSidebar}
size={"icon"}
>
<i className={`bi-layout-sidebar`} />
</Button>
</TooltipTrigger>
<TooltipContent>
{sidebarIsCollapsed
? t("expand_sidebar")
: t("shrink_sidebar")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
<div
className={cn(
"flex flex-col",
sidebarIsCollapsed ? "my-auto justify-center gap-3" : "gap-1"
)}
>
<SidebarHighlightLink
title={t("dashboard")}
href={`/dashboard`}
icon={"bi-house"}
active={active === `/dashboard`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
<SidebarHighlightLink
title={t("links")}
href={`/links`}
icon={"bi-link-45deg"}
active={active === `/links`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
<SidebarHighlightLink
title={t("pinned")}
href={`/links/pinned`}
icon={"bi-pin-angle"}
active={active === `/links/pinned`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
<SidebarHighlightLink
title={t("collections")}
href={`/collections`}
icon={"bi-folder"}
active={active === `/collections`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
<SidebarHighlightLink
title={t("tags")}
href={`/tags`}
icon={"bi-hash"}
active={active === `/tags`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
</div>
{sidebarIsCollapsed && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" onClick={toggleSidebar} size={"icon"}>
<i className={`bi-layout-sidebar`} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{sidebarIsCollapsed ? t("expand_sidebar") : t("shrink_sidebar")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<Disclosure defaultOpen={collectionDisclosure}>
<Disclosure.Button
onClick={() => {
setCollectionDisclosure(!collectionDisclosure);
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">{t("collections")}</p>
<i
className={`bi-chevron-down ${
collectionDisclosure ? "rotate-reverse" : "rotate"
}`}
></i>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel>
<CollectionListing />
</Disclosure.Panel>
</Transition>
</Disclosure>
<Disclosure defaultOpen={tagDisclosure}>
<Disclosure.Button
onClick={() => {
setTagDisclosure(!tagDisclosure);
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">{t("tags")}</p>
<i
className={`bi-chevron-down ${
tagDisclosure ? "rotate-reverse" : "rotate"
}`}
></i>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="flex flex-col gap-1">
{isLoading ? (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
) : (
<TagListing tags={tags} active={active} />
)}
</Disclosure.Panel>
</Transition>
</Disclosure>
{sidebarIsCollapsed ? (
<></>
) : (
<>
<Disclosure defaultOpen={collectionDisclosure}>
<Disclosure.Button
onClick={() => {
setCollectionDisclosure(!collectionDisclosure);
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">{t("collections")}</p>
<i
className={`bi-chevron-down ${
collectionDisclosure ? "rotate-reverse" : "rotate"
}`}
></i>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel>
<CollectionListing />
</Disclosure.Panel>
</Transition>
</Disclosure>
<Disclosure defaultOpen={tagDisclosure}>
<Disclosure.Button
onClick={() => {
setTagDisclosure(!tagDisclosure);
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">{t("tags")}</p>
<i
className={`bi-chevron-down ${
tagDisclosure ? "rotate-reverse" : "rotate"
}`}
></i>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="flex flex-col gap-1">
{isLoading ? (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
) : (
<TagListing tags={tags} active={active} />
)}
</Disclosure.Panel>
</Transition>
</Disclosure>
</>
)}
</div>
);
}

View File

@@ -1,27 +1,56 @@
import Link from "next/link";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export default function SidebarHighlightLink({
title,
href,
icon,
active,
sidebarIsCollapsed,
}: {
title: string;
href: string;
icon: string;
active?: boolean;
sidebarIsCollapsed?: boolean;
}) {
return (
<Link href={href}>
<div
title={title}
className={`${
active || false ? "bg-primary/20" : "hover:bg-neutral/20"
} duration-200 px-3 py-1 cursor-pointer flex items-center gap-2 w-full rounded-lg capitalize`}
>
<i className={`${icon} text-primary text-xl drop-shadow`}></i>
<p className="truncate w-full font-semibold text-sm">{title}</p>
</div>
</Link>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link href={href} title={title}>
<div
className={cn(
active ? "bg-primary/20" : "hover:bg-neutral/20",
"duration-200 cursor-pointer flex items-center gap-2 capitalize",
sidebarIsCollapsed
? "rounded-md h-8 w-8"
: "rounded-lg px-3 py-1"
)}
>
<i
className={cn(
icon,
"text-primary text-xl drop-shadow",
sidebarIsCollapsed && "w-full text-center"
)}
></i>
{!sidebarIsCollapsed && (
<p className="truncate w-full font-semibold text-sm">{title}</p>
)}
</div>
</Link>
</TooltipTrigger>
{sidebarIsCollapsed && (
<TooltipContent side="right">{title}</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -1,5 +1,4 @@
import Link from "next/link";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "next-i18next";
import {
DropdownMenu,
@@ -39,16 +38,16 @@ export default function TagCard({
return (
<div
className={cn(
"relative rounded-xl p-2 flex gap-2 flex-col shadow-md cursor-pointer hover:shadow-none hover:bg-opacity-70 duration-200 border border-neutral-content",
"relative rounded-xl p-2 shadow-md cursor-pointer hover:shadow-none hover:bg-opacity-70 duration-200 border border-neutral-content",
editMode ? "bg-base-300" : "bg-base-200",
selected && "border-primary"
)}
onClick={() =>
editMode ? onSelect(tag.id) : router.push(`/tags/${tag.id}`)
}
>
{editMode ? (
<Checkbox checked={selected} className="absolute top-3 right-3 z-20" />
<Checkbox
checked={selected}
className="absolute top-3 right-3 z-20 pointer-events-none"
/>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -79,25 +78,32 @@ export default function TagCard({
</DropdownMenu>
)}
<h2 className="truncate leading-tight py-1 pr-8" title={tag.name}>
{tag.name}
</h2>
<div
className="flex gap-2 flex-col"
onClick={() =>
editMode ? onSelect(tag.id) : router.push(`/tags/${tag.id}`)
}
>
<h2 className="truncate leading-tight py-1 pr-8" title={tag.name}>
{tag.name}
</h2>
<div className="flex justify-between items-center mt-auto">
<div className="text-xs flex gap-1 items-center">
<i
className="bi-calendar3 text-neutral"
title={t("collection_publicly_shared")}
></i>
{formattedDate}
</div>
<div className="flex justify-between items-center mt-auto">
<div className="text-xs flex gap-1 items-center">
<i
className="bi-calendar3 text-neutral"
title={t("collection_publicly_shared")}
></i>
{formattedDate}
</div>
<div className="text-xs flex gap-1 items-center">
<i
className="bi-link-45deg text-lg leading-none text-neutral"
title={t("collection_publicly_shared")}
></i>
{tag._count?.links}
<div className="text-xs flex gap-1 items-center">
<i
className="bi-link-45deg text-lg leading-none text-neutral"
title={t("collection_publicly_shared")}
></i>
{tag._count?.links}
</div>
</div>
</div>

View File

@@ -8,7 +8,9 @@ interface Props {
children: ReactNode;
}
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
const STRIPE_ENABLED = process.env.NEXT_PUBLIC_STRIPE === "true";
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default function AuthRedirect({ children }: Props) {
const router = useRouter();
@@ -22,11 +24,19 @@ export default function AuthRedirect({ children }: Props) {
const isLoggedIn = status === "authenticated";
const isUnauthenticated = status === "unauthenticated";
const isPublicPage = router.pathname.startsWith("/public");
const trialEndTime =
new Date(user?.createdAt || 0).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
const hasInactiveSubscription =
user?.id &&
!user?.subscription?.active &&
!user.parentSubscription?.active &&
stripeEnabled;
STRIPE_ENABLED &&
(REQUIRE_CC || daysLeft <= 0);
// There are better ways of doing this... but this one works for now
const routes = [

View File

@@ -30,7 +30,7 @@ export default function CenteredForm({
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
className="h-12 w-auto mx-auto"
/>
) : (
<Image
@@ -38,7 +38,7 @@ export default function CenteredForm({
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
className="h-12 w-auto mx-auto"
/>
)}
{text && (

View File

@@ -10,27 +10,35 @@ interface Props {
export default function MainLayout({ children }: Props) {
const showAnnouncementBar = localStorage.getItem("showAnnouncementBar");
const sidebarState = localStorage.getItem("sidebarIsCollapsed");
const [showAnnouncement, setShowAnnouncement] = useState(
showAnnouncementBar ? showAnnouncementBar === "true" : true
);
const [sidebarIsCollapsed, setSidebarIsCollapsed] = useState(
sidebarState ? sidebarState === "true" : false
);
useEffect(() => {
getLatestVersion(setShowAnnouncement);
}, []);
useEffect(() => {
if (showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "true");
setShowAnnouncement(true);
} else if (!showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "false");
setShowAnnouncement(false);
}
localStorage.setItem(
"showAnnouncementBar",
showAnnouncement ? "true" : "false"
);
}, [showAnnouncement]);
const toggleAnnouncementBar = () => {
setShowAnnouncement(!showAnnouncement);
};
useEffect(() => {
localStorage.setItem(
"sidebarIsCollapsed",
sidebarIsCollapsed ? "true" : "false"
);
}, [sidebarIsCollapsed]);
const toggleAnnouncementBar = () => setShowAnnouncement(!showAnnouncement);
const toggleSidebar = () => setSidebarIsCollapsed(!sidebarIsCollapsed);
return (
<div className="flex" data-testid="dashboard-wrapper">
@@ -38,11 +46,19 @@ export default function MainLayout({ children }: Props) {
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
)}
<div className="hidden lg:block">
<Sidebar className={`fixed top-0`} />
<Sidebar
className={`${sidebarIsCollapsed ? "w-14" : "w-80"}`}
toggleSidebar={toggleSidebar}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
</div>
<div
className={`lg:w-[calc(100%-320px)] w-full sm:pb-0 pb-20 flex flex-col min-h-screen lg:ml-80`}
className={`${
sidebarIsCollapsed
? "lg:w-[calc(100%-56px)]"
: "lg:w-[calc(100%-320px)]"
} w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto`}
>
<Navbar />
{children}

View File

@@ -8,6 +8,14 @@ export default async function exportData(userId: number) {
include: {
rssSubscriptions: true,
links: {
omit: {
textContent: true, // Exclude to reduce payload size
preview: true,
image: true,
readable: true,
monolith: true,
pdf: true,
},
include: {
tags: true,
},

View File

@@ -28,6 +28,9 @@ export default async function mergeTags(
where: {
tags: {
some: {
id: {
in: tagIds,
},
ownerId: userId,
},
},

View File

@@ -37,7 +37,8 @@ export default async function postUser(
};
}
const { name, email, password, invite } = dataValidation.data;
const { name, email, password, invite, acceptPromotionalEmails } =
dataValidation.data;
let { username } = dataValidation.data;
if (invite && (!stripeEnabled || !emailEnabled)) {
@@ -109,6 +110,7 @@ export default async function postUser(
},
}
: undefined,
acceptPromotionalEmails: acceptPromotionalEmails || false,
dashboardSections: {
createMany: {
data: [

View File

@@ -2,6 +2,11 @@ import verifySubscription from "./stripe/verifySubscription";
import { prisma } from "@linkwarden/prisma";
import stripeSDK from "./stripe/stripeSDK";
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
const MANAGED_PAYMENTS_ENABLED =
process.env.MANAGED_PAYMENTS_ENABLED === "true";
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
export default async function paymentCheckout(email: string, priceId: string) {
const stripe = stripeSDK();
@@ -15,9 +20,22 @@ export default async function paymentCheckout(email: string, priceId: string) {
},
});
if (!user) {
return { response: "User not found", status: 404 };
}
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
const subscription = await verifySubscription(user);
if (subscription) {
if (
subscription?.subscriptions?.active ||
subscription?.parentSubscription?.active
) {
// To prevent users from creating multiple subscriptions
return { response: "/dashboard", status: 200 };
}
@@ -44,12 +62,22 @@ export default async function paymentCheckout(email: string, priceId: string) {
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
success_url: `${process.env.BASE_URL}/dashboard`,
cancel_url: `${process.env.BASE_URL}/login`,
subscription_data: {
trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
: 14,
},
...(process.env.MANAGED_PAYMENTS_ENABLED === "true"
...(REQUIRE_CC
? {
subscription_data: {
trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
: 14,
},
}
: daysLeft > 0
? {
subscription_data: {
trial_period_days: daysLeft,
},
}
: {}),
...(MANAGED_PAYMENTS_ENABLED
? {
managed_payments: {
enabled: true,

View File

@@ -7,10 +7,25 @@ interface UserIncludingSubscription extends User {
parentSubscription: Subscription | null;
}
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default async function verifySubscription(
user?: UserIncludingSubscription | null
) {
if (!user || (!user.subscriptions && !user.parentSubscription)) {
if (!user) return null;
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
if (
!user.subscriptions &&
!user.parentSubscription &&
(REQUIRE_CC || daysLeft <= 0)
) {
return null;
}
@@ -19,8 +34,9 @@ export default async function verifySubscription(
}
if (
!user.subscriptions?.active ||
new Date() > user.subscriptions.currentPeriodEnd
(!user.subscriptions?.active ||
new Date() > user.subscriptions.currentPeriodEnd) &&
(REQUIRE_CC || daysLeft <= 0)
) {
const subscription = await checkSubscriptionByEmail(user.email as string);

View File

@@ -46,6 +46,17 @@ export default async function verifyUser({
return null;
}
if (
!user.emailVerified &&
process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"
) {
res.status(401).json({
response:
"Email not verified, please verify your email to continue using Linkwarden.",
});
return null;
}
if (STRIPE_SECRET_KEY) {
const subscribedUser = await verifySubscription(user);

View File

@@ -1,6 +1,6 @@
{
"name": "@linkwarden/web",
"version": "v2.12.2",
"version": "v2.13.0",
"main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>",
@@ -74,7 +74,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^6.9.3",
"papaparse": "^5.5.3",
"playwright": "^1.45.0",
"playwright": "^1.55.0",
"react": "18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "18.2.0",
@@ -98,7 +98,7 @@
"zustand": "^4.3.8"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"@playwright/test": "^1.55.0",
"@types/bcrypt": "^5.0.0",
"@types/dompurify": "^3.0.4",
"@types/jsdom": "^21.1.3",

View File

@@ -9,17 +9,17 @@ import PageHeader from "@/components/PageHeader";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function Collections() {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const { data: collections = [], isLoading } = useCollections();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const { data } = useSession();
@@ -62,23 +62,22 @@ export default function Collections() {
title={t("collections")}
description={t("collections_you_own")}
/>
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="text-neutral" variant="ghost" size="icon">
<i className={"bi-three-dots text-neutral text-xl"}></i>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
<DropdownMenuItem
onSelect={() => setNewCollectionModal(true)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setNewCollectionModal(true)}
>
<i className="bi-folder"></i>
{t("new_collection")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<i className="bi-plus-lg text-xl text-neutral"></i>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("new_collection")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex gap-3 justify-end">
<div className="relative mt-2">
@@ -87,23 +86,45 @@ export default function Collections() {
</div>
</div>
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections
.filter((e) => e.ownerId === data?.user.id && e.parentId === null)
.map((e) => (
<CollectionCard key={e.id} collection={e} />
))}
{!isLoading && collections && !collections[0] ? (
<div
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-xl cursor-pointer flex flex-col gap-4 justify-center items-center group"
onClick={() => setNewCollectionModal(true)}
style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
>
<p className="group-hover:opacity-0 duration-100">
{t("new_collection")}
<p className="text-center text-xl">
{t("create_your_first_collection")}
</p>
<i className="bi-plus-lg text-5xl group-hover:text-7xl group-hover:-mt-10 text-primary drop-shadow duration-100"></i>
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
{t("create_your_first_collection_desc")}
</p>
<Button
className="mx-auto mt-5"
variant={"accent"}
onClick={() => setNewCollectionModal(true)}
>
<i className="bi-plus-lg text-xl mr-2" />
{t("new_collection")}
</Button>
</div>
</div>
) : (
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections
.filter((e) => e.ownerId === data?.user.id && e.parentId === null)
.map((e) => (
<CollectionCard key={e.id} collection={e} />
))}
<div
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-xl cursor-pointer flex flex-col gap-4 justify-center items-center group"
onClick={() => setNewCollectionModal(true)}
>
<p className="group-hover:opacity-0 duration-100">
{t("new_collection")}
</p>
<i className="bi-plus-lg text-5xl group-hover:text-7xl group-hover:-mt-10 text-primary drop-shadow duration-100"></i>
</div>
</div>
)}
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] && (
<>

View File

@@ -288,7 +288,7 @@ export default function Dashboard() {
setActiveLink={setActiveLink}
>
<MainLayout>
<div className="p-5 flex flex-col gap-4 h-full">
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<i className="bi-house-fill text-primary" />

View File

@@ -15,6 +15,7 @@ import { i18n } from "next-i18next.config";
import { Trans, useTranslation } from "next-i18next";
import { useConfig } from "@linkwarden/router/config";
import { Separator } from "@/components/ui/separator";
import Checkbox from "@/components/Checkbox";
type FormData = {
name: string;
@@ -22,6 +23,7 @@ type FormData = {
email?: string;
password: string;
passwordConfirmation: string;
acceptPromotionalEmails: boolean;
};
export default function Register({
@@ -39,6 +41,7 @@ export default function Register({
email: config?.EMAIL_PROVIDER ? "" : undefined,
password: "",
passwordConfirmation: "",
acceptPromotionalEmails: false,
});
async function registerUser(event: FormEvent<HTMLFormElement>) {
@@ -251,27 +254,41 @@ export default function Register({
</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>
<>
<Checkbox
className="p-0"
label={t("accept_promotional_emails")}
state={form.acceptPromotionalEmails}
onClick={(e) =>
setForm({
...form,
acceptPromotionalEmails: e.target.checked,
})
}
/>
<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

View File

@@ -32,6 +32,8 @@ type UserModal = {
userId: number | null;
};
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
export default function Billing() {
const router = useRouter();
const { t } = useTranslation();
@@ -40,9 +42,20 @@ export default function Billing() {
const { data: users = [] } = useUsers();
useEffect(() => {
if (!process.env.NEXT_PUBLIC_STRIPE || account?.parentSubscriptionId)
if (!process.env.NEXT_PUBLIC_STRIPE || account?.parentSubscriptionId) {
router.push("/settings/account");
}, []);
} else if (account?.createdAt) {
const trialEndTime =
new Date(account.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
if (daysLeft > 0 && !account.subscription?.active) {
router.push("/subscribe");
}
}
}, [account]);
const [searchQuery, setSearchQuery] = useState("");
const [filteredUsers, setFilteredUsers] = useState<User[]>();

View File

@@ -9,6 +9,12 @@ import getServerSideProps from "@/lib/client/getServerSideProps";
import { Trans, useTranslation } from "next-i18next";
import { useUser } from "@linkwarden/router/user";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { cn } from "@/lib/utils";
const TRIAL_PERIOD_DAYS =
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default function Subscribe() {
const { t } = useTranslation();
@@ -21,6 +27,18 @@ export default function Subscribe() {
const { data: user } = useUser();
const [daysLeft, setDaysLeft] = useState<number>(0);
useEffect(() => {
if (user?.createdAt) {
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
setDaysLeft(Math.floor((trialEndTime - Date.now()) / 86400000));
}
}, [user]);
useEffect(() => {
if (
session.status === "authenticated" &&
@@ -45,9 +63,13 @@ export default function Subscribe() {
return (
<CenteredForm
text={`Start with a ${
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
}-day free trial, cancel anytime!`}
text={
daysLeft <= 0
? "Your free trial has ended, subscribe to continue."
: `You have ${REQUIRE_CC ? 14 : daysLeft || 0} ${
!REQUIRE_CC && daysLeft === 1 ? "day" : "days"
} left in your free trial.`
}
>
<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-xl shadow-md border border-neutral-content">
<p className="sm:text-3xl text-xl text-center font-extralight">
@@ -116,11 +138,11 @@ export default function Subscribe() {
<p className="text-sm">
{plan === Plan.monthly
? t("total_monthly_desc", {
count: Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS),
count: REQUIRE_CC ? 14 : daysLeft,
monthlyPrice: "4",
})
: t("total_annual_desc", {
count: Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS),
count: REQUIRE_CC ? 14 : daysLeft,
annualPrice: "36",
})}
</p>
@@ -128,21 +150,39 @@ export default function Subscribe() {
</fieldset>
</div>
<Button
type="button"
variant="accent"
size="full"
onClick={submit}
disabled={submitLoader}
>
{t("complete_subscription")}
</Button>
<div
onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
className={cn(
"flex gap-3 flex-col",
REQUIRE_CC || daysLeft <= 0 ? "" : "sm:flex-row-reverse"
)}
>
{t("sign_out")}
<Button
type="button"
variant="accent"
size="full"
onClick={submit}
disabled={submitLoader}
>
{t("complete_subscription")}
</Button>
{REQUIRE_CC || daysLeft <= 0 ? (
<div
onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
>
{t("sign_out")}
</div>
) : (
<Button
className=""
variant="metal"
size="full"
onClick={() => router.push("/dashboard")}
>
{t("subscribe_later")}
</Button>
)}
</div>
</div>
</CenteredForm>

View File

@@ -151,7 +151,7 @@ export default function Index() {
setActiveLink={setActiveLink}
>
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full">
<div className="p-5 flex flex-col gap-5 w-full h-full">
<LinkListOptions
t={t}
viewMode={viewMode}
@@ -192,12 +192,7 @@ export default function Index() {
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
asChild
variant="ghost"
size="icon"
title={t("more")}
>
<Button variant="ghost" size="icon" title={t("more")}>
<i className="bi-three-dots text-xl text-neutral" />
</Button>
</DropdownMenuTrigger>
@@ -241,6 +236,20 @@ export default function Index() {
placeholderCount={1}
useData={data}
/>
{!data.isLoading && links && !links[0] && (
<div
style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
>
<p className="text-center text-xl">
{t("this_tag_has_no_links")}
</p>
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
{t("this_tag_has_no_links_desc")}
</p>
</div>
)}
</div>
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal

View File

@@ -8,7 +8,6 @@ import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
@@ -35,7 +34,7 @@ enum TagSort {
export default function Tags() {
const { t } = useTranslation();
const { data: tags = [] } = useTags();
const { data: tags = [], isLoading } = useTags();
const [sortBy, setSortBy] = useState<TagSort>(TagSort.DateNewestFirst);
const [newTagModal, setNewTagModal] = useState(false);
@@ -78,21 +77,22 @@ export default function Tags() {
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<PageHeader icon={"bi-hash"} title={t("tags")} />
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="text-neutral" variant="ghost" size="icon">
<i className={"bi-three-dots text-neutral text-xl"}></i>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setNewTagModal(true)}
>
<i className="bi-plus-lg text-xl text-neutral"></i>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
<DropdownMenuItem onSelect={() => setNewTagModal(true)}>
<i className="bi-plus-lg" />
{t("new_tag")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("new_tag")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex gap-3 justify-end">
@@ -238,6 +238,26 @@ export default function Tags() {
/>
))}
</div>
{!isLoading && tags && !tags[0] && (
<div
style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
>
<p className="text-center text-xl">{t("create_your_first_tag")}</p>
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
{t("create_your_first_tag_desc")}
</p>
<Button
className="mx-auto mt-5"
variant={"accent"}
onClick={() => setNewTagModal(true)}
>
<i className="bi-plus-lg text-xl mr-2" />
{t("new_tag")}
</Button>
</div>
)}
</div>
{newTagModal && <NewTagModal onClose={() => setNewTagModal(false)} />}

View File

@@ -513,5 +513,15 @@
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links.",
"subscribe_later": "Subscribe Later?",
"create_your_first_tag": "Create Your First Tag!",
"create_your_first_tag_desc": "Tags help you categorize and find your Links easily. You can create Tags based on topics, projects, or any system that works for you.",
"create_your_first_collection": "Create Your First Collection!",
"create_your_first_collection_desc": "Collections are like folders for your Links which can then be shared with others.",
"this_tag_has_no_links": "This Tag Has No Links",
"this_tag_has_no_links_desc": "Use this Tag while creating or editing Links!",
"accept_promotional_emails": "Get notified about new features and offers via email.",
"expand_sidebar": "Expand Sidebar",
"shrink_sidebar": "Shrink Sidebar"
}

View File

@@ -170,6 +170,7 @@ export default async function autoTagLink(
id: user.id,
},
},
aiGenerated: true,
},
})),
},

View File

@@ -5,6 +5,9 @@ type PickLinksOptions = {
maxBatchLinks: number;
};
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default async function getLinkBatchFairly({
maxBatchLinks,
}: PickLinksOptions) {
@@ -38,9 +41,26 @@ export default async function getLinkBatchFairly({
OR: [
{ subscriptions: { is: { active: true } } },
{ parentSubscription: { is: { active: true } } },
...(REQUIRE_CC
? []
: [
{
createdAt: {
gte: new Date(
new Date().getTime() -
Number(TRIAL_PERIOD_DAYS) * 86400000
),
},
},
]),
],
}
: {}),
...(process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"
? {
emailVerified: { not: null },
}
: {}),
},
orderBy: [{ lastPickedAt: { sort: "asc", nulls: "first" } }, { id: "asc" }],
select: { id: true, lastPickedAt: true },

View File

@@ -26,14 +26,14 @@
"meilisearch": "^0.48.2",
"node-fetch": "^2.7.0",
"ollama-ai-provider": "^1.2.0",
"playwright": "^1.45.0",
"playwright": "^1.55.0",
"rss-parser": "^3.13.0",
"socks-proxy-agent": "^8.0.2",
"tsx": "^4.19.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"@playwright/test": "^1.55.0",
"@types/node": "^22.14.1",
"nodemon": "^3.1.9",
"typescript": "^5.8.3"

View File

@@ -52,7 +52,8 @@ export const PostUserSchema = () => {
.min(3)
.max(50)
.regex(/^[a-z0-9_-]{3,50}$/),
invite: z.boolean().optional(),
invite: z.boolean().default(false),
acceptPromotionalEmails: z.boolean().default(false),
});
};

View File

@@ -2,6 +2,8 @@ import { prisma } from "@linkwarden/prisma";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
const stripeEnabled = process.env.STRIPE_SECRET_KEY;
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export const hasPassedLimit = async (
userId: number,
@@ -22,6 +24,7 @@ export const hasPassedLimit = async (
select: {
parentSubscriptionId: true,
subscriptions: { select: { id: true, quantity: true } },
createdAt: true,
},
});
@@ -29,6 +32,22 @@ export const hasPassedLimit = async (
return true;
}
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
if (!REQUIRE_CC && daysLeft > 0) {
const totalLinks = await prisma.link.count({
where: {
createdById: userId,
},
});
return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0;
}
const subscriptionId = user?.parentSubscriptionId ?? user?.subscriptions?.id;
let quantity = user?.subscriptions?.quantity;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "acceptPromotionalEmails" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -68,6 +68,7 @@ model User {
referredBy String?
dashboardSections DashboardSection[]
lastPickedAt DateTime?
acceptPromotionalEmails Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
}

View File

@@ -29,7 +29,9 @@ const useCollections = (auth?: MobileAuth) => {
: undefined
);
const data = await response.json();
return data.response;
if (Array.isArray(data.response)) return data.response;
else return [];
},
enabled: status === "authenticated",
});

View File

@@ -3221,12 +3221,12 @@
tiny-glob "^0.2.9"
tslib "^2.4.0"
"@playwright/test@^1.45.0":
version "1.45.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.45.0.tgz#790a66165a46466c0d7099dd260881802f5aba7e"
integrity sha512-TVYsfMlGAaxeUllNkywbwek67Ncf8FRGn8ZlRdO291OL3NjG9oMbfVhyP82HQF0CZLMrYsvesqoUekxdWuF9Qw==
"@playwright/test@^1.55.0":
version "1.55.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.55.0.tgz#080fa6d9ee6d749ff523b1c18259572d0268b963"
integrity sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==
dependencies:
playwright "1.45.0"
playwright "1.55.0"
"@prisma/client@^6.10.1":
version "6.10.1"
@@ -11188,17 +11188,17 @@ pkg-dir@^4.2.0:
dependencies:
find-up "^4.0.0"
playwright-core@1.45.0:
version "1.45.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.45.0.tgz#5741a670b7c9060ce06852c0051d84736fb94edc"
integrity sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==
playwright-core@1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.55.0.tgz#ec8a9f8ef118afb3e86e0f46f1393e3bea32adf4"
integrity sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==
playwright@1.45.0, playwright@^1.45.0:
version "1.45.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.45.0.tgz#400c709c64438690f13705cb9c88ef93089c5c27"
integrity sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==
playwright@1.55.0, playwright@^1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.55.0.tgz#7aca7ac3ffd9e083a8ad8b2514d6f9ba401cc78b"
integrity sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==
dependencies:
playwright-core "1.45.0"
playwright-core "1.55.0"
optionalDependencies:
fsevents "2.3.2"