Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae6656e0ec | |||
| fbca98984b | |||
| 4da2310e95 | |||
| 93bcfc67fe | |||
| d16b296b15 | |||
| 3fc61ac5ce | |||
| ced51e4801 | |||
| 254c090605 | |||
| 2a83ced9d8 | |||
| 52d333f085 | |||
| fbbb97b4cd | |||
| 4e29330472 | |||
| 44c82ff426 | |||
| 29e0370808 | |||
| 74399c1708 | |||
| 1dde8a6088 | |||
| e872c25332 | |||
| dea1e12700 | |||
| 055869883a | |||
| a5d3926d84 | |||
| eee6a807da | |||
| e24ae15a73 | |||
| f1dadf1546 | |||
| 7e9eae0ef2 | |||
| 6b28abc405 | |||
| ee6dcdcc5b |
@@ -1,22 +1,22 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
|
||||
{
|
||||
"name": "Node.js & TypeScript",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
|
||||
"name": "Node.js & TypeScript",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "yarn install",
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "yarn install",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
"remoteUser": "root"
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
"remoteUser": "root"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ RE_ARCHIVE_LIMIT=
|
||||
NEXT_PUBLIC_MAX_FILE_SIZE=
|
||||
MAX_LINKS_PER_USER=
|
||||
ARCHIVE_TAKE_COUNT=
|
||||
BROWSER_TIMEOUT=
|
||||
IGNORE_UNAUTHORIZED_CA=
|
||||
|
||||
# AWS S3 Settings
|
||||
SPACES_KEY=
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# Security Policy
|
||||
# Security
|
||||
|
||||
## Supported Versions
|
||||
The Linkwarden team and community take security bugs in Linkwarden seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 1.x.x | ✅ |
|
||||
# Reporting Security Issues
|
||||
|
||||
## Reporting a Vulnerability
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
First off, we really appreciate the time you spent!
|
||||
Instead, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/linkwarden/linkwarden/security/advisories/new) tab.
|
||||
|
||||
If you found a vulnerability, these are the ways you can reach us:
|
||||
You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message:
|
||||
[security@linkwarden.app](mailto:security@linkwarden.app)
|
||||
|
||||
Email: [security@linkwarden.app](mailto:security@linkwarden.app)
|
||||
|
||||
Or you can directly DM me via Twitter: [@daniel31x13](https://twitter.com/Daniel31X13).
|
||||
After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
|
||||
# Preferred Languages
|
||||
|
||||
We prefer all communications to be in English.
|
||||
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
public
|
||||
|
||||
*.lock
|
||||
*.log
|
||||
|
||||
.github
|
||||
|
||||
data
|
||||
pgdata
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -27,17 +27,21 @@ Additionally, Linkwarden is designed with collaboration in mind, sharing links w
|
||||
<img src="./assets/dashboard.png" />
|
||||
|
||||
<div align="center">
|
||||
<img src="./assets/all_links.png" width="32%" />
|
||||
<img src="./assets/all_links.jpg" width="23%" />
|
||||
|
||||
<img src="./assets/all_collections.png" width="32%" />
|
||||
<img src="./assets/list_view.jpg" width="23%" />
|
||||
|
||||
<img src="./assets/manage_team.png" width="32%" />
|
||||
<img src="./assets/all_collections.jpg" width="23%" />
|
||||
|
||||
<img src="./assets/readable_view.png" width="32%" />
|
||||
<img src="./assets/manage_team.jpg" width="23%" />
|
||||
|
||||
<img src="./assets/public_page.png" width="32%" />
|
||||
<img src="./assets/readable_view.jpg" width="23%" />
|
||||
|
||||
<img src="./assets/light_mode.png" width="32%" />
|
||||
<img src="./assets/preserved_formats.jpg" width="23%" />
|
||||
|
||||
<img src="./assets/public_page.jpg" width="23%" />
|
||||
|
||||
<img src="./assets/light_dashboard.jpg" width="23%" />
|
||||
</div>
|
||||
|
||||
<details>
|
||||
|
||||
|
After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 799 KiB |
|
Before Width: | Height: | Size: 369 KiB After Width: | Height: | Size: 786 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
Before Width: | Height: | Size: 785 KiB |
|
Before Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 664 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 657 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 945 KiB |
@@ -22,9 +22,7 @@ export default function FilterSearchDropdown({
|
||||
role="button"
|
||||
className="btn btn-sm btn-square btn-ghost"
|
||||
>
|
||||
<i
|
||||
className="bi-funnel text-neutral text-2xl"
|
||||
></i>
|
||||
<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">
|
||||
<li>
|
||||
|
||||
@@ -81,68 +81,75 @@ export default function LinkGrid({ link, count, className }: Props) {
|
||||
ref={ref}
|
||||
className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative"
|
||||
>
|
||||
<div className="relative rounded-t-2xl h-40 overflow-hidden">
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
|
||||
style={{ filter: "blur(2px)" }}
|
||||
draggable="false"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
|
||||
) : (
|
||||
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||
)}
|
||||
<div
|
||||
style={
|
||||
{
|
||||
// background:
|
||||
// "radial-gradient(circle, rgba(255, 255, 255, 0.5), transparent)",
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
target="_blank"
|
||||
className="rounded-2xl cursor-pointer"
|
||||
>
|
||||
<div className="relative rounded-t-2xl h-40 overflow-hidden">
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
|
||||
style={{ filter: "blur(2px)" }}
|
||||
draggable="false"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
|
||||
) : (
|
||||
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||
)}
|
||||
<div
|
||||
style={
|
||||
{
|
||||
// background:
|
||||
// "radial-gradient(circle, rgba(255, 255, 255, 0.5), transparent)",
|
||||
}
|
||||
}
|
||||
}
|
||||
className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"
|
||||
>
|
||||
<LinkIcon link={link} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="p-3 mt-1">
|
||||
<p className="truncate w-full pr-8 text-primary">
|
||||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
target="_blank"
|
||||
title={link.url || ""}
|
||||
className="w-fit"
|
||||
>
|
||||
<div className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-60 duration-100">
|
||||
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
|
||||
<p className="text-sm truncate">{shortendURL}</p>
|
||||
className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"
|
||||
>
|
||||
<LinkIcon link={link} />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
||||
<div className="cursor-pointer w-fit">
|
||||
{collection ? (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
) : undefined}
|
||||
</div>
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
|
||||
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="p-3 mt-1">
|
||||
<p className="truncate w-full pr-8 text-primary">
|
||||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
target="_blank"
|
||||
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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
||||
<div className="cursor-pointer w-fit">
|
||||
{collection ? (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
) : undefined}
|
||||
</div>
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{showInfo ? (
|
||||
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function LinkCollection({
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
router.push(`/collections/${link.collection.id}`);
|
||||
}}
|
||||
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import React from "react";
|
||||
|
||||
export default function LinkDate({ link }: {
|
||||
export default function LinkDate({
|
||||
link,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}) {
|
||||
const formattedDate = new Date(link.createdAt as string).toLocaleString(
|
||||
@@ -10,7 +12,7 @@ export default function LinkDate({ link }: {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -55,8 +55,9 @@ export default function LinkCardCompact({ link, count, className }: Props) {
|
||||
!showInfo ? "hover:bg-base-300" : ""
|
||||
} duration-200 rounded-lg`}
|
||||
>
|
||||
<div
|
||||
onClick={() => link.url && window.open(link.url || "", "_blank")}
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
target="_blank"
|
||||
className="flex items-center cursor-pointer py-3 px-3"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
@@ -91,7 +92,7 @@ export default function LinkCardCompact({ link, count, className }: Props) {
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<LinkActions
|
||||
link={link}
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
||||
<p>Are you sure you want to delete this Link?</p>
|
||||
|
||||
<div role="alert" className="alert alert-warning">
|
||||
<i className="bi-exclamation-triangle text-xl"/>
|
||||
<i className="bi-exclamation-triangle text-xl" />
|
||||
<span>
|
||||
<b>Warning:</b> This action is irreversible!
|
||||
</span>
|
||||
@@ -64,7 +64,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
||||
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
|
||||
onClick={deleteLink}
|
||||
>
|
||||
<i className="bi-trash text-xl"/>
|
||||
<i className="bi-trash text-xl" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -227,10 +227,10 @@ export default function EditCollectionSharingModal({
|
||||
e.canCreate && e.canUpdate && e.canDelete
|
||||
? "Admin"
|
||||
: e.canCreate && !e.canUpdate && !e.canDelete
|
||||
? "Contributor"
|
||||
: !e.canCreate && !e.canUpdate && !e.canDelete
|
||||
? "Viewer"
|
||||
: undefined;
|
||||
? "Contributor"
|
||||
: !e.canCreate && !e.canUpdate && !e.canDelete
|
||||
? "Viewer"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
|
||||
title={link.url}
|
||||
target="_blank"
|
||||
>
|
||||
<i className="bi-link-45deg text-xl"/>
|
||||
<i className="bi-link-45deg text-xl" />
|
||||
<p>{shortendURL}</p>
|
||||
</Link>
|
||||
) : undefined}
|
||||
@@ -116,13 +116,13 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
|
||||
defaultValue={
|
||||
link.collection.id
|
||||
? {
|
||||
value: link.collection.id,
|
||||
label: link.collection.name,
|
||||
}
|
||||
value: link.collection.id,
|
||||
label: link.collection.name,
|
||||
}
|
||||
: {
|
||||
value: null as unknown as number,
|
||||
label: "Unorganized",
|
||||
}
|
||||
value: null as unknown as number,
|
||||
label: "Unorganized",
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -26,12 +26,12 @@ export default function Navbar() {
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
const handleToggle = () => {
|
||||
if (settings.theme === "dark") {
|
||||
updateSettings({ theme: "light" });
|
||||
} else {
|
||||
updateSettings({ theme: "dark" });
|
||||
}
|
||||
const [colorTheme, mode] = (settings.theme || "default-light").split('-');
|
||||
const newMode = mode === "dark" ? "light" : "dark";
|
||||
const newTheme = `${colorTheme}-${newMode}`;
|
||||
updateSettings({ theme: newTheme });
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSidebar(false);
|
||||
@@ -135,16 +135,16 @@ export default function Navbar() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<div
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
handleToggle();
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
Switch to {settings.theme === "light" ? "Dark" : "Light"}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
handleToggle();
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
Switch to {(settings.theme || "default-light").endsWith("-dark") ? "Light" : "Dark"}
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div
|
||||
|
||||
@@ -102,9 +102,9 @@ export default function PreservedFormatRow({
|
||||
) : undefined}
|
||||
|
||||
<Link
|
||||
href={`${isPublic ? "/public" : ""}/preserved/${
|
||||
link?.id
|
||||
}?format=${format}`}
|
||||
href={`${
|
||||
isPublic ? "/public" : ""
|
||||
}/preserved/${link?.id}?format=${format}`}
|
||||
target="_blank"
|
||||
className="btn btn-sm btn-square"
|
||||
>
|
||||
|
||||
@@ -47,11 +47,11 @@ export default function SearchBar({ placeholder }: Props) {
|
||||
"/public/collections/" +
|
||||
router.query.id +
|
||||
"?q=" +
|
||||
encodeURIComponent(searchQuery || ""),
|
||||
encodeURIComponent(searchQuery || "")
|
||||
);
|
||||
} else {
|
||||
return router.push(
|
||||
"/search?q=" + encodeURIComponent(searchQuery),
|
||||
"/search?q=" + encodeURIComponent(searchQuery)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.4.0";
|
||||
const LINKWARDEN_VERSION = "v2.4.7";
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
|
||||
|
||||
@@ -2,39 +2,46 @@ import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function ToggleDarkMode({ className }: Props) {
|
||||
const { settings, updateSettings } = useLocalSettingsStore();
|
||||
const { updateSettings } = useLocalSettingsStore();
|
||||
const [theme, setTheme] = useState('default-light');
|
||||
|
||||
const [theme, setTheme] = useState(localStorage.getItem("theme"));
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem("theme");
|
||||
if (storedTheme) {
|
||||
setTheme(storedTheme);
|
||||
} else {
|
||||
// Default theme if not set in localStorage
|
||||
localStorage.setItem("theme", "default-light");
|
||||
setTheme("default-light");
|
||||
}
|
||||
console.log("Initial theme from localStorage:", storedTheme || "default-light");
|
||||
}, []);
|
||||
|
||||
const handleToggle = (e: any) => {
|
||||
setTheme(e.target.checked ? "dark" : "light");
|
||||
};
|
||||
const handleToggle = () => {
|
||||
const [currentColorTheme, currentMode] = theme.split('-');
|
||||
const newMode = currentMode === 'light' ? 'dark' : 'light';
|
||||
const newTheme = `${currentColorTheme}-${newMode}`;
|
||||
|
||||
useEffect(() => {
|
||||
updateSettings({ theme: theme as string });
|
||||
}, [theme]);
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
updateSettings({ theme: newTheme });
|
||||
console.log("New theme set:", newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip={`Switch to ${settings.theme === "light" ? "Dark" : "Light"}`}
|
||||
>
|
||||
<label
|
||||
className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={handleToggle}
|
||||
className="theme-controller"
|
||||
checked={localStorage.getItem("theme") === "light" ? false : true}
|
||||
/>
|
||||
<i className="bi-sun-fill text-xl swap-on"></i>
|
||||
<i className="bi-moon-fill text-xl swap-off"></i>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
const isDarkMode = theme.endsWith('-dark');
|
||||
|
||||
return (
|
||||
<div className="tooltip tooltip-bottom" data-tip={`Switch to ${isDarkMode ? "Light" : "Dark"}`}>
|
||||
<label className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}>
|
||||
<input type="checkbox" onChange={handleToggle} className="theme-controller" checked={isDarkMode} />
|
||||
<i className="bi-sun-fill text-xl swap-on"></i>
|
||||
<i className="bi-moon-fill text-xl swap-off"></i>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
|
||||
restart: always
|
||||
image: ghcr.io/linkwarden/linkwarden:latest
|
||||
# build: . # uncomment this line to build from source
|
||||
image: ghcr.io/linkwarden/linkwarden:latest # comment this line to build from source
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SetStateAction, useEffect } from "react";
|
||||
type Props<
|
||||
T extends
|
||||
| CollectionIncludingMembersAndLinkCount
|
||||
| LinkIncludingShortenedCollectionAndTags
|
||||
| LinkIncludingShortenedCollectionAndTags,
|
||||
> = {
|
||||
sortBy: Sort;
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props<
|
||||
export default function useSort<
|
||||
T extends
|
||||
| CollectionIncludingMembersAndLinkCount
|
||||
| LinkIncludingShortenedCollectionAndTags
|
||||
| LinkIncludingShortenedCollectionAndTags,
|
||||
>({ sortBy, data, setData }: Props<T>) {
|
||||
useEffect(() => {
|
||||
const dataArray = [...data];
|
||||
|
||||
@@ -17,232 +17,260 @@ type LinksAndCollectionAndOwner = Link & {
|
||||
};
|
||||
};
|
||||
|
||||
const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5;
|
||||
|
||||
export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext(devices["Desktop Chrome"]);
|
||||
const page = await context.newPage();
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Browser has been open for more than ${BROWSER_TIMEOUT} minutes.`
|
||||
)
|
||||
),
|
||||
BROWSER_TIMEOUT * 60000
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
const validatedUrl = link.url
|
||||
? await validateUrlSize(link.url)
|
||||
: undefined;
|
||||
|
||||
if (validatedUrl === null) throw "File is too large to be stored.";
|
||||
if (validatedUrl === null) throw "File is too large to be stored.";
|
||||
|
||||
const contentType = validatedUrl?.get("content-type");
|
||||
let linkType = "url";
|
||||
let imageExtension = "png";
|
||||
const contentType = validatedUrl?.get("content-type");
|
||||
let linkType = "url";
|
||||
let imageExtension = "png";
|
||||
|
||||
if (!link.url) linkType = link.type;
|
||||
else if (contentType === "application/pdf") linkType = "pdf";
|
||||
else if (contentType?.startsWith("image")) {
|
||||
linkType = "image";
|
||||
if (contentType === "image/jpeg") imageExtension = "jpeg";
|
||||
else if (contentType === "image/png") imageExtension = "png";
|
||||
}
|
||||
|
||||
const user = link.collection?.owner;
|
||||
|
||||
// send to archive.org
|
||||
if (user.archiveAsWaybackMachine && link.url) sendToWayback(link.url);
|
||||
|
||||
const targetLink = await prisma.link.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
type: linkType,
|
||||
image:
|
||||
user.archiveAsScreenshot && !link.image?.startsWith("archive")
|
||||
? "pending"
|
||||
: undefined,
|
||||
pdf:
|
||||
user.archiveAsPDF && !link.pdf?.startsWith("archive")
|
||||
? "pending"
|
||||
: undefined,
|
||||
readable: !link.readable?.startsWith("archive") ? "pending" : undefined,
|
||||
preview: !link.readable?.startsWith("archive") ? "pending" : undefined,
|
||||
lastPreserved: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (linkType === "image" && !link.image?.startsWith("archive")) {
|
||||
await imageHandler(link, imageExtension); // archive image (jpeg/png)
|
||||
return;
|
||||
} else if (linkType === "pdf" && !link.pdf?.startsWith("archive")) {
|
||||
await pdfHandler(link); // archive pdf
|
||||
return;
|
||||
} else if (link.url) {
|
||||
// archive url
|
||||
|
||||
await page.goto(link.url, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const content = await page.content();
|
||||
|
||||
// TODO single file
|
||||
// const session = await page.context().newCDPSession(page);
|
||||
// const doc = await session.send("Page.captureSnapshot", {
|
||||
// format: "mhtml",
|
||||
// });
|
||||
// const saveDocLocally = (doc: any) => {
|
||||
// console.log(doc);
|
||||
// return createFile({
|
||||
// data: doc,
|
||||
// filePath: `archives/${targetLink.collectionId}/${link.id}.mhtml`,
|
||||
// });
|
||||
// };
|
||||
// saveDocLocally(doc.data);
|
||||
|
||||
// Readability
|
||||
const window = new JSDOM("").window;
|
||||
const purify = DOMPurify(window);
|
||||
const cleanedUpContent = purify.sanitize(content);
|
||||
const dom = new JSDOM(cleanedUpContent, { url: link.url || "" });
|
||||
const article = new Readability(dom.window.document).parse();
|
||||
const articleText = article?.textContent
|
||||
.replace(/ +(?= )/g, "") // strip out multiple spaces
|
||||
.replace(/(\r\n|\n|\r)/gm, " "); // strip out line breaks
|
||||
if (
|
||||
articleText &&
|
||||
articleText !== "" &&
|
||||
!link.readable?.startsWith("archive")
|
||||
) {
|
||||
await createFile({
|
||||
data: JSON.stringify(article),
|
||||
filePath: `archives/${targetLink.collectionId}/${link.id}_readability.json`,
|
||||
});
|
||||
|
||||
await prisma.link.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
readable: `archives/${targetLink.collectionId}/${link.id}_readability.json`,
|
||||
textContent: articleText,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Preview
|
||||
|
||||
const ogImageUrl = await page.evaluate(() => {
|
||||
const metaTag = document.querySelector('meta[property="og:image"]');
|
||||
return metaTag ? (metaTag as any).content : null;
|
||||
});
|
||||
|
||||
createFolder({
|
||||
filePath: `archives/preview/${link.collectionId}`,
|
||||
});
|
||||
|
||||
if (ogImageUrl) {
|
||||
console.log("Found og:image URL:", ogImageUrl);
|
||||
|
||||
// Download the image
|
||||
const imageResponse = await page.goto(ogImageUrl);
|
||||
|
||||
// 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.");
|
||||
}
|
||||
if (!link.url) linkType = link.type;
|
||||
else if (contentType?.includes("application/pdf")) linkType = "pdf";
|
||||
else if (contentType?.startsWith("image")) {
|
||||
linkType = "image";
|
||||
if (contentType.includes("image/jpeg")) imageExtension = "jpeg";
|
||||
else if (contentType.includes("image/png")) imageExtension = "png";
|
||||
}
|
||||
|
||||
await page.goBack();
|
||||
} else if (!link.preview?.startsWith("archive")) {
|
||||
console.log("No og:image found");
|
||||
await page
|
||||
.screenshot({ type: "jpeg", quality: 20 })
|
||||
.then((screenshot) => {
|
||||
return createFile({
|
||||
data: screenshot,
|
||||
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||
const user = link.collection?.owner;
|
||||
|
||||
// send to archive.org
|
||||
if (user.archiveAsWaybackMachine && link.url) sendToWayback(link.url);
|
||||
|
||||
const targetLink = await prisma.link.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
type: linkType,
|
||||
image:
|
||||
user.archiveAsScreenshot && !link.image?.startsWith("archive")
|
||||
? "pending"
|
||||
: undefined,
|
||||
pdf:
|
||||
user.archiveAsPDF && !link.pdf?.startsWith("archive")
|
||||
? "pending"
|
||||
: undefined,
|
||||
readable: !link.readable?.startsWith("archive")
|
||||
? "pending"
|
||||
: undefined,
|
||||
preview: !link.readable?.startsWith("archive")
|
||||
? "pending"
|
||||
: undefined,
|
||||
lastPreserved: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (linkType === "image" && !link.image?.startsWith("archive")) {
|
||||
await imageHandler(link, imageExtension); // archive image (jpeg/png)
|
||||
return;
|
||||
} else if (linkType === "pdf" && !link.pdf?.startsWith("archive")) {
|
||||
await pdfHandler(link); // archive pdf
|
||||
return;
|
||||
} else if (link.url) {
|
||||
// archive url
|
||||
|
||||
await page.goto(link.url, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const content = await page.content();
|
||||
|
||||
// TODO single file
|
||||
// const session = await page.context().newCDPSession(page);
|
||||
// const doc = await session.send("Page.captureSnapshot", {
|
||||
// format: "mhtml",
|
||||
// });
|
||||
// const saveDocLocally = (doc: any) => {
|
||||
// console.log(doc);
|
||||
// return createFile({
|
||||
// data: doc,
|
||||
// filePath: `archives/${targetLink.collectionId}/${link.id}.mhtml`,
|
||||
// });
|
||||
// };
|
||||
// saveDocLocally(doc.data);
|
||||
|
||||
// Readability
|
||||
const window = new JSDOM("").window;
|
||||
const purify = DOMPurify(window);
|
||||
const cleanedUpContent = purify.sanitize(content);
|
||||
const dom = new JSDOM(cleanedUpContent, { url: link.url || "" });
|
||||
const article = new Readability(dom.window.document).parse();
|
||||
const articleText = article?.textContent
|
||||
.replace(/ +(?= )/g, "") // strip out multiple spaces
|
||||
.replace(/(\r\n|\n|\r)/gm, " "); // strip out line breaks
|
||||
if (
|
||||
articleText &&
|
||||
articleText !== "" &&
|
||||
!link.readable?.startsWith("archive")
|
||||
) {
|
||||
await createFile({
|
||||
data: JSON.stringify(article),
|
||||
filePath: `archives/${targetLink.collectionId}/${link.id}_readability.json`,
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
return prisma.link.update({
|
||||
|
||||
await prisma.link.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||
readable: `archives/${targetLink.collectionId}/${link.id}_readability.json`,
|
||||
textContent: articleText,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Preview
|
||||
|
||||
const ogImageUrl = await page.evaluate(() => {
|
||||
const metaTag = document.querySelector('meta[property="og:image"]');
|
||||
return metaTag ? (metaTag as any).content : null;
|
||||
});
|
||||
}
|
||||
|
||||
// Screenshot/PDF
|
||||
await page.evaluate(
|
||||
autoScroll,
|
||||
Number(process.env.AUTOSCROLL_TIMEOUT) || 30
|
||||
);
|
||||
createFolder({
|
||||
filePath: `archives/preview/${link.collectionId}`,
|
||||
});
|
||||
|
||||
// Check if the user hasn't deleted the link by the time we're done scrolling
|
||||
const linkExists = await prisma.link.findUnique({
|
||||
where: { id: link.id },
|
||||
});
|
||||
if (linkExists) {
|
||||
const processingPromises = [];
|
||||
if (ogImageUrl) {
|
||||
console.log("Found og:image URL:", ogImageUrl);
|
||||
|
||||
if (user.archiveAsScreenshot && !link.image?.startsWith("archive")) {
|
||||
processingPromises.push(
|
||||
page.screenshot({ fullPage: true }).then((screenshot) => {
|
||||
return createFile({
|
||||
data: screenshot,
|
||||
filePath: `archives/${linkExists.collectionId}/${link.id}.png`,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
|
||||
processingPromises.push(
|
||||
page
|
||||
.pdf({
|
||||
width: "1366px",
|
||||
height: "1931px",
|
||||
printBackground: true,
|
||||
margin: { top: "15px", bottom: "15px" },
|
||||
})
|
||||
.then((pdf) => {
|
||||
// Download the image
|
||||
const imageResponse = await page.goto(ogImageUrl);
|
||||
|
||||
// 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 page.goBack();
|
||||
} else if (!link.preview?.startsWith("archive")) {
|
||||
console.log("No og:image found");
|
||||
await page
|
||||
.screenshot({ type: "jpeg", quality: 20 })
|
||||
.then((screenshot) => {
|
||||
return createFile({
|
||||
data: pdf,
|
||||
filePath: `archives/${linkExists.collectionId}/${link.id}.pdf`,
|
||||
data: screenshot,
|
||||
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`,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Screenshot/PDF
|
||||
await page.evaluate(
|
||||
autoScroll,
|
||||
Number(process.env.AUTOSCROLL_TIMEOUT) || 30
|
||||
);
|
||||
|
||||
// Check if the user hasn't deleted the link by the time we're done scrolling
|
||||
const linkExists = await prisma.link.findUnique({
|
||||
where: { id: link.id },
|
||||
});
|
||||
if (linkExists) {
|
||||
const processingPromises = [];
|
||||
|
||||
if (
|
||||
user.archiveAsScreenshot &&
|
||||
!link.image?.startsWith("archive")
|
||||
) {
|
||||
processingPromises.push(
|
||||
page.screenshot({ fullPage: true }).then((screenshot) => {
|
||||
return createFile({
|
||||
data: screenshot,
|
||||
filePath: `archives/${linkExists.collectionId}/${link.id}.png`,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
|
||||
processingPromises.push(
|
||||
page
|
||||
.pdf({
|
||||
width: "1366px",
|
||||
height: "1931px",
|
||||
printBackground: true,
|
||||
margin: { top: "15px", bottom: "15px" },
|
||||
})
|
||||
.then((pdf) => {
|
||||
return createFile({
|
||||
data: pdf,
|
||||
filePath: `archives/${linkExists.collectionId}/${link.id}.pdf`,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
await Promise.allSettled(processingPromises);
|
||||
await prisma.link.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
image: user.archiveAsScreenshot
|
||||
? `archives/${linkExists.collectionId}/${link.id}.png`
|
||||
: undefined,
|
||||
pdf: user.archiveAsPDF
|
||||
? `archives/${linkExists.collectionId}/${link.id}.pdf`
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(processingPromises);
|
||||
await prisma.link.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
image: user.archiveAsScreenshot
|
||||
? `archives/${linkExists.collectionId}/${link.id}.png`
|
||||
: undefined,
|
||||
pdf: user.archiveAsPDF
|
||||
? `archives/${linkExists.collectionId}/${link.id}.pdf`
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
})(),
|
||||
timeoutPromise,
|
||||
]);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
console.log("Failed Link details:", link);
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function getDashboardData(
|
||||
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
|
||||
|
||||
const pinnedLinks = await prisma.link.findMany({
|
||||
take: 6,
|
||||
take: 8,
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
|
||||
@@ -62,13 +62,16 @@ export default async function postLink(
|
||||
link.description && link.description !== ""
|
||||
? link.description
|
||||
: link.url
|
||||
? await getTitle(link.url)
|
||||
: undefined;
|
||||
? await getTitle(link.url)
|
||||
: undefined;
|
||||
|
||||
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
|
||||
|
||||
if (validatedUrl === null)
|
||||
return { response: "File is too large to be stored.", status: 400 };
|
||||
return {
|
||||
response: "Something went wrong while retrieving the file size.",
|
||||
status: 400,
|
||||
};
|
||||
|
||||
const contentType = validatedUrl?.get("content-type");
|
||||
let linkType = "url";
|
||||
|
||||
@@ -5,6 +5,9 @@ import Stripe from "stripe";
|
||||
import { DeleteUserBody } from "@/types/global";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
|
||||
const keycloakEnabled = process.env.KEYCLOAK_CLIENT_SECRET;
|
||||
const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET;
|
||||
|
||||
export default async function deleteUserById(
|
||||
userId: number,
|
||||
body: DeleteUserBody
|
||||
@@ -22,7 +25,7 @@ export default async function deleteUserById(
|
||||
}
|
||||
|
||||
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
|
||||
if (!process.env.KEYCLOAK_CLIENT_SECRET) {
|
||||
if (!keycloakEnabled && !authentikEnabled) {
|
||||
const isPasswordValid = bcrypt.compareSync(
|
||||
body.password,
|
||||
user.password as string
|
||||
|
||||
@@ -23,6 +23,7 @@ export default async function updateUserById(
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (ssoUser) {
|
||||
// deny changes to SSO-defined properties
|
||||
if (data.email !== user?.email) {
|
||||
@@ -49,7 +50,7 @@ export default async function updateUserById(
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
if (data.image !== "") {
|
||||
if (data.image?.startsWith("data:image/jpeg;base64")) {
|
||||
return {
|
||||
response: "SSO Users cannot change their avatar.",
|
||||
status: 400,
|
||||
|
||||
@@ -40,7 +40,10 @@ async function emptyS3Directory(bucket: string, dir: string) {
|
||||
export default async function removeFolder({ filePath }: { filePath: string }) {
|
||||
if (s3Client) {
|
||||
try {
|
||||
await emptyS3Directory(process.env.SPACES_BUCKET_NAME as string, filePath);
|
||||
await emptyS3Directory(
|
||||
process.env.SPACES_BUCKET_NAME as string,
|
||||
filePath
|
||||
);
|
||||
} catch (err) {
|
||||
console.log("Error", err);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import fetch from "node-fetch";
|
||||
import https from "https";
|
||||
|
||||
export default async function validateUrlSize(url: string) {
|
||||
try {
|
||||
const response = await fetch(url, { method: "HEAD" });
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized:
|
||||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD",
|
||||
agent: httpsAgent,
|
||||
});
|
||||
|
||||
const totalSizeMB =
|
||||
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
||||
|
||||
@@ -42,7 +42,8 @@ export default async function verifyUser({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.username && !ssoUser) { // SSO users don't need a username
|
||||
if (!user.username && !ssoUser) {
|
||||
// SSO users don't need a username
|
||||
res.status(401).json({
|
||||
response: "Username not found.",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import fetch from "node-fetch";
|
||||
import https from "https";
|
||||
export default async function getTitle(url: string) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized:
|
||||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
agent: httpsAgent,
|
||||
});
|
||||
const text = await response.text();
|
||||
|
||||
// regular expression to find the <title> tag
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkwarden",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.7",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||
@@ -15,7 +15,8 @@
|
||||
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
|
||||
"start": "concurrently \"next start\" \"yarn worker:prod\"",
|
||||
"build": "next build",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^1.0.1",
|
||||
@@ -38,6 +39,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"csstype": "^3.1.2",
|
||||
"dompurify": "^3.0.6",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "8.46.0",
|
||||
"eslint-config-next": "13.4.9",
|
||||
"formidable": "^3.5.1",
|
||||
@@ -64,11 +66,13 @@
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/dompurify": "^3.0.4",
|
||||
"@types/jsdom": "^21.1.3",
|
||||
"@types/node-fetch": "^2.6.10",
|
||||
"@types/shelljs": "^0.8.15",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"daisyui": "^4.4.2",
|
||||
"nodemon": "^3.0.2",
|
||||
"postcss": "^8.4.26",
|
||||
"prettier": "3.1.1",
|
||||
"prisma": "^5.1.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import "@/styles/globals.css";
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
@@ -9,50 +9,62 @@ import { Toaster } from "react-hot-toast";
|
||||
import { Session } from "next-auth";
|
||||
|
||||
export default function App({
|
||||
Component,
|
||||
pageProps,
|
||||
Component,
|
||||
pageProps,
|
||||
}: AppProps<{
|
||||
session: Session;
|
||||
session: Session;
|
||||
}>) {
|
||||
return (
|
||||
<SessionProvider
|
||||
session={pageProps.session}
|
||||
refetchOnWindowFocus={false}
|
||||
basePath="/api/v1/auth"
|
||||
>
|
||||
<Head>
|
||||
<title>Linkwarden</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</Head>
|
||||
<AuthRedirect>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
reverseOrder={false}
|
||||
toastOptions={{
|
||||
className:
|
||||
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
|
||||
}}
|
||||
/>
|
||||
<Component {...pageProps} />
|
||||
</AuthRedirect>
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let theme = localStorage.getItem("theme");
|
||||
if (!theme || !theme.includes("-")) {
|
||||
theme = "default-dark"; // Default theme
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<SessionProvider
|
||||
session={pageProps.session}
|
||||
refetchOnWindowFocus={false}
|
||||
basePath="/api/v1/auth"
|
||||
>
|
||||
<Head>
|
||||
<title>Linkwarden</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</Head>
|
||||
<AuthRedirect>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
reverseOrder={false}
|
||||
toastOptions={{
|
||||
className:
|
||||
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
|
||||
}}
|
||||
/>
|
||||
<Component {...pageProps} />
|
||||
</AuthRedirect>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,7 +249,8 @@ if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name ?? profile.preferred_username,
|
||||
username: profile.preferred_username,
|
||||
name: profile.name || "",
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
};
|
||||
@@ -589,13 +590,15 @@ if (process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === "true") {
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name ?? profile.preferred_username,
|
||||
username: profile.preferred_username,
|
||||
name: profile.name || "",
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const _linkAccount = adapter.linkAccount;
|
||||
adapter.linkAccount = (account) => {
|
||||
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
|
||||
|
||||
@@ -1,238 +1,408 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import * as process from "process";
|
||||
|
||||
export type ResponseData = {
|
||||
credentialsEnabled: string|undefined
|
||||
emailEnabled: string|undefined
|
||||
registrationDisabled: string|undefined
|
||||
buttonAuths: {
|
||||
method: string
|
||||
name: string
|
||||
}[]
|
||||
}
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||
res.json(getLogins());
|
||||
credentialsEnabled: string | undefined;
|
||||
emailEnabled: string | undefined;
|
||||
registrationDisabled: string | undefined;
|
||||
buttonAuths: {
|
||||
method: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
res.json(getLogins());
|
||||
}
|
||||
|
||||
export function getLogins() {
|
||||
const buttonAuths = []
|
||||
const buttonAuths = [];
|
||||
|
||||
// 42 School
|
||||
if (process.env.NEXT_PUBLIC_FORTYTWO_ENABLED === 'true') {
|
||||
buttonAuths.push({method: '42-school', name: process.env.FORTYTWO_CUSTOM_NAME ?? '42 School'});
|
||||
}
|
||||
// Apple
|
||||
if (process.env.NEXT_PUBLIC_APPLE_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'apple', name: process.env.APPLE_CUSTOM_NAME ?? 'Apple'});
|
||||
}
|
||||
// Atlassian
|
||||
if (process.env.NEXT_PUBLIC_ATLASSIAN_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'atlassian', name: process.env.ATLASSIAN_CUSTOM_NAME ?? 'Atlassian'});
|
||||
}
|
||||
// Auth0
|
||||
if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'auth0', name: process.env.AUTH0_CUSTOM_NAME ?? 'Auth0'});
|
||||
}
|
||||
// Authentik
|
||||
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'authentik', name: process.env.AUTHENTIK_CUSTOM_NAME ?? 'Authentik'});
|
||||
}
|
||||
// Battle.net
|
||||
if (process.env.NEXT_PUBLIC_BATTLENET_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'battlenet', name: process.env.BATTLENET_CUSTOM_NAME ?? 'Battle.net'});
|
||||
}
|
||||
// Box
|
||||
if (process.env.NEXT_PUBLIC_BOX_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'box', name: process.env.BOX_CUSTOM_NAME ?? 'Box'});
|
||||
}
|
||||
// Cognito
|
||||
if (process.env.NEXT_PUBLIC_COGNITO_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'cognito', name: process.env.COGNITO_CUSTOM_NAME ?? 'Cognito'});
|
||||
}
|
||||
// Coinbase
|
||||
if (process.env.NEXT_PUBLIC_COINBASE_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'coinbase', name: process.env.COINBASE_CUSTOM_NAME ?? 'Coinbase'});
|
||||
}
|
||||
// Discord
|
||||
if (process.env.NEXT_PUBLIC_DISCORD_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'discord', name: process.env.DISCORD_CUSTOM_NAME ?? 'Discord'});
|
||||
}
|
||||
// Dropbox
|
||||
if (process.env.NEXT_PUBLIC_DROPBOX_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'dropbox', name: process.env.DROPBOX_CUSTOM_NAME ?? 'Dropbox'});
|
||||
}
|
||||
// Duende IdentityServer6
|
||||
if (process.env.NEXT_PUBLIC_DUENDE_IDS6_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'duende-identityserver6', name: process.env.DUENDE_IDS6_CUSTOM_NAME ?? 'DuendeIdentityServer6'});
|
||||
}
|
||||
// EVE Online
|
||||
if (process.env.NEXT_PUBLIC_EVEONLINE_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'eveonline', name: process.env.EVEONLINE_CUSTOM_NAME ?? 'EVE Online'});
|
||||
}
|
||||
// Facebook
|
||||
if (process.env.NEXT_PUBLIC_FACEBOOK_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'facebook', name: process.env.FACEBOOK_CUSTOM_NAME ?? 'Facebook'});
|
||||
}
|
||||
// FACEIT
|
||||
if (process.env.NEXT_PUBLIC_FACEIT_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'faceit', name: process.env.FACEIT_CUSTOM_NAME ?? 'FACEIT'});
|
||||
}
|
||||
// Foursquare
|
||||
if (process.env.NEXT_PUBLIC_FOURSQUARE_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'foursquare', name: process.env.FOURSQUARE_CUSTOM_NAME ?? 'Foursquare'});
|
||||
}
|
||||
// Freshbooks
|
||||
if (process.env.NEXT_PUBLIC_FRESHBOOKS_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'freshbooks', name: process.env.FRESHBOOKS_CUSTOM_NAME ?? 'Freshbooks'});
|
||||
}
|
||||
// FusionAuth
|
||||
if (process.env.NEXT_PUBLIC_FUSIONAUTH_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'fusionauth', name: process.env.FUSIONAUTH_CUSTOM_NAME ?? 'FusionAuth'});
|
||||
}
|
||||
// GitHub
|
||||
if (process.env.NEXT_PUBLIC_GITHUB_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'github', name: process.env.GITHUB_CUSTOM_NAME ?? 'GitHub'});
|
||||
}
|
||||
// GitLab
|
||||
if (process.env.NEXT_PUBLIC_GITLAB_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'gitlab', name: process.env.GITLAB_CUSTOM_NAME ?? 'GitLab'});
|
||||
}
|
||||
// Google
|
||||
if (process.env.NEXT_PUBLIC_GOOGLE_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'google', name: process.env.GOOGLE_CUSTOM_NAME ?? 'Google'});
|
||||
}
|
||||
// HubSpot
|
||||
if (process.env.NEXT_PUBLIC_HUBSPOT_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'hubspot', name: process.env.HUBSPOT_CUSTOM_NAME ?? 'HubSpot'});
|
||||
}
|
||||
// IdentityServer4
|
||||
if (process.env.NEXT_PUBLIC_IDS4_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'identity-server4', name: process.env.IDS4_CUSTOM_NAME ?? 'IdentityServer4'});
|
||||
}
|
||||
// Kakao
|
||||
if (process.env.NEXT_PUBLIC_KAKAO_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'kakao', name: process.env.KAKAO_CUSTOM_NAME ?? 'Kakao'});
|
||||
}
|
||||
// Keycloak
|
||||
if (process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'keycloak', name: process.env.KEYCLOAK_CUSTOM_NAME ?? 'Keycloak'});
|
||||
}
|
||||
// LINE
|
||||
if (process.env.NEXT_PUBLIC_LINE_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'line', name: process.env.LINE_CUSTOM_NAME ?? 'LINE'});
|
||||
}
|
||||
// LinkedIn
|
||||
if (process.env.NEXT_PUBLIC_LINKEDIN_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'linkedin', name: process.env.LINKEDIN_CUSTOM_NAME ?? 'LinkedIn'});
|
||||
}
|
||||
// MailChimp
|
||||
if (process.env.NEXT_PUBLIC_MAILCHIMP_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'mailchimp', name: process.env.MAILCHIMP_CUSTOM_NAME ?? 'Mailchimp'});
|
||||
}
|
||||
// Mail.ru
|
||||
if (process.env.NEXT_PUBLIC_MAILRU_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'mailru', name: process.env.MAILRU_CUSTOM_NAME ?? 'Mail.ru'});
|
||||
}
|
||||
// Naver
|
||||
if (process.env.NEXT_PUBLIC_NAVER_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'naver', name: process.env.NAVER_CUSTOM_NAME ?? 'Naver'});
|
||||
}
|
||||
// Netlify
|
||||
if (process.env.NEXT_PUBLIC_NETLIFY_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'netlify', name: process.env.NETLIFY_CUSTOM_NAME ?? 'Netlify'});
|
||||
}
|
||||
// Okta
|
||||
if (process.env.NEXT_PUBLIC_OKTA_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'okta', name: process.env.OKTA_CUSTOM_NAME ?? 'Okta'});
|
||||
}
|
||||
// OneLogin
|
||||
if (process.env.NEXT_PUBLIC_ONELOGIN_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'onelogin', name: process.env.ONELOGIN_CUSTOM_NAME ?? 'OneLogin'});
|
||||
}
|
||||
// Osso
|
||||
if (process.env.NEXT_PUBLIC_OSSO_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'osso', name: process.env.OSSO_CUSTOM_NAME ?? 'Osso'});
|
||||
}
|
||||
// osu!
|
||||
if (process.env.NEXT_PUBLIC_OSU_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'osu', name: process.env.OSU_CUSTOM_NAME ?? 'Osu!'});
|
||||
}
|
||||
// Patreon
|
||||
if (process.env.NEXT_PUBLIC_PATREON_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'patreon', name: process.env.PATREON_CUSTOM_NAME ?? 'Patreon'});
|
||||
}
|
||||
// Pinterest
|
||||
if (process.env.NEXT_PUBLIC_PINTEREST_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'pinterest', name: process.env.PINTEREST_CUSTOM_NAME ?? 'Pinterest'});
|
||||
}
|
||||
// Pipedrive
|
||||
if (process.env.NEXT_PUBLIC_PIPEDRIVE_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'pipedrive', name: process.env.PIPEDRIVE_CUSTOM_NAME ?? 'Pipedrive'});
|
||||
}
|
||||
// Reddit
|
||||
if (process.env.NEXT_PUBLIC_REDDIT_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'reddit', name: process.env.REDDIT_CUSTOM_NAME ?? 'Reddit'});
|
||||
}
|
||||
// Salesforce
|
||||
if (process.env.NEXT_PUBLIC_SALESFORCE_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'salesforce', name: process.env.SALESFORCE_CUSTOM_NAME ?? 'Salesforce'});
|
||||
}
|
||||
// Slack
|
||||
if (process.env.NEXT_PUBLIC_SLACK_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'slack', name: process.env.SLACK_CUSTOM_NAME ?? 'Slack'});
|
||||
}
|
||||
// Spotify
|
||||
if (process.env.NEXT_PUBLIC_SPOTIFY_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'spotify', name: process.env.SPOTIFY_CUSTOM_NAME ?? 'Spotify'});
|
||||
}
|
||||
// Strava
|
||||
if (process.env.NEXT_PUBLIC_STRAVA_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'strava', name: process.env.STRAVA_CUSTOM_NAME ?? 'Strava'});
|
||||
}
|
||||
// Todoist
|
||||
if (process.env.NEXT_PUBLIC_TODOIST_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'todoist', name: process.env.TODOIST_CUSTOM_NAME ?? 'Todoist'});
|
||||
}
|
||||
// Twitch
|
||||
if (process.env.NEXT_PUBLIC_TWITCH_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'twitch', name: process.env.TWITCH_CUSTOM_NAME ?? 'Twitch'});
|
||||
}
|
||||
// United Effects
|
||||
if (process.env.NEXT_PUBLIC_UNITED_EFFECTS_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'united-effects', name: process.env.UNITED_EFFECTS_CUSTOM_NAME ?? 'United Effects'});
|
||||
}
|
||||
// VK
|
||||
if (process.env.NEXT_PUBLIC_VK_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'vk', name: process.env.VK_CUSTOM_NAME ?? 'VK'});
|
||||
}
|
||||
// Wikimedia
|
||||
if (process.env.NEXT_PUBLIC_WIKIMEDIA_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'wikimedia', name: process.env.WIKIMEDIA_CUSTOM_NAME ?? 'Wikimedia'});
|
||||
}
|
||||
// Wordpress.com
|
||||
if (process.env.NEXT_PUBLIC_WORDPRESS_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'wordpress', name: process.env.WORDPRESS_CUSTOM_NAME ?? 'WordPress.com'});
|
||||
}
|
||||
// Yandex
|
||||
if (process.env.NEXT_PUBLIC_YANDEX_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'yandex', name: process.env.YANDEX_CUSTOM_NAME ?? 'Yandex'});
|
||||
}
|
||||
// Zitadel
|
||||
if (process.env.NEXT_PUBLIC_ZITADEL_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'zitadel', name: process.env.ZITADEL_CUSTOM_NAME ?? 'ZITADEL'});
|
||||
}
|
||||
// Zoho
|
||||
if (process.env.NEXT_PUBLIC_ZOHO_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'zoho', name: process.env.ZOHO_CUSTOM_NAME ?? 'Zoho'});
|
||||
}
|
||||
// Zoom
|
||||
if (process.env.NEXT_PUBLIC_ZOOM_ENABLED === 'true') {
|
||||
buttonAuths.push({method: 'zoom', name: process.env.ZOOM_CUSTOM_NAME ?? 'Zoom'});
|
||||
}
|
||||
return {
|
||||
credentialsEnabled: (process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === 'true' || process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined) ? "true" : "false",
|
||||
emailEnabled: (process.env.NEXT_PUBLIC_EMAIL_PROVIDER === 'true' ? 'true' : 'false'),
|
||||
registrationDisabled: (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === 'true' ? 'true' : 'false'),
|
||||
buttonAuths: buttonAuths
|
||||
};
|
||||
}
|
||||
// 42 School
|
||||
if (process.env.NEXT_PUBLIC_FORTYTWO_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "42-school",
|
||||
name: process.env.FORTYTWO_CUSTOM_NAME ?? "42 School",
|
||||
});
|
||||
}
|
||||
// Apple
|
||||
if (process.env.NEXT_PUBLIC_APPLE_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "apple",
|
||||
name: process.env.APPLE_CUSTOM_NAME ?? "Apple",
|
||||
});
|
||||
}
|
||||
// Atlassian
|
||||
if (process.env.NEXT_PUBLIC_ATLASSIAN_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "atlassian",
|
||||
name: process.env.ATLASSIAN_CUSTOM_NAME ?? "Atlassian",
|
||||
});
|
||||
}
|
||||
// Auth0
|
||||
if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "auth0",
|
||||
name: process.env.AUTH0_CUSTOM_NAME ?? "Auth0",
|
||||
});
|
||||
}
|
||||
// Authentik
|
||||
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "authentik",
|
||||
name: process.env.AUTHENTIK_CUSTOM_NAME ?? "Authentik",
|
||||
});
|
||||
}
|
||||
// Battle.net
|
||||
if (process.env.NEXT_PUBLIC_BATTLENET_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "battlenet",
|
||||
name: process.env.BATTLENET_CUSTOM_NAME ?? "Battle.net",
|
||||
});
|
||||
}
|
||||
// Box
|
||||
if (process.env.NEXT_PUBLIC_BOX_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "box",
|
||||
name: process.env.BOX_CUSTOM_NAME ?? "Box",
|
||||
});
|
||||
}
|
||||
// Cognito
|
||||
if (process.env.NEXT_PUBLIC_COGNITO_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "cognito",
|
||||
name: process.env.COGNITO_CUSTOM_NAME ?? "Cognito",
|
||||
});
|
||||
}
|
||||
// Coinbase
|
||||
if (process.env.NEXT_PUBLIC_COINBASE_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "coinbase",
|
||||
name: process.env.COINBASE_CUSTOM_NAME ?? "Coinbase",
|
||||
});
|
||||
}
|
||||
// Discord
|
||||
if (process.env.NEXT_PUBLIC_DISCORD_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "discord",
|
||||
name: process.env.DISCORD_CUSTOM_NAME ?? "Discord",
|
||||
});
|
||||
}
|
||||
// Dropbox
|
||||
if (process.env.NEXT_PUBLIC_DROPBOX_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "dropbox",
|
||||
name: process.env.DROPBOX_CUSTOM_NAME ?? "Dropbox",
|
||||
});
|
||||
}
|
||||
// Duende IdentityServer6
|
||||
if (process.env.NEXT_PUBLIC_DUENDE_IDS6_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "duende-identityserver6",
|
||||
name: process.env.DUENDE_IDS6_CUSTOM_NAME ?? "DuendeIdentityServer6",
|
||||
});
|
||||
}
|
||||
// EVE Online
|
||||
if (process.env.NEXT_PUBLIC_EVEONLINE_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "eveonline",
|
||||
name: process.env.EVEONLINE_CUSTOM_NAME ?? "EVE Online",
|
||||
});
|
||||
}
|
||||
// Facebook
|
||||
if (process.env.NEXT_PUBLIC_FACEBOOK_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "facebook",
|
||||
name: process.env.FACEBOOK_CUSTOM_NAME ?? "Facebook",
|
||||
});
|
||||
}
|
||||
// FACEIT
|
||||
if (process.env.NEXT_PUBLIC_FACEIT_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "faceit",
|
||||
name: process.env.FACEIT_CUSTOM_NAME ?? "FACEIT",
|
||||
});
|
||||
}
|
||||
// Foursquare
|
||||
if (process.env.NEXT_PUBLIC_FOURSQUARE_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "foursquare",
|
||||
name: process.env.FOURSQUARE_CUSTOM_NAME ?? "Foursquare",
|
||||
});
|
||||
}
|
||||
// Freshbooks
|
||||
if (process.env.NEXT_PUBLIC_FRESHBOOKS_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "freshbooks",
|
||||
name: process.env.FRESHBOOKS_CUSTOM_NAME ?? "Freshbooks",
|
||||
});
|
||||
}
|
||||
// FusionAuth
|
||||
if (process.env.NEXT_PUBLIC_FUSIONAUTH_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "fusionauth",
|
||||
name: process.env.FUSIONAUTH_CUSTOM_NAME ?? "FusionAuth",
|
||||
});
|
||||
}
|
||||
// GitHub
|
||||
if (process.env.NEXT_PUBLIC_GITHUB_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "github",
|
||||
name: process.env.GITHUB_CUSTOM_NAME ?? "GitHub",
|
||||
});
|
||||
}
|
||||
// GitLab
|
||||
if (process.env.NEXT_PUBLIC_GITLAB_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "gitlab",
|
||||
name: process.env.GITLAB_CUSTOM_NAME ?? "GitLab",
|
||||
});
|
||||
}
|
||||
// Google
|
||||
if (process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "google",
|
||||
name: process.env.GOOGLE_CUSTOM_NAME ?? "Google",
|
||||
});
|
||||
}
|
||||
// HubSpot
|
||||
if (process.env.NEXT_PUBLIC_HUBSPOT_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "hubspot",
|
||||
name: process.env.HUBSPOT_CUSTOM_NAME ?? "HubSpot",
|
||||
});
|
||||
}
|
||||
// IdentityServer4
|
||||
if (process.env.NEXT_PUBLIC_IDS4_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "identity-server4",
|
||||
name: process.env.IDS4_CUSTOM_NAME ?? "IdentityServer4",
|
||||
});
|
||||
}
|
||||
// Kakao
|
||||
if (process.env.NEXT_PUBLIC_KAKAO_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "kakao",
|
||||
name: process.env.KAKAO_CUSTOM_NAME ?? "Kakao",
|
||||
});
|
||||
}
|
||||
// Keycloak
|
||||
if (process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "keycloak",
|
||||
name: process.env.KEYCLOAK_CUSTOM_NAME ?? "Keycloak",
|
||||
});
|
||||
}
|
||||
// LINE
|
||||
if (process.env.NEXT_PUBLIC_LINE_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "line",
|
||||
name: process.env.LINE_CUSTOM_NAME ?? "LINE",
|
||||
});
|
||||
}
|
||||
// LinkedIn
|
||||
if (process.env.NEXT_PUBLIC_LINKEDIN_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "linkedin",
|
||||
name: process.env.LINKEDIN_CUSTOM_NAME ?? "LinkedIn",
|
||||
});
|
||||
}
|
||||
// MailChimp
|
||||
if (process.env.NEXT_PUBLIC_MAILCHIMP_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "mailchimp",
|
||||
name: process.env.MAILCHIMP_CUSTOM_NAME ?? "Mailchimp",
|
||||
});
|
||||
}
|
||||
// Mail.ru
|
||||
if (process.env.NEXT_PUBLIC_MAILRU_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "mailru",
|
||||
name: process.env.MAILRU_CUSTOM_NAME ?? "Mail.ru",
|
||||
});
|
||||
}
|
||||
// Naver
|
||||
if (process.env.NEXT_PUBLIC_NAVER_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "naver",
|
||||
name: process.env.NAVER_CUSTOM_NAME ?? "Naver",
|
||||
});
|
||||
}
|
||||
// Netlify
|
||||
if (process.env.NEXT_PUBLIC_NETLIFY_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "netlify",
|
||||
name: process.env.NETLIFY_CUSTOM_NAME ?? "Netlify",
|
||||
});
|
||||
}
|
||||
// Okta
|
||||
if (process.env.NEXT_PUBLIC_OKTA_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "okta",
|
||||
name: process.env.OKTA_CUSTOM_NAME ?? "Okta",
|
||||
});
|
||||
}
|
||||
// OneLogin
|
||||
if (process.env.NEXT_PUBLIC_ONELOGIN_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "onelogin",
|
||||
name: process.env.ONELOGIN_CUSTOM_NAME ?? "OneLogin",
|
||||
});
|
||||
}
|
||||
// Osso
|
||||
if (process.env.NEXT_PUBLIC_OSSO_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "osso",
|
||||
name: process.env.OSSO_CUSTOM_NAME ?? "Osso",
|
||||
});
|
||||
}
|
||||
// osu!
|
||||
if (process.env.NEXT_PUBLIC_OSU_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "osu",
|
||||
name: process.env.OSU_CUSTOM_NAME ?? "Osu!",
|
||||
});
|
||||
}
|
||||
// Patreon
|
||||
if (process.env.NEXT_PUBLIC_PATREON_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "patreon",
|
||||
name: process.env.PATREON_CUSTOM_NAME ?? "Patreon",
|
||||
});
|
||||
}
|
||||
// Pinterest
|
||||
if (process.env.NEXT_PUBLIC_PINTEREST_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "pinterest",
|
||||
name: process.env.PINTEREST_CUSTOM_NAME ?? "Pinterest",
|
||||
});
|
||||
}
|
||||
// Pipedrive
|
||||
if (process.env.NEXT_PUBLIC_PIPEDRIVE_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "pipedrive",
|
||||
name: process.env.PIPEDRIVE_CUSTOM_NAME ?? "Pipedrive",
|
||||
});
|
||||
}
|
||||
// Reddit
|
||||
if (process.env.NEXT_PUBLIC_REDDIT_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "reddit",
|
||||
name: process.env.REDDIT_CUSTOM_NAME ?? "Reddit",
|
||||
});
|
||||
}
|
||||
// Salesforce
|
||||
if (process.env.NEXT_PUBLIC_SALESFORCE_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "salesforce",
|
||||
name: process.env.SALESFORCE_CUSTOM_NAME ?? "Salesforce",
|
||||
});
|
||||
}
|
||||
// Slack
|
||||
if (process.env.NEXT_PUBLIC_SLACK_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "slack",
|
||||
name: process.env.SLACK_CUSTOM_NAME ?? "Slack",
|
||||
});
|
||||
}
|
||||
// Spotify
|
||||
if (process.env.NEXT_PUBLIC_SPOTIFY_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "spotify",
|
||||
name: process.env.SPOTIFY_CUSTOM_NAME ?? "Spotify",
|
||||
});
|
||||
}
|
||||
// Strava
|
||||
if (process.env.NEXT_PUBLIC_STRAVA_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "strava",
|
||||
name: process.env.STRAVA_CUSTOM_NAME ?? "Strava",
|
||||
});
|
||||
}
|
||||
// Todoist
|
||||
if (process.env.NEXT_PUBLIC_TODOIST_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "todoist",
|
||||
name: process.env.TODOIST_CUSTOM_NAME ?? "Todoist",
|
||||
});
|
||||
}
|
||||
// Twitch
|
||||
if (process.env.NEXT_PUBLIC_TWITCH_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "twitch",
|
||||
name: process.env.TWITCH_CUSTOM_NAME ?? "Twitch",
|
||||
});
|
||||
}
|
||||
// United Effects
|
||||
if (process.env.NEXT_PUBLIC_UNITED_EFFECTS_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "united-effects",
|
||||
name: process.env.UNITED_EFFECTS_CUSTOM_NAME ?? "United Effects",
|
||||
});
|
||||
}
|
||||
// VK
|
||||
if (process.env.NEXT_PUBLIC_VK_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "vk",
|
||||
name: process.env.VK_CUSTOM_NAME ?? "VK",
|
||||
});
|
||||
}
|
||||
// Wikimedia
|
||||
if (process.env.NEXT_PUBLIC_WIKIMEDIA_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "wikimedia",
|
||||
name: process.env.WIKIMEDIA_CUSTOM_NAME ?? "Wikimedia",
|
||||
});
|
||||
}
|
||||
// Wordpress.com
|
||||
if (process.env.NEXT_PUBLIC_WORDPRESS_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "wordpress",
|
||||
name: process.env.WORDPRESS_CUSTOM_NAME ?? "WordPress.com",
|
||||
});
|
||||
}
|
||||
// Yandex
|
||||
if (process.env.NEXT_PUBLIC_YANDEX_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "yandex",
|
||||
name: process.env.YANDEX_CUSTOM_NAME ?? "Yandex",
|
||||
});
|
||||
}
|
||||
// Zitadel
|
||||
if (process.env.NEXT_PUBLIC_ZITADEL_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "zitadel",
|
||||
name: process.env.ZITADEL_CUSTOM_NAME ?? "ZITADEL",
|
||||
});
|
||||
}
|
||||
// Zoho
|
||||
if (process.env.NEXT_PUBLIC_ZOHO_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "zoho",
|
||||
name: process.env.ZOHO_CUSTOM_NAME ?? "Zoho",
|
||||
});
|
||||
}
|
||||
// Zoom
|
||||
if (process.env.NEXT_PUBLIC_ZOOM_ENABLED === "true") {
|
||||
buttonAuths.push({
|
||||
method: "zoom",
|
||||
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
|
||||
});
|
||||
}
|
||||
return {
|
||||
credentialsEnabled:
|
||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
|
||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
|
||||
? "true"
|
||||
: "false",
|
||||
emailEnabled:
|
||||
process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" ? "true" : "false",
|
||||
registrationDisabled:
|
||||
process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true"
|
||||
? "true"
|
||||
: "false",
|
||||
buttonAuths: buttonAuths,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,12 +101,14 @@ export default function Index() {
|
||||
return (
|
||||
<MainLayout>
|
||||
<div
|
||||
className="h-[60rem] p-5 flex gap-3 flex-col"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${
|
||||
settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
className="h-[60rem] p-5 flex gap-3 flex-col"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${
|
||||
(settings.theme || "default-light").endsWith("-dark") ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${
|
||||
(settings.theme || "default-light").endsWith("-dark") ? "#171717" : "#ffffff"
|
||||
} 100%)`,
|
||||
}}
|
||||
>
|
||||
{activeCollection && (
|
||||
<div className="flex gap-3 items-start justify-between">
|
||||
|
||||
@@ -19,293 +19,293 @@ import ViewDropdown from "@/components/ViewDropdown";
|
||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { collections } = useCollectionStore();
|
||||
const { links } = useLinkStore();
|
||||
const { tags } = useTagStore();
|
||||
const { collections } = useCollectionStore();
|
||||
const { links } = useLinkStore();
|
||||
const { tags } = useTagStore();
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
|
||||
const [showLinks, setShowLinks] = useState(3);
|
||||
const [showLinks, setShowLinks] = useState(3);
|
||||
|
||||
useLinks({ pinnedOnly: true, sort: 0 });
|
||||
useLinks({ pinnedOnly: true, sort: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setNumberOfLinks(
|
||||
collections.reduce(
|
||||
(accumulator, collection) =>
|
||||
accumulator + (collection._count as any).links,
|
||||
0
|
||||
)
|
||||
);
|
||||
}, [collections]);
|
||||
useEffect(() => {
|
||||
setNumberOfLinks(
|
||||
collections.reduce(
|
||||
(accumulator, collection) =>
|
||||
accumulator + (collection._count as any).links,
|
||||
0
|
||||
)
|
||||
);
|
||||
}, [collections]);
|
||||
|
||||
const handleNumberOfLinksToShow = () => {
|
||||
if (window.innerWidth > 1900) {
|
||||
setShowLinks(8);
|
||||
} else if (window.innerWidth > 1280) {
|
||||
setShowLinks(6);
|
||||
} else if (window.innerWidth > 650) {
|
||||
setShowLinks(4);
|
||||
} else setShowLinks(3);
|
||||
};
|
||||
const handleNumberOfLinksToShow = () => {
|
||||
if (window.innerWidth > 1900) {
|
||||
setShowLinks(8);
|
||||
} else if (window.innerWidth > 1280) {
|
||||
setShowLinks(6);
|
||||
} else if (window.innerWidth > 650) {
|
||||
setShowLinks(4);
|
||||
} else setShowLinks(3);
|
||||
};
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
handleNumberOfLinksToShow();
|
||||
}, [width]);
|
||||
useEffect(() => {
|
||||
handleNumberOfLinksToShow();
|
||||
}, [width]);
|
||||
|
||||
const importBookmarks = async (e: any, format: MigrationFormat) => {
|
||||
const file: File = e.target.files[0];
|
||||
const importBookmarks = async (e: any, format: MigrationFormat) => {
|
||||
const file: File = e.target.files[0];
|
||||
|
||||
if (file) {
|
||||
var reader = new FileReader();
|
||||
reader.readAsText(file, "UTF-8");
|
||||
reader.onload = async function (e) {
|
||||
const load = toast.loading("Importing...");
|
||||
if (file) {
|
||||
var reader = new FileReader();
|
||||
reader.readAsText(file, "UTF-8");
|
||||
reader.onload = async function (e) {
|
||||
const load = toast.loading("Importing...");
|
||||
|
||||
const request: string = e.target?.result as string;
|
||||
const request: string = e.target?.result as string;
|
||||
|
||||
const body: MigrationRequest = {
|
||||
format,
|
||||
data: request,
|
||||
};
|
||||
const body: MigrationRequest = {
|
||||
format,
|
||||
data: request,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/v1/migration", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const response = await fetch("/api/v1/migration", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json();
|
||||
|
||||
toast.dismiss(load);
|
||||
toast.dismiss(load);
|
||||
|
||||
toast.success("Imported the Bookmarks! Reloading the page...");
|
||||
toast.success("Imported the Bookmarks! Reloading the page...");
|
||||
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 2000);
|
||||
};
|
||||
reader.onerror = function (e) {
|
||||
console.log("Error:", e);
|
||||
};
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 2000);
|
||||
};
|
||||
reader.onerror = function (e) {
|
||||
console.log("Error:", e);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const [newLinkModal, setNewLinkModal] = useState(false);
|
||||
const [newLinkModal, setNewLinkModal] = useState(false);
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
);
|
||||
|
||||
const linkView = {
|
||||
[ViewMode.Card]: CardView,
|
||||
// [ViewMode.Grid]: GridView,
|
||||
[ViewMode.List]: ListView,
|
||||
};
|
||||
const linkView = {
|
||||
[ViewMode.Card]: CardView,
|
||||
// [ViewMode.Grid]: GridView,
|
||||
[ViewMode.List]: ListView,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const LinkComponent = linkView[viewMode];
|
||||
// @ts-ignore
|
||||
const LinkComponent = linkView[viewMode];
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<PageHeader
|
||||
icon={"bi-house "}
|
||||
title={"Dashboard"}
|
||||
description={"A brief overview of your data"}
|
||||
/>
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-evenly flex-col xl:flex-row xl:items-center gap-2 xl:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200">
|
||||
<DashboardItem
|
||||
name={numberOfLinks === 1 ? "Link" : "Links"}
|
||||
value={numberOfLinks}
|
||||
icon={"bi-link-45deg"}
|
||||
/>
|
||||
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
|
||||
<DashboardItem
|
||||
name={collections.length === 1 ? "Collection" : "Collections"}
|
||||
value={collections.length}
|
||||
icon={"bi-folder"}
|
||||
/>
|
||||
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
|
||||
<DashboardItem
|
||||
name={tags.length === 1 ? "Tag" : "Tags"}
|
||||
value={tags.length}
|
||||
icon={"bi-hash"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<PageHeader
|
||||
icon={"bi-clock-history"}
|
||||
title={"Recent"}
|
||||
description={"Recently added Links"}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/links"
|
||||
className="flex items-center text-sm text-black/75 dark:text-white/75 gap-2 cursor-pointer"
|
||||
>
|
||||
View All
|
||||
<i className="bi-chevron-right text-sm"></i>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ flex: "0 1 auto" }}
|
||||
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
|
||||
>
|
||||
{links[0] ? (
|
||||
<div className="w-full">
|
||||
<LinkComponent links={links.slice(0, showLinks)} />
|
||||
return (
|
||||
<MainLayout>
|
||||
<div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<PageHeader
|
||||
icon={"bi-house "}
|
||||
title={"Dashboard"}
|
||||
description={"A brief overview of your data"}
|
||||
/>
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
<div>
|
||||
<div className="flex justify-evenly flex-col xl:flex-row xl:items-center gap-2 xl:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200">
|
||||
<DashboardItem
|
||||
name={numberOfLinks === 1 ? "Link" : "Links"}
|
||||
value={numberOfLinks}
|
||||
icon={"bi-link-45deg"}
|
||||
/>
|
||||
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
|
||||
<DashboardItem
|
||||
name={collections.length === 1 ? "Collection" : "Collections"}
|
||||
value={collections.length}
|
||||
icon={"bi-folder"}
|
||||
/>
|
||||
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
|
||||
<DashboardItem
|
||||
name={tags.length === 1 ? "Tag" : "Tags"}
|
||||
value={tags.length}
|
||||
icon={"bi-hash"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<PageHeader
|
||||
icon={"bi-clock-history"}
|
||||
title={"Recent"}
|
||||
description={"Recently added Links"}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/links"
|
||||
className="flex items-center text-sm text-neutral gap-2 cursor-pointer"
|
||||
>
|
||||
View All
|
||||
<i className="bi-chevron-right text-sm"></i>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200"
|
||||
style={{ flex: "0 1 auto" }}
|
||||
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
|
||||
>
|
||||
<p className="text-center text-2xl">
|
||||
View Your Recently Added Links Here!
|
||||
</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
|
||||
This section will view your latest added Links across every
|
||||
Collections you have access to.
|
||||
</p>
|
||||
|
||||
<div className="text-center w-full mt-4 flex flex-wrap gap-4 justify-center">
|
||||
<div
|
||||
onClick={() => {
|
||||
setNewLinkModal(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-2 text-sm btn btn-accent dark:border-violet-400 text-white"
|
||||
>
|
||||
<i className="bi-plus-lg text-xl duration-100"></i>
|
||||
<span className="group-hover:opacity-0 text-right duration-100">
|
||||
Add New Link
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="dropdown dropdown-bottom">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral"
|
||||
id="import-dropdown"
|
||||
>
|
||||
<i className="bi-cloud-upload text-xl duration-100"></i>
|
||||
<p>Import From</p>
|
||||
{links[0] ? (
|
||||
<div className="w-full">
|
||||
<LinkComponent links={links.slice(0, showLinks)} />
|
||||
</div>
|
||||
<ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60">
|
||||
<li>
|
||||
<label
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
htmlFor="import-linkwarden-file"
|
||||
title="JSON File"
|
||||
>
|
||||
From Linkwarden
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
id="import-linkwarden-file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
importBookmarks(e, MigrationFormat.linkwarden)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
htmlFor="import-html-file"
|
||||
title="HTML File"
|
||||
>
|
||||
From Bookmarks HTML file
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
id="import-html-file"
|
||||
accept=".html"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
importBookmarks(e, MigrationFormat.htmlFile)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200"
|
||||
>
|
||||
<p className="text-center text-2xl">
|
||||
View Your Recently Added Links Here!
|
||||
</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
|
||||
This section will view your latest added Links across every
|
||||
Collections you have access to.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<PageHeader
|
||||
icon={"bi-pin-angle"}
|
||||
title={"Pinned"}
|
||||
description={"Your pinned Links"}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/links/pinned"
|
||||
className="flex items-center text-sm text-black/75 dark:text-white/75 gap-2 cursor-pointer"
|
||||
>
|
||||
View All
|
||||
<i className="bi-chevron-right text-sm "></i>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-center w-full mt-4 flex flex-wrap gap-4 justify-center">
|
||||
<div
|
||||
onClick={() => {
|
||||
setNewLinkModal(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-2 text-sm btn btn-accent dark:border-violet-400 text-white"
|
||||
>
|
||||
<i className="bi-plus-lg text-xl duration-100"></i>
|
||||
<span className="group-hover:opacity-0 text-right duration-100">
|
||||
Add New Link
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
|
||||
>
|
||||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={`grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 w-full`}
|
||||
>
|
||||
{links
|
||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||
.map((e, i) => <LinkCard key={i} link={e} count={i} />)
|
||||
.slice(0, showLinks)}
|
||||
</div>
|
||||
<div className="dropdown dropdown-bottom">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral"
|
||||
id="import-dropdown"
|
||||
>
|
||||
<i className="bi-cloud-upload text-xl duration-100"></i>
|
||||
<p>Import From</p>
|
||||
</div>
|
||||
<ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60">
|
||||
<li>
|
||||
<label
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
htmlFor="import-linkwarden-file"
|
||||
title="JSON File"
|
||||
>
|
||||
From Linkwarden
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
id="import-linkwarden-file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
importBookmarks(e, MigrationFormat.linkwarden)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
htmlFor="import-html-file"
|
||||
title="HTML File"
|
||||
>
|
||||
From Bookmarks HTML file
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
id="import-html-file"
|
||||
accept=".html"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
importBookmarks(e, MigrationFormat.htmlFile)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<PageHeader
|
||||
icon={"bi-pin-angle"}
|
||||
title={"Pinned"}
|
||||
description={"Your pinned Links"}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/links/pinned"
|
||||
className="flex items-center text-sm text-neutral gap-2 cursor-pointer"
|
||||
>
|
||||
View All
|
||||
<i className="bi-chevron-right text-sm "></i>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200"
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
|
||||
>
|
||||
<p className="text-center text-2xl">
|
||||
Pin Your Favorite Links Here!
|
||||
</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
|
||||
You can Pin your favorite Links by clicking on the three dots on
|
||||
each Link and clicking{" "}
|
||||
<span className="font-semibold">Pin to Dashboard</span>.
|
||||
</p>
|
||||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={`grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 w-full`}
|
||||
>
|
||||
{links
|
||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||
.map((e, i) => <LinkCard key={i} link={e} count={i} />)
|
||||
.slice(0, showLinks)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200"
|
||||
>
|
||||
<p className="text-center text-2xl">
|
||||
Pin Your Favorite Links Here!
|
||||
</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
|
||||
You can Pin your favorite Links by clicking on the three dots on
|
||||
each Link and clicking{" "}
|
||||
<span className="font-semibold">Pin to Dashboard</span>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{newLinkModal ? (
|
||||
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||
) : undefined}
|
||||
</MainLayout>
|
||||
);
|
||||
</div>
|
||||
{newLinkModal ? (
|
||||
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||
) : undefined}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useState, FormEvent } from "react";
|
||||
import React, { useState, FormEvent } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getLogins } from "./api/v1/logins";
|
||||
import { InferGetServerSidePropsType } from "next";
|
||||
@@ -131,18 +131,17 @@ export default function Login({
|
||||
const Buttons: any = [];
|
||||
availableLogins.buttonAuths.forEach((value, index) => {
|
||||
Buttons.push(
|
||||
<>
|
||||
<React.Fragment key={index}>
|
||||
{index !== 0 ? <div className="divider my-1">OR</div> : undefined}
|
||||
|
||||
<AccentSubmitButton
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => loginUserButton(value.method)}
|
||||
label={`Sign in with ${value.name}`}
|
||||
className=" w-full text-center"
|
||||
loading={submitLoader}
|
||||
/>
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
return Buttons;
|
||||
|
||||
@@ -1,106 +1,125 @@
|
||||
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useState, useEffect } from "react";
|
||||
import useAccountStore from "@/store/account";
|
||||
import { AccountSettings } from "@/types/global";
|
||||
import { toast } from "react-hot-toast";
|
||||
import React from "react";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
|
||||
export default function Appearance() {
|
||||
const { updateSettings } = useLocalSettingsStore();
|
||||
const submit = async () => {
|
||||
const { updateSettings } = useLocalSettingsStore();
|
||||
const { account, updateAccount } = useAccountStore();
|
||||
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!");
|
||||
toast.success("Settings Applied!");
|
||||
} else toast.error(response.data as string);
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [user, setUser] = useState<AccountSettings>(
|
||||
!objectIsEmpty(account)
|
||||
? account
|
||||
: ({
|
||||
// @ts-ignore
|
||||
id: null,
|
||||
name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
emailVerified: null,
|
||||
blurredFavicons: null,
|
||||
image: "",
|
||||
isPrivate: true,
|
||||
// @ts-ignore
|
||||
createdAt: null,
|
||||
whitelistedUsers: [],
|
||||
} as unknown as AccountSettings)
|
||||
);
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
// Combine colorTheme and mode into a single state
|
||||
const [theme, setTheme] = useState(localStorage.getItem("theme") || "default-dark");
|
||||
|
||||
const { account, updateAccount } = useAccountStore();
|
||||
function objectIsEmpty(obj: object) {
|
||||
return Object.keys(obj).length === 0;
|
||||
}
|
||||
|
||||
const [user, setUser] = useState<AccountSettings>(
|
||||
!objectIsEmpty(account)
|
||||
? account
|
||||
: ({
|
||||
// @ts-ignore
|
||||
id: null,
|
||||
name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
emailVerified: null,
|
||||
blurredFavicons: null,
|
||||
image: "",
|
||||
isPrivate: true,
|
||||
// @ts-ignore
|
||||
createdAt: null,
|
||||
whitelistedUsers: [],
|
||||
} as unknown as AccountSettings)
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!objectIsEmpty(account)) setUser({ ...account });
|
||||
}, [account]);
|
||||
|
||||
function objectIsEmpty(obj: object) {
|
||||
return Object.keys(obj).length === 0;
|
||||
}
|
||||
const handleThemeChange = (newThemePart: string, isColorTheme: boolean) => {
|
||||
const currentTheme = localStorage.getItem("theme") || "default-light";
|
||||
const [currentColorTheme, currentMode] = currentTheme.split('-');
|
||||
const newTheme = isColorTheme ? `${newThemePart}-${currentMode}` : `${currentColorTheme}-${newThemePart}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!objectIsEmpty(account)) setUser({ ...account });
|
||||
}, [account]);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
updateSettings({ theme: newTheme });
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<p className="capitalize text-3xl font-thin inline">Appearance</p>
|
||||
// Update the theme state
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
<div className="divider my-3"></div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<p className="mb-3">Select Theme</p>
|
||||
<div className="flex gap-3 w-full">
|
||||
<div
|
||||
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-black ${
|
||||
localStorage.getItem("theme") === "dark"
|
||||
? "dark:outline-primary text-primary"
|
||||
: "text-white"
|
||||
}`}
|
||||
onClick={() => updateSettings({ theme: "dark" })}
|
||||
>
|
||||
<i className="bi-moon-fill text-6xl"></i>
|
||||
<p className="ml-2 text-2xl">Dark</p>
|
||||
|
||||
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
|
||||
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<p className="capitalize text-3xl font-thin inline">Appearance</p>
|
||||
<div className="divider my-3"></div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
<div>
|
||||
<p className="mb-3">Select Mode</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{["light", "dark"].map((modeOption) => (
|
||||
<button
|
||||
key={modeOption}
|
||||
onClick={() => handleThemeChange(modeOption, false)}
|
||||
className={`w-full text-center h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none ${theme.endsWith(modeOption) ? "ring-2 ring-primary" : "ring-2 ring-neutral"}`}
|
||||
>
|
||||
{modeOption === 'light' ?
|
||||
<i className={`bi-sun-fill text-6xl ${theme.endsWith(modeOption) ? "text-primary" : ""}`}></i> :
|
||||
<i className={`bi-moon-fill text-6xl ${theme.endsWith(modeOption) ? "text-primary" : ""}`}></i>}
|
||||
<p className="ml-2 text-2xl">{modeOption.charAt(0).toUpperCase() + modeOption.slice(1)}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-white ${
|
||||
localStorage.getItem("theme") === "light"
|
||||
? "outline-primary text-primary"
|
||||
: "text-black"
|
||||
}`}
|
||||
onClick={() => updateSettings({ theme: "light" })}
|
||||
>
|
||||
<i className="bi-sun-fill text-6xl"></i>
|
||||
<p className="ml-2 text-2xl">Light</p>
|
||||
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <SubmitButton
|
||||
onClick={submit}
|
||||
loading={submitLoader}
|
||||
label="Save"
|
||||
className="mt-2 mx-auto lg:mx-0"
|
||||
/> */}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
<div>
|
||||
<p className="mb-3">Select Color Theme</p>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{["default", "red", "green", "orange"].map((colorTheme) => (
|
||||
<button
|
||||
key={colorTheme}
|
||||
onClick={() => handleThemeChange(colorTheme, true)}
|
||||
className={`w-full text-center h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none ${theme.startsWith(colorTheme) ? "ring-2 ring-primary" : "ring-2 ring-neutral"}`}
|
||||
>
|
||||
{colorTheme.charAt(0).toUpperCase() + colorTheme.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={submitLoader}
|
||||
className="mt-2 mx-auto lg:mx-0 bg-primary text-white rounded-md px-4 py-2"
|
||||
>
|
||||
{submitLoader ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import CenteredForm from "@/layouts/CenteredForm";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
|
||||
const keycloakEnabled = process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === "true";
|
||||
const authentikEnabled = process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true";
|
||||
|
||||
export default function Delete() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [comment, setComment] = useState<string>();
|
||||
@@ -23,7 +26,7 @@ export default function Delete() {
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED !== "true" && password == "") {
|
||||
if (!keycloakEnabled && !authentikEnabled && password == "") {
|
||||
return toast.error("Please fill the required fields.");
|
||||
}
|
||||
|
||||
@@ -57,9 +60,7 @@ export default function Delete() {
|
||||
href="/settings/account"
|
||||
className="absolute top-4 left-4 btn btn-ghost btn-square btn-sm"
|
||||
>
|
||||
<i
|
||||
className="bi-chevron-left text-neutral text-xl"
|
||||
></i>
|
||||
<i className="bi-chevron-left text-neutral text-xl"></i>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 w-full rounded-md h-8">
|
||||
<p className="text-red-500 dark:text-red-500 truncate w-full text-3xl text-center">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -10,7 +10,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testDir: "./e2e",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
@@ -20,31 +20,31 @@ export default defineConfig({
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://127.0.0.1:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
|
||||
@@ -3,4 +3,4 @@ module.exports = {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 71 KiB |
@@ -1,3 +1,4 @@
|
||||
import 'dotenv/config';
|
||||
import { Collection, Link, User } from "@prisma/client";
|
||||
import { prisma } from "../lib/api/db";
|
||||
import archiveHandler from "../lib/api/archiveHandler";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { create } from "zustand";
|
||||
import {ViewMode} from "@/types/global";
|
||||
import { ViewMode } from "@/types/global";
|
||||
|
||||
type LocalSettings = {
|
||||
theme?: string;
|
||||
viewMode?: string
|
||||
viewMode?: string;
|
||||
};
|
||||
|
||||
type LocalSettingsStore = {
|
||||
@@ -18,34 +18,39 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
|
||||
viewMode: "",
|
||||
},
|
||||
updateSettings: async (newSettings) => {
|
||||
if (
|
||||
newSettings.theme &&
|
||||
newSettings.theme !== localStorage.getItem("theme")
|
||||
) {
|
||||
if (newSettings.theme) {
|
||||
localStorage.setItem("theme", newSettings.theme);
|
||||
|
||||
const localTheme = localStorage.getItem("theme") || "";
|
||||
|
||||
document.querySelector("html")?.setAttribute("data-theme", localTheme);
|
||||
document.documentElement.setAttribute('data-theme', newSettings.theme);
|
||||
|
||||
if (newSettings.theme.endsWith("-dark")) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
newSettings.viewMode &&
|
||||
newSettings.viewMode !== localStorage.getItem("viewMode")
|
||||
) {
|
||||
if (newSettings.viewMode) {
|
||||
localStorage.setItem("viewMode", newSettings.viewMode);
|
||||
|
||||
// const localTheme = localStorage.getItem("viewMode") || "";
|
||||
}
|
||||
|
||||
set((state) => ({ settings: { ...state.settings, ...newSettings } }));
|
||||
},
|
||||
setSettings: async () => {
|
||||
if (!localStorage.getItem("theme")) {
|
||||
localStorage.setItem("theme", "dark");
|
||||
let theme = localStorage.getItem("theme");
|
||||
if (!theme || !theme.includes("-")) {
|
||||
theme = "default-dark"; // Default theme
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
|
||||
const localTheme = localStorage.getItem("theme") || "";
|
||||
const localTheme = theme;
|
||||
|
||||
document.documentElement.setAttribute('data-theme', localTheme);
|
||||
|
||||
if (localTheme.endsWith("-dark")) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, theme: localTheme },
|
||||
|
||||
@@ -5,7 +5,7 @@ module.exports = {
|
||||
daisyui: {
|
||||
themes: [
|
||||
{
|
||||
light: {
|
||||
"default-light": {
|
||||
primary: "#0369a1",
|
||||
secondary: "#0891b2",
|
||||
accent: "#6d28d9",
|
||||
@@ -21,7 +21,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
{
|
||||
dark: {
|
||||
"default-dark": {
|
||||
primary: "#7dd3fc",
|
||||
secondary: "#22d3ee",
|
||||
accent: "#6d28d9",
|
||||
@@ -36,21 +36,124 @@ module.exports = {
|
||||
error: "#f1293c",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
darkMode: ["class", '[data-theme="dark"]'],
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
// Red Light Theme
|
||||
{
|
||||
"red-light": {
|
||||
primary: "#ef4444",
|
||||
secondary: "#dc2626",
|
||||
accent: "#6d28d9",
|
||||
neutral: "#6b7280",
|
||||
"neutral-content": "#d1d5db",
|
||||
"base-100": "#ffffff",
|
||||
"base-200": "#f3f4f6",
|
||||
"base-content": "#0a0a0a",
|
||||
info: "#a5f3fc",
|
||||
success: "#22c55e",
|
||||
warning: "#facc15",
|
||||
error: "#dc2626",
|
||||
},
|
||||
},
|
||||
// Red Dark Theme
|
||||
{
|
||||
"red-dark": {
|
||||
primary: "#ef4444",
|
||||
secondary: "#dc2626",
|
||||
accent: "#6d28d9",
|
||||
neutral: "#9ca3af",
|
||||
"neutral-content": "#404040",
|
||||
"base-100": "#171717",
|
||||
"base-200": "#262626",
|
||||
"base-content": "#fafafa",
|
||||
info: "#009ee4",
|
||||
success: "#00b17d",
|
||||
warning: "#eac700",
|
||||
error: "#f1293c",
|
||||
},
|
||||
},
|
||||
// Green Light Theme
|
||||
{
|
||||
"green-light": {
|
||||
primary: "#22c55e",
|
||||
secondary: "#16a34a",
|
||||
accent: "#6d28d9",
|
||||
neutral: "#6b7280",
|
||||
"neutral-content": "#d1d5db",
|
||||
"base-100": "#ffffff",
|
||||
"base-200": "#f3f4f6",
|
||||
"base-content": "#0a0a0a",
|
||||
info: "#a5f3fc",
|
||||
success: "#22c55e",
|
||||
warning: "#facc15",
|
||||
error: "#dc2626",
|
||||
},
|
||||
},
|
||||
// Green Dark Theme
|
||||
{
|
||||
"green-dark": {
|
||||
primary: "#22c55e",
|
||||
secondary: "#16a34a",
|
||||
accent: "#6d28d9",
|
||||
neutral: "#9ca3af",
|
||||
"neutral-content": "#404040",
|
||||
"base-100": "#171717",
|
||||
"base-200": "#262626",
|
||||
"base-content": "#fafafa",
|
||||
info: "#009ee4",
|
||||
success: "#00b17d",
|
||||
warning: "#eac700",
|
||||
error: "#f1293c",
|
||||
},
|
||||
},
|
||||
// Orange Light Theme
|
||||
{
|
||||
"orange-light": {
|
||||
primary: "#f97316",
|
||||
secondary: "#ea580c",
|
||||
accent: "#6d28d9",
|
||||
neutral: "#9ca3af",
|
||||
"neutral-content": "#404040",
|
||||
"base-100": "#171717",
|
||||
"base-200": "#262626",
|
||||
"base-content": "#fafafa",
|
||||
info: "#009ee4",
|
||||
success: "#00b17d",
|
||||
warning: "#eac700",
|
||||
error: "#f1293c",
|
||||
},
|
||||
},
|
||||
// Orange Dark Theme
|
||||
{
|
||||
"orange-dark": {
|
||||
primary: "#f97316",
|
||||
secondary: "#ea580c",
|
||||
accent: "#6d28d9",
|
||||
neutral: "#9ca3af",
|
||||
"neutral-content": "#404040",
|
||||
"base-100": "#171717",
|
||||
"base-200": "#262626",
|
||||
"base-content": "#fafafa",
|
||||
info: "#009ee4",
|
||||
success: "#00b17d",
|
||||
warning: "#eac700",
|
||||
error: "#f1293c",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
darkMode: ["class", '[data-theme="dark"]'],
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
|
||||
// For the "layouts" directory
|
||||
"./layouts/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
plugins: [
|
||||
require("daisyui"),
|
||||
plugin(({ addVariant }) => {
|
||||
addVariant("dark", '&[data-theme="dark"]');
|
||||
}),
|
||||
],
|
||||
};
|
||||
"./layouts/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
plugins: [
|
||||
require("daisyui"),
|
||||
plugin(({ addVariant }) => {
|
||||
addVariant("dark", '&[data-theme="dark"]');
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ declare global {
|
||||
NEXT_PUBLIC_MAX_FILE_SIZE?: string;
|
||||
MAX_LINKS_PER_USER?: string;
|
||||
ARCHIVE_TAKE_COUNT?: string;
|
||||
IGNORE_UNAUTHORIZED_CA?: string;
|
||||
|
||||
SPACES_KEY?: string;
|
||||
SPACES_SECRET?: string;
|
||||
|
||||
@@ -1795,6 +1795,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca"
|
||||
integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
|
||||
|
||||
"@types/node-fetch@^2.6.10":
|
||||
version "2.6.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.10.tgz#ff5c1ceacab782f2b7ce69957d38c1c27b0dc469"
|
||||
integrity sha512-PPpPK6F9ALFTn59Ka3BaL+qGuipRfxNE8qVgkp0bVixeiR2c2/L+IVOiBdu9JhhT22sWnQEp6YyHGI2b2+CMcA==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
form-data "^4.0.0"
|
||||
|
||||
"@types/node@*", "@types/node@>=8.1.0":
|
||||
version "20.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.4.tgz#c79c7cc22c9d0e97a7944954c9e663bcbd92b0cb"
|
||||
@@ -2706,6 +2714,11 @@ dompurify@^3.0.6:
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.6.tgz#925ebd576d54a9531b5d76f0a5bef32548351dae"
|
||||
integrity sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w==
|
||||
|
||||
dotenv@^16.3.1:
|
||||
version "16.3.1"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e"
|
||||
integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==
|
||||
|
||||
ecc-jsbn@~0.1.1:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
||||
@@ -4751,6 +4764,11 @@ prelude-ls@^1.2.1:
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
|
||||
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
|
||||
|
||||
prettier@3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.1.1.tgz#6ba9f23165d690b6cbdaa88cb0807278f7019848"
|
||||
integrity sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==
|
||||
|
||||
pretty-format@^3.8.0:
|
||||
version "3.8.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
|
||||
|
||||