Merge pull request #314 from linkwarden/dev

v2.3.0
This commit is contained in:
Daniel 2023-11-24 20:52:57 +03:30 committed by GitHub
commit f78eefbb3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 1819 additions and 605 deletions

View File

@ -1,9 +1,13 @@
NEXTAUTH_SECRET=very_sensitive_secret
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
NEXTAUTH_URL=http://localhost:3000
# Additional Optional Settings
# Manual installation database settings
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
# Docker installation database settings
POSTGRES_PASSWORD=super_secret_password
# Additional Optional Settings
PAGINATION_TAKE_COUNT=
STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT=
@ -14,12 +18,17 @@ RE_ARCHIVE_LIMIT=
SPACES_KEY=
SPACES_SECRET=
SPACES_ENDPOINT=
SPACES_BUCKET_NAME=
SPACES_REGION=
SPACES_FORCE_PATH_STYLE=
# SMTP Settings
NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM=
EMAIL_SERVER=
# Docker postgres settings
POSTGRES_PASSWORD=
# Keycloak
NEXT_PUBLIC_KEYCLOAK_ENABLED=
KEYCLOAK_ISSUER=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=

View File

@ -11,7 +11,7 @@
<div align='center'>
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
</div>
@ -21,17 +21,31 @@
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
<img src="./assets/showcase_image.png" />
> **Note**
> [!TIP]
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer [self-hosting](https://docs.linkwarden.app/self-hosting/installation) Linkwarden, no problem! You'll still have access to all the premium features.
<img src="./assets/dashboard.png" />
<div align="center">
<img src="./assets/all_links.png" width="32%" />
<img src="./assets/all_collections.png" width="32%" />
<img src="./assets/manage_team.png" width="32%" />
<img src="./assets/readable_view.png" width="32%" />
<img src="./assets/public_page.png" width="32%" />
<img src="./assets/light_mode.png" width="32%" />
</div>
<details>
<summary><b>A bit of a "history"</b></summary>
Linkwarden has been completely rebuilt and redesigned from ground up, so pretty much the only thing it has in common with its predecessor is the idea behind it - bookmark management.
**What happened to the old version?**
We highly recommend that you don't use the old version because it is no longer maintained and has far fewer features. However, if you still want to check it out, we've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
We've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
</details>
@ -41,8 +55,8 @@ We highly recommend that you don't use the old version because it is no longer m
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- 📂 Organize links by collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection.
- 🔐 Customize the permissions of each member.
- 🌐 Share your collected links with the world.
- 🎛️ Customize the permissions of each member.
- 🌐 Share your collected links and preserved formats with the world.
- 📌 Pin your favorite links to dashboard.
- 🔍 Full text search, filter and sort for easy retrieval.
- 📱 Responsive design and supports most modern browsers.
@ -50,6 +64,8 @@ We highly recommend that you don't use the old version because it is no longer m
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import your bookmarks from other browsers.
- ⚡️ Powerful API.
- 🔐 SSO and Keycloak integration. (Enterprise and Self-hosted users only)
- ✅ And many more features!
## Suggestions
@ -79,16 +95,6 @@ If you want to contribute, Thanks! Start by checking our [public roadmap](https:
If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks!
## Screenshots
<div align="center">
<img src="./assets/collections.png" height="150" />
<img src="./assets/collaborators.png" height="150" />
<img src="./assets/link_details.png" height="150" />
</div>
## Support ❤
Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well!

BIN
assets/all_collections.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

BIN
assets/all_links.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB

BIN
assets/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

BIN
assets/light_dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

BIN
assets/light_mode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

BIN
assets/manage_team.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

BIN
assets/public_page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

BIN
assets/readable_view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

View File

@ -20,7 +20,7 @@ export default function dashboardItem({ name, value, icon }: Props) {
<p className="text-gray-500 dark:text-gray-400 text-sm tracking-wider">
{name}
</p>
<p className="font-thin text-6xl text-sky-500 dark:text-sky-500">
<p className="font-thin text-6xl text-sky-500 dark:text-sky-500 mt-2">
{value}
</p>
</div>

View File

@ -56,7 +56,7 @@ export default function FilterSearchDropdown({
}
/>
<Checkbox
label="Text Content"
label="Full Content"
state={searchFilter.textContent}
onClick={() =>
setSearchFilter({

View File

@ -150,7 +150,7 @@ export default function LinkCard({ link, count, className }: Props) {
setExpandDropdown({ x: e.clientX, y: e.clientY });
}}
id={"expand-dropdown" + link.id}
className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-5 top-5 z-10 duration-100 p-1"
className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-4 top-4 z-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
@ -163,7 +163,7 @@ export default function LinkCard({ link, count, className }: Props) {
<div
onClick={() => router.push("/links/" + link.id)}
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5"
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-4"
>
{url && account.displayLinkIcons && (
<Image
@ -173,7 +173,7 @@ export default function LinkCard({ link, count, className }: Props) {
alt=""
className={`${
account.blurredFavicons ? "blur-sm " : ""
}absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none`}
}absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none z-10`}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
@ -208,6 +208,27 @@ export default function LinkCard({ link, count, className }: Props) {
{collection?.name}
</p>
</Link>
{/* {link.tags[0] ? (
<div className="flex gap-3 items-center flex-wrap my-2 truncate relative">
<div className="flex gap-1 items-center flex-nowrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="px-2 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</Link>
))}
</div>
<div className="absolute w-1/2 top-0 bottom-0 right-0 bg-gradient-to-r from-transparent to-slate-100 dark:to-neutral-800 to-35%"></div>
</div>
) : undefined} */}
<Link
href={link.url}
target="_blank"
@ -256,7 +277,7 @@ export default function LinkCard({ link, count, className }: Props) {
: undefined,
permissions === true
? {
name: "Refresh Formats",
name: "Refresh Link",
onClick: updateArchive,
}
: undefined,

View File

@ -20,7 +20,7 @@ type Props = {
onClick?: Function;
};
export default function SettingsSidebar({ className, onClick }: Props) {
export default function LinkSidebar({ className, onClick }: Props) {
const session = useSession();
const userId = session.data?.user.id;
@ -42,7 +42,7 @@ export default function SettingsSidebar({ className, onClick }: Props) {
useEffect(() => {
if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection.id));
setLinkCollection(collections.find((e) => e.id === link?.collection?.id));
}, [link]);
return (

View File

@ -18,7 +18,7 @@ type Props = {
SetStateAction<CollectionIncludingMembersAndLinkCount>
>;
collection: CollectionIncludingMembersAndLinkCount;
method: "CREATE" | "UPDATE";
method: "CREATE" | "UPDATE" | "VIEW_TEAM";
};
export default function CollectionInfo({

View File

@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCrown } from "@fortawesome/free-solid-svg-icons";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import ProfilePhoto from "@/components/ProfilePhoto";
import getPublicUserData from "@/lib/client/getPublicUserData";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
};
export default function ViewTeam({ collection }: Props) {
const [collectionOwner, setCollectionOwner] = useState({
id: null,
name: "",
username: "",
image: "",
});
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
fetchOwner();
}, []);
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<p className="ml-10 text-xl font-thin">Team</p>
<p>Here are all the members who are collaborating on this collection.</p>
<div
className="relative border px-2 rounded-md border-sky-100 dark:border-neutral-700 flex min-h-[4rem] gap-2 justify-between"
title={`'@${collectionOwner.username}' is the owner of this collection.`}
>
<div className="flex items-center gap-2 w-full">
<ProfilePhoto
src={collectionOwner.image ? collectionOwner.image : undefined}
className="border-[3px]"
/>
<div className="w-full">
<div className="flex items-center gap-1 w-full justify-between">
<p className="text-sm font-bold text-black dark:text-white">
{collectionOwner.name}
</p>
<div className="flex text-xs gap-1 items-center">
<FontAwesomeIcon
icon={faCrown}
className="w-3 h-3 text-yellow-500"
/>
Admin
</div>
</div>
<p className="text-gray-500 dark:text-gray-300">
@{collectionOwner.username}
</p>
</div>
</div>
</div>
{collection?.members[0]?.user && (
<>
<div className="flex flex-col gap-3 rounded-md">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<div
key={i}
className="relative border p-2 rounded-md border-sky-100 dark:border-neutral-700 flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
>
<div className="flex items-center gap-2">
<ProfilePhoto
src={e.user.image ? e.user.image : undefined}
className="border-[3px]"
/>
<div>
<p className="text-sm font-bold text-black dark:text-white">
{e.user.name}
</p>
<p className="text-gray-500 dark:text-gray-300">
@{e.user.username}
</p>
</div>
</div>
</div>
);
})}
</div>
</>
)}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import TeamManagement from "./TeamManagement";
import { useState } from "react";
import DeleteCollection from "./DeleteCollection";
import ViewTeam from "./ViewTeam";
type Props =
| {
@ -21,6 +22,14 @@ type Props =
isOwner: boolean;
className?: string;
defaultIndex?: number;
}
| {
toggleCollectionModal: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
method: "VIEW_TEAM";
isOwner: boolean;
className?: string;
defaultIndex?: number;
};
export default function CollectionModal({
@ -46,10 +55,11 @@ export default function CollectionModal({
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && (
<p className="text-xl text-black dark:text-white text-center">
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
New Collection
</p>
)}
{method !== "VIEW_TEAM" && (
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-black dark:text-white">
{method === "UPDATE" && (
<>
@ -85,6 +95,7 @@ export default function CollectionModal({
</>
)}
</Tab.List>
)}
<Tab.Panels>
{(isOwner || method === "CREATE") && (
<Tab.Panel>
@ -115,6 +126,14 @@ export default function CollectionModal({
</Tab.Panel>
</>
)}
{method === "VIEW_TEAM" && (
<>
<Tab.Panel>
<ViewTeam collection={collection} />
</Tab.Panel>
</>
)}
</Tab.Panels>
</Tab.Group>
</div>

View File

@ -1,4 +1,7 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -27,7 +30,14 @@ export default function PreservedFormats() {
useEffect(() => {
let interval: NodeJS.Timer | undefined;
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") {
interval = setInterval(() => getLink(link.id as number), 5000);
let isPublicRoute = router.pathname.startsWith("/public")
? true
: undefined;
interval = setInterval(
() => getLink(link.id as number, isPublicRoute),
5000
);
} else {
if (interval) {
clearInterval(interval);
@ -58,15 +68,16 @@ export default function PreservedFormats() {
} else toast.error(data.response);
};
const handleDownload = (format: "png" | "pdf") => {
const path = `/api/v1/archives/${link?.collection.id}/${link?.id}.${format}`;
const handleDownload = (format: ArchivedFormat) => {
const path = `/api/v1/archives/${link?.id}?format=${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download = format === "pdf" ? "PDF" : "Screenshot";
link.download =
format === ArchivedFormat.screenshot ? "Screenshot" : "PDF";
link.click();
} else {
console.error("Failed to download file");
@ -91,7 +102,7 @@ export default function PreservedFormats() {
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("png")}
onClick={() => handleDownload(ArchivedFormat.screenshot)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
@ -101,7 +112,7 @@ export default function PreservedFormats() {
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.png`}
href={`/api/v1/archives/${link?.id}?format=${ArchivedFormat.screenshot}`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
@ -126,7 +137,7 @@ export default function PreservedFormats() {
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("pdf")}
onClick={() => handleDownload(ArchivedFormat.pdf)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
@ -136,7 +147,7 @@ export default function PreservedFormats() {
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.pdf`}
href={`/api/v1/archives/${link?.id}?format=${ArchivedFormat.pdf}`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
@ -163,7 +174,7 @@ export default function PreservedFormats() {
onClick={() => updateArchive()}
>
<p>Update Preserved Formats</p>
<p className="text-xs">(Refresh Formats)</p>
<p className="text-xs">(Refresh Link)</p>
</div>
) : undefined}
<Link

View File

@ -6,12 +6,13 @@ import Dropdown from "@/components/Dropdown";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import Sidebar from "@/components/Sidebar";
import { useRouter } from "next/router";
import Search from "@/components/Search";
import SearchBar from "@/components/SearchBar";
import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto";
import useModalStore from "@/store/modals";
import { useTheme } from "next-themes";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import ToggleDarkMode from "./ToggleDarkMode";
export default function Navbar() {
const { setModal } = useModalStore();
@ -56,7 +57,7 @@ export default function Navbar() {
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div>
<Search />
<SearchBar />
<div className="flex items-center gap-2">
<div
onClick={() => {
@ -76,6 +77,9 @@ export default function Navbar() {
New Link
</span>
</div>
<ToggleDarkMode className="sm:flex hidden" />
<div className="relative">
<div
className="flex gap-1 group sm:hover:bg-slate-200 sm:hover:dark:bg-neutral-700 sm:hover:p-1 sm:hover:pr-2 duration-100 h-10 rounded-full items-center w-fit cursor-pointer"
@ -89,7 +93,7 @@ export default function Navbar() {
/>
<p
id="profile-dropdown"
className="font-bold text-black dark:text-white leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
className="text-black dark:text-white leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
>
{account.name}
</p>

View File

@ -38,6 +38,7 @@ export default function ProfilePhoto({ src, className, priority }: Props) {
width={112}
priority={priority}
draggable={false}
onError={() => setImage("")}
className={`h-10 w-10 bg-sky-600 dark:bg-sky-600 shadow rounded-full aspect-square border border-slate-200 dark:border-neutral-700 ${
className || ""
}`}

View File

@ -1,100 +0,0 @@
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image";
import { Link as LinkType, Tag } from "@prisma/client";
import isValidUrl from "@/lib/client/isValidUrl";
import unescapeString from "@/lib/client/unescapeString";
interface LinksIncludingTags extends LinkType {
tags: Tag[];
}
type Props = {
link: LinksIncludingTags;
count: number;
};
export default function LinkCard({ link, count }: Props) {
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
const formattedDate = new Date(
link.createdAt as unknown as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
return (
<a href={link.url} target="_blank" rel="noreferrer" className="rounded-3xl">
<div className="border border-solid border-sky-100 bg-gradient-to-tr from-slate-200 from-10% to-gray-50 via-20% shadow-md sm:hover:shadow-none duration-100 rounded-3xl cursor-pointer p-5 flex items-start relative gap-5 sm:gap-10 group/item">
{url && (
<>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={42}
height={42}
alt=""
className="select-none mt-3 z-10 rounded-md shadow border-[3px] border-white bg-white"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={80}
height={80}
alt=""
className="blur-sm absolute left-2 opacity-40 select-none hidden sm:block"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
</>
)}
<div className="flex justify-between items-center gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between">
<div className="flex items-baseline gap-1">
<p className="text-xs text-gray-500">{count + 1}</p>
<p className="text-lg text-black">
{unescapeString(link.name || link.description)}
</p>
</div>
<p className="text-gray-500 text-sm font-medium">
{unescapeString(link.description)}
</p>
<div className="flex gap-3 items-center flex-wrap my-3">
<div className="flex gap-1 items-center flex-wrap mt-1">
{link.tags.map((e, i) => (
<p
key={i}
className="px-2 py-1 bg-sky-200 text-black text-xs rounded-3xl cursor-pointer truncate max-w-[10rem]"
>
{e.name}
</p>
))}
</div>
</div>
<div className="flex gap-2 items-center flex-wrap mt-2">
<p className="text-gray-500">{formattedDate}</p>
<div className="text-black flex items-center gap-1">
<p>{url ? url.host : link.url}</p>
</div>
</div>
</div>
<div className="hidden sm:group-hover/item:block duration-100 text-slate-500">
<FontAwesomeIcon
icon={faChevronRight}
className="w-7 h-7 slide-right-with-fade"
/>
</div>
</div>
</div>
</a>
);
}

View File

@ -0,0 +1,96 @@
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image";
import { Link as LinkType, Tag } from "@prisma/client";
import isValidUrl from "@/lib/client/isValidUrl";
import unescapeString from "@/lib/client/unescapeString";
import { TagIncludingLinkCount } from "@/types/global";
import Link from "next/link";
interface LinksIncludingTags extends LinkType {
tags: TagIncludingLinkCount[];
}
type Props = {
link: LinksIncludingTags;
count: number;
};
export default function LinkCard({ link, count }: Props) {
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
const formattedDate = new Date(
link.createdAt as unknown as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
return (
<div className="border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-lg p-3 flex items-start relative gap-3 group/item">
<div className="flex justify-between items-end gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-center gap-2">
<p className="text-2xl">
{url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={30}
height={30}
alt=""
className="select-none z-10 rounded-md shadow border-[1px] border-white bg-white float-left mr-2"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
)}
{unescapeString(link.name || link.description)}
</p>
</div>
<div className="flex gap-3 items-center flex-wrap my-2">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/public/collections/20?q=" + e.name}
key={i}
className="px-2 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</Link>
))}
</div>
</div>
<div className="flex gap-1 items-center flex-wrap text-sm text-gray-500 dark:text-gray-300">
<p>{formattedDate}</p>
<p>·</p>
<Link
href={url ? url.href : link.url}
target="_blank"
className="hover:opacity-50 duration-100 truncate w-52 sm:w-fit"
title={url ? url.href : link.url}
>
{url ? url.host : link.url}
</Link>
</div>
<div className="w-full">
{unescapeString(link.description)}{" "}
<Link
href={`/public/links/${link.id}`}
className="flex gap-1 items-center flex-wrap text-sm text-gray-500 dark:text-gray-300 hover:opacity-50 duration-100 min-w-fit float-right mt-1 ml-2"
>
<p>Read</p>
<FontAwesomeIcon
icon={faChevronRight}
className="w-3 h-3 mt-[0.15rem]"
/>
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { toast } from "react-hot-toast";
type Props = {
placeHolder?: string;
};
export default function PublicSearchBar({ placeHolder }: Props) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState<string>("");
useEffect(() => {
router.query.q
? setSearchQuery(decodeURIComponent(router.query.q as string))
: setSearchQuery("");
}, [router.query.q]);
return (
<div className="flex items-center relative group">
<label
htmlFor="search-box"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md text-sky-500 dark:text-sky-500"
>
<FontAwesomeIcon icon={faMagnifyingGlass} className="w-4 h-4" />
</label>
<input
id="search-box"
type="text"
placeholder={placeHolder}
value={searchQuery}
onChange={(e) => {
e.target.value.includes("%") &&
toast.error("The search query should not contain '%'.");
setSearchQuery(e.target.value.replace("%", ""));
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (!searchQuery) {
return router.push("/public/collections/" + router.query.id);
}
return router.push(
"/public/collections/" +
router.query.id +
"?q=" +
encodeURIComponent(searchQuery || "")
);
}
}}
className="border text-sm border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-8 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800"
/>
</div>
);
}

View File

@ -4,24 +4,17 @@ import { useState } from "react";
import { useRouter } from "next/router";
import { toast } from "react-hot-toast";
export default function Search() {
export default function SearchBar() {
const router = useRouter();
const routeQuery = router.query.query;
const routeQuery = router.query.q;
const [searchQuery, setSearchQuery] = useState(
routeQuery ? decodeURIComponent(routeQuery as string) : ""
);
const [searchBox, setSearchBox] = useState(
router.pathname.startsWith("/search") || false
);
return (
<div
className="flex items-center relative group"
onClick={() => setSearchBox(true)}
>
<div className="flex items-center relative group">
<label
htmlFor="search-box"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md p-1 text-sky-500 dark:text-sky-500"
@ -43,7 +36,6 @@ export default function Search() {
e.key === "Enter" &&
router.push("/search?q=" + encodeURIComponent(searchQuery))
}
autoFocus={searchBox}
className="border border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-10 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800"
/>
</div>

View File

@ -5,6 +5,7 @@ import {
faPalette,
faBoxArchive,
faKey,
faLock,
} from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import { useRouter } from "next/router";
@ -20,7 +21,7 @@ import {
} from "@fortawesome/free-brands-svg-icons";
export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = "v2.2.1";
const LINKWARDEN_VERSION = "v2.3.0";
const { collections } = useCollectionStore();
@ -96,6 +97,23 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div>
</Link>
<Link href="/settings/api">
<div
className={`${
active === `/settings/api` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faKey}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
API Keys
</p>
</div>
</Link>
<Link href="/settings/password">
<div
className={`${
@ -105,7 +123,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faKey}
icon={faLock}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>

View File

@ -176,6 +176,9 @@ export default function Sidebar({ className }: { className?: string }) {
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/>
) : undefined}
<div className="drop-shadow text-gray-500 dark:text-gray-300 text-xs">
{e._count?.links}
</div>
</div>
</Link>
);
@ -235,6 +238,9 @@ export default function Sidebar({ className }: { className?: string }) {
<p className="text-black dark:text-white truncate w-full pr-7">
{e.name}
</p>
<div className="drop-shadow text-gray-500 dark:text-gray-300 text-xs">
{e._count?.links}
</div>
</div>
</Link>
);

View File

@ -2,7 +2,11 @@ import { useTheme } from "next-themes";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
export default function ToggleDarkMode() {
type Props = {
className?: string;
};
export default function ToggleDarkMode({ className }: Props) {
const { theme, setTheme } = useTheme();
const handleToggle = () => {
@ -15,15 +19,13 @@ export default function ToggleDarkMode() {
return (
<div
className="flex gap-1 duration-100 h-10 rounded-full items-center w-fit cursor-pointer"
className={`cursor-pointer flex select-none border border-sky-600 items-center justify-center dark:bg-neutral-900 bg-white hover:border-sky-500 group duration-100 rounded-full text-white w-10 h-10 ${className}`}
onClick={handleToggle}
>
<div className="shadow bg-sky-700 dark:bg-sky-400 flex items-center justify-center rounded-full text-white w-10 h-10 duration-100">
<FontAwesomeIcon
icon={theme === "dark" ? faSun : faMoon}
className="w-1/2 h-1/2"
className="w-1/2 h-1/2 text-sky-600 group-hover:text-sky-500"
/>
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
version: "3.5"
services:
postgres:
image: postgres
image: postgres:16-alpine
env_file: .env
restart: always
volumes:

View File

@ -50,13 +50,17 @@ export default function useLinks(
.join("&");
};
const queryString = buildQueryString(params);
let queryString = buildQueryString(params);
const response = await fetch(
`/api/v1/${
router.asPath === "/dashboard" ? "dashboard" : "links"
}?${queryString}`
);
let basePath;
if (router.pathname === "/dashboard") basePath = "/api/v1/dashboard";
else if (router.pathname.startsWith("/public/collections/[id]")) {
queryString = queryString + "&collectionId=" + router.query.id;
basePath = "/api/v1/public/collections/links";
} else basePath = "/api/v1/links";
const response = await fetch(`${basePath}?${queryString}`);
const data = await response.json();

View File

@ -5,8 +5,7 @@ import useModalStore from "@/store/modals";
import { useRouter } from "next/router";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import {
faPen,
@ -66,22 +65,22 @@ export default function LinkLayout({ children }: Props) {
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection.id));
}, [link]);
setLinkCollection(collections.find((e) => e.id === link?.collection?.id));
}, [link, collections]);
return (
<>
<ModalManagement />
<div className="flex mx-auto">
<div className="hidden lg:block fixed left-5 h-screen">
{/* <div className="hidden lg:block fixed left-5 h-screen">
<LinkSidebar />
</div>
</div> */}
<div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5">
<div className="flex gap-3 mb-5 duration-100 items-center justify-between">
@ -93,19 +92,33 @@ export default function LinkLayout({ children }: Props) {
</div> */}
<div
onClick={() => router.push(`/collections/${linkCollection?.id}`)}
onClick={() => {
if (router.pathname.startsWith("/public")) {
router.push(
`/public/collections/${
linkCollection?.id || link?.collection.id
}`
);
} else {
router.push(`/dashboard`);
}
}}
className="inline-flex gap-1 hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" />
Back{" "}
<span className="hidden sm:inline-block">
to <span className="capitalize">{linkCollection?.name}</span>
to{" "}
<span className="capitalize">
{router.pathname.startsWith("/public")
? linkCollection?.name || link?.collection?.name
: "Dashboard"}
</span>
</span>
</div>
<div className="lg:hidden">
<div className="flex gap-5">
{link?.collection.ownerId === userId ||
{link?.collection?.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate
) ? (
@ -150,7 +163,7 @@ export default function LinkLayout({ children }: Props) {
/>
</div>
{link?.collection.ownerId === userId ||
{link?.collection?.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete
) ? (
@ -172,7 +185,6 @@ export default function LinkLayout({ children }: Props) {
) : undefined}
</div>
</div>
</div>
{children}

View File

@ -49,8 +49,8 @@ export default function SettingsLayout({ children }: Props) {
<SettingsSidebar />
</div>
<div className="w-full flex flex-col min-h-screen p-5 lg:ml-64">
<div className="flex gap-3">
<div className="w-full min-h-screen p-5 lg:ml-64">
<div className="gap-2 inline-flex mr-3">
<div
onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
@ -60,18 +60,12 @@ export default function SettingsLayout({ children }: Props) {
<Link
href="/dashboard"
className="inline-flex gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
className="inline-flex w-fit gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5" />
</Link>
<p className="capitalize text-3xl font-thin">
{router.asPath.split("/").pop()} Settings
</p>
</div>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
{children}
{sidebar ? (

View File

@ -10,14 +10,10 @@ export default async function deleteCollection(
if (!collectionId)
return { response: "Please choose a valid collection.", status: 401 };
const collectionIsAccessible = (await getPermission({
const collectionIsAccessible = await getPermission({
userId,
collectionId,
})) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId

View File

@ -11,14 +11,10 @@ export default async function updateCollection(
if (!collectionId)
return { response: "Please choose a valid collection.", status: 401 };
const collectionIsAccessible = (await getPermission({
const collectionIsAccessible = await getPermission({
userId,
collectionId,
})) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
});
if (!(collectionIsAccessible?.ownerId === userId))
return { response: "Collection is not accessible.", status: 401 };

View File

@ -6,11 +6,7 @@ import removeFile from "@/lib/api/storage/removeFile";
export default async function deleteLink(userId: number, linkId: number) {
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
const collectionIsAccessible = (await getPermission({ userId, linkId })) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
const collectionIsAccessible = await getPermission({ userId, linkId });
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canDelete

View File

@ -1,5 +1,5 @@
import { prisma } from "@/lib/api/db";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import { Collection, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
export default async function getLinkById(userId: number, linkId: number) {
@ -9,11 +9,7 @@ export default async function getLinkById(userId: number, linkId: number) {
status: 401,
};
const collectionIsAccessible = (await getPermission({ userId, linkId })) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
const collectionIsAccessible = await getPermission({ userId, linkId });
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId
@ -27,7 +23,7 @@ export default async function getLinkById(userId: number, linkId: number) {
status: 401,
};
else {
const updatedLink = await prisma.link.findUnique({
const link = await prisma.link.findUnique({
where: {
id: linkId,
},
@ -43,6 +39,6 @@ export default async function getLinkById(userId: number, linkId: number) {
},
});
return { response: updatedLink, status: 200 };
return { response: link, status: 200 };
}
}

View File

@ -15,11 +15,7 @@ export default async function updateLinkById(
status: 401,
};
const collectionIsAccessible = (await getPermission({ userId, linkId })) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
const collectionIsAccessible = await getPermission({ userId, linkId });
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate

View File

@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import getTitle from "@/lib/api/getTitle";
import archive from "@/lib/api/archive";
import { Collection, UsersAndCollections } from "@prisma/client";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import createFolder from "@/lib/api/storage/createFolder";
@ -27,14 +27,10 @@ export default async function postLink(
link.collection.name = link.collection.name.trim();
if (link.collection.id) {
const collectionIsAccessible = (await getPermission({
const collectionIsAccessible = await getPermission({
userId,
collectionId: link.collection.id,
})) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate

View File

@ -0,0 +1,32 @@
import { prisma } from "@/lib/api/db";
export default async function getPublicCollection(id: number) {
const collection = await prisma.collection.findFirst({
where: {
id,
isPublic: true,
},
include: {
members: {
include: {
user: {
select: {
username: true,
name: true,
image: true,
},
},
},
},
_count: {
select: { links: true },
},
},
});
if (collection) {
return { response: collection, status: 200 };
} else {
return { response: "Collection not found.", status: 400 };
}
}

View File

@ -1,45 +0,0 @@
import { prisma } from "@/lib/api/db";
import { PublicLinkRequestQuery } from "@/types/global";
export default async function getCollection(body: string) {
const query: PublicLinkRequestQuery = JSON.parse(decodeURIComponent(body));
console.log(query);
let data;
const collection = await prisma.collection.findFirst({
where: {
id: query.collectionId,
isPublic: true,
},
});
if (collection) {
const links = await prisma.link.findMany({
take: Number(process.env.PAGINATION_TAKE_COUNT) || 20,
skip: query.cursor ? 1 : undefined,
cursor: query.cursor
? {
id: query.cursor,
}
: undefined,
where: {
collection: {
id: query.collectionId,
},
},
include: {
tags: true,
},
orderBy: {
createdAt: "desc",
},
});
data = { ...collection, links: [...links] };
return { response: data, status: 200 };
} else {
return { response: "Collection not found...", status: 400 };
}
}

View File

@ -0,0 +1,88 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getLink(
query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly">
) {
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
let order: any;
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
else if (query.sort === Sort.NameZA) order = { name: "desc" };
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
const searchConditions = [];
if (query.searchQueryString) {
if (query.searchByName) {
searchConditions.push({
name: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByUrl) {
searchConditions.push({
url: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByDescription) {
searchConditions.push({
description: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByTextContent) {
searchConditions.push({
textContent: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByTags) {
searchConditions.push({
tags: {
some: {
name: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
},
},
});
}
}
const links = await prisma.link.findMany({
take: Number(process.env.PAGINATION_TAKE_COUNT) || 20,
skip: query.cursor ? 1 : undefined,
cursor: query.cursor ? { id: query.cursor } : undefined,
where: {
collection: {
id: query.collectionId,
isPublic: true,
},
[query.searchQueryString ? "OR" : "AND"]: [...searchConditions],
},
include: {
tags: true,
},
orderBy: order || { createdAt: "desc" },
});
return { response: links, status: 200 };
}

View File

@ -0,0 +1,24 @@
import { prisma } from "@/lib/api/db";
export default async function getLinkById(linkId: number) {
if (!linkId)
return {
response: "Please choose a valid link.",
status: 401,
};
const link = await prisma.link.findFirst({
where: {
id: linkId,
collection: {
isPublic: true,
},
},
include: {
tags: true,
collection: true,
},
});
return { response: link, status: 200 };
}

View File

@ -1,6 +1,6 @@
import { prisma } from "@/lib/api/db";
export default async function getPublicUserById(
export default async function getPublicUser(
targetId: number | string,
isId: boolean,
requestingId?: number
@ -31,13 +31,16 @@ export default async function getPublicUserById(
if (user?.isPrivate) {
if (requestingId) {
const requestingUsername = (
await prisma.user.findUnique({ where: { id: requestingId } })
)?.username;
const requestingUser = await prisma.user.findUnique({
where: { id: requestingId },
});
if (
!requestingUsername ||
!whitelistedUsernames.includes(requestingUsername?.toLowerCase())
requestingUser?.id !== requestingId &&
(!requestingUser?.username ||
!whitelistedUsernames.includes(
requestingUser.username?.toLowerCase()
))
) {
return {
response: "User not found or profile is private.",

View File

@ -30,6 +30,11 @@ export default async function getTags(userId: number) {
},
],
},
include: {
_count: {
select: { links: true },
},
},
// orderBy: {
// links: {
// _count: "desc",

View File

@ -30,6 +30,11 @@ export default async function postUser(
? !body.password || !body.name || !body.email
: !body.username || !body.password || !body.name;
if (!body.password || body.password.length < 8)
return res
.status(400)
.json({ response: "Password must be at least 8 characters." });
if (checkHasEmptyFields)
return res
.status(400)

View File

@ -21,8 +21,12 @@ export default async function deleteUserById(
};
}
// Then, we check if the provided password matches the one stored in the database
const isPasswordValid = bcrypt.compareSync(body.password, user.password);
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
if (!process.env.KEYCLOAK_CLIENT_SECRET) {
const isPasswordValid = bcrypt.compareSync(
body.password,
user.password as string
);
if (!isPasswordValid) {
return {
@ -30,6 +34,7 @@ export default async function deleteUserById(
status: 401, // Unauthorized
};
}
}
// Delete the user and all related data within a transaction
await prisma

View File

@ -23,6 +23,11 @@ export default async function updateUserById(
response: "Username invalid.",
status: 400,
};
if (data.newPassword && data.newPassword?.length < 8)
return {
response: "Password must be at least 8 characters.",
status: 400,
};
// Check email (if enabled)
const checkEmail =

View File

@ -27,11 +27,9 @@ export default async function getPermission({
} else if (collectionId) {
const check = await prisma.collection.findFirst({
where: {
AND: {
id: collectionId,
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
},
},
include: { members: true },
});

View File

@ -14,7 +14,7 @@ export default async function createFile({
}) {
if (s3Client) {
const bucketParams: PutObjectCommandInput = {
Bucket: process.env.BUCKET_NAME,
Bucket: process.env.SPACES_BUCKET_NAME,
Key: filePath,
Body: isBase64 ? Buffer.from(data as string, "base64") : data,
};

View File

@ -5,7 +5,7 @@ import removeFile from "./removeFile";
export default async function moveFile(from: string, to: string) {
if (s3Client) {
const Bucket = process.env.BUCKET_NAME;
const Bucket = process.env.SPACES_BUCKET_NAME;
const copyParams = {
Bucket: Bucket,

View File

@ -20,7 +20,7 @@ export default async function readFile(filePath: string) {
if (s3Client) {
const bucketParams: GetObjectCommandInput = {
Bucket: process.env.BUCKET_NAME,
Bucket: process.env.SPACES_BUCKET_NAME,
Key: filePath,
};

View File

@ -6,7 +6,7 @@ import { PutObjectCommandInput, DeleteObjectCommand } from "@aws-sdk/client-s3";
export default async function removeFile({ filePath }: { filePath: string }) {
if (s3Client) {
const bucketParams: PutObjectCommandInput = {
Bucket: process.env.BUCKET_NAME,
Bucket: process.env.SPACES_BUCKET_NAME,
Key: filePath,
};

View File

@ -40,7 +40,7 @@ async function emptyS3Directory(bucket: string, dir: string) {
export default async function removeFolder({ filePath }: { filePath: string }) {
if (s3Client) {
try {
await emptyS3Directory(process.env.BUCKET_NAME as string, filePath);
await emptyS3Directory(process.env.SPACES_BUCKET_NAME as string, filePath);
} catch (err) {
console.log("Error", err);
}

View File

@ -6,7 +6,7 @@ const s3Client: S3 | undefined =
process.env.SPACES_KEY &&
process.env.SPACES_SECRET
? new S3({
forcePathStyle: false,
forcePathStyle: !!process.env.SPACES_FORCE_PATH_STYLE,
endpoint: process.env.SPACES_ENDPOINT,
region: process.env.SPACES_REGION,
credentials: {

View File

@ -1,33 +1,17 @@
import {
PublicCollectionIncludingLinks,
PublicLinkRequestQuery,
} from "@/types/global";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { Dispatch, SetStateAction } from "react";
const getPublicCollectionData = async (
collectionId: number,
prevData: PublicCollectionIncludingLinks,
setData: Dispatch<SetStateAction<PublicCollectionIncludingLinks | undefined>>
setData: Dispatch<
SetStateAction<CollectionIncludingMembersAndLinkCount | undefined>
>
) => {
const requestBody: PublicLinkRequestQuery = {
cursor: prevData?.links?.at(-1)?.id,
collectionId,
};
const encodedData = encodeURIComponent(JSON.stringify(requestBody));
const res = await fetch(
"/api/v1/public/collections?body=" + encodeURIComponent(encodedData)
);
const res = await fetch("/api/v1/public/collections/" + collectionId);
const data = await res.json();
prevData
? setData({
...data.response,
links: [...prevData.links, ...data.response.links],
})
: setData(data.response);
setData(data.response);
return data;
};

View File

@ -22,7 +22,6 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.15",
"@mozilla/readability": "^0.4.4",
"@next/font": "13.4.9",
"@prisma/client": "^4.16.2",
"@stripe/stripe-js": "^1.54.1",
"@types/crypto-js": "^4.1.1",
@ -40,6 +39,7 @@
"eslint-config-next": "13.4.9",
"framer-motion": "^10.16.4",
"jsdom": "^22.1.0",
"lottie-web": "^5.12.2",
"micro": "^10.0.1",
"next": "13.4.12",
"next-auth": "^4.22.1",
@ -63,6 +63,7 @@
"@types/dompurify": "^3.0.4",
"@types/jsdom": "^21.1.3",
"autoprefixer": "^10.4.14",
"daisyui": "^4.4.2",
"postcss": "^8.4.26",
"prisma": "^5.1.0",
"tailwindcss": "^3.3.3"

View File

@ -1,32 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import getPermission from "@/lib/api/getPermission";
import readFile from "@/lib/api/storage/readFile";
import verifyUser from "@/lib/api/verifyUser";
export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (!req.query.params)
return res.status(401).json({ response: "Invalid parameters." });
const user = await verifyUser({ req, res });
if (!user) return;
const collectionId = req.query.params[0];
const linkId = req.query.params[1];
const collectionIsAccessible = await getPermission({
userId: user.id,
collectionId: Number(collectionId),
});
if (!collectionIsAccessible)
return res
.status(401)
.json({ response: "You don't have access to this collection." });
const { file, contentType, status } = await readFile(
`archives/${collectionId}/${linkId}`
);
res.setHeader("Content-Type", contentType).status(status as number);
return res.send(file);
}

View File

@ -0,0 +1,49 @@
import type { NextApiRequest, NextApiResponse } from "next";
import readFile from "@/lib/api/storage/readFile";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db";
import { ArchivedFormat } from "@/types/global";
export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const linkId = Number(req.query.linkId);
const format = Number(req.query.format);
let suffix;
if (format === ArchivedFormat.screenshot) suffix = ".png";
else if (format === ArchivedFormat.pdf) suffix = ".pdf";
else if (format === ArchivedFormat.readability) suffix = "_readability.json";
if (!linkId || !suffix)
return res.status(401).json({ response: "Invalid parameters." });
const token = await getToken({ req });
const userId = token?.id;
const collectionIsAccessible = await prisma.collection.findFirst({
where: {
links: {
some: {
id: linkId,
},
},
OR: [
{ ownerId: userId || -1 },
{ members: { some: { userId: userId || -1 } } },
{ isPublic: true },
],
},
});
if (!collectionIsAccessible)
return res
.status(401)
.json({ response: "You don't have access to this collection." });
const { file, contentType, status } = await readFile(
`archives/${collectionIsAccessible.id}/${linkId + suffix}`
);
res.setHeader("Content-Type", contentType).status(status as number);
return res.send(file);
}

View File

@ -9,10 +9,15 @@ import { Adapter } from "next-auth/adapters";
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
import { Provider } from "next-auth/providers";
import verifySubscription from "@/lib/api/verifySubscription";
import KeycloakProvider from "next-auth/providers/keycloak";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
const keycloakEnabled = process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === "true";
const adapter = PrismaAdapter(prisma);
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const providers: Provider[] = [
@ -59,7 +64,7 @@ const providers: Provider[] = [
}),
];
if (emailEnabled)
if (emailEnabled) {
providers.push(
EmailProvider({
server: process.env.EMAIL_SERVER,
@ -70,9 +75,36 @@ if (emailEnabled)
},
})
);
}
if (keycloakEnabled) {
providers.push(
KeycloakProvider({
id: "keycloak",
name: "Keycloak",
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER,
profile: (profile) => {
return {
id: profile.sub,
username: profile.preferred_username,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: profile.picture,
};
},
})
);
const _linkAccount = adapter.linkAccount;
adapter.linkAccount = (account) => {
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
return _linkAccount ? _linkAccount(data) : undefined;
};
}
export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma) as Adapter,
adapter: adapter as Adapter,
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
@ -85,7 +117,8 @@ export const authOptions: AuthOptions = {
callbacks: {
async jwt({ token, trigger, user }) {
token.sub = token.sub ? Number(token.sub) : undefined;
if (trigger === "signIn") token.id = user?.id as number;
if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
return token;
},

View File

@ -1,21 +1,21 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db";
import readFile from "@/lib/api/storage/readFile";
import verifyUser from "@/lib/api/verifyUser";
import { getToken } from "next-auth/jwt";
export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const queryId = Number(req.query.id);
const user = await verifyUser({ req, res });
if (!user) return;
if (!queryId)
return res
.setHeader("Content-Type", "text/plain")
.status(401)
.send("Invalid parameters.");
if (user.id !== queryId) {
const token = await getToken({ req });
const userId = token?.id;
if (req.method === "GET") {
const targetUser = await prisma.user.findUnique({
where: {
id: queryId,
@ -25,19 +25,39 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
},
});
if (targetUser?.isPrivate) {
if (!userId) {
return res
.setHeader("Content-Type", "text/plain")
.status(400)
.send("File inaccessible.");
}
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
subscriptions: true,
},
});
const whitelistedUsernames = targetUser?.whitelistedUsers.map(
(whitelistedUsername) => whitelistedUsername.username
);
if (
targetUser?.isPrivate &&
user.username &&
!whitelistedUsernames?.includes(user.username)
) {
if (!user?.username) {
return res
.setHeader("Content-Type", "text/plain")
.status(400)
.send("File not found.");
.send("File inaccessible.");
}
if (user.username && !whitelistedUsernames?.includes(user.username)) {
return res
.setHeader("Content-Type", "text/plain")
.status(400)
.send("File inaccessible.");
}
}
@ -49,4 +69,5 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
.setHeader("Content-Type", contentType)
.status(status as number)
.send(file);
}
}

View File

@ -1,18 +1,18 @@
import getCollection from "@/lib/api/controllers/public/getCollection";
import getPublicCollection from "@/lib/api/controllers/public/collections/getPublicCollection";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function collections(
export default async function collection(
req: NextApiRequest,
res: NextApiResponse
) {
if (!req?.query?.body) {
if (!req?.query?.id) {
return res
.status(401)
.json({ response: "Please choose a valid collection." });
}
if (req.method === "GET") {
const collection = await getCollection(req?.query?.body as string);
const collection = await getPublicCollection(Number(req?.query?.id));
return res
.status(collection.status)
.json({ response: collection.response });

View File

@ -0,0 +1,41 @@
import getPublicLinksUnderCollection from "@/lib/api/controllers/public/links/getPublicLinksUnderCollection";
import { LinkRequestQuery } from "@/types/global";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function collections(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === "GET") {
// Convert the type of the request query to "LinkRequestQuery"
const convertedData: Omit<LinkRequestQuery, "tagId"> = {
sort: Number(req.query.sort as string),
cursor: req.query.cursor ? Number(req.query.cursor as string) : undefined,
collectionId: req.query.collectionId
? Number(req.query.collectionId as string)
: undefined,
pinnedOnly: req.query.pinnedOnly
? req.query.pinnedOnly === "true"
: undefined,
searchQueryString: req.query.searchQueryString
? (req.query.searchQueryString as string)
: undefined,
searchByName: req.query.searchByName === "true" ? true : undefined,
searchByUrl: req.query.searchByUrl === "true" ? true : undefined,
searchByDescription:
req.query.searchByDescription === "true" ? true : undefined,
searchByTextContent:
req.query.searchByTextContent === "true" ? true : undefined,
searchByTags: req.query.searchByTags === "true" ? true : undefined,
};
if (!convertedData.collectionId) {
return res
.status(400)
.json({ response: "Please choose a valid collection." });
}
const links = await getPublicLinksUnderCollection(convertedData);
return res.status(links.status).json({ response: links.response });
}
}

View File

@ -0,0 +1,13 @@
import getLinkById from "@/lib/api/controllers/public/links/linkId/getLinkById";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function link(req: NextApiRequest, res: NextApiResponse) {
if (!req?.query?.id) {
return res.status(401).json({ response: "Please choose a valid link." });
}
if (req.method === "GET") {
const link = await getLinkById(Number(req?.query?.id));
return res.status(link.status).json({ response: link.response });
}
}

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
import getPublicUserById from "@/lib/api/controllers/public/users/getPublicUserById";
import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser";
import { getToken } from "next-auth/jwt";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
@ -12,7 +12,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
const isId = lookupId.split("").every((e) => Number.isInteger(parseInt(e)));
if (req.method === "GET") {
const users = await getPublicUserById(lookupId, isId, requestingId);
const users = await getPublicUser(lookupId, isId, requestingId);
return res.status(users.status).json({ response: users.response });
}
}

View File

@ -3,7 +3,10 @@ import React, { useEffect, useState } from "react";
import Link from "next/link";
import useLinkStore from "@/store/links";
import { useRouter } from "next/router";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief";
import { useTheme } from "next-themes";
@ -63,7 +66,9 @@ export default function Index() {
link?.readabilityPath &&
link?.readabilityPath !== "pending"
) {
const response = await fetch(`/api/v1/${link?.readabilityPath}`);
const response = await fetch(
`/api/v1/archives/${link?.id}?format=${ArchivedFormat.readability}`
);
const data = await response?.json();
@ -146,7 +151,7 @@ export default function Index() {
>
<div
id="link-banner"
className="link-banner p-3 mb-6 relative bg-opacity-10 border border-solid border-sky-100 dark:border-neutral-700 shadow-md"
className="link-banner p-5 mb-4 relative bg-opacity-10 border border-solid border-sky-100 dark:border-neutral-700 shadow-md"
>
<div id="link-banner-inner" className="link-banner-inner"></div>
@ -233,7 +238,7 @@ export default function Index() {
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
className="px-2 py-1 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
className="px-2 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</p>

View File

@ -12,6 +12,7 @@ interface FormData {
}
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
const keycloakEnabled = process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED;
export default function Login() {
const [submitLoader, setSubmitLoader] = useState(false);
@ -47,6 +48,18 @@ export default function Login() {
}
}
async function loginUserKeycloak() {
setSubmitLoader(true);
const load = toast.loading("Authenticating...");
const res = await signIn("keycloak", {});
toast.dismiss(load);
setSubmitLoader(false);
}
return (
<CenteredForm text="Sign in to your account">
<form onSubmit={loginUser}>
@ -102,6 +115,15 @@ export default function Login() {
className=" w-full text-center"
loading={submitLoader}
/>
{process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === "true" ? (
<SubmitButton
type="button"
onClick={loginUserKeycloak}
label="Sign in with Keycloak"
className=" w-full text-center"
loading={submitLoader}
/>
) : undefined}
{process.env.NEXT_PUBLIC_DISABLE_REGISTRATION ===
"true" ? undefined : (
<div className="flex items-baseline gap-1 justify-center">

View File

@ -1,12 +1,26 @@
"use client";
import LinkCard from "@/components/PublicPage/LinkCard";
import useDetectPageBottom from "@/hooks/useDetectPageBottom";
import PublicLinkCard from "@/components/PublicPage/PublicLinkCard";
import getPublicCollectionData from "@/lib/client/getPublicCollectionData";
import { PublicCollectionIncludingLinks } from "@/types/global";
import { CollectionIncludingMembersAndLinkCount, Sort } from "@/types/global";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { motion, Variants } from "framer-motion";
import Head from "next/head";
import useLinks from "@/hooks/useLinks";
import useLinkStore from "@/store/links";
import ProfilePhoto from "@/components/ProfilePhoto";
import useModalStore from "@/store/modals";
import ModalManagement from "@/components/ModalManagement";
import ToggleDarkMode from "@/components/ToggleDarkMode";
import { useTheme } from "next-themes";
import getPublicUserData from "@/lib/client/getPublicUserData";
import Image from "next/image";
import Link from "next/link";
import PublicSearchBar from "@/components/PublicPage/PublicSearchBar";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFilter, faSort } from "@fortawesome/free-solid-svg-icons";
import FilterSearchDropdown from "@/components/FilterSearchDropdown";
import SortDropdown from "@/components/SortDropdown";
const cardVariants: Variants = {
offscreen: {
@ -23,71 +37,222 @@ const cardVariants: Variants = {
};
export default function PublicCollections() {
const { links } = useLinkStore();
const { modal, setModal } = useModalStore();
useEffect(() => {
modal
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "auto");
}, [modal]);
const { theme } = useTheme();
const router = useRouter();
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const [data, setData] = useState<PublicCollectionIncludingLinks>();
const [collectionOwner, setCollectionOwner] = useState({
id: null,
name: "",
username: "",
image: "",
});
document.body.style.background = "white";
const [searchFilter, setSearchFilter] = useState({
name: true,
url: true,
description: true,
textContent: true,
tags: true,
});
const [filterDropdown, setFilterDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({
sort: sortBy,
searchQueryString: router.query.q
? decodeURIComponent(router.query.q as string)
: undefined,
searchByName: searchFilter.name,
searchByUrl: searchFilter.url,
searchByDescription: searchFilter.description,
searchByTextContent: searchFilter.textContent,
searchByTags: searchFilter.tags,
});
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
useEffect(() => {
if (router.query.id) {
getPublicCollectionData(
Number(router.query.id),
data as PublicCollectionIncludingLinks,
setData
);
getPublicCollectionData(Number(router.query.id), setCollection);
}
// document
// .querySelector("body")
// ?.classList.add(
// "bg-gradient-to-br",
// "from-slate-50",
// "to-sky-50",
// "min-h-screen"
// );
}, []);
useEffect(() => {
if (reachedBottom && router.query.id) {
getPublicCollectionData(
Number(router.query.id),
data as PublicCollectionIncludingLinks,
setData
);
const fetchOwner = async () => {
if (collection) {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
}
};
setReachedBottom(false);
}, [reachedBottom]);
fetchOwner();
}, [collection]);
return data ? (
<div className="max-w-4xl mx-auto p-5 bg">
{data ? (
return collection ? (
<div
className="h-screen"
style={{
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
theme === "dark" ? "#262626" : "#f3f4f6"
} 50%, ${theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
}}
>
<ModalManagement />
{collection ? (
<Head>
<title>{data.name} | Linkwarden</title>
<title>{collection.name} | Linkwarden</title>
<meta
property="og:title"
content={`${data.name} | Linkwarden`}
content={`${collection.name} | Linkwarden`}
key="title"
/>
</Head>
) : undefined}
<div
className={`border border-solid border-sky-100 text-center bg-gradient-to-tr from-sky-100 from-10% via-gray-100 via-20% rounded-3xl shadow-lg p-5`}
>
<p className="text-5xl text-black mb-5 capitalize">{data.name}</p>
{data.description && (
<>
<hr className="mt-5 max-w-[30rem] mx-auto border-1 border-slate-400" />
<p className="mt-2 text-gray-500">{data.description}</p>
</>
)}
<div className="max-w-4xl mx-auto p-5 bg">
<div className="flex items-center justify-between">
<p className="text-4xl font-thin mb-2 capitalize mt-10">
{collection.name}
</p>
<div className="flex gap-2 items-center mt-8 min-w-fit">
<ToggleDarkMode className="w-8 h-8 flex" />
<Link href="https://linkwarden.app/" target="_blank">
<Image
src={`/icon.png`}
width={551}
height={551}
alt="Linkwarden"
title="Linkwarden"
className="h-8 w-fit mx-auto"
/>
</Link>
</div>
</div>
<div className="flex flex-col gap-5 my-8">
{data?.links?.map((e, i) => {
<div>
<div className={`min-w-[15rem]`}>
<div
onClick={() =>
setModal({
modal: "COLLECTION",
state: true,
method: "VIEW_TEAM",
isOwner: false,
active: collection,
defaultIndex: 0,
})
}
className="hover:opacity-80 duration-100 flex justify-center sm:justify-end items-start w-fit cursor-pointer"
>
{collectionOwner.id ? (
<ProfilePhoto
src={
collectionOwner.image ? collectionOwner.image : undefined
}
className={`w-8 h-8 border-2`}
/>
) : undefined}
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={e.user.image ? e.user.image : undefined}
className={`w-8 h-8 border-2`}
/>
);
})
.slice(0, 3)}
{collection?.members.length &&
collection.members.length - 3 > 0 ? (
<div className="w-8 h-8 min-w-[2rem] text-white text-sm flex items-center justify-center rounded-full border-2 bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700">
+{collection?.members?.length - 3}
</div>
) : null}
<p className="ml-2 mt-1 text-gray-500 dark:text-gray-300">
By {collectionOwner.name} and {collection.members.length}{" "}
others.
</p>
</div>
</div>
</div>
<p className="mt-5">{collection.description}</p>
<hr className="mt-5 border-1 border-neutral-500" />
<div className="flex mb-5 mt-10 flex-col gap-5">
<div className="flex justify-between">
<PublicSearchBar
placeHolder={`Search ${collection._count?.links} Links`}
/>
<div className="flex gap-3 items-center">
<div className="relative">
<div
onClick={() => setFilterDropdown(!filterDropdown)}
id="filter-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-neutral-500 hover:bg-opacity-40 duration-100 p-1"
>
<FontAwesomeIcon
icon={faFilter}
id="filter-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{filterDropdown ? (
<FilterSearchDropdown
setFilterDropdown={setFilterDropdown}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
) : null}
</div>
<div className="relative">
<div
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-neutral-500 hover:bg-opacity-40 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div>
</div>
</div>
<div className="flex flex-col gap-5">
{links
?.filter((e) => e.collectionId === Number(router.query.id))
.map((e, i) => {
return (
<motion.div
key={i}
@ -96,17 +261,19 @@ export default function PublicCollections() {
viewport={{ once: true, amount: 0.8 }}
>
<motion.div variants={cardVariants}>
<LinkCard link={e} count={i} />
<PublicLinkCard link={e as any} count={i} />
</motion.div>
</motion.div>
);
})}
</div>
{/* <p className="text-center font-bold text-gray-500">
{/* <p className="text-center text-gray-500">
List created with <span className="text-black">Linkwarden.</span>
</p> */}
</div>
</div>
</div>
) : (
<></>
);

305
pages/public/links/[id].tsx Normal file
View File

@ -0,0 +1,305 @@
import LinkLayout from "@/layouts/LinkLayout";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import useLinkStore from "@/store/links";
import { useRouter } from "next/router";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief";
import { useTheme } from "next-themes";
import unescapeString from "@/lib/client/unescapeString";
import isValidUrl from "@/lib/client/isValidUrl";
import DOMPurify from "dompurify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons";
import useModalStore from "@/store/modals";
import { useSession } from "next-auth/react";
type LinkContent = {
title: string;
content: string;
textContent: string;
length: number;
excerpt: string;
byline: string;
dir: string;
siteName: string;
lang: string;
};
export default function Index() {
const { theme } = useTheme();
const { links, getLink } = useLinkStore();
const { setModal } = useModalStore();
const session = useSession();
const userId = session.data?.user.id;
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const [linkContent, setLinkContent] = useState<LinkContent>();
const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const router = useRouter();
useEffect(() => {
const fetchLink = async () => {
if (router.query.id) {
await getLink(Number(router.query.id), true);
}
};
fetchLink();
}, []);
useEffect(() => {
if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
const fetchLinkContent = async () => {
if (
router.query.id &&
link?.readabilityPath &&
link?.readabilityPath !== "pending"
) {
const response = await fetch(
`/api/v1/archives/${link?.id}?format=${ArchivedFormat.readability}`
);
const data = await response?.json();
setLinkContent(data);
}
};
fetchLinkContent();
}, [link]);
useEffect(() => {
let interval: NodeJS.Timer | undefined;
if (
link?.screenshotPath === "pending" ||
link?.pdfPath === "pending" ||
link?.readabilityPath === "pending"
) {
interval = setInterval(() => getLink(link.id as number, true), 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.screenshotPath, link?.pdfPath, link?.readabilityPath]);
const colorThief = new ColorThief();
const rgbToHex = (r: number, g: number, b: number): string =>
"#" +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("");
useEffect(() => {
const banner = document.getElementById("link-banner");
const bannerInner = document.getElementById("link-banner-inner");
if (colorPalette && banner && bannerInner) {
if (colorPalette[0] && colorPalette[1]) {
banner.style.background = `linear-gradient(to right, ${rgbToHex(
colorPalette[0][0],
colorPalette[0][1],
colorPalette[0][2]
)}30, ${rgbToHex(
colorPalette[1][0],
colorPalette[1][1],
colorPalette[1][2]
)}30)`;
}
if (colorPalette[2] && colorPalette[3]) {
bannerInner.style.background = `linear-gradient(to left, ${rgbToHex(
colorPalette[2][0],
colorPalette[2][1],
colorPalette[2][2]
)}30, ${rgbToHex(
colorPalette[3][0],
colorPalette[3][1],
colorPalette[3][2]
)})30`;
}
}
}, [colorPalette, theme]);
return (
<LinkLayout>
<div
className={`flex flex-col max-w-screen-md h-full ${
theme === "dark" ? "banner-dark-mode" : "banner-light-mode"
}`}
>
<div
id="link-banner"
className="link-banner p-5 mb-4 relative bg-opacity-10 border border-solid border-sky-100 dark:border-neutral-700 shadow-md"
>
<div id="link-banner-inner" className="link-banner-inner"></div>
<div className={`relative flex flex-col gap-3 items-start`}>
<div className="flex gap-3 items-end">
{!imageError && link?.url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={42}
height={42}
alt=""
id={"favicon-" + link.id}
className="select-none mt-2 w-10 rounded-md shadow border-[3px] border-white dark:border-neutral-900 bg-white dark:bg-neutral-900 aspect-square"
draggable="false"
onLoad={(e) => {
try {
const color = colorThief.getPalette(
e.target as HTMLImageElement,
4
);
setColorPalette(color);
} catch (err) {
console.log(err);
}
}}
onError={(e) => {
setImageError(true);
}}
/>
)}
<div className="flex gap-2 text-sm text-gray-500 dark:text-gray-300">
<p className=" min-w-fit">
{link?.createdAt
? new Date(link?.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: undefined}
</p>
{link?.url ? (
<>
<p></p>
<Link
href={link?.url || ""}
title={link?.url}
target="_blank"
className="hover:opacity-60 duration-100 break-all"
>
{isValidUrl(link?.url || "")
? new URL(link?.url as string).host
: undefined}
</Link>
</>
) : undefined}
</div>
</div>
<div className="flex flex-col gap-2">
<p className="capitalize text-2xl sm:text-3xl font-thin">
{unescapeString(link?.name || link?.description || "")}
</p>
<div className="flex gap-1 items-center flex-wrap">
<Link
href={`/collections/${link?.collection?.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
>
<FontAwesomeIcon
icon={faFolder}
className="w-5 h-5 drop-shadow"
style={{ color: link?.collection?.color }}
/>
<p
title={link?.collection?.name}
className="text-black dark:text-white text-lg truncate max-w-[12rem]"
>
{link?.collection?.name}
</p>
</Link>
{link?.tags.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
className="px-2 py-1 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</p>
</Link>
))}
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-5 h-full">
{link?.readabilityPath?.startsWith("archives") ? (
<div
className="line-break px-3 reader-view"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(linkContent?.content || "") || "",
}}
></div>
) : (
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800">
{link?.readabilityPath === "pending" ? (
<p className="text-center">
Generating readable format, please wait...
</p>
) : (
<>
<p className="text-center text-2xl text-black dark:text-white">
There is no reader view for this webpage
</p>
<p className="text-center text-sm text-black dark:text-white">
{link?.collection?.ownerId === userId
? "You can update (refetch) the preserved formats by managing them below"
: "The collections owners can refetch the preserved formats"}
</p>
{link?.collection?.ownerId === userId ? (
<div
onClick={() =>
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "FORMATS",
})
: undefined
}
className="mt-4 flex gap-2 w-fit mx-auto relative items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-md dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100"
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-5 h-5 duration-100"
/>
<p>Manage preserved formats</p>
</div>
) : undefined}
</>
)}
</div>
)}
</div>
</div>
</LinkLayout>
);
}

View File

@ -151,6 +151,10 @@ export default function Account() {
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Account Settings</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
<div className="flex flex-col gap-10">
<div className="grid sm:grid-cols-2 gap-3 auto-rows-auto">
<div className="flex flex-col gap-3">

78
pages/settings/api.tsx Normal file
View File

@ -0,0 +1,78 @@
import Checkbox from "@/components/Checkbox";
import SubmitButton from "@/components/SubmitButton";
import SettingsLayout from "@/layouts/SettingsLayout";
import React, { useEffect, useState } from "react";
import useAccountStore from "@/store/account";
import { toast } from "react-hot-toast";
import { AccountSettings } from "@/types/global";
import TextInput from "@/components/TextInput";
export default function Api() {
const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore();
const [user, setUser] = useState<AccountSettings>(account);
const [archiveAsScreenshot, setArchiveAsScreenshot] =
useState<boolean>(false);
const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false);
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
useState<boolean>(false);
useEffect(() => {
setUser({
...account,
archiveAsScreenshot,
archiveAsPDF,
archiveAsWaybackMachine,
});
}, [account, archiveAsScreenshot, archiveAsPDF, archiveAsWaybackMachine]);
function objectIsEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
useEffect(() => {
if (!objectIsEmpty(account)) {
setArchiveAsScreenshot(account.archiveAsScreenshot);
setArchiveAsPDF(account.archiveAsPDF);
setArchiveAsWaybackMachine(account.archiveAsWaybackMachine);
}
}, [account]);
const submit = async () => {
// setSubmitLoader(true);
// const load = toast.loading("Applying...");
// const response = await updateAccount({
// ...user,
// });
// toast.dismiss(load);
// if (response.ok) {
// toast.success("Settings Applied!");
// } else toast.error(response.data as string);
// setSubmitLoader(false);
};
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">API Keys (Soon)</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
<div className="flex flex-col gap-3">
<div className="badge bg-orange-500 rounded-md border border-black w-fit px-2 text-black">
Status: Under Development
</div>
<p>This page will be for creating and managing your API keys.</p>
<p>
For now, you can <i>temporarily</i> use your{" "}
<code className="text-xs whitespace-nowrap bg-gray-500/40 rounded-md px-2 py-1">
next-auth.session-token
</code>{" "}
in your browser cookies as the API key for your integrations.
</p>
</div>
</SettingsLayout>
);
}

View File

@ -68,6 +68,10 @@ export default function Appearance() {
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Appearance</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
<div className="flex flex-col gap-10">
<div>
<p className="mb-3">Select Theme</p>

View File

@ -57,6 +57,10 @@ export default function Archive() {
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Archive Settings</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
<p>Formats to Archive webpages:</p>
<div className="p-3">
<Checkbox

View File

@ -11,9 +11,13 @@ export default function Billing() {
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Billing Settings</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
<div className="w-full mx-auto flex flex-col gap-3 justify-between">
<p className="text-md text-black dark:text-white">
To manage/cancel your subsciption, visit the{" "}
To manage/cancel your subscription, visit the{" "}
<a
href={process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL}
className="underline"

View File

@ -25,7 +25,7 @@ export default function Password() {
},
};
if (password == "") {
if (process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED !== "true" && password == "") {
return toast.error("Please fill the required fields.");
}
@ -78,6 +78,7 @@ export default function Password() {
. This action is irreversible!
</p>
{process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED !== "true" ? (
<div>
<p className="mb-2 text-black dark:text-white">
Confirm Your Password
@ -90,6 +91,7 @@ export default function Password() {
type="password"
/>
</div>
) : undefined}
{process.env.NEXT_PUBLIC_STRIPE ? (
<fieldset className="border rounded-md p-2 border-sky-500">

View File

@ -45,6 +45,10 @@ export default function Password() {
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Change Password</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
<p className="mb-3">
To change your password, please fill out the following. Your password
should be at least 8 characters.

View File

@ -11,10 +11,9 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "next/router";
import { FormEvent, useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout";
import { Tag } from "@prisma/client";
import useTagStore from "@/store/tags";
import SortDropdown from "@/components/SortDropdown";
import { Sort } from "@/types/global";
import { Sort, TagIncludingLinkCount } from "@/types/global";
import useLinks from "@/hooks/useLinks";
import Dropdown from "@/components/Dropdown";
import { toast } from "react-hot-toast";
@ -33,7 +32,7 @@ export default function Index() {
const [renameTag, setRenameTag] = useState(false);
const [newTagName, setNewTagName] = useState<string>();
const [activeTag, setActiveTag] = useState<Tag>();
const [activeTag, setActiveTag] = useState<TagIncludingLinkCount>();
useLinks({ tagId: Number(router.query.id), sort: sortBy });

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "password" DROP NOT NULL;

View File

@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "ApiKeys" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiKeys_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKeys_token_key" ON "ApiKeys"("token");
-- CreateIndex
CREATE UNIQUE INDEX "ApiKeys_token_userId_key" ON "ApiKeys"("token", "userId");
-- AddForeignKey
ALTER TABLE "ApiKeys" ADD CONSTRAINT "ApiKeys_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,34 @@
/*
Warnings:
- You are about to drop the `ApiKeys` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ApiKeys" DROP CONSTRAINT "ApiKeys_userId_fkey";
-- DropTable
DROP TABLE "ApiKeys";
-- CreateTable
CREATE TABLE "ApiKey" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_token_key" ON "ApiKey"("token");
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_token_userId_key" ON "ApiKey"("token", "userId");
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "blurredFavicons" SET DEFAULT false;

View File

@ -7,46 +7,55 @@ datasource db {
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId Int
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model User {
id Int @id @default(autoincrement())
name String
username String? @unique
email String? @unique
emailVerified DateTime?
image String?
password String
accounts Account[]
password String?
collections Collection[]
tags Tag[]
pinnedLinks Link[]
collectionsJoined UsersAndCollections[]
whitelistedUsers WhitelistedUser[]
apiKeys ApiKey[]
subscriptions Subscription?
archiveAsScreenshot Boolean @default(true)
archiveAsPDF Boolean @default(true)
archiveAsWaybackMachine Boolean @default(false)
isPrivate Boolean @default(false)
displayLinkIcons Boolean @default(true)
blurredFavicons Boolean @default(true)
blurredFavicons Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
}
model WhitelistedUser {
id Int @id @default(autoincrement())
username String @default("")
User User? @relation(fields: [userId], references: [id])
userId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
}
@ -55,7 +64,6 @@ model VerificationToken {
identifier String
token String @unique
expires DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
@ -68,7 +76,6 @@ model Collection {
description String @default("")
color String @default("#0ea5e9")
isPublic Boolean @default(false)
owner User @relation(fields: [ownerId], references: [id])
ownerId Int
members UsersAndCollections[]
@ -82,14 +89,11 @@ model Collection {
model UsersAndCollections {
user User @relation(fields: [userId], references: [id])
userId Int
collection Collection @relation(fields: [collectionId], references: [id])
collectionId Int
canCreate Boolean
canUpdate Boolean
canDelete Boolean
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
@ -101,21 +105,15 @@ model Link {
name String
url String
description String @default("")
pinnedBy User[]
collection Collection @relation(fields: [collectionId], references: [id])
collectionId Int
tags Tag[]
textContent String?
screenshotPath String?
pdfPath String?
readabilityPath String?
lastPreserved DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
}
@ -126,7 +124,6 @@ model Tag {
links Link[]
owner User @relation(fields: [ownerId], references: [id])
ownerId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
@ -139,10 +136,22 @@ model Subscription {
stripeSubscriptionId String @unique
currentPeriodStart DateTime
currentPeriodEnd DateTime
user User @relation(fields: [userId], references: [id])
userId Int @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
}
model ApiKey {
id Int @id @default(autoincrement())
name String
user User @relation(fields: [userId], references: [id])
userId Int
token String @unique
expires DateTime
lastUsedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
@@unique([token, userId])
}

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

View File

@ -17,7 +17,7 @@ type LinkStore = {
addLink: (
body: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>;
getLink: (linkId: number) => Promise<ResponseObject>;
getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
updateLink: (
link: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>;
@ -66,8 +66,12 @@ const useLinkStore = create<LinkStore>()((set) => ({
return { ok: response.ok, data: data.response };
},
getLink: async (linkId) => {
const response = await fetch(`/api/v1/links/${linkId}`);
getLink: async (linkId, publicRoute) => {
const path = publicRoute
? `/api/v1/public/links/${linkId}`
: `/api/v1/links/${linkId}`;
const response = await fetch(path);
const data = await response.json();

View File

@ -39,6 +39,14 @@ type Modal =
active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
| {
modal: "COLLECTION";
state: boolean;
method: "VIEW_TEAM";
isOwner?: boolean;
active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
| null;
type ModalsStore = {
@ -46,11 +54,11 @@ type ModalsStore = {
setModal: (modal: Modal) => void;
};
const useLocalSettingsStore = create<ModalsStore>((set) => ({
const useModalStore = create<ModalsStore>((set) => ({
modal: null,
setModal: (modal: Modal) => {
set({ modal });
},
}));
export default useLocalSettingsStore;
export default useModalStore;

View File

@ -1,5 +1,5 @@
import { create } from "zustand";
import { Tag } from "@prisma/client";
import { TagIncludingLinkCount } from "@/types/global";
type ResponseObject = {
ok: boolean;
@ -7,9 +7,9 @@ type ResponseObject = {
};
type TagStore = {
tags: Tag[];
tags: TagIncludingLinkCount[];
setTags: () => void;
updateTag: (tag: Tag) => Promise<ResponseObject>;
updateTag: (tag: TagIncludingLinkCount) => Promise<ResponseObject>;
removeTag: (tagId: number) => Promise<ResponseObject>;
};

View File

@ -142,7 +142,7 @@ body {
/* Theme */
@layer base {
body {
@apply dark:bg-neutral-900 bg-white dark:text-white;
@apply dark:bg-neutral-900 bg-white text-black dark:text-white;
}
}
@ -190,7 +190,11 @@ body {
/* Reader view custom stylings */
.reader-view {
line-height: 3rem;
line-height: 2.8rem;
}
.reader-view p {
font-size: 1.15rem;
line-height: 2.5rem;
}
.reader-view h1 {
font-size: 2.2rem;
@ -230,6 +234,8 @@ body {
}
.reader-view img {
margin: auto;
margin-top: 2rem;
margin-bottom: 2rem;
border-radius: 10px;
}
.reader-view pre {

View File

@ -2,6 +2,9 @@
module.exports = {
darkMode: "class",
// daisyui: {
// themes: ["light", "dark"],
// },
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
@ -10,5 +13,7 @@ module.exports = {
// For the "layouts" directory
"./layouts/**/*.{js,ts,jsx,tsx}",
],
plugins: [],
plugins: [
// require("daisyui")
],
};

View File

@ -13,8 +13,14 @@ declare global {
SPACES_KEY?: string;
SPACES_SECRET?: string;
SPACES_ENDPOINT?: string;
BUCKET_NAME?: string;
SPACES_BUCKET_NAME?: string;
SPACES_REGION?: string;
SPACES_FORCE_PATH_STYLE?: string;
NEXT_PUBLIC_KEYCLOAK_ENABLED?: string;
KEYCLOAK_ISSUER?: string;
KEYCLOAK_CLIENT_ID?: string;
KEYCLOAK_CLIENT_SECRET?: string;
NEXT_PUBLIC_EMAIL_PROVIDER?: string;
EMAIL_FROM?: string;

View File

@ -37,6 +37,10 @@ export interface CollectionIncludingMembersAndLinkCount
members: Member[];
}
export interface TagIncludingLinkCount extends Tag {
_count?: { links: number };
}
export interface AccountSettings extends User {
newPassword?: string;
whitelistedUsers: string[];
@ -111,3 +115,9 @@ export type DeleteUserBody = {
feedback?: Stripe.SubscriptionCancelParams.CancellationDetails.Feedback;
};
};
export enum ArchivedFormat {
screenshot,
pdf,
readability,
}

View File

@ -919,11 +919,6 @@
dependencies:
glob "7.1.7"
"@next/font@13.4.9":
version "13.4.9"
resolved "https://registry.yarnpkg.com/@next/font/-/font-13.4.9.tgz#5540e69a1a5fbd1113d622a89cdd21c0ab3906c8"
integrity sha512-aR0XEyd1cxqaKuelQFDGwUBYV0wyZfJTNiRoSk1XsECTyMhiSMmCOY7yOPMuPlw+6cctca0GyZXGGFb5EVhiRw==
"@next/swc-darwin-arm64@13.4.12":
version "13.4.12"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.12.tgz#326c830b111de8a1a51ac0cbc3bcb157c4c4f92c"
@ -2134,6 +2129,14 @@ crypto-js@^4.2.0:
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
css-selector-tokenizer@^0.8:
version "0.8.0"
resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz#88267ef6238e64f2215ea2764b3e2cf498b845dd"
integrity sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==
dependencies:
cssesc "^3.0.0"
fastparse "^1.1.2"
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
@ -2156,6 +2159,11 @@ csstype@^3.1.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
culori@^3:
version "3.2.0"
resolved "https://registry.yarnpkg.com/culori/-/culori-3.2.0.tgz#df6561503f0cc20e8e1c029f086466666c0ac62f"
integrity sha512-HIEbTSP7vs1mPq/2P9In6QyFE0Tkpevh0k9a+FkjhD+cwsYm9WRSbn4uMdW9O0yXlNYC3ppxL3gWWPOcvEl57w==
cwise-compiler@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/cwise-compiler/-/cwise-compiler-1.1.3.tgz#f4d667410e850d3a313a7d2db7b1e505bb034cc5"
@ -2163,6 +2171,16 @@ cwise-compiler@^1.1.2:
dependencies:
uniq "^1.0.0"
daisyui@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/daisyui/-/daisyui-4.4.2.tgz#669ee310be42894abe36d493635000d010fa4023"
integrity sha512-Ecg5loskj9dkaAnTSK5Xn5jb24TqDlQIg/NJ025jCkw2S/zw12btjvLgY2Sv5Ws1DFVoVBRs3XYXyojZG7zVnw==
dependencies:
css-selector-tokenizer "^0.8"
culori "^3"
picocolors "^1"
postcss-js "^4"
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@ -2731,6 +2749,11 @@ fast-xml-parser@4.2.5:
dependencies:
strnum "^1.0.5"
fastparse@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.2.tgz#91728c5a5942eced8531283c79441ee4122c35a9"
integrity sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==
fastq@^1.6.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
@ -3627,6 +3650,11 @@ loose-envify@^1.1.0, loose-envify@^1.4.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
lottie-web@^5.12.2:
version "5.12.2"
resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.12.2.tgz#579ca9fe6d3fd9e352571edd3c0be162492f68e5"
integrity sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg==
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
@ -4113,7 +4141,7 @@ performance-now@^2.1.0:
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
picocolors@^1.0.0:
picocolors@^1, picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
@ -4159,7 +4187,7 @@ postcss-import@^15.1.0:
read-cache "^1.0.0"
resolve "^1.1.7"
postcss-js@^4.0.1:
postcss-js@^4, postcss-js@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2"
integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==