mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 03:47:02 +00:00
Merge pull request #1416 from linkwarden/feat/improvements
Feat/improvements
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -28,6 +28,9 @@ export default async function mergeTags(
|
||||
where: {
|
||||
tags: {
|
||||
some: {
|
||||
id: {
|
||||
in: tagIds,
|
||||
},
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] && (
|
||||
<>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ export default async function autoTagLink(
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
aiGenerated: true,
|
||||
},
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "acceptPromotionalEmails" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
28
yarn.lock
28
yarn.lock
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user