mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 02:27:00 +00:00
feat(web): finished readable dropdown
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
apps/web/components/ui/separator.tsx
Normal file
29
apps/web/components/ui/separator.tsx
Normal 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 }
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
28
apps/web/pages/api/v1/users/[id]/preference.tsx
Normal file
28
apps/web/pages/api/v1/users/[id]/preference.tsx
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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})`);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "theme" SET DEFAULT 'DARK';
|
||||
@@ -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';
|
||||
@@ -9,6 +9,7 @@
|
||||
"scripts": {
|
||||
"generate": "prisma generate",
|
||||
"deploy": "prisma migrate deploy",
|
||||
"dev": "prisma migrate dev",
|
||||
"studio": "prisma studio"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user