feat(web): finished readable dropdown

This commit is contained in:
daniel31x13
2025-06-03 15:57:32 -04:00
parent ae099dce4f
commit 47c711fea6
25 changed files with 851 additions and 279 deletions

View File

@@ -140,6 +140,7 @@ const IconPopover = ({
</div>
<div className="flex flex-row gap-2 justify-between items-center mt-2">
<Button size="sm" variant="ghost" onClick={() => reset()}>
<i className="bi-arrow-counterclockwise text-neutral" />
{t("reset_defaults")}
</Button>
<p className="text-neutral text-xs">{t("click_out_to_apply")}</p>

View File

@@ -93,12 +93,12 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
return (
<>
{!monolithLoaded && (
<PreservationSkeleton className="max-w-screen-lg h-[calc(100vh-3rem)]" />
<PreservationSkeleton className="max-w-screen-lg h-[calc(100vh-3.1rem)]" />
)}
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.monolith}&_=${link.updatedAt}`}
className={clsx(
"w-full border-none h-[calc(100vh-3rem)]",
"w-full border-none h-[calc(100vh-3.1rem)]",
monolithLoaded ? "block" : "hidden"
)}
onLoad={() => setMonolithLoaded(true)}
@@ -110,12 +110,12 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
return (
<>
{!pdfLoaded && (
<PreservationSkeleton className="max-w-screen-lg h-[calc(100vh-3rem)]" />
<PreservationSkeleton className="max-w-screen-lg h-[calc(100vh-3.1rem)]" />
)}
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}&_=${link.updatedAt}`}
className={clsx(
"w-full border-none h-[calc(100vh-3rem)]",
"w-full border-none h-[calc(100vh-3.1rem)]",
pdfLoaded ? "block" : "hidden"
)}
onLoad={() => setPdfLoaded(true)}
@@ -128,12 +128,12 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
return (
<>
{!imageLoaded && (
<PreservationSkeleton className="max-w-screen-lg h-[calc(100vh-3rem)]" />
<PreservationSkeleton className="max-w-screen-lg h-[calc(100vh-3.1rem)]" />
)}
<div
className={clsx(
"overflow-auto flex items-start",
imageLoaded && "h-[calc(100vh-3rem)]"
imageLoaded && "h-[calc(100vh-3.1rem)]"
)}
>
<img

View File

@@ -9,12 +9,11 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
@@ -26,6 +25,12 @@ import { useCollections } from "@linkwarden/router/collections";
import clsx from "clsx";
import ToggleDarkMode from "../ToggleDarkMode";
import { FitWidth, FormatLineSpacing, FormatSize } from "../ui/icons";
import { useUpdateUserPreference, useUser } from "@linkwarden/router/user";
import { Caveat } from "next/font/google";
import { Bentham } from "next/font/google";
const caveat = Caveat({ subsets: ["latin"] });
const bentham = Bentham({ subsets: ["latin"], weight: "400" });
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@@ -33,8 +38,59 @@ type Props = {
className?: string;
};
const fontSizes = [
"12px",
"14px",
"16px",
"18px",
"20px",
"22px",
"24px",
"26px",
"28px",
"30px",
"32px",
"34px",
"36px",
"38px",
"40px",
"42px",
"44px",
"46px",
"48px",
"50px",
];
const lineHeights = [
"1",
"1.1",
"1.2",
"1.3",
"1.4",
"1.5",
"1.6",
"1.7",
"1.8",
"1.9",
"2",
"2.1",
"2.2",
"2.3",
"2.4",
"2.5",
"2.6",
"2.7",
"2.8",
"2.9",
"3",
];
const lineWidth = ["narrower", "narrow", "normal", "wide", "wider", "full"];
const PreservationNavbar = ({ link, format, className }: Props) => {
const { data: collections = [] } = useCollections();
const { data } = useUser();
const updateUserPreference = useUpdateUserPreference();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
@@ -80,186 +136,377 @@ const PreservationNavbar = ({ link, format, className }: Props) => {
};
return (
<div className={clsx("p-2 z-10 bg-base-100", className)}>
<div className="max-w-6xl flex gap-2 justify-between mx-auto">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link href={`/dashboard`}>
<i className="bi-chevron-left text-lg text-neutral" />
</Link>
<div
className={clsx(
"p-2 z-10 bg-base-100 flex gap-2 justify-between",
className
)}
>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link href={`/dashboard`}>
<i className="bi-chevron-left text-lg text-neutral" />
</Link>
</Button>
{format === ArchivedFormat.readability ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon">
<i className="bi-type text-xl text-neutral" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-64">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div className="flex items-center gap-2 justify-between w-full">
<div className="flex items-center gap-2">
<i className="bi-fonts text-lg leading-none text-neutral" />
{t("font_family")}
</div>
<p
className="text-neutral capitalize"
style={{
fontFamily:
(data?.readableFontFamily === "caveat"
? caveat.style.fontFamily
: data?.readableFontFamily === "bentham"
? bentham.style.fontFamily
: data?.readableFontFamily) || "sans-serif",
}}
>
{data?.readableFontFamily?.replace("-", " ")}
</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuCheckboxItem
style={{ fontFamily: "sans-serif" }}
checked={
!data?.readableFontFamily ||
data?.readableFontFamily === "sans-serif"
}
onSelect={() => {
updateUserPreference.mutate({
readableFontFamily: "sans-serif",
});
}}
>
Sans Serif
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
style={{ fontFamily: "serif" }}
checked={data?.readableFontFamily === "serif"}
onSelect={() => {
updateUserPreference.mutate({
readableFontFamily: "serif",
});
}}
>
Serif
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
style={{ fontFamily: "monospace" }}
checked={data?.readableFontFamily === "monospace"}
onSelect={() => {
updateUserPreference.mutate({
readableFontFamily: "monospace",
});
}}
>
Monospace
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
style={{ fontFamily: "cursive" }}
checked={data?.readableFontFamily === "cursive"}
onSelect={() => {
updateUserPreference.mutate({
readableFontFamily: "cursive",
});
}}
>
Cursive
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
style={{ fontFamily: caveat.style.fontFamily }}
checked={data?.readableFontFamily === "caveat"}
onSelect={() => {
updateUserPreference.mutate({
readableFontFamily: "caveat",
});
}}
>
<span className={caveat.className}>Caveat</span>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
style={{ fontFamily: bentham.style.fontFamily }}
checked={data?.readableFontFamily === "bentham"}
onSelect={() => {
updateUserPreference.mutate({
readableFontFamily: "bentham",
});
}}
>
<span className={bentham.className}>Bentham</span>
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuLabel className="flex items-center justify-between font-normal gap-2 pr-0">
<div className="flex items-center gap-2">
<FormatSize className="text-lg" />
{t("font_size")}
</div>
<div className="flex items-center gap-1">
<p className="text-neutral">
{data?.readableFontSize || "24px"}
</p>
<Button
variant="ghost"
size="icon"
onClick={() => {
const currentIndex = fontSizes.indexOf(
data?.readableFontSize || "24px"
);
if (currentIndex > 0) {
updateUserPreference.mutate({
readableFontSize: fontSizes[currentIndex - 1],
});
}
}}
>
<i className="bi-dash text-lg" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
const currentIndex = fontSizes.indexOf(
data?.readableFontSize || "24px"
);
if (currentIndex < fontSizes.length - 1) {
updateUserPreference.mutate({
readableFontSize: fontSizes[currentIndex + 1],
});
}
}}
>
<i className="bi-plus text-lg" />
</Button>
</div>
</DropdownMenuLabel>
<DropdownMenuLabel className="flex items-center justify-between font-normal gap-2 pr-0">
<div className="flex items-center gap-2">
<FormatLineSpacing className="text-lg" />
{t("line_height")}
</div>
<div className="flex items-center gap-1">
<p className="text-neutral">
{data?.readableLineHeight || "1.8"}
</p>
<Button
variant="ghost"
size="icon"
onClick={() => {
const currentIndex = lineHeights.indexOf(
data?.readableLineHeight || "1.8"
);
if (currentIndex > 0) {
updateUserPreference.mutate({
readableLineHeight: lineHeights[currentIndex - 1],
});
}
}}
>
<i className="bi-dash text-lg" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
const currentIndex = lineHeights.indexOf(
data?.readableLineHeight || "1.8"
);
if (currentIndex < lineHeights.length - 1) {
updateUserPreference.mutate({
readableLineHeight: lineHeights[currentIndex + 1],
});
}
}}
>
<i className="bi-plus text-lg" />
</Button>
</div>
</DropdownMenuLabel>
<DropdownMenuLabel className="flex items-center justify-between font-normal gap-2 pr-0">
<div className="flex items-center gap-2">
<FitWidth className="text-lg" />
{t("line_width")}
</div>
<div className="flex items-center gap-1">
<p className="text-neutral capitalize">
{data?.readableLineWidth || "normal"}
</p>
<Button
variant="ghost"
size="icon"
onClick={() => {
const currentIndex = lineWidth.indexOf(
data?.readableLineWidth || "normal"
);
if (currentIndex > 0) {
updateUserPreference.mutate({
readableLineWidth: lineWidth[currentIndex - 1],
});
}
}}
>
<i className="bi-dash text-lg" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
const currentIndex = lineWidth.indexOf(
data?.readableLineWidth || "normal"
);
if (currentIndex < lineWidth.length - 1) {
updateUserPreference.mutate({
readableLineWidth: lineWidth[currentIndex + 1],
});
}
}}
>
<i className="bi-plus text-lg" />
</Button>
</div>
</DropdownMenuLabel>
<DropdownMenuItem
onSelect={() =>
updateUserPreference.mutate({
readableFontFamily: "sans-serif",
readableFontSize: "18px",
readableLineHeight: "1.8",
readableLineWidth: "normal",
})
}
className="w-fit mx-auto"
>
<i className="bi-arrow-counterclockwise text-neutral" />
<p className="text-center">{t("reset_defaults")}</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="ghost" size="icon" onClick={handleDownload}>
<i className="bi-cloud-arrow-down text-xl text-neutral" />
</Button>
{format === ArchivedFormat.readability ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon">
<i className="bi-type text-xl text-neutral" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel className="flex items-center justify-between font-normal">
{t("theme")}
<ToggleDarkMode />
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<i className="bi-fonts text-lg leading-none text-neutral" />
{t("font_family")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>a</DropdownMenuItem>
<DropdownMenuItem>b</DropdownMenuItem>
<DropdownMenuItem>c</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FormatSize className="text-lg" />
{t("font_size")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>a</DropdownMenuItem>
<DropdownMenuItem>b</DropdownMenuItem>
<DropdownMenuItem>c</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FormatLineSpacing className="text-lg" />
{t("line_height")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>a</DropdownMenuItem>
<DropdownMenuItem>b</DropdownMenuItem>
<DropdownMenuItem>c</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FitWidth className="text-lg" />
{t("line_width")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>a</DropdownMenuItem>
<DropdownMenuItem>b</DropdownMenuItem>
<DropdownMenuItem>c</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="ghost" size="icon" onClick={handleDownload}>
<i className="bi-cloud-arrow-down text-xl text-neutral" />
</Button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" className="h-8 text-neutral">
{format === ArchivedFormat.readability
? t("readable")
: format === ArchivedFormat.monolith
? t("webpage")
: format === ArchivedFormat.pdf
? t("pdf")
: t("screenshot")}
<i className="bi-chevron-down" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{formatAvailable(link, "readable") && (
<DropdownMenuCheckboxItem
onSelect={() =>
router.push(
{
query: {
...router.query,
format: ArchivedFormat.readability,
},
},
undefined,
{ shallow: true }
)
}
checked={format === ArchivedFormat.readability}
>
{t("readable")}
</DropdownMenuCheckboxItem>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" className="h-8 text-neutral">
{format === ArchivedFormat.readability
? t("readable")
: format === ArchivedFormat.monolith
? t("webpage")
: format === ArchivedFormat.pdf
? t("pdf")
: t("screenshot")}
<i className="bi-chevron-down" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{formatAvailable(link, "readable") && (
<DropdownMenuCheckboxItem
onSelect={() =>
router.push(
{
query: {
...router.query,
format: ArchivedFormat.readability,
},
{formatAvailable(link, "monolith") && (
<DropdownMenuCheckboxItem
onSelect={() =>
router.push(
{
query: {
...router.query,
format: ArchivedFormat.monolith,
},
undefined,
{ shallow: true }
)
}
checked={format === ArchivedFormat.readability}
>
{t("readable")}
</DropdownMenuCheckboxItem>
)}
{formatAvailable(link, "monolith") && (
<DropdownMenuCheckboxItem
onSelect={() =>
router.push(
{
query: {
...router.query,
format: ArchivedFormat.monolith,
},
},
undefined,
{ shallow: true }
)
}
checked={format === ArchivedFormat.monolith}
>
{t("webpage")}
</DropdownMenuCheckboxItem>
)}
{formatAvailable(link, "image") && (
<DropdownMenuCheckboxItem
onSelect={() =>
router.push(
{
query: {
...router.query,
format: link?.image?.endsWith(".png")
? ArchivedFormat.png
: ArchivedFormat.jpeg,
},
undefined,
{ shallow: true }
)
}
checked={format === ArchivedFormat.monolith}
>
{t("webpage")}
</DropdownMenuCheckboxItem>
)}
{formatAvailable(link, "image") && (
<DropdownMenuCheckboxItem
onSelect={() =>
router.push(
{
query: {
...router.query,
format: link?.image?.endsWith(".png")
? ArchivedFormat.png
: ArchivedFormat.jpeg,
},
},
undefined,
{ shallow: true }
)
}
checked={
format === ArchivedFormat.png || format === ArchivedFormat.jpeg
}
>
{t("screenshot")}
</DropdownMenuCheckboxItem>
)}
{formatAvailable(link, "pdf") && (
<DropdownMenuCheckboxItem
onSelect={() =>
router.push(
{
query: {
...router.query,
format: ArchivedFormat.pdf,
},
undefined,
{ shallow: true }
)
}
checked={
format === ArchivedFormat.png ||
format === ArchivedFormat.jpeg
}
>
{t("screenshot")}
</DropdownMenuCheckboxItem>
)}
{formatAvailable(link, "pdf") && (
<DropdownMenuCheckboxItem
onSelect={() =>
router.push(
{
query: {
...router.query,
format: ArchivedFormat.pdf,
},
},
undefined,
{ shallow: true }
)
}
checked={format === ArchivedFormat.pdf}
>
{t("pdf")}
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
},
undefined,
{ shallow: true }
)
}
checked={format === ArchivedFormat.pdf}
>
{t("pdf")}
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex gap-2 items-center text-neutral">
<LinkActions
link={link}
collection={collection}
linkModal={linkModal}
setLinkModal={(e) => setLinkModal(e)}
ghost
/>
</div>
<div className="flex gap-2 items-center text-neutral">
<ToggleDarkMode />
<LinkActions
link={link}
collection={collection}
linkModal={linkModal}
setLinkModal={(e) => setLinkModal(e)}
ghost
/>
</div>
</div>
);

View File

@@ -63,24 +63,20 @@ export default function PreservationPageContent() {
}, [router.query.format]);
return (
<div className="relative">
<div>
{link?.id && (
<PreservationNavbar
link={link}
format={Number(router.query.format)}
className={`
transform transition-transform duration-200 ease-in-out
${
showNavbar
? "translate-y-0"
: "-translate-y-full fixed top-0 left-0 right-0"
}
transform transition-transform duration-200 ease-in-out fixed top-0 left-0 right-0
${showNavbar ? "translate-y-0" : "-translate-y-full"}
`}
/>
)}
<div
className={`bg-base-200 overflow-y-auto w-screen ${
showNavbar ? "h-[calc(100vh-3rem)]" : "h-screen"
showNavbar ? "h-[calc(100vh-3.1rem)] mt-[3.1rem]" : "h-screen"
}`}
ref={scrollRef}
>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { PreservationSkeleton } from "../Skeletons";
import { useTranslation } from "next-i18next";
@@ -19,6 +19,13 @@ import {
useRemoveHighlight,
} from "@linkwarden/router/highlights";
import { Highlight } from "@linkwarden/prisma/client";
import { useUser } from "@linkwarden/router/user";
import { Caveat } from "next/font/google";
import { Bentham } from "next/font/google";
import { Separator } from "../ui/separator";
const caveat = Caveat({ subsets: ["latin"] });
const bentham = Bentham({ subsets: ["latin"], weight: "400" });
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@@ -58,40 +65,102 @@ export default function ReadableView({ link }: Props) {
fetchLinkContent();
}, [link]);
const { data: user } = useUser();
useEffect(() => {
if (!user) return;
const readerViews = document.getElementsByClassName("reader-view");
const getFont = () => {
if (user.readableFontFamily === "caveat") {
return caveat.style.fontFamily;
} else if (user.readableFontFamily === "bentham") {
return bentham.style.fontFamily;
} else return user.readableFontFamily;
};
for (const view of Array.from(readerViews)) {
const paragraphs = view.getElementsByTagName("p");
for (const paragraph of Array.from(paragraphs)) {
paragraph.style.fontSize = user.readableFontSize || "18px";
paragraph.style.lineHeight = user.readableLineHeight || "1.8";
}
const paragraphToUserReadableFontSizeRatio =
parseInt(user.readableFontSize || "18") / 18;
const headers1 = view.getElementsByTagName("h1");
for (const header of Array.from(headers1)) {
header.style.fontSize =
35 * paragraphToUserReadableFontSizeRatio + "px";
}
const headers2 = view.getElementsByTagName("h2");
for (const header of Array.from(headers2)) {
header.style.fontSize =
30 * paragraphToUserReadableFontSizeRatio + "px";
}
const headers3 = view.getElementsByTagName("h3");
for (const header of Array.from(headers3)) {
header.style.fontSize =
26 * paragraphToUserReadableFontSizeRatio + "px";
}
const headers4 = view.getElementsByTagName("h4");
for (const header of Array.from(headers4)) {
header.style.fontSize =
21 * paragraphToUserReadableFontSizeRatio + "px";
}
const headers5 = view.getElementsByTagName("h5");
for (const header of Array.from(headers5)) {
header.style.fontSize =
18 * paragraphToUserReadableFontSizeRatio + "px";
}
(view as HTMLElement).style.fontFamily = `${getFont()}`;
}
}, [
user?.theme,
user?.readableFontFamily,
user?.readableFontSize,
user?.readableLineHeight,
linkContent,
]);
const handleMouseUp = (e: React.MouseEvent) => {
const containerEl = containerRef.current;
if (!containerEl) return;
const containerRect = containerEl.getBoundingClientRect();
const target = e.target as HTMLElement;
const highlightId = Number(target.dataset.highlightId);
const selection = window.getSelection();
if (highlightId) {
const rect = target.getBoundingClientRect();
const relativeX = rect.left - containerRect.left + rect.width / 2;
const relativeY = rect.top - containerRect.top - 5;
setSelectionMenu({
show: true,
highlightId: highlightId,
});
setMenuPosition({
x: rect.left + window.scrollX + rect.width / 2,
y: rect.top + window.scrollY - 5,
x: relativeX,
y: relativeY,
});
return;
} else if (
selection &&
selection.rangeCount > 0 &&
!selection.isCollapsed
) {
}
if (selection && selection.rangeCount > 0 && !selection.isCollapsed) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect && rect.width && rect.height) {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft =
window.scrollX || document.documentElement.scrollLeft;
const relativeX = rect.left - containerRect.left + rect.width / 2;
const relativeY = rect.top - containerRect.top - 5;
setMenuPosition({
x: rect.left + scrollLeft + rect.width / 2,
y: rect.top + scrollTop - 5,
x: relativeX,
y: relativeY,
});
setSelectionMenu({
show: true,
@@ -276,31 +345,50 @@ export default function ReadableView({ link }: Props) {
}
};
const containerRef = useRef<HTMLDivElement>(null);
return (
<div className="flex flex-col gap-3 items-start p-3 max-w-screen-lg mx-auto bg-base-200 mt-5">
<div className="flex gap-3 items-start">
<div className="flex flex-col w-full gap-1">
<p className="md:text-4xl text-xl">
{unescapeString(link?.name || link?.description || link?.url || "")}
</p>
{link?.url && (
<Link
href={link?.url || ""}
title={link?.url}
target="_blank"
className="hover:opacity-60 duration-100 break-all text-sm flex items-center gap-1 text-neutral w-fit"
>
<i className="bi-link-45deg" />
{isValidUrl(link?.url || "") && new URL(link?.url as string).host}
</Link>
)}
</div>
<div
ref={containerRef}
className={clsx(
"flex flex-col gap-3 items-start p-3 mx-auto bg-base-200 mt-5 relative",
user?.readableLineWidth === "narrower"
? "max-w-screen-sm"
: user?.readableLineWidth === "narrow"
? "max-w-screen-md"
: user?.readableLineWidth === "normal"
? "max-w-screen-lg"
: user?.readableLineWidth === "wide"
? "max-w-screen-xl"
: user?.readableLineWidth === "wider"
? "max-w-screen-2xl"
: ""
)}
>
<div className="reader-view">
<h1>
{unescapeString(link?.name || link?.description || link?.url || "")}
</h1>
</div>
{link?.url && (
<Link
href={link?.url || ""}
title={link?.url}
target="_blank"
className="hover:opacity-60 duration-100 break-all text-sm flex items-center gap-1 text-neutral w-fit"
>
<i className="bi-link-45deg" />
{isValidUrl(link?.url || "") && new URL(link?.url as string).host}
</Link>
)}
<div className="text-sm text-neutral flex justify-between w-full gap-2">
<LinkDate link={link} />
</div>
<Separator className="mt-5 mb-2" />
{link?.readable?.startsWith("archives") ? (
<>
{linkContent ? (
@@ -309,7 +397,6 @@ export default function ReadableView({ link }: Props) {
onMouseUp={handleMouseUp}
>
<div
id="readable-view"
className="line-break px-1 reader-view read-only"
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>

View File

@@ -57,7 +57,8 @@ export default function SearchBar({ placeholder }: Props) {
}
}
}}
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:focus:w-80 md:w-[15rem] md:max-w-full duration-200 outline-none"
style={{ transition: "width 0.2s ease-in-out" }}
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:focus:w-80 md:w-[15rem] md:max-w-full outline-none"
/>
</div>
);

View File

@@ -1,5 +1,4 @@
import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState, ChangeEvent } from "react";
import { ChangeEvent } from "react";
import { useTranslation } from "next-i18next";
import { Button } from "./ui/button";
import {
@@ -8,6 +7,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useUpdateUserPreference, useUser } from "@linkwarden/router/user";
type Props = {
className?: string;
@@ -16,22 +16,15 @@ type Props = {
export default function ToggleDarkMode({ className, align }: Props) {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const [theme, setTheme] = useState<string | null>(
localStorage.getItem("theme")
);
const { data } = useUser();
const updateUserPreference = useUpdateUserPreference();
const handleToggle = (e: ChangeEvent<HTMLInputElement>) => {
setTheme(e.target.checked ? "dark" : "light");
updateUserPreference.mutateAsync({
theme: e.target.checked ? "dark" : "light",
});
};
useEffect(() => {
if (theme) {
updateSettings({ theme });
}
}, [theme]);
return (
<TooltipProvider>
<Tooltip>
@@ -49,7 +42,7 @@ export default function ToggleDarkMode({ className, align }: Props) {
type="checkbox"
onChange={handleToggle}
className="theme-controller"
checked={theme === "dark"}
checked={data?.theme === "dark"}
/>
<i className="bi-sun-fill text-xl swap-on"></i>
<i className="bi-moon-fill text-xl swap-off"></i>
@@ -59,7 +52,7 @@ export default function ToggleDarkMode({ className, align }: Props) {
<TooltipContent side={align || "bottom"}>
<p>
{t("switch_to", {
theme: settings.theme === "light" ? "Dark" : "Light",
theme: data?.theme === "light" ? "Dark" : "Light",
})}
</p>
</TooltipContent>

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -1,4 +1,5 @@
import { prisma } from "@linkwarden/prisma";
import { Subscription, User, WhitelistedUser } from "@linkwarden/prisma/client";
export default async function getUserById(userId: number) {
const user = await prisma.user.findUnique({
@@ -43,7 +44,9 @@ export default async function getUserById(userId: number) {
email: parentSubscription?.user.email,
},
},
};
} as Omit<User, "password"> &
Partial<WhitelistedUser> &
Partial<Subscription>;
return { response: data, status: 200 };
}

View File

@@ -0,0 +1,72 @@
import { prisma } from "@linkwarden/prisma";
import {
UpdateUserPreferenceSchemaType,
UpdateUserPreferenceSchema,
} from "@linkwarden/lib/schemaValidation";
export default async function updateUserPreference(
userId: number,
body: UpdateUserPreferenceSchemaType
) {
const dataValidation = UpdateUserPreferenceSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const data = dataValidation.data;
const updatedUser = await prisma.user.update({
where: {
id: userId,
},
data: {
theme: data.theme,
readableFontFamily: data.readableFontFamily,
readableFontSize: data.readableFontSize,
readableLineHeight: data.readableLineHeight,
readableLineWidth: data.readableLineWidth,
},
include: {
whitelistedUsers: true,
subscriptions: true,
parentSubscription: {
include: {
user: true,
},
},
},
});
if (!updatedUser)
return { response: "User not found or profile is private.", status: 404 };
const whitelistedUsernames = updatedUser.whitelistedUsers?.map(
(usernames) => usernames.username
);
const { password, subscriptions, parentSubscription, ...lessSensitiveInfo } =
updatedUser;
const response = {
...lessSensitiveInfo,
whitelistedUsers: whitelistedUsernames,
subscription: {
active: subscriptions?.active,
quantity: subscriptions?.quantity,
},
parentSubscription: {
active: parentSubscription?.active,
user: {
email: parentSubscription?.user.email,
},
},
};
return { response, status: 200 };
}

View File

@@ -27,6 +27,7 @@
"@phosphor-icons/core": "^2.1.1",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",

View File

@@ -2,7 +2,7 @@ import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en">
<Html lang="en" data-theme="dark">
<Head />
<body>
<Main />

View File

@@ -0,0 +1,28 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import updateUserPreference from "@/lib/api/controllers/users/userId/updateUserPreference";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
if (!user) return;
const queryId = Number(req.query.id);
if (!queryId) {
return res.status(400).json({ response: "Invalid request." });
}
if (user.id !== queryId)
return res.status(401).json({ response: "Permission denied." });
if (req.method === "PUT") {
if (process.env.NEXT_PUBLIC_DEMO === "true")
return res.status(400).json({
response:
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const updated = await updateUserPreference(user.id, req.body);
return res.status(updated.status).json({ response: updated.response });
}
}

View File

@@ -2,7 +2,6 @@ import { Sort } from "@linkwarden/types";
import { create } from "zustand";
type LocalSettings = {
theme: string;
viewMode: string;
color: string;
show: {
@@ -28,7 +27,6 @@ type LocalSettingsStore = {
const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
settings: {
theme: "",
viewMode: "",
color: "",
show: {
@@ -46,12 +44,7 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
sortBy: Sort.DateNewestFirst,
},
updateSettings: (newSettings) => {
const { theme, viewMode, color, sortBy, show, columns } = newSettings;
if (theme !== undefined && theme !== localStorage.getItem("theme")) {
localStorage.setItem("theme", theme);
document.querySelector("html")?.setAttribute("data-theme", theme);
}
const { viewMode, color, sortBy, show, columns } = newSettings;
if (
viewMode !== undefined &&
@@ -89,9 +82,6 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
}));
},
setSettings: () => {
const theme = localStorage.getItem("theme") || "dark";
localStorage.setItem("theme", theme);
const color = localStorage.getItem("color") || "--default";
localStorage.setItem("color", color);
@@ -120,7 +110,6 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
set({
settings: {
theme,
viewMode,
color,
show,
@@ -129,7 +118,6 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
},
});
document.querySelector("html")?.setAttribute("data-theme", theme);
document.documentElement.style.setProperty("--p", `var(${color})`);
},
}));

View File

@@ -229,26 +229,30 @@
padding-right: 0.25rem;
}
.reader-view p {
font-size: 1.15rem;
font-size: 18px;
line-height: 1.8;
margin-bottom: 1rem;
margin-top: 1rem;
margin-bottom: 16px;
margin-top: 16px;
}
.reader-view h1 {
font-size: 2.2rem;
line-height: 1.3;
font-size: 35px;
line-height: 1.4;
}
.reader-view h2 {
font-size: 1.9rem;
font-size: 30px;
line-height: 1.4;
}
.reader-view h3 {
font-size: 1.6rem;
font-size: 26px;
line-height: 1.4;
}
.reader-view h4 {
font-size: 1.3rem;
font-size: 21px;
line-height: 1.4;
}
.reader-view h5 {
font-size: 1.1rem;
font-size: 18px;
line-height: 1.4;
}
.reader-view li {
list-style: inside;

View File

@@ -16,6 +16,7 @@
"concurrently:dev": "concurrently \"dotenv -- yarn workspace @linkwarden/web dev\" \"dotenv -- yarn workspace @linkwarden/worker dev\"",
"concurrently:start": "concurrently \"dotenv -- yarn workspace @linkwarden/web start\" \"dotenv -- yarn workspace @linkwarden/worker start\"",
"prisma:deploy": "dotenv -- yarn workspace @linkwarden/prisma deploy",
"prisma:dev": "dotenv -- yarn workspace @linkwarden/prisma dev",
"prisma:generate": "yarn workspace @linkwarden/prisma generate",
"prisma:studio": "yarn workspace @linkwarden/prisma studio",
"format": "yarn workspaces run format",

View File

@@ -1,5 +1,9 @@
import { ArchivedFormat, TokenExpiry } from "@linkwarden/types";
import { AiTaggingMethod, LinksRouteTo } from "@linkwarden/prisma/client";
import {
AiTaggingMethod,
LinksRouteTo,
Theme,
} from "@linkwarden/prisma/client";
import { z } from "zod";
// const stringField = z.string({
@@ -41,12 +45,12 @@ export const PostUserSchema = () => {
username: emailEnabled
? z.string().optional()
: z
.string()
.trim()
.toLowerCase()
.min(3)
.max(50)
.regex(/^[a-z0-9_-]{3,50}$/),
.string()
.trim()
.toLowerCase()
.min(3)
.max(50)
.regex(/^[a-z0-9_-]{3,50}$/),
invite: z.boolean().optional(),
});
};
@@ -91,6 +95,28 @@ export const UpdateUserSchema = () => {
});
};
export const UpdateUserPreferenceSchema = z.object({
theme: z.nativeEnum(Theme).optional(),
readableFontFamily: z.string().trim().max(100).optional(),
readableFontSize: z.string().trim().max(100).optional(),
readableLineHeight: z.string().trim().max(100).optional(),
readableLineWidth: z.string().trim().max(100).optional(),
// archiveAsScreenshot: z.boolean().optional(),
// archiveAsMonolith: z.boolean().optional(),
// archiveAsPDF: z.boolean().optional(),
// archiveAsReadable: z.boolean().optional(),
// archiveAsWaybackMachine: z.boolean().optional(),
// aiTaggingMethod: z.nativeEnum(AiTaggingMethod).optional(),
// aiPredefinedTags: z.array(z.string().max(20).trim()).max(20).optional(),
// aiTagExistingLinks: z.boolean().optional(),
// preventDuplicateLinks: z.boolean().optional(),
// linksRouteTo: z.nativeEnum(LinksRouteTo).optional(),
});
export type UpdateUserPreferenceSchemaType = z.infer<
typeof UpdateUserPreferenceSchema
>;
export const PostSessionSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(8),
@@ -254,7 +280,9 @@ export type PostHighlightSchemaType = z.infer<typeof PostHighlightSchema>;
export const LinkArchiveActionSchema = z.object({
action: z.enum(["allAndRePreserve", "allAndIgnore", "allBroken"]).optional(),
linkIds: z.array(z.number()).optional()
linkIds: z.array(z.number()).optional(),
});
export type LinkArchiveActionSchemaType = z.infer<typeof LinkArchiveActionSchema>;
export type LinkArchiveActionSchemaType = z.infer<
typeof LinkArchiveActionSchema
>;

View File

@@ -0,0 +1,9 @@
-- CreateEnum
CREATE TYPE "Theme" AS ENUM ('DARK', 'LIGHT', 'AUTO');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "readableFontFamily" TEXT,
ADD COLUMN "readableFontSize" TEXT,
ADD COLUMN "readableLineHeight" TEXT,
ADD COLUMN "readableLineWidth" TEXT,
ADD COLUMN "theme" "Theme" NOT NULL DEFAULT 'AUTO';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "theme" SET DEFAULT 'DARK';

View File

@@ -0,0 +1,19 @@
/*
Warnings:
- The values [DARK,LIGHT,AUTO] on the enum `Theme` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "Theme_new" AS ENUM ('dark', 'light', 'auto');
ALTER TABLE "User" ALTER COLUMN "theme" DROP DEFAULT;
ALTER TABLE "User" ALTER COLUMN "theme" TYPE "Theme_new" USING ("theme"::text::"Theme_new");
ALTER TYPE "Theme" RENAME TO "Theme_old";
ALTER TYPE "Theme_new" RENAME TO "Theme";
DROP TYPE "Theme_old";
ALTER TABLE "User" ALTER COLUMN "theme" SET DEFAULT 'dark';
COMMIT;
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "theme" SET DEFAULT 'dark';

View File

@@ -9,6 +9,7 @@
"scripts": {
"generate": "prisma generate",
"deploy": "prisma migrate deploy",
"dev": "prisma migrate dev",
"studio": "prisma studio"
},
"devDependencies": {

View File

@@ -51,8 +51,13 @@ model User {
subscriptions Subscription?
linksRouteTo LinksRouteTo @default(ORIGINAL)
aiTaggingMethod AiTaggingMethod @default(DISABLED)
aiPredefinedTags String[] @default([])
aiPredefinedTags String[] @default([])
aiTagExistingLinks Boolean @default(false)
theme Theme @default(dark)
readableFontFamily String?
readableFontSize String?
readableLineHeight String?
readableLineWidth String?
dashboardRecentLinks Boolean @default(true)
dashboardPinnedLinks Boolean @default(true)
preventDuplicateLinks Boolean @default(false)
@@ -67,6 +72,12 @@ model User {
updatedAt DateTime @default(now()) @updatedAt
}
enum Theme {
dark
light
auto
}
enum AiTaggingMethod {
DISABLED
GENERATE

View File

@@ -1,3 +1,5 @@
import { UpdateUserPreferenceSchemaType } from "@linkwarden/lib/schemaValidation";
import { Subscription, User, WhitelistedUser } from "@linkwarden/prisma/client";
import { MobileAuth } from "@linkwarden/types";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
@@ -35,14 +37,20 @@ const useUser = (auth?: MobileAuth) => {
if (!response.ok) throw new Error("Failed to fetch user data.");
const data = await response.json();
const data = (await response.json()).response as Omit<User, "password"> &
Partial<WhitelistedUser> &
Partial<Subscription>;
return data.response;
document.querySelector("html")?.setAttribute("data-theme", data.theme);
return data;
},
enabled: !auth
? !!userId && status === "authenticated"
: status === "authenticated",
placeholderData: {},
placeholderData: {} as Omit<User, "password"> &
Partial<WhitelistedUser> &
Partial<Subscription>,
});
};
@@ -75,4 +83,40 @@ const useUpdateUser = () => {
});
};
export { useUser, useUpdateUser };
const useUpdateUserPreference = () => {
const queryClient = useQueryClient();
const { data: session } = useSession();
return useMutation({
mutationFn: async (preference: UpdateUserPreferenceSchemaType) => {
const response = await fetch(
`/api/v1/users/${session?.user.id}/preference`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(preference),
}
);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response as Omit<User, "password"> &
Partial<WhitelistedUser> &
Partial<Subscription>;
},
onSuccess: (data) => {
queryClient.setQueryData(["user"], data);
document.querySelector("html")?.setAttribute("data-theme", data.theme);
},
onMutate: async (user) => {
await queryClient.cancelQueries({ queryKey: ["user"] });
queryClient.setQueryData(["user"], (oldData: any) => {
return { ...oldData, ...user };
});
},
});
};
export { useUser, useUpdateUser, useUpdateUserPreference };

View File

@@ -3535,6 +3535,13 @@
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-separator@^1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
integrity sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.1.tgz#e7868c669c974d649070e9ecbec0b367ee0b4d81"