Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e51fba41e7 | |||
| e8edd1c9a0 | |||
| f30c652676 | |||
| 8cf621bc62 | |||
| 87eb2471ff | |||
| 58b6f7339c | |||
| 5503483502 | |||
| a6d018fb53 | |||
| 3929f32e63 | |||
| c08522386b | |||
| b51a876904 | |||
| 2e2d7baee1 | |||
| 495af0a752 | |||
| 388b9d9184 | |||
| e5fcf18fa4 | |||
| a3d3b353a1 | |||
| 546e216ac9 | |||
| 53a65774f0 | |||
| ce2eb8eafb | |||
| 4e20d71a41 | |||
| 9fce74971f | |||
| bde7b9aae0 | |||
| 7dd254af48 | |||
| 3969cc5abd |
@@ -21,6 +21,7 @@ ARCHIVE_TAKE_COUNT=
|
|||||||
BROWSER_TIMEOUT=
|
BROWSER_TIMEOUT=
|
||||||
IGNORE_UNAUTHORIZED_CA=
|
IGNORE_UNAUTHORIZED_CA=
|
||||||
IGNORE_HTTPS_ERRORS=
|
IGNORE_HTTPS_ERRORS=
|
||||||
|
IGNORE_URL_SIZE_LIMIT=
|
||||||
|
|
||||||
# AWS S3 Settings
|
# AWS S3 Settings
|
||||||
SPACES_KEY=
|
SPACES_KEY=
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -6,14 +6,13 @@ export default function LinkDate({
|
|||||||
}: {
|
}: {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
}) {
|
}) {
|
||||||
const formattedDate = new Date(link.createdAt as string).toLocaleString(
|
const formattedDate = new Date(
|
||||||
"en-US",
|
(link.importDate || link.createdAt) as string
|
||||||
{
|
).toLocaleString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 text-neutral">
|
<div className="flex items-center gap-1 text-neutral">
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
const [imageError, setImageError] = useState<boolean>(false);
|
const [imageError, setImageError] = useState<boolean>(false);
|
||||||
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
||||||
|
|
||||||
|
const [date, setDate] = useState<Date | string>();
|
||||||
|
|
||||||
const colorThief = new ColorThief();
|
const colorThief = new ColorThief();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -54,6 +56,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchLinkContent();
|
fetchLinkContent();
|
||||||
|
|
||||||
|
setDate(link.importDate || link.createdAt);
|
||||||
}, [link]);
|
}, [link]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -211,8 +215,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="min-w-fit text-sm text-neutral">
|
<p className="min-w-fit text-sm text-neutral">
|
||||||
{link?.createdAt
|
{date
|
||||||
? new Date(link?.createdAt).toLocaleString("en-US", {
|
? new Date(date).toLocaleString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
const LINKWARDEN_VERSION = "v2.5.1";
|
const LINKWARDEN_VERSION = "v2.5.2";
|
||||||
|
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default async function postLink(
|
|||||||
return { response: "Collection is not accessible.", status: 401 };
|
return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
|
||||||
link.collection.id = findCollection.id;
|
link.collection.id = findCollection.id;
|
||||||
|
link.collection.ownerId = findCollection.ownerId;
|
||||||
} else {
|
} else {
|
||||||
const collection = await prisma.collection.create({
|
const collection = await prisma.collection.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
|
|||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { parse, Node, Element, TextNode } from "himalaya";
|
import { parse, Node, Element, TextNode } from "himalaya";
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
|
||||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
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);
|
const jsonData = parse(document.documentElement.outerHTML);
|
||||||
|
|
||||||
for (const item of jsonData) {
|
const processedArray = processNodes(jsonData);
|
||||||
|
|
||||||
|
for (const item of processedArray) {
|
||||||
console.log(item);
|
console.log(item);
|
||||||
await processBookmarks(userId, item as Element);
|
await processBookmarks(userId, item as Element);
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,9 @@ async function processBookmarks(
|
|||||||
} else if (item.type === "element" && item.tagName === "a") {
|
} else if (item.type === "element" && item.tagName === "a") {
|
||||||
// process link
|
// 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 = (
|
const linkName = (
|
||||||
item?.children.find((e) => e.type === "text") as TextNode
|
item?.children.find((e) => e.type === "text") as TextNode
|
||||||
)?.content;
|
)?.content;
|
||||||
@@ -82,14 +87,33 @@ async function processBookmarks(
|
|||||||
.find((e) => e.key === "tags")
|
.find((e) => e.key === "tags")
|
||||||
?.value.split(",");
|
?.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) {
|
if (linkUrl && parentCollectionId) {
|
||||||
await createLink(
|
await createLink(
|
||||||
userId,
|
userId,
|
||||||
linkUrl,
|
linkUrl,
|
||||||
parentCollectionId,
|
parentCollectionId,
|
||||||
linkName,
|
linkName,
|
||||||
"",
|
linkDesc,
|
||||||
linkTags
|
linkTags,
|
||||||
|
linkDate
|
||||||
);
|
);
|
||||||
} else if (linkUrl) {
|
} else if (linkUrl) {
|
||||||
// create a collection named "Imported Bookmarks" and add the link to it
|
// create a collection named "Imported Bookmarks" and add the link to it
|
||||||
@@ -100,8 +124,9 @@ async function processBookmarks(
|
|||||||
linkUrl,
|
linkUrl,
|
||||||
collectionId,
|
collectionId,
|
||||||
linkName,
|
linkName,
|
||||||
"",
|
linkDesc,
|
||||||
linkTags
|
linkTags,
|
||||||
|
linkDate
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +185,8 @@ const createLink = async (
|
|||||||
collectionId: number,
|
collectionId: number,
|
||||||
name?: string,
|
name?: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
tags?: string[]
|
tags?: string[],
|
||||||
|
importDate?: Date
|
||||||
) => {
|
) => {
|
||||||
await prisma.link.create({
|
await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -193,6 +219,48 @@ const createLink = async (
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,11 +16,8 @@ export default async function paymentCheckout(
|
|||||||
|
|
||||||
const isExistingCustomer = listByEmail?.data[0]?.id || undefined;
|
const isExistingCustomer = listByEmail?.data[0]?.id || undefined;
|
||||||
|
|
||||||
console.log("isExistingCustomer", listByEmail?.data[0]);
|
|
||||||
|
|
||||||
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
|
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
|
||||||
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
|
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: isExistingCustomer ? isExistingCustomer : undefined,
|
customer: isExistingCustomer ? isExistingCustomer : undefined,
|
||||||
line_items: [
|
line_items: [
|
||||||
@@ -37,9 +34,9 @@ export default async function paymentCheckout(
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
subscription_data: {
|
subscription_data: {
|
||||||
trial_period_days: isExistingCustomer
|
trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
|
||||||
? undefined
|
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
|
||||||
: NEXT_PUBLIC_TRIAL_PERIOD_DAYS,
|
: 14,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
|
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||||
|
|
||||||
export default async function validateUrlSize(url: string) {
|
export default async function validateUrlSize(url: string) {
|
||||||
|
if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const httpsAgent = new https.Agent({
|
const httpsAgent = new https.Agent({
|
||||||
rejectUnauthorized:
|
rejectUnauthorized:
|
||||||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(url, {
|
let fetchOpts = {
|
||||||
method: "HEAD",
|
method: "HEAD",
|
||||||
agent: httpsAgent,
|
agent: httpsAgent,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
let proxy = new URL(process.env.PROXY);
|
||||||
|
if (process.env.PROXY_USERNAME) {
|
||||||
|
proxy.username = process.env.PROXY_USERNAME;
|
||||||
|
proxy.password = process.env.PROXY_PASSWORD || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchOpts = {
|
||||||
|
method: "HEAD",
|
||||||
|
agent: new SocksProxyAgent(proxy.toString()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOpts);
|
||||||
|
|
||||||
const totalSizeMB =
|
const totalSizeMB =
|
||||||
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
||||||
|
|||||||
+17
-6
@@ -27,14 +27,25 @@ export default async function getTitle(url: string) {
|
|||||||
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
|
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, fetchOpts);
|
const responsePromise = fetch(url, fetchOpts);
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error("Fetch title timeout"));
|
||||||
|
}, 10 * 1000); // Stop after 10 seconds
|
||||||
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const response = await Promise.race([responsePromise, timeoutPromise]);
|
||||||
|
|
||||||
// regular expression to find the <title> tag
|
if ((response as any)?.status) {
|
||||||
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
const text = await (response as any).text();
|
||||||
if (match) return match[1];
|
|
||||||
else return "";
|
// regular expression to find the <title> tag
|
||||||
|
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
||||||
|
if (match) return match[1];
|
||||||
|
else return "";
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkwarden",
|
"name": "linkwarden",
|
||||||
"version": "2.5.1",
|
"version": "0.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --",
|
"dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --",
|
||||||
"worker:dev": "nodemon --skip-project scripts/worker.ts",
|
"worker:dev": "nodemon --skip-project scripts/worker.ts",
|
||||||
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
|
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
|
||||||
"start": "concurrently -k -P \"next start {@}\" \"yarn worker:prod\" --",
|
"start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
|
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
|
||||||
|
|||||||
+10
-20
@@ -5,7 +5,6 @@ import { useRouter } from "next/router";
|
|||||||
import CenteredForm from "@/layouts/CenteredForm";
|
import CenteredForm from "@/layouts/CenteredForm";
|
||||||
import { Plan } from "@/types/global";
|
import { Plan } from "@/types/global";
|
||||||
import AccentSubmitButton from "@/components/AccentSubmitButton";
|
import AccentSubmitButton from "@/components/AccentSubmitButton";
|
||||||
import useAccountStore from "@/store/account";
|
|
||||||
|
|
||||||
export default function Subscribe() {
|
export default function Subscribe() {
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
@@ -13,8 +12,6 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
const [plan, setPlan] = useState<Plan>(1);
|
const [plan, setPlan] = useState<Plan>(1);
|
||||||
|
|
||||||
const { account } = useAccountStore();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -30,13 +27,9 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredForm
|
<CenteredForm
|
||||||
text={
|
text={`Start with a ${
|
||||||
account.username
|
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
|
||||||
? ""
|
}-day free trial, cancel anytime!`}
|
||||||
: `Start with a ${
|
|
||||||
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
|
|
||||||
}-day free trial, cancel anytime!`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
|
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
|
||||||
<p className="sm:text-3xl text-2xl text-center font-extralight">
|
<p className="sm:text-3xl text-2xl text-center font-extralight">
|
||||||
@@ -44,6 +37,7 @@ export default function Subscribe() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="divider my-0"></div>
|
<div className="divider my-0"></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
You will be redirected to Stripe, feel free to reach out to us at{" "}
|
You will be redirected to Stripe, feel free to reach out to us at{" "}
|
||||||
@@ -53,6 +47,7 @@ export default function Subscribe() {
|
|||||||
in case of any issue.
|
in case of any issue.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 border border-solid border-neutral-content w-4/5 mx-auto p-1 rounded-xl relative">
|
<div className="flex gap-3 border border-solid border-neutral-content w-4/5 mx-auto p-1 rounded-xl relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPlan(Plan.monthly)}
|
onClick={() => setPlan(Plan.monthly)}
|
||||||
@@ -79,6 +74,7 @@ export default function Subscribe() {
|
|||||||
25% Off
|
25% Off
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 justify-center items-center">
|
<div className="flex flex-col gap-2 justify-center items-center">
|
||||||
<p className="text-3xl">
|
<p className="text-3xl">
|
||||||
${plan === Plan.monthly ? "4" : "3"}
|
${plan === Plan.monthly ? "4" : "3"}
|
||||||
@@ -93,20 +89,13 @@ export default function Subscribe() {
|
|||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{account.username
|
{process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then $
|
||||||
? ""
|
{plan === Plan.monthly ? "4 per month" : "36 annually"}
|
||||||
: `${process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then `}
|
|
||||||
${plan === Plan.monthly ? "4 per month" : "36 annually"}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">+ VAT if applicable</p>
|
<p className="text-sm">+ VAT if applicable</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<p className="text-sm mb-5">
|
|
||||||
{account.username
|
|
||||||
? "Please note that since your trial has been previously ended, your subscription will start immediately. You can cancel anytime."
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccentSubmitButton
|
<AccentSubmitButton
|
||||||
type="button"
|
type="button"
|
||||||
label="Complete Subscription!"
|
label="Complete Subscription!"
|
||||||
@@ -114,6 +103,7 @@ export default function Subscribe() {
|
|||||||
onClick={submit}
|
onClick={submit}
|
||||||
loading={submitLoader}
|
loading={submitLoader}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => signOut()}
|
onClick={() => signOut()}
|
||||||
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
|
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Link" ADD COLUMN "importDate" TIMESTAMP(3);
|
||||||
@@ -128,6 +128,7 @@ model Link {
|
|||||||
pdf String?
|
pdf String?
|
||||||
readable String?
|
readable String?
|
||||||
lastPreserved DateTime?
|
lastPreserved DateTime?
|
||||||
|
importDate DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -13,6 +13,7 @@ declare global {
|
|||||||
MAX_LINKS_PER_USER?: string;
|
MAX_LINKS_PER_USER?: string;
|
||||||
ARCHIVE_TAKE_COUNT?: string;
|
ARCHIVE_TAKE_COUNT?: string;
|
||||||
IGNORE_UNAUTHORIZED_CA?: string;
|
IGNORE_UNAUTHORIZED_CA?: string;
|
||||||
|
IGNORE_URL_SIZE_LIMIT?: string;
|
||||||
|
|
||||||
SPACES_KEY?: string;
|
SPACES_KEY?: string;
|
||||||
SPACES_SECRET?: string;
|
SPACES_SECRET?: string;
|
||||||
|
|||||||
+7
-1
@@ -7,10 +7,16 @@ type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
|
|||||||
export interface LinkIncludingShortenedCollectionAndTags
|
export interface LinkIncludingShortenedCollectionAndTags
|
||||||
extends Omit<
|
extends Omit<
|
||||||
Link,
|
Link,
|
||||||
"id" | "createdAt" | "collectionId" | "updatedAt" | "lastPreserved"
|
| "id"
|
||||||
|
| "createdAt"
|
||||||
|
| "collectionId"
|
||||||
|
| "updatedAt"
|
||||||
|
| "lastPreserved"
|
||||||
|
| "importDate"
|
||||||
> {
|
> {
|
||||||
id?: number;
|
id?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
importDate?: string;
|
||||||
collectionId?: number;
|
collectionId?: number;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
pinnedBy?: {
|
pinnedBy?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user