Merge branch 'dev' into missing-duplicate-checks
This commit is contained in:
commit
baadd6c06b
|
@ -75,6 +75,13 @@ AUTH0_ISSUER=
|
|||
AUTH0_CLIENT_SECRET=
|
||||
AUTH0_CLIENT_ID=
|
||||
|
||||
# Authelia
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED=""
|
||||
AUTHELIA_CLIENT_ID=""
|
||||
AUTHELIA_CLIENT_SECRET=""
|
||||
AUTHELIA_WELLKNOWN_URL=""
|
||||
|
||||
|
||||
# Authentik
|
||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||
AUTHENTIK_CUSTOM_NAME=
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
# Architecture
|
||||
|
||||
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
|
||||
|
||||
When you start Linkwarden, there are mainly two components that run:
|
||||
|
||||
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
|
||||
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
|
||||
|
||||
## Main Tech Stack
|
||||
|
||||
- [NextJS](https://github.com/vercel/next.js)
|
||||
- [TypeScript](https://github.com/microsoft/TypeScript)
|
||||
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
|
||||
- [DaisyUI](https://github.com/saadeghi/daisyui)
|
||||
- [Prisma](https://github.com/prisma/prisma)
|
||||
- [Playwright](https://github.com/microsoft/playwright)
|
||||
- [Zustand](https://github.com/pmndrs/zustand)
|
||||
|
||||
## Folder Structure
|
||||
|
||||
Here's a summary of the main files and folders in the project:
|
||||
|
||||
```
|
||||
linkwarden
|
||||
├── components # React components
|
||||
├── hooks # React reusable hooks
|
||||
├── layouts # Layouts for pages
|
||||
├── lib
|
||||
│ ├── api # Server-side functions (controllers, etc.)
|
||||
│ ├── client # Client-side functions
|
||||
│ └── shared # Shared functions between client and server
|
||||
├── pages # Pages and API routes
|
||||
├── prisma # Prisma schema and migrations
|
||||
├── scripts
|
||||
│ ├── migration # Scripts for breaking changes
|
||||
│ └── worker.ts # Background worker for archiving links
|
||||
├── store # Zustand stores
|
||||
├── styles # Styles
|
||||
└── types # TypeScript types
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).
|
|
@ -47,7 +47,10 @@ const CollectionListing = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (account.username) {
|
||||
if (!account.collectionOrder || account.collectionOrder.length === 0)
|
||||
if (
|
||||
(!account.collectionOrder || account.collectionOrder.length === 0) &&
|
||||
collections.length > 0
|
||||
)
|
||||
updateAccount({
|
||||
...account,
|
||||
collectionOrder: collections
|
||||
|
|
|
@ -26,7 +26,7 @@ export default function FilterSearchDropdown({
|
|||
>
|
||||
<i className="bi-funnel text-neutral text-2xl"></i>
|
||||
</div>
|
||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mt-1">
|
||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-56 mt-1">
|
||||
<li>
|
||||
<label
|
||||
className="label cursor-pointer flex justify-start"
|
||||
|
@ -84,27 +84,6 @@ export default function FilterSearchDropdown({
|
|||
<span className="label-text">Description</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label
|
||||
className="label cursor-pointer flex justify-start"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="search-filter-checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={searchFilter.textContent}
|
||||
onChange={() => {
|
||||
setSearchFilter({
|
||||
...searchFilter,
|
||||
textContent: !searchFilter.textContent,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">Full Content</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label
|
||||
className="label cursor-pointer flex justify-start"
|
||||
|
@ -126,6 +105,29 @@ export default function FilterSearchDropdown({
|
|||
<span className="label-text">Tags</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label
|
||||
className="label cursor-pointer flex justify-between"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="search-filter-checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={searchFilter.textContent}
|
||||
onChange={() => {
|
||||
setSearchFilter({
|
||||
...searchFilter,
|
||||
textContent: !searchFilter.textContent,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">Full Content</span>
|
||||
|
||||
<div className="ml-auto badge badge-sm badge-neutral">Slower</div>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import LinkCard from "@/components/LinkViews/LinkCard";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { link } from "fs";
|
||||
import { GridLoader } from "react-spinners";
|
||||
|
||||
export default function CardView({
|
||||
links,
|
||||
showCheckbox = true,
|
||||
editMode,
|
||||
isLoading,
|
||||
}: {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
showCheckbox?: boolean;
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
|
@ -23,6 +25,15 @@ export default function CardView({
|
|||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading && links.length > 0 && (
|
||||
<GridLoader
|
||||
color="oklch(var(--p))"
|
||||
loading={true}
|
||||
size={20}
|
||||
className="fixed top-5 right-5 opacity-50 z-30"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import LinkList from "@/components/LinkViews/LinkList";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { GridLoader } from "react-spinners";
|
||||
|
||||
export default function ListView({
|
||||
links,
|
||||
editMode,
|
||||
isLoading,
|
||||
}: {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1 flex-col">
|
||||
|
@ -21,6 +24,15 @@ export default function ListView({
|
|||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading && links.length > 0 && (
|
||||
<GridLoader
|
||||
color="oklch(var(--p))"
|
||||
loading={true}
|
||||
size={20}
|
||||
className="fixed top-5 right-5 opacity-50 z-30"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
|||
import useAccountStore from "@/store/account";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
|
@ -53,7 +54,9 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
|||
let shortendURL;
|
||||
|
||||
try {
|
||||
shortendURL = new URL(link.url || "").host.toLowerCase();
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
@ -109,7 +112,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
|||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
// window.open ('www.yourdomain.com', '_ blank');
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
@ -162,12 +164,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
|||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
<div title={link.url || ""} className="w-fit">
|
||||
<div className="flex gap-1 item-center select-none text-neutral mt-1">
|
||||
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
|
||||
<p className="text-sm truncate">{shortendURL}</p>
|
||||
</div>
|
||||
</div>
|
||||
<LinkTypeBadge link={link} />
|
||||
</div>
|
||||
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
|
|
@ -122,6 +122,7 @@ export default function LinkActions({
|
|||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
{link.type === "url" && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -134,6 +135,7 @@ export default function LinkActions({
|
|||
Preserved Formats
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{permissions === true || permissions?.canDelete ? (
|
||||
<li>
|
||||
<div
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
|
@ -15,12 +16,12 @@ export default function LinkCollection({
|
|||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div
|
||||
<Link
|
||||
href={`/collections/${link.collection.id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.push(`/collections/${link.collection.id}`);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
|
||||
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none"
|
||||
title={collection?.name}
|
||||
>
|
||||
<i
|
||||
|
@ -28,6 +29,6 @@ export default function LinkCollection({
|
|||
style={{ color: collection?.color }}
|
||||
></i>
|
||||
<p className="truncate capitalize">{collection?.name}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,14 +6,13 @@ export default function LinkDate({
|
|||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}) {
|
||||
const formattedDate = new Date(link.createdAt as string).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
const formattedDate = new Date(
|
||||
(link.importDate || link.createdAt) as string
|
||||
).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-neutral">
|
||||
|
|
|
@ -6,9 +6,11 @@ import React from "react";
|
|||
export default function LinkIcon({
|
||||
link,
|
||||
width,
|
||||
className,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
width?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const url =
|
||||
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
|
||||
|
@ -16,13 +18,16 @@ export default function LinkIcon({
|
|||
const iconClasses: string =
|
||||
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" +
|
||||
" " +
|
||||
(width || "w-12");
|
||||
(width || "w-12") +
|
||||
" " +
|
||||
(className || "");
|
||||
|
||||
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{link.url && url && showFavicon ? (
|
||||
{link.type === "url" && url ? (
|
||||
showFavicon ? (
|
||||
<Image
|
||||
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
|
||||
width={64}
|
||||
|
@ -34,15 +39,34 @@ export default function LinkIcon({
|
|||
setShowFavicon(false);
|
||||
}}
|
||||
/>
|
||||
) : showFavicon === false ? (
|
||||
<div className={iconClasses}>
|
||||
<i className="bi-link-45deg text-4xl text-black"></i>
|
||||
</div>
|
||||
) : (
|
||||
<LinkPlaceholderIcon iconClasses={iconClasses} icon="bi-link-45deg" />
|
||||
)
|
||||
) : link.type === "pdf" ? (
|
||||
<i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
|
||||
<LinkPlaceholderIcon
|
||||
iconClasses={iconClasses}
|
||||
icon="bi-file-earmark-pdf"
|
||||
/>
|
||||
) : link.type === "image" ? (
|
||||
<i className={`bi-file-earmark-image ${iconClasses}`}></i>
|
||||
<LinkPlaceholderIcon
|
||||
iconClasses={iconClasses}
|
||||
icon="bi-file-earmark-image"
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const LinkPlaceholderIcon = ({
|
||||
iconClasses,
|
||||
icon,
|
||||
}: {
|
||||
iconClasses: string;
|
||||
icon: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={`text-4xl text-black aspect-square ${iconClasses}`}>
|
||||
<i className={`${icon} m-auto`}></i>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
export default function LinkTypeBadge({
|
||||
link,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}) {
|
||||
let shortendURL;
|
||||
|
||||
if (link.type === "url" && link.url) {
|
||||
try {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
return link.url && shortendURL ? (
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
target="_blank"
|
||||
title={link.url || ""}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
|
||||
>
|
||||
<i className="bi-link-45deg text-lg mt-[0.1rem] leading-none"></i>
|
||||
<p className="text-sm truncate">{shortendURL}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="badge badge-primary badge-sm my-1 select-none">
|
||||
{link.type}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -16,6 +16,7 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
|||
import useAccountStore from "@/store/account";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
|
@ -56,14 +57,6 @@ export default function LinkCardCompact({
|
|||
}
|
||||
};
|
||||
|
||||
let shortendURL;
|
||||
|
||||
try {
|
||||
shortendURL = new URL(link.url || "").host.toLowerCase();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
|
@ -130,7 +123,11 @@ export default function LinkCardCompact({
|
|||
}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
||||
<LinkIcon
|
||||
link={link}
|
||||
width="sm:w-12 w-8"
|
||||
className="mt-1 sm:mt-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[calc(100%-56px)] ml-2">
|
||||
|
@ -143,16 +140,7 @@ export default function LinkCardCompact({
|
|||
{collection ? (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
) : undefined}
|
||||
{link.url ? (
|
||||
<div className="flex items-center gap-1 w-fit text-neutral truncate">
|
||||
<i className="bi-link-45deg text-lg" />
|
||||
<p className="truncate w-full select-none">{shortendURL}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="badge badge-primary badge-sm my-1 select-none">
|
||||
{link.type}
|
||||
</div>
|
||||
)}
|
||||
<LinkTypeBadge link={link} />
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -43,7 +43,7 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||
|
||||
const [file, setFile] = useState<File>();
|
||||
|
||||
const { addLink } = useLinkStore();
|
||||
const { uploadFile } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||
|
@ -100,48 +100,15 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||
|
||||
const submit = async () => {
|
||||
if (!submitLoader && file) {
|
||||
let fileType: ArchivedFormat | null = null;
|
||||
let linkType: "url" | "image" | "pdf" | null = null;
|
||||
|
||||
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
|
||||
fileType = ArchivedFormat.jpeg;
|
||||
linkType = "image";
|
||||
} else if (file.type === "image/png") {
|
||||
fileType = ArchivedFormat.png;
|
||||
linkType = "image";
|
||||
} else if (file.type === "application/pdf") {
|
||||
fileType = ArchivedFormat.pdf;
|
||||
linkType = "pdf";
|
||||
}
|
||||
|
||||
if (fileType !== null && linkType !== null) {
|
||||
setSubmitLoader(true);
|
||||
|
||||
let response;
|
||||
|
||||
const load = toast.loading("Creating...");
|
||||
|
||||
response = await addLink({
|
||||
...link,
|
||||
type: linkType,
|
||||
name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
|
||||
});
|
||||
const response = await uploadFile(link, file);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
const formBody = new FormData();
|
||||
file && formBody.append("file", file);
|
||||
|
||||
await fetch(
|
||||
`/api/v1/archives/${
|
||||
(response.data as LinkIncludingShortenedCollectionAndTags).id
|
||||
}?format=${fileType}`,
|
||||
{
|
||||
body: formBody,
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
toast.success(`Created!`);
|
||||
onClose();
|
||||
} else toast.error(response.data as string);
|
||||
|
@ -150,7 +117,6 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -238,7 +204,7 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||
className="btn btn-accent dark:border-violet-400 text-white"
|
||||
onClick={submit}
|
||||
>
|
||||
Create Link
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
@ -65,7 +65,7 @@ export default function Navbar() {
|
|||
<ToggleDarkMode className="hidden sm:inline-grid" />
|
||||
|
||||
<div className="dropdown dropdown-end sm:inline-block hidden">
|
||||
<div className="tooltip tooltip-bottom z-10" data-tip="Create New...">
|
||||
<div className="tooltip tooltip-bottom" data-tip="Create New...">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
|
@ -93,7 +93,7 @@ export default function Navbar() {
|
|||
New Link
|
||||
</div>
|
||||
</li>
|
||||
{/* <li>
|
||||
<li>
|
||||
<div
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
|
@ -104,7 +104,7 @@ export default function Navbar() {
|
|||
>
|
||||
Upload File
|
||||
</div>
|
||||
</li> */}
|
||||
</li>
|
||||
<li>
|
||||
<div
|
||||
onClick={() => {
|
||||
|
|
|
@ -34,6 +34,8 @@ export default function ReadableView({ link }: Props) {
|
|||
const [imageError, setImageError] = useState<boolean>(false);
|
||||
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
||||
|
||||
const [date, setDate] = useState<Date | string>();
|
||||
|
||||
const colorThief = new ColorThief();
|
||||
|
||||
const router = useRouter();
|
||||
|
@ -54,6 +56,8 @@ export default function ReadableView({ link }: Props) {
|
|||
};
|
||||
|
||||
fetchLinkContent();
|
||||
|
||||
setDate(link.importDate || link.createdAt);
|
||||
}, [link]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -211,8 +215,8 @@ export default function ReadableView({ link }: Props) {
|
|||
</div>
|
||||
|
||||
<p className="min-w-fit text-sm text-neutral">
|
||||
{link?.createdAt
|
||||
? new Date(link?.createdAt).toLocaleString("en-US", {
|
||||
{date
|
||||
? new Date(date).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
const LINKWARDEN_VERSION = "v2.5.0";
|
||||
const LINKWARDEN_VERSION = "v2.5.1";
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { LinkRequestQuery } from "@/types/global";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import useDetectPageBottom from "./useDetectPageBottom";
|
||||
import { useRouter } from "next/router";
|
||||
import useLinkStore from "@/store/links";
|
||||
|
@ -22,6 +22,8 @@ export default function useLinks(
|
|||
useLinkStore();
|
||||
const router = useRouter();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
|
||||
|
||||
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
|
||||
|
@ -61,10 +63,14 @@ export default function useLinks(
|
|||
basePath = "/api/v1/public/collections/links";
|
||||
} else basePath = "/api/v1/links";
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await fetch(`${basePath}?${queryString}`);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.ok) setLinks(data.response, isInitialCall);
|
||||
};
|
||||
|
||||
|
@ -92,4 +98,6 @@ export default function useLinks(
|
|||
|
||||
setReachedBottom(false);
|
||||
}, [reachedBottom]);
|
||||
|
||||
return { isLoading };
|
||||
}
|
||||
|
|
|
@ -7,9 +7,9 @@ import { JSDOM } from "jsdom";
|
|||
import DOMPurify from "dompurify";
|
||||
import { Collection, Link, User } from "@prisma/client";
|
||||
import validateUrlSize from "./validateUrlSize";
|
||||
import removeFile from "./storage/removeFile";
|
||||
import Jimp from "jimp";
|
||||
import createFolder from "./storage/createFolder";
|
||||
import generatePreview from "./generatePreview";
|
||||
import { removeFiles } from "./manageLinkFiles";
|
||||
|
||||
type LinksAndCollectionAndOwner = Link & {
|
||||
collection: Collection & {
|
||||
|
@ -51,6 +51,14 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||
);
|
||||
});
|
||||
|
||||
createFolder({
|
||||
filePath: `archives/preview/${link.collectionId}`,
|
||||
});
|
||||
|
||||
createFolder({
|
||||
filePath: `archives/${link.collectionId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
|
@ -162,10 +170,6 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||
return metaTag ? (metaTag as any).content : null;
|
||||
});
|
||||
|
||||
createFolder({
|
||||
filePath: `archives/preview/${link.collectionId}`,
|
||||
});
|
||||
|
||||
if (ogImageUrl) {
|
||||
console.log("Found og:image URL:", ogImageUrl);
|
||||
|
||||
|
@ -175,35 +179,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||
// Check if imageResponse is not null
|
||||
if (imageResponse && !link.preview?.startsWith("archive")) {
|
||||
const buffer = await imageResponse.body();
|
||||
|
||||
// Check if buffer is not null
|
||||
if (buffer) {
|
||||
// Load the image using Jimp
|
||||
Jimp.read(buffer, async (err, image) => {
|
||||
if (image && !err) {
|
||||
image?.resize(1280, Jimp.AUTO).quality(20);
|
||||
const processedBuffer = await image?.getBufferAsync(
|
||||
Jimp.MIME_JPEG
|
||||
);
|
||||
|
||||
createFile({
|
||||
data: processedBuffer,
|
||||
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||
}).then(() => {
|
||||
return prisma.link.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error("Error processing the image:", err);
|
||||
});
|
||||
} else {
|
||||
console.log("No image data found.");
|
||||
}
|
||||
await generatePreview(buffer, link.collectionId, link.id);
|
||||
}
|
||||
|
||||
await page.goBack();
|
||||
|
@ -323,14 +299,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||
},
|
||||
});
|
||||
else {
|
||||
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
|
||||
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
|
||||
removeFile({
|
||||
filePath: `archives/${link.collectionId}/${link.id}_readability.json`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||
});
|
||||
await removeFiles(link.id, link.collectionId);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
|
|
@ -32,11 +32,12 @@ export default async function checkSubscriptionByEmail(email: string) {
|
|||
customer.subscriptions?.data.some((subscription) => {
|
||||
subscription.current_period_end;
|
||||
|
||||
active = subscription.items.data.some(
|
||||
active =
|
||||
subscription.items.data.some(
|
||||
(e) =>
|
||||
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
|
||||
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
|
||||
);
|
||||
) || false;
|
||||
stripeSubscriptionId = subscription.id;
|
||||
currentPeriodStart = subscription.current_period_start * 1000;
|
||||
currentPeriodEnd = subscription.current_period_end * 1000;
|
||||
|
@ -44,7 +45,7 @@ export default async function checkSubscriptionByEmail(email: string) {
|
|||
});
|
||||
|
||||
return {
|
||||
active,
|
||||
active: active || false,
|
||||
stripeSubscriptionId,
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
|
|
|
@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
|
|||
import { UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
import { removeFiles } from "@/lib/api/manageLinkFiles";
|
||||
|
||||
export default async function deleteLinksById(
|
||||
userId: number,
|
||||
|
@ -43,15 +44,7 @@ export default async function deleteLinksById(
|
|||
const linkId = linkIds[i];
|
||||
const collectionIsAccessible = collectionIsAccessibleArray[i];
|
||||
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||
});
|
||||
if (collectionIsAccessible) removeFiles(linkId, collectionIsAccessible.id);
|
||||
}
|
||||
|
||||
return { response: deletedLinks, status: 200 };
|
||||
|
|
|
@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
|
|||
import { Link, UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
import { removeFiles } from "@/lib/api/manageLinkFiles";
|
||||
|
||||
export default async function deleteLink(userId: number, linkId: number) {
|
||||
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
|
||||
|
@ -12,7 +13,10 @@ export default async function deleteLink(userId: number, linkId: number) {
|
|||
(e: UsersAndCollections) => e.userId === userId && e.canDelete
|
||||
);
|
||||
|
||||
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
|
||||
if (
|
||||
!collectionIsAccessible ||
|
||||
!(collectionIsAccessible?.ownerId === userId || memberHasAccess)
|
||||
)
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
|
||||
const deleteLink: Link = await prisma.link.delete({
|
||||
|
@ -21,15 +25,7 @@ export default async function deleteLink(userId: number, linkId: number) {
|
|||
},
|
||||
});
|
||||
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||
});
|
||||
removeFiles(linkId, collectionIsAccessible.id);
|
||||
|
||||
return { response: deleteLink, status: 200 };
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db";
|
|||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import moveFile from "@/lib/api/storage/moveFile";
|
||||
import { moveFiles } from "@/lib/api/manageLinkFiles";
|
||||
|
||||
export default async function updateLinkById(
|
||||
userId: number,
|
||||
|
@ -146,20 +146,7 @@ export default async function updateLinkById(
|
|||
});
|
||||
|
||||
if (collectionIsAccessible?.id !== data.collection.id) {
|
||||
await moveFile(
|
||||
`archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||
`archives/${data.collection.id}/${linkId}.pdf`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||
`archives/${data.collection.id}/${linkId}.png`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||
`archives/${data.collection.id}/${linkId}_readability.json`
|
||||
);
|
||||
await moveFiles(linkId, collectionIsAccessible?.id, data.collection.id);
|
||||
}
|
||||
|
||||
return { response: updatedLink, status: 200 };
|
||||
|
|
|
@ -12,6 +12,7 @@ export default async function postLink(
|
|||
link: LinkIncludingShortenedCollectionAndTags,
|
||||
userId: number
|
||||
) {
|
||||
if (link.url || link.type === "url") {
|
||||
try {
|
||||
new URL(link.url || "");
|
||||
} catch (error) {
|
||||
|
@ -21,6 +22,7 @@ export default async function postLink(
|
|||
status: 400,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!link.collection.id && link.collection.name) {
|
||||
link.collection.name = link.collection.name.trim();
|
||||
|
@ -48,6 +50,7 @@ export default async function postLink(
|
|||
return { response: "Collection is not accessible.", status: 401 };
|
||||
|
||||
link.collection.id = findCollection.id;
|
||||
link.collection.ownerId = findCollection.ownerId;
|
||||
} else {
|
||||
const collection = await prisma.collection.create({
|
||||
data: {
|
||||
|
@ -180,7 +183,7 @@ export default async function postLink(
|
|||
|
||||
const newLink = await prisma.link.create({
|
||||
data: {
|
||||
url: link.url?.trim().replace(/\/+$/, ""),
|
||||
url: link.url?.trim().replace(/\/+$/, "") || null,
|
||||
name: link.name,
|
||||
description,
|
||||
type: linkType,
|
||||
|
|
|
@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
|
|||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { parse, Node, Element, TextNode } from "himalaya";
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||
|
||||
|
@ -36,7 +37,9 @@ export default async function importFromHTMLFile(
|
|||
|
||||
const jsonData = parse(document.documentElement.outerHTML);
|
||||
|
||||
for (const item of jsonData) {
|
||||
const processedArray = processNodes(jsonData);
|
||||
|
||||
for (const item of processedArray) {
|
||||
console.log(item);
|
||||
await processBookmarks(userId, item as Element);
|
||||
}
|
||||
|
@ -74,7 +77,9 @@ async function processBookmarks(
|
|||
} else if (item.type === "element" && item.tagName === "a") {
|
||||
// process link
|
||||
|
||||
const linkUrl = item?.attributes.find((e) => e.key === "href")?.value;
|
||||
const linkUrl = item?.attributes.find(
|
||||
(e) => e.key.toLowerCase() === "href"
|
||||
)?.value;
|
||||
const linkName = (
|
||||
item?.children.find((e) => e.type === "text") as TextNode
|
||||
)?.content;
|
||||
|
@ -82,14 +87,33 @@ async function processBookmarks(
|
|||
.find((e) => e.key === "tags")
|
||||
?.value.split(",");
|
||||
|
||||
// set date if available
|
||||
const linkDateValue = item?.attributes.find(
|
||||
(e) => e.key.toLowerCase() === "add_date"
|
||||
)?.value;
|
||||
|
||||
const linkDate = linkDateValue
|
||||
? new Date(Number(linkDateValue) * 1000)
|
||||
: undefined;
|
||||
|
||||
let linkDesc =
|
||||
(
|
||||
(
|
||||
item?.children?.find(
|
||||
(e) => e.type === "element" && e.tagName === "dd"
|
||||
) as Element
|
||||
)?.children[0] as TextNode
|
||||
)?.content || "";
|
||||
|
||||
if (linkUrl && parentCollectionId) {
|
||||
await createLink(
|
||||
userId,
|
||||
linkUrl,
|
||||
parentCollectionId,
|
||||
linkName,
|
||||
"",
|
||||
linkTags
|
||||
linkDesc,
|
||||
linkTags,
|
||||
linkDate
|
||||
);
|
||||
} else if (linkUrl) {
|
||||
// create a collection named "Imported Bookmarks" and add the link to it
|
||||
|
@ -100,8 +124,9 @@ async function processBookmarks(
|
|||
linkUrl,
|
||||
collectionId,
|
||||
linkName,
|
||||
"",
|
||||
linkTags
|
||||
linkDesc,
|
||||
linkTags,
|
||||
linkDate
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -160,7 +185,8 @@ const createLink = async (
|
|||
collectionId: number,
|
||||
name?: string,
|
||||
description?: string,
|
||||
tags?: string[]
|
||||
tags?: string[],
|
||||
importDate?: Date
|
||||
) => {
|
||||
await prisma.link.create({
|
||||
data: {
|
||||
|
@ -193,6 +219,48 @@ const createLink = async (
|
|||
}),
|
||||
}
|
||||
: undefined,
|
||||
importDate: importDate || undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function processNodes(nodes: Node[]) {
|
||||
const findAndProcessDL = (node: Node) => {
|
||||
if (node.type === "element" && node.tagName === "dl") {
|
||||
processDLChildren(node);
|
||||
} else if (
|
||||
node.type === "element" &&
|
||||
node.children &&
|
||||
node.children.length
|
||||
) {
|
||||
node.children.forEach((child) => findAndProcessDL(child));
|
||||
}
|
||||
};
|
||||
|
||||
const processDLChildren = (dlNode: Element) => {
|
||||
dlNode.children.forEach((child, i) => {
|
||||
if (child.type === "element" && child.tagName === "dt") {
|
||||
const nextSibling = dlNode.children[i + 1];
|
||||
if (
|
||||
nextSibling &&
|
||||
nextSibling.type === "element" &&
|
||||
nextSibling.tagName === "dd"
|
||||
) {
|
||||
const aElement = child.children.find(
|
||||
(el) => el.type === "element" && el.tagName === "a"
|
||||
);
|
||||
if (aElement && aElement.type === "element") {
|
||||
// Add the 'dd' element as a child of the 'a' element
|
||||
aElement.children.push(nextSibling);
|
||||
// Remove the 'dd' from the parent 'dl' to avoid duplicate processing
|
||||
dlNode.children.splice(i + 1, 1);
|
||||
// Adjust the loop counter due to the removal
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
nodes.forEach(findAndProcessDL);
|
||||
return nodes;
|
||||
}
|
||||
|
|
|
@ -71,6 +71,10 @@ export default async function deleteUserById(
|
|||
|
||||
// Delete archive folders
|
||||
removeFolder({ filePath: `archives/${collection.id}` });
|
||||
|
||||
await removeFolder({
|
||||
filePath: `archives/preview/${collection.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete collections after cleaning up related data
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import Jimp from "jimp";
|
||||
import { prisma } from "./db";
|
||||
import createFile from "./storage/createFile";
|
||||
import createFolder from "./storage/createFolder";
|
||||
|
||||
const generatePreview = async (
|
||||
buffer: Buffer,
|
||||
collectionId: number,
|
||||
linkId: number
|
||||
) => {
|
||||
if (buffer && collectionId && linkId) {
|
||||
// Load the image using Jimp
|
||||
await Jimp.read(buffer, async (err, image) => {
|
||||
if (image && !err) {
|
||||
image?.resize(1280, Jimp.AUTO).quality(20);
|
||||
const processedBuffer = await image?.getBufferAsync(Jimp.MIME_JPEG);
|
||||
|
||||
createFile({
|
||||
data: processedBuffer,
|
||||
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
|
||||
}).then(() => {
|
||||
return prisma.link.update({
|
||||
where: { id: linkId },
|
||||
data: {
|
||||
preview: `archives/preview/${collectionId}/${linkId}.jpeg`,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error("Error processing the image:", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default generatePreview;
|
|
@ -0,0 +1,61 @@
|
|||
import moveFile from "./storage/moveFile";
|
||||
import removeFile from "./storage/removeFile";
|
||||
|
||||
const removeFiles = async (linkId: number, collectionId: number) => {
|
||||
// PDF
|
||||
await removeFile({
|
||||
filePath: `archives/${collectionId}/${linkId}.pdf`,
|
||||
});
|
||||
// Images
|
||||
await removeFile({
|
||||
filePath: `archives/${collectionId}/${linkId}.png`,
|
||||
});
|
||||
await removeFile({
|
||||
filePath: `archives/${collectionId}/${linkId}.jpeg`,
|
||||
});
|
||||
await removeFile({
|
||||
filePath: `archives/${collectionId}/${linkId}.jpg`,
|
||||
});
|
||||
// Preview
|
||||
await removeFile({
|
||||
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
|
||||
});
|
||||
// Readability
|
||||
await removeFile({
|
||||
filePath: `archives/${collectionId}/${linkId}_readability.json`,
|
||||
});
|
||||
};
|
||||
|
||||
const moveFiles = async (linkId: number, from: number, to: number) => {
|
||||
await moveFile(
|
||||
`archives/${from}/${linkId}.pdf`,
|
||||
`archives/${to}/${linkId}.pdf`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${from}/${linkId}.png`,
|
||||
`archives/${to}/${linkId}.png`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${from}/${linkId}.jpeg`,
|
||||
`archives/${to}/${linkId}.jpeg`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${from}/${linkId}.jpg`,
|
||||
`archives/${to}/${linkId}.jpg`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/preview/${from}/${linkId}.jpeg`,
|
||||
`archives/preview/${to}/${linkId}.jpeg`
|
||||
);
|
||||
|
||||
await moveFile(
|
||||
`archives/${from}/${linkId}_readability.json`,
|
||||
`archives/${to}/${linkId}_readability.json`
|
||||
);
|
||||
};
|
||||
|
||||
export { removeFiles, moveFiles };
|
|
@ -17,15 +17,7 @@ export default async function verifySubscription(
|
|||
|
||||
const currentDate = new Date();
|
||||
|
||||
if (
|
||||
subscription &&
|
||||
currentDate > subscription.currentPeriodEnd &&
|
||||
!subscription.active
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!subscription || currentDate > subscription.currentPeriodEnd) {
|
||||
if (!subscription?.active || currentDate > subscription.currentPeriodEnd) {
|
||||
const {
|
||||
active,
|
||||
stripeSubscriptionId,
|
||||
|
@ -59,14 +51,20 @@ export default async function verifySubscription(
|
|||
},
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
}
|
||||
} else if (!active) {
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (subscription)
|
||||
await prisma.subscription.delete({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!active) {
|
||||
if (user.username)
|
||||
// await prisma.user.update({
|
||||
// where: { id: user.id },
|
||||
// data: { username: null },
|
||||
// });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ export default async function verifyUser({
|
|||
}
|
||||
|
||||
if (STRIPE_SECRET_KEY) {
|
||||
const subscribedUser = verifySubscription(user);
|
||||
const subscribedUser = await verifySubscription(user);
|
||||
|
||||
if (!subscribedUser) {
|
||||
res.status(401).json({
|
||||
|
|
|
@ -16,24 +16,30 @@ export const generateLinkHref = (
|
|||
): string => {
|
||||
// Return the links href based on the account's preference
|
||||
// If the user's preference is not available, return the original link
|
||||
switch (account.linksRouteTo) {
|
||||
case LinksRouteTo.ORIGINAL:
|
||||
if (account.linksRouteTo === LinksRouteTo.ORIGINAL && link.type === "url") {
|
||||
return link.url || "";
|
||||
case LinksRouteTo.PDF:
|
||||
} else if (account.linksRouteTo === LinksRouteTo.PDF || link.type === "pdf") {
|
||||
if (!pdfAvailable(link)) return link.url || "";
|
||||
|
||||
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
|
||||
case LinksRouteTo.READABLE:
|
||||
} else if (
|
||||
account.linksRouteTo === LinksRouteTo.READABLE &&
|
||||
link.type === "url"
|
||||
) {
|
||||
if (!readabilityAvailable(link)) return link.url || "";
|
||||
|
||||
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
|
||||
case LinksRouteTo.SCREENSHOT:
|
||||
} else if (
|
||||
account.linksRouteTo === LinksRouteTo.SCREENSHOT ||
|
||||
link.type === "image"
|
||||
) {
|
||||
console.log(link);
|
||||
if (!screenshotAvailable(link)) return link.url || "";
|
||||
|
||||
return `/preserved/${link?.id}?format=${
|
||||
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
|
||||
}`;
|
||||
default:
|
||||
} else {
|
||||
return link.url || "";
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "linkwarden",
|
||||
"version": "2.5.0",
|
||||
"version": "2.5.1",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||
|
@ -10,10 +10,10 @@
|
|||
"seed": "node ./prisma/seed.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"next dev\" \"yarn worker:dev\"",
|
||||
"dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --",
|
||||
"worker:dev": "nodemon --skip-project scripts/worker.ts",
|
||||
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
|
||||
"start": "concurrently \"next start\" \"yarn worker:prod\"",
|
||||
"start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --",
|
||||
"build": "next build",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
|
||||
|
@ -61,6 +61,7 @@
|
|||
"react-hot-toast": "^2.4.1",
|
||||
"react-image-file-resizer": "^0.4.8",
|
||||
"react-select": "^5.7.4",
|
||||
"react-spinners": "^0.13.8",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"stripe": "^12.13.0",
|
||||
"vaul": "^0.8.8",
|
||||
|
@ -78,7 +79,7 @@
|
|||
"nodemon": "^3.0.2",
|
||||
"postcss": "^8.4.26",
|
||||
"prettier": "3.1.1",
|
||||
"prisma": "^5.1.0",
|
||||
"prisma": "^4.16.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "4.9.4"
|
||||
|
|
|
@ -9,6 +9,8 @@ import formidable from "formidable";
|
|||
import createFile from "@/lib/api/storage/createFile";
|
||||
import fs from "fs";
|
||||
import verifyToken from "@/lib/api/verifyToken";
|
||||
import generatePreview from "@/lib/api/generatePreview";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
@ -73,83 +75,97 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
return res.send(file);
|
||||
}
|
||||
} else if (req.method === "POST") {
|
||||
const user = await verifyUser({ req, res });
|
||||
if (!user) return;
|
||||
|
||||
const collectionPermissions = await getPermission({
|
||||
userId: user.id,
|
||||
linkId,
|
||||
});
|
||||
|
||||
const memberHasAccess = collectionPermissions?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
|
||||
);
|
||||
|
||||
if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
|
||||
// await uploadHandler(linkId, )
|
||||
|
||||
const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
|
||||
|
||||
const form = formidable({
|
||||
maxFields: 1,
|
||||
maxFiles: 1,
|
||||
maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
|
||||
});
|
||||
|
||||
form.parse(req, async (err, fields, files) => {
|
||||
const allowedMIMETypes = [
|
||||
"application/pdf",
|
||||
"image/png",
|
||||
"image/jpg",
|
||||
"image/jpeg",
|
||||
];
|
||||
|
||||
if (
|
||||
err ||
|
||||
!files.file ||
|
||||
!files.file[0] ||
|
||||
!allowedMIMETypes.includes(files.file[0].mimetype || "")
|
||||
) {
|
||||
// Handle parsing error
|
||||
return res.status(500).json({
|
||||
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
|
||||
});
|
||||
} else {
|
||||
const fileBuffer = fs.readFileSync(files.file[0].filepath);
|
||||
|
||||
const linkStillExists = await prisma.link.findUnique({
|
||||
where: { id: linkId },
|
||||
});
|
||||
|
||||
if (linkStillExists && files.file[0].mimetype?.includes("image")) {
|
||||
const collectionId = collectionPermissions?.id as number;
|
||||
createFolder({
|
||||
filePath: `archives/preview/${collectionId}`,
|
||||
});
|
||||
|
||||
generatePreview(fileBuffer, collectionId, linkId);
|
||||
}
|
||||
// else if (req.method === "POST") {
|
||||
// const user = await verifyUser({ req, res });
|
||||
// if (!user) return;
|
||||
|
||||
// const collectionPermissions = await getPermission({
|
||||
// userId: user.id,
|
||||
// linkId,
|
||||
// });
|
||||
if (linkStillExists) {
|
||||
await createFile({
|
||||
filePath: `archives/${collectionPermissions?.id}/${
|
||||
linkId + suffix
|
||||
}`,
|
||||
data: fileBuffer,
|
||||
});
|
||||
|
||||
// const memberHasAccess = collectionPermissions?.members.some(
|
||||
// (e: UsersAndCollections) => e.userId === user.id && e.canCreate
|
||||
// );
|
||||
await prisma.link.update({
|
||||
where: { id: linkId },
|
||||
data: {
|
||||
preview: files.file[0].mimetype?.includes("pdf")
|
||||
? "unavailable"
|
||||
: undefined,
|
||||
image: files.file[0].mimetype?.includes("image")
|
||||
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
|
||||
: null,
|
||||
pdf: files.file[0].mimetype?.includes("pdf")
|
||||
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
|
||||
: null,
|
||||
lastPreserved: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
|
||||
// return { response: "Collection is not accessible.", status: 401 };
|
||||
fs.unlinkSync(files.file[0].filepath);
|
||||
}
|
||||
|
||||
// // await uploadHandler(linkId, )
|
||||
|
||||
// const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
|
||||
|
||||
// const form = formidable({
|
||||
// maxFields: 1,
|
||||
// maxFiles: 1,
|
||||
// maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
|
||||
// });
|
||||
|
||||
// form.parse(req, async (err, fields, files) => {
|
||||
// const allowedMIMETypes = [
|
||||
// "application/pdf",
|
||||
// "image/png",
|
||||
// "image/jpg",
|
||||
// "image/jpeg",
|
||||
// ];
|
||||
|
||||
// if (
|
||||
// err ||
|
||||
// !files.file ||
|
||||
// !files.file[0] ||
|
||||
// !allowedMIMETypes.includes(files.file[0].mimetype || "")
|
||||
// ) {
|
||||
// // Handle parsing error
|
||||
// return res.status(500).json({
|
||||
// response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
|
||||
// });
|
||||
// } else {
|
||||
// const fileBuffer = fs.readFileSync(files.file[0].filepath);
|
||||
|
||||
// const linkStillExists = await prisma.link.findUnique({
|
||||
// where: { id: linkId },
|
||||
// });
|
||||
|
||||
// if (linkStillExists) {
|
||||
// await createFile({
|
||||
// filePath: `archives/${collectionPermissions?.id}/${
|
||||
// linkId + suffix
|
||||
// }`,
|
||||
// data: fileBuffer,
|
||||
// });
|
||||
|
||||
// await prisma.link.update({
|
||||
// where: { id: linkId },
|
||||
// data: {
|
||||
// image: `archives/${collectionPermissions?.id}/${
|
||||
// linkId + suffix
|
||||
// }`,
|
||||
// lastPreserved: new Date().toISOString(),
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// fs.unlinkSync(files.file[0].filepath);
|
||||
// }
|
||||
|
||||
// return res.status(200).json({
|
||||
// response: files,
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
return res.status(200).json({
|
||||
response: files,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -240,6 +240,37 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") {
|
|||
};
|
||||
}
|
||||
|
||||
// Authelia
|
||||
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
|
||||
providers.push(
|
||||
{
|
||||
id: "authelia",
|
||||
name: "Authelia",
|
||||
type: "oauth",
|
||||
clientId: process.env.AUTHELIA_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTHELIA_CLIENT_SECRET!,
|
||||
wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!,
|
||||
authorization: { params: { scope: "openid email profile" } },
|
||||
idToken: true,
|
||||
checks: ["pkce", "state"],
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
username: profile.preferred_username,
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const _linkAccount = adapter.linkAccount;
|
||||
adapter.linkAccount = (account) => {
|
||||
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
|
||||
return _linkAccount ? _linkAccount(data) : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// Authentik
|
||||
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
|
||||
providers.push(
|
||||
|
|
|
@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import verifyUser from "@/lib/api/verifyUser";
|
||||
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
import { Collection, Link } from "@prisma/client";
|
||||
import { removeFiles } from "@/lib/api/manageLinkFiles";
|
||||
|
||||
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
|
||||
|
||||
|
@ -80,16 +80,5 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
|
|||
},
|
||||
});
|
||||
|
||||
await removeFile({
|
||||
filePath: `archives/${link.collection.id}/${link.id}.pdf`,
|
||||
});
|
||||
await removeFile({
|
||||
filePath: `archives/${link.collection.id}/${link.id}.png`,
|
||||
});
|
||||
await removeFile({
|
||||
filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
|
||||
});
|
||||
await removeFile({
|
||||
filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
|
||||
});
|
||||
await removeFiles(link.id, link.collection.id);
|
||||
};
|
||||
|
|
|
@ -391,6 +391,13 @@ export function getLogins() {
|
|||
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
|
||||
});
|
||||
}
|
||||
// Authelia
|
||||
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "authelia",
|
||||
name: process.env.AUTHELIA_CUSTOM_NAME ?? "Authelia",
|
||||
});
|
||||
}
|
||||
return {
|
||||
credentialsEnabled:
|
||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
|
||||
|
|
|
@ -168,10 +168,7 @@ export default function Dashboard() {
|
|||
>
|
||||
{links[0] ? (
|
||||
<div className="w-full">
|
||||
<LinkComponent
|
||||
links={links.slice(0, showLinks)}
|
||||
showCheckbox={false}
|
||||
/>
|
||||
<LinkComponent links={links.slice(0, showLinks)} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
@ -282,7 +279,6 @@ export default function Dashboard() {
|
|||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<div className="w-full">
|
||||
<LinkComponent
|
||||
showCheckbox={false}
|
||||
links={links
|
||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||
.slice(0, showLinks)}
|
||||
|
|
|
@ -60,8 +60,8 @@ export default function PublicCollections() {
|
|||
name: true,
|
||||
url: true,
|
||||
description: true,
|
||||
textContent: true,
|
||||
tags: true,
|
||||
textContent: false,
|
||||
});
|
||||
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
|
|
|
@ -5,12 +5,13 @@ import MainLayout from "@/layouts/MainLayout";
|
|||
import useLinkStore from "@/store/links";
|
||||
import { Sort, ViewMode } from "@/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ViewDropdown from "@/components/ViewDropdown";
|
||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import { GridLoader, PropagateLoader } from "react-spinners";
|
||||
|
||||
export default function Search() {
|
||||
const { links } = useLinkStore();
|
||||
|
@ -21,8 +22,8 @@ export default function Search() {
|
|||
name: true,
|
||||
url: true,
|
||||
description: true,
|
||||
textContent: true,
|
||||
tags: true,
|
||||
textContent: false,
|
||||
});
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
|
@ -30,7 +31,7 @@ export default function Search() {
|
|||
);
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
|
||||
useLinks({
|
||||
const { isLoading } = useLinks({
|
||||
sort: sortBy,
|
||||
searchQueryString: decodeURIComponent(router.query.q as string),
|
||||
searchByName: searchFilter.name,
|
||||
|
@ -40,6 +41,10 @@ export default function Search() {
|
|||
searchByTags: searchFilter.tags,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log("isLoading", isLoading);
|
||||
}, [isLoading]);
|
||||
|
||||
const linkView = {
|
||||
[ViewMode.Card]: CardView,
|
||||
// [ViewMode.Grid]: GridView,
|
||||
|
@ -51,7 +56,7 @@ export default function Search() {
|
|||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full">
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<div className="flex justify-between">
|
||||
<PageHeader icon={"bi-search"} title={"Search Results"} />
|
||||
|
||||
|
@ -67,15 +72,24 @@ export default function Search() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{links[0] ? (
|
||||
<LinkComponent links={links} />
|
||||
) : (
|
||||
{!isLoading && !links[0] ? (
|
||||
<p>
|
||||
Nothing found.{" "}
|
||||
<span className="font-bold text-xl" title="Shruggie">
|
||||
¯\_(ツ)_/¯
|
||||
</span>
|
||||
</p>
|
||||
) : links[0] ? (
|
||||
<LinkComponent links={links} isLoading={isLoading} />
|
||||
) : (
|
||||
isLoading && (
|
||||
<GridLoader
|
||||
color="oklch(var(--p))"
|
||||
loading={true}
|
||||
size={20}
|
||||
className="m-auto py-10"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
-- CreateIndex
|
||||
CREATE INDEX "Collection_ownerId_idx" ON "Collection"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UsersAndCollections_userId_idx" ON "UsersAndCollections"("userId");
|
|
@ -0,0 +1,2 @@
|
|||
-- CreateIndex
|
||||
CREATE INDEX "Tag_ownerId_idx" ON "Tag"("ownerId");
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Link" ADD COLUMN "importDate" TIMESTAMP(3);
|
|
@ -93,6 +93,8 @@ model Collection {
|
|||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model UsersAndCollections {
|
||||
|
@ -107,6 +109,7 @@ model UsersAndCollections {
|
|||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@id([userId, collectionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Link {
|
||||
|
@ -125,6 +128,7 @@ model Link {
|
|||
pdf String?
|
||||
readable String?
|
||||
lastPreserved DateTime?
|
||||
importDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
@ -139,6 +143,7 @@ model Tag {
|
|||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique([name, ownerId])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { create } from "zustand";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import useTagStore from "./tags";
|
||||
import useCollectionStore from "./collections";
|
||||
|
||||
|
@ -19,6 +22,10 @@ type LinkStore = {
|
|||
addLink: (
|
||||
body: LinkIncludingShortenedCollectionAndTags
|
||||
) => Promise<ResponseObject>;
|
||||
uploadFile: (
|
||||
link: LinkIncludingShortenedCollectionAndTags,
|
||||
file: File
|
||||
) => Promise<ResponseObject>;
|
||||
getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
|
||||
updateLink: (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
|
@ -79,6 +86,82 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
|||
|
||||
return { ok: response.ok, data: data.response };
|
||||
},
|
||||
uploadFile: async (link, file) => {
|
||||
let fileType: ArchivedFormat | null = null;
|
||||
let linkType: "url" | "image" | "pdf" | null = null;
|
||||
|
||||
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
|
||||
fileType = ArchivedFormat.jpeg;
|
||||
linkType = "image";
|
||||
} else if (file.type === "image/png") {
|
||||
fileType = ArchivedFormat.png;
|
||||
linkType = "image";
|
||||
} else if (file.type === "application/pdf") {
|
||||
fileType = ArchivedFormat.pdf;
|
||||
linkType = "pdf";
|
||||
} else {
|
||||
return { ok: false, data: "Invalid file type." };
|
||||
}
|
||||
|
||||
const response = await fetch("/api/v1/links", {
|
||||
body: JSON.stringify({
|
||||
...link,
|
||||
type: linkType,
|
||||
name: link.name ? link.name : file.name,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const createdLink: LinkIncludingShortenedCollectionAndTags = data.response;
|
||||
|
||||
console.log(data);
|
||||
|
||||
if (response.ok) {
|
||||
const formBody = new FormData();
|
||||
file && formBody.append("file", file);
|
||||
|
||||
await fetch(
|
||||
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
|
||||
{
|
||||
body: formBody,
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
// get file extension
|
||||
const extension = file.name.split(".").pop() || "";
|
||||
|
||||
set((state) => ({
|
||||
links: [
|
||||
{
|
||||
...createdLink,
|
||||
image:
|
||||
linkType === "image"
|
||||
? `archives/${createdLink.collectionId}/${
|
||||
createdLink.id + extension
|
||||
}`
|
||||
: null,
|
||||
pdf:
|
||||
linkType === "pdf"
|
||||
? `archives/${createdLink.collectionId}/${
|
||||
createdLink.id + ".pdf"
|
||||
}`
|
||||
: null,
|
||||
},
|
||||
...state.links,
|
||||
],
|
||||
}));
|
||||
useTagStore.getState().setTags();
|
||||
useCollectionStore.getState().setCollections();
|
||||
}
|
||||
|
||||
return { ok: response.ok, data: data.response };
|
||||
},
|
||||
getLink: async (linkId, publicRoute) => {
|
||||
const path = publicRoute
|
||||
? `/api/v1/public/links/${linkId}`
|
||||
|
|
|
@ -76,6 +76,13 @@ declare global {
|
|||
AUTH0_CLIENT_SECRET?: string;
|
||||
AUTH0_CLIENT_ID?: string;
|
||||
|
||||
// Authelia
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED?: string;
|
||||
AUTHELIA_CUSTOM_NAME?: string;
|
||||
AUTHELIA_CLIENT_ID?: string;
|
||||
AUTHELIA_CLIENT_SECRET?: string;
|
||||
AUTHELIA_WELLKNOWN_URL?: string;
|
||||
|
||||
// Authentik
|
||||
NEXT_PUBLIC_AUTHENTIK_ENABLED?: string;
|
||||
AUTHENTIK_CUSTOM_NAME?: string;
|
||||
|
@ -410,4 +417,4 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export { };
|
||||
|
|
|
@ -7,10 +7,16 @@ type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
|
|||
export interface LinkIncludingShortenedCollectionAndTags
|
||||
extends Omit<
|
||||
Link,
|
||||
"id" | "createdAt" | "collectionId" | "updatedAt" | "lastPreserved"
|
||||
| "id"
|
||||
| "createdAt"
|
||||
| "collectionId"
|
||||
| "updatedAt"
|
||||
| "lastPreserved"
|
||||
| "importDate"
|
||||
> {
|
||||
id?: number;
|
||||
createdAt?: string;
|
||||
importDate?: string;
|
||||
collectionId?: number;
|
||||
tags: Tag[];
|
||||
pinnedBy?: {
|
||||
|
|
23
yarn.lock
23
yarn.lock
|
@ -1301,10 +1301,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz#d3b5dcf95b6d220e258cbf6ae19b06d30a7e9f14"
|
||||
integrity sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==
|
||||
|
||||
"@prisma/engines@5.1.0":
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.0.tgz#4ccf7f344eaeee08ca1e4a1bb2dc14e36ff1d5ec"
|
||||
integrity sha512-HqaFsnPmZOdMWkPq6tT2eTVTQyaAXEDdKszcZ4yc7DGMBIYRP6j/zAJTtZUG9SsMV8FaucdL5vRyxY/p5Ni28g==
|
||||
"@prisma/engines@4.16.2":
|
||||
version "4.16.2"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.16.2.tgz#5ec8dd672c2173d597e469194916ad4826ce2e5f"
|
||||
integrity sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==
|
||||
|
||||
"@radix-ui/primitive@1.0.1":
|
||||
version "1.0.1"
|
||||
|
@ -5038,12 +5038,12 @@ pretty-format@^3.8.0:
|
|||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
|
||||
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
|
||||
|
||||
prisma@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.1.0.tgz#29e316b54844f5694a83017a9781a6d6f7cb99ea"
|
||||
integrity sha512-wkXvh+6wxk03G8qwpZMOed4Y3j+EQ+bMTlvbDZHeal6k1E8QuGKzRO7DRXlE1NV0WNgOAas8kwZqcLETQ2+BiQ==
|
||||
prisma@^4.16.2:
|
||||
version "4.16.2"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.16.2.tgz#469e0a0991c6ae5bcde289401726bb012253339e"
|
||||
integrity sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g==
|
||||
dependencies:
|
||||
"@prisma/engines" "5.1.0"
|
||||
"@prisma/engines" "4.16.2"
|
||||
|
||||
process@^0.11.10:
|
||||
version "0.11.10"
|
||||
|
@ -5211,6 +5211,11 @@ react-select@^5.7.4:
|
|||
react-transition-group "^4.3.0"
|
||||
use-isomorphic-layout-effect "^1.1.2"
|
||||
|
||||
react-spinners@^0.13.8:
|
||||
version "0.13.8"
|
||||
resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.13.8.tgz#5262571be0f745d86bbd49a1e6b49f9f9cb19acc"
|
||||
integrity sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==
|
||||
|
||||
react-style-singleton@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
|
||||
|
|
Ŝarĝante…
Reference in New Issue