Merge pull request #754 from linkwarden/feat/customizable-links

Feat/customizable links
This commit is contained in:
Daniel
2024-09-04 22:20:16 -04:00
committed by GitHub
50 changed files with 1843 additions and 1200 deletions
+21 -6
View File
@@ -17,6 +17,8 @@ import toast from "react-hot-toast";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections, useUpdateCollection } from "@/hooks/store/collections"; import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
import { useUpdateUser, useUser } from "@/hooks/store/user"; import { useUpdateUser, useUser } from "@/hooks/store/user";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
interface ExtendedTreeItem extends TreeItem { interface ExtendedTreeItem extends TreeItem {
data: Collection; data: Collection;
@@ -256,7 +258,7 @@ const renderItem = (
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`} } duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
> >
{Icon(item as ExtendedTreeItem, onExpand, onCollapse)} {Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link <Link
href={`/collections/${collection.id}`} href={`/collections/${collection.id}`}
@@ -266,10 +268,21 @@ const renderItem = (
<div <div
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`} className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
> >
<i {collection.icon ? (
className="bi-folder-fill text-2xl drop-shadow" <Icon
style={{ color: collection.color }} icon={collection.icon}
></i> size={30}
weight={(collection.iconWeight || "regular") as IconWeight}
color={collection.color}
className="-mr-[0.15rem]"
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: collection.color }}
></i>
)}
<p className="truncate w-full">{collection.name}</p> <p className="truncate w-full">{collection.name}</p>
{collection.isPublic && ( {collection.isPublic && (
@@ -288,7 +301,7 @@ const renderItem = (
); );
}; };
const Icon = ( const Dropdown = (
item: ExtendedTreeItem, item: ExtendedTreeItem,
onExpand: (id: ItemId) => void, onExpand: (id: ItemId) => void,
onCollapse: (id: ItemId) => void onCollapse: (id: ItemId) => void
@@ -332,6 +345,8 @@ const buildTreeFromCollections = (
name: collection.name, name: collection.name,
description: collection.description, description: collection.description,
color: collection.color, color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
isPublic: collection.isPublic, isPublic: collection.isPublic,
ownerId: collection.ownerId, ownerId: collection.ownerId,
createdAt: collection.createdAt, createdAt: collection.createdAt,
+2 -2
View File
@@ -40,13 +40,13 @@ export default function Drawer({
<ClickAwayHandler <ClickAwayHandler
onClickOutside={() => dismissible && setDrawerIsOpen(false)} onClickOutside={() => dismissible && setDrawerIsOpen(false)}
> >
<D.Content className="flex flex-col rounded-t-2xl min-h-max mt-24 fixed bottom-0 left-0 right-0 z-30 h-[90%]"> <D.Content className="flex flex-col rounded-t-2xl mt-24 fixed bottom-0 left-0 right-0 z-30 h-[90%]">
<div <div
className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto" className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"
data-testid="mobile-modal-container" data-testid="mobile-modal-container"
> >
<div <div
className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5" className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5 relative z-20"
data-testid="mobile-modal-slider" data-testid="mobile-modal-slider"
/> />
{children} {children}
+16
View File
@@ -0,0 +1,16 @@
import React, { forwardRef } from "react";
import * as Icons from "@phosphor-icons/react";
type Props = {
icon: string;
} & Icons.IconProps;
const Icon = forwardRef<SVGSVGElement, Props>(({ icon, ...rest }, ref) => {
const IconComponent: any = Icons[icon as keyof typeof Icons];
if (!IconComponent) {
return null;
} else return <IconComponent ref={ref} {...rest} />;
});
export default Icon;
+45
View File
@@ -0,0 +1,45 @@
import { icons } from "@/lib/client/icons";
import Fuse from "fuse.js";
import { useMemo } from "react";
const fuse = new Fuse(icons, {
keys: [{ name: "name", weight: 4 }, "tags", "categories"],
threshold: 0.2,
useExtendedSearch: true,
});
type Props = {
query: string;
color: string;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
iconName?: string;
setIconName: Function;
};
const IconGrid = ({ query, color, weight, iconName, setIconName }: Props) => {
const filteredQueryResultsSelector = useMemo(() => {
if (!query) {
return icons;
}
return fuse.search(query).map((result) => result.item);
}, [query]);
return filteredQueryResultsSelector.map((icon) => {
const IconComponent = icon.Icon;
return (
<div
key={icon.pascal_name}
onClick={() => setIconName(icon.pascal_name)}
className={`cursor-pointer btn p-1 box-border bg-base-100 border-none w-full ${
icon.pascal_name === iconName
? "outline outline-1 outline-primary"
: ""
}`}
>
<IconComponent size={32} weight={weight} color={color} />
</div>
);
});
};
export default IconGrid;
+72 -35
View File
@@ -1,44 +1,81 @@
import { icons } from "@/lib/client/icons"; import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import Fuse from "fuse.js";
import TextInput from "./TextInput"; import TextInput from "./TextInput";
import Popover from "./Popover";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "next-i18next";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import IconGrid from "./IconGrid";
import IconPopover from "./IconPopover";
import clsx from "clsx";
const fuse = new Fuse(icons, { type Props = {
keys: [{ name: "name", weight: 4 }, "tags", "categories"], alignment?: string;
threshold: 0.2, color: string;
useExtendedSearch: true, setColor: Function;
}); iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
hideDefaultIcon?: boolean;
reset: Function;
className?: string;
};
type Props = {}; const IconPicker = ({
alignment,
const IconPicker = (props: Props) => { color,
const [query, setQuery] = useState(""); setColor,
iconName,
const filteredQueryResultsSelector = useMemo(() => { setIconName,
if (!query) { weight,
return icons; setWeight,
} hideDefaultIcon,
return fuse.search(query).map((result) => result.item); className,
}, [query]); reset,
}: Props) => {
const { t } = useTranslation();
const [iconPicker, setIconPicker] = useState(false);
return ( return (
<div className="w-fit"> <div className="relative">
<TextInput <div
className="p-2 rounded w-full mb-5" onClick={() => setIconPicker(!iconPicker)}
placeholder="Search icons" className="btn btn-square w-20 h-20"
value={query} >
onChange={(e) => setQuery(e.target.value)} {iconName ? (
/> <Icon
<div className="grid grid-cols-6 gap-5 w-fit"> icon={iconName}
{filteredQueryResultsSelector.map((icon) => { size={60}
const IconComponent = icon.Icon; weight={(weight || "regular") as IconWeight}
return ( color={color || "#0ea5e9"}
<div key={icon.name} onClick={() => console.log(icon.name)}> />
<IconComponent size={32} weight="fill" /> ) : !iconName && hideDefaultIcon ? (
</div> <p className="p-1">{t("set_custom_icon")}</p>
); ) : (
})} <i
className="bi-folder-fill text-6xl"
style={{ color: color || "#0ea5e9" }}
></i>
)}
</div> </div>
{iconPicker && (
<IconPopover
alignment={alignment}
color={color}
setColor={setColor}
iconName={iconName}
setIconName={setIconName}
weight={weight}
setWeight={setWeight}
reset={reset}
onClose={() => setIconPicker(false)}
className={clsx(
className,
alignment || "lg:-translate-x-1/3 top-20 left-0"
)}
/>
)}
</div> </div>
); );
}; };
+142
View File
@@ -0,0 +1,142 @@
import React, { useState } from "react";
import TextInput from "./TextInput";
import Popover from "./Popover";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "next-i18next";
import IconGrid from "./IconGrid";
import clsx from "clsx";
type Props = {
alignment?: string;
color: string;
setColor: Function;
iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
reset: Function;
className?: string;
onClose: Function;
};
const IconPopover = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
reset,
className,
onClose,
}: Props) => {
const { t } = useTranslation();
const [query, setQuery] = useState("");
return (
<Popover
onClose={() => onClose()}
className={clsx(
className,
"fade-in bg-base-200 border border-neutral-content p-2 w-[22.5rem] rounded-lg shadow-md"
)}
>
<div className="flex gap-2 h-full w-full">
<div className="flex flex-col gap-2 w-full">
<TextInput
className="p-2 rounded w-full h-7 text-sm"
placeholder={t("search")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="grid grid-cols-6 gap-1 w-full overflow-y-auto h-44 border border-neutral-content bg-base-100 rounded-md p-2">
<IconGrid
query={query}
color={color}
weight={weight}
iconName={iconName}
setIconName={setIconName}
/>
</div>
<div className="flex gap-2 color-picker w-full">
<HexColorPicker color={color} onChange={(e) => setColor(e)} />
<div className="grid grid-cols-2 gap-1 text-sm">
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="regular"
checked={weight === "regular"}
onChange={() => setWeight("regular")}
/>
{t("regular")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="thin"
checked={weight === "thin"}
onChange={() => setWeight("thin")}
/>
{t("thin")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="light"
checked={weight === "light"}
onChange={() => setWeight("light")}
/>
{t("light_icon")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="bold"
checked={weight === "bold"}
onChange={() => setWeight("bold")}
/>
{t("bold")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="fill"
checked={weight === "fill"}
onChange={() => setWeight("fill")}
/>
{t("fill")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="duotone"
checked={weight === "duotone"}
onChange={() => setWeight("duotone")}
/>
{t("duotone")}
</label>
</div>
</div>
<div
className="btn btn-neutral btn-sm mt-2 w-fit mx-auto"
onClick={reset as React.MouseEventHandler<HTMLDivElement>}
>
{t("reset_defaults")}
</div>
</div>
</div>
</Popover>
);
};
export default IconPopover;
@@ -16,6 +16,8 @@ type Props = {
} }
| undefined; | undefined;
creatable?: boolean; creatable?: boolean;
autoFocus?: boolean;
onBlur?: any;
}; };
export default function CollectionSelection({ export default function CollectionSelection({
@@ -23,6 +25,8 @@ export default function CollectionSelection({
defaultValue, defaultValue,
showDefaultValue = true, showDefaultValue = true,
creatable = true, creatable = true,
autoFocus,
onBlur,
}: Props) { }: Props) {
const { data: collections = [] } = useCollections(); const { data: collections = [] } = useCollections();
@@ -76,7 +80,7 @@ export default function CollectionSelection({
return ( return (
<div <div
{...innerProps} {...innerProps}
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content cursor-pointer" className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content duration-100 cursor-pointer"
> >
<div className="flex w-full justify-between items-center"> <div className="flex w-full justify-between items-center">
<span>{data.label}</span> <span>{data.label}</span>
@@ -104,6 +108,8 @@ export default function CollectionSelection({
onChange={onChange} onChange={onChange}
options={options} options={options}
styles={styles} styles={styles}
autoFocus={autoFocus}
onBlur={onBlur}
defaultValue={showDefaultValue ? defaultValue : null} defaultValue={showDefaultValue ? defaultValue : null}
components={{ components={{
Option: customOption, Option: customOption,
@@ -120,7 +126,9 @@ export default function CollectionSelection({
onChange={onChange} onChange={onChange}
options={options} options={options}
styles={styles} styles={styles}
autoFocus={autoFocus}
defaultValue={showDefaultValue ? defaultValue : null} defaultValue={showDefaultValue ? defaultValue : null}
onBlur={onBlur}
components={{ components={{
Option: customOption, Option: customOption,
}} }}
+10 -1
View File
@@ -10,9 +10,16 @@ type Props = {
value: number; value: number;
label: string; label: string;
}[]; }[];
autoFocus?: boolean;
onBlur?: any;
}; };
export default function TagSelection({ onChange, defaultValue }: Props) { export default function TagSelection({
onChange,
defaultValue,
autoFocus,
onBlur,
}: Props) {
const { data: tags = [] } = useTags(); const { data: tags = [] } = useTags();
const [options, setOptions] = useState<Options[]>([]); const [options, setOptions] = useState<Options[]>([]);
@@ -35,6 +42,8 @@ export default function TagSelection({ onChange, defaultValue }: Props) {
styles={styles} styles={styles}
defaultValue={defaultValue} defaultValue={defaultValue}
isMulti isMulti
autoFocus={autoFocus}
onBlur={onBlur}
/> />
); );
} }
+15 -6
View File
@@ -14,7 +14,7 @@ export const styles: StylesConfig = {
? "oklch(var(--p))" ? "oklch(var(--p))"
: "oklch(var(--nc))", : "oklch(var(--nc))",
}, },
transition: "all 50ms", transition: "all 100ms",
}), }),
control: (styles, state) => ({ control: (styles, state) => ({
...styles, ...styles,
@@ -50,19 +50,28 @@ export const styles: StylesConfig = {
multiValue: (styles) => { multiValue: (styles) => {
return { return {
...styles, ...styles,
backgroundColor: "#0ea5e9", backgroundColor: "oklch(var(--b2))",
color: "white", color: "oklch(var(--bc))",
display: "flex",
alignItems: "center",
gap: "0.1rem",
marginRight: "0.4rem",
}; };
}, },
multiValueLabel: (styles) => ({ multiValueLabel: (styles) => ({
...styles, ...styles,
color: "white", color: "oklch(var(--bc))",
}), }),
multiValueRemove: (styles) => ({ multiValueRemove: (styles) => ({
...styles, ...styles,
height: "1.2rem",
width: "1.2rem",
borderRadius: "100px",
transition: "all 100ms",
color: "oklch(var(--w))",
":hover": { ":hover": {
color: "white", color: "red",
backgroundColor: "#38bdf8", backgroundColor: "oklch(var(--nc))",
}, },
}), }),
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
+510 -168
View File
@@ -4,31 +4,63 @@ import {
ArchivedFormat, ArchivedFormat,
} from "@/types/global"; } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
import { useSession } from "next-auth/react";
import { import {
pdfAvailable, pdfAvailable,
readabilityAvailable, readabilityAvailable,
monolithAvailable, monolithAvailable,
screenshotAvailable, screenshotAvailable,
previewAvailable,
} from "@/lib/shared/getArchiveValidity"; } from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow"; import PreservedFormatRow from "@/components/PreserverdFormatRow";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners"; import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import { useGetLink } from "@/hooks/store/links"; import {
useGetLink,
useUpdateLink,
useUpdatePreview,
} from "@/hooks/store/links";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon"; import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import CopyButton from "./CopyButton"; import CopyButton from "./CopyButton";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import Image from "next/image";
import clsx from "clsx";
import toast from "react-hot-toast";
import CollectionSelection from "./InputSelect/CollectionSelection";
import TagSelection from "./InputSelect/TagSelection";
import unescapeString from "@/lib/client/unescapeString";
import IconPopover from "./IconPopover";
import TextInput from "./TextInput";
import usePermissions from "@/hooks/usePermissions";
type Props = { type Props = {
className?: string; className?: string;
link: LinkIncludingShortenedCollectionAndTags; activeLink: LinkIncludingShortenedCollectionAndTags;
standalone?: boolean;
mode?: "view" | "edit";
setMode?: Function;
}; };
export default function LinkDetails({ className, link }: Props) { export default function LinkDetails({
className,
activeLink,
standalone,
mode = "view",
setMode,
}: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
useEffect(() => {
setLink(activeLink);
}, [activeLink]);
const permissions = usePermissions(link.collection.id as number);
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession();
const getLink = useGetLink(); const getLink = useGetLink();
const { data: user = {} } = useUser(); const { data: user = {} } = useUser();
@@ -117,188 +149,498 @@ export default function LinkDetails({ className, link }: Props) {
clearInterval(interval); clearInterval(interval);
} }
}; };
}, [link?.monolith]); }, [link.monolith]);
const router = useRouter(); const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false; const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const updateLink = useUpdateLink();
const updatePreview = useUpdatePreview();
const submit = async (e?: any) => {
e?.preventDefault();
const { updatedAt: b, ...oldLink } = activeLink;
const { updatedAt: a, ...newLink } = link;
if (JSON.stringify(oldLink) === JSON.stringify(newLink)) {
return;
}
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setMode && setMode("view");
setLink(data);
}
},
});
};
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
const [iconPopover, setIconPopover] = useState(false);
return ( return (
<div className={className} data-vaul-no-drag> <div className={clsx(className)} data-vaul-no-drag>
<LinkIcon link={link} className="mx-auto" /> <div
className={clsx(
{link.name && <p className="text-xl text-center mt-2">{link.name}</p>} standalone && "sm:border sm:border-neutral-content sm:rounded-2xl p-5"
)}
{link.url && ( >
<> <div
<br /> className={clsx(
"overflow-hidden select-none relative group h-40 opacity-80",
<p className="text-sm mb-2 text-neutral">{t("link")}</p> standalone
? "sm:max-w-xl -mx-5 -mt-5 sm:rounded-t-2xl"
<div className="relative"> : "-mx-4 -mt-4"
<div className="rounded-lg p-2 bg-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14"> )}
<Link href={link.url} title={link.url} target="_blank">
{link.url}
</Link>
<div className="absolute right-0 px-2 bg-base-200">
<CopyButton text={link.url} />
</div>
</div>
</div>
</>
)}
<br />
<p className="text-sm mb-2 text-neutral">{t("collection")}</p>
<div className="relative">
<Link
href={
isPublicRoute
? `/public/collections/${link.collection.id}`
: `/collections/${link.collection.id}`
}
className="rounded-lg p-2 bg-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14"
> >
<p>{link.collection.name}</p> {previewAvailable(link) ? (
<div className="absolute right-0 px-2 bg-base-200"> <Image
<i src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
className="bi-folder-fill text-xl" width={1280}
style={{ color: link.collection.color }} height={720}
></i> alt=""
</div> className="object-cover scale-105"
</Link> style={{
</div> filter: "blur(1px)",
}}
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40"></div>
) : (
<div className="duration-100 h-40 skeleton rounded-b-none"></div>
)}
{link.tags[0] && ( {!standalone && (permissions === true || permissions?.canUpdate) && (
<> <div className="absolute top-0 bottom-0 left-0 right-0 opacity-0 group-hover:opacity-100 duration-100 flex justify-end items-end">
<br /> <label className="btn btn-xs mb-2 mr-3 opacity-50 hover:opacity-100">
{t("upload_preview_image")}
<input
type="file"
accept="image/jpg, image/jpeg, image/png"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
<div> const load = toast.loading(t("updating"));
<p className="text-sm mb-2 text-neutral">{t("tags")}</p>
</div>
<div className="flex gap-2"> await updatePreview.mutateAsync(
{link.tags.map((tag) => {
isPublicRoute ? ( linkId: link.id as number,
<div key={tag.id} className="rounded-lg px-3 py-1 bg-base-200"> file,
{tag.name} },
</div> {
) : ( onSettled: (data, error) => {
<Link toast.dismiss(load);
href={"/tags/" + tag.id}
key={tag.id} if (error) {
className="rounded-lg px-3 py-1 bg-base-200" toast.error(error.message);
> } else {
{tag.name} toast.success(t("updated"));
</Link> setLink({ updatedAt: data.updatedAt, ...link });
) }
},
}
);
}}
className="hidden"
/>
</label>
</div>
)}
</div>
{!standalone && (permissions === true || permissions?.canUpdate) ? (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<div className="tooltip tooltip-bottom" data-tip={t("change_icon")}>
<LinkIcon
link={link}
className="hover:bg-opacity-70 duration-100 cursor-pointer"
onClick={() => setIconPopover(true)}
/>
</div>
{iconPopover && (
<IconPopover
color={link.color || "#006796"}
setColor={(color: string) => setLink({ ...link, color })}
weight={(link.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setLink({ ...link, iconWeight })
}
iconName={link.icon as string}
setIconName={(icon: string) => setLink({ ...link, icon })}
reset={() =>
setLink({
...link,
color: "",
icon: "",
iconWeight: "",
})
}
className="top-12"
onClose={() => {
setIconPopover(false);
submit();
}}
/>
)} )}
</div> </div>
</> ) : (
)} <div className="-mt-14 ml-8 relative w-fit pb-2">
<LinkIcon link={link} onClick={() => setIconPopover(true)} />
</div>
)}
<div className="max-w-xl sm:px-8 p-5 pb-8 pt-2">
{mode === "view" && (
<div className="text-xl mt-2 pr-7">
<p
className={clsx("relative w-fit", !link.name && "text-neutral")}
>
{link.name || t("untitled")}
</p>
</div>
)}
{link.url && (
<>
<br />
<p className="text-sm mb-2 text-neutral">{t("link")}</p>
<div className="relative">
<div className="rounded-md p-2 bg-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14">
<Link href={link.url} title={link.url} target="_blank">
{link.url}
</Link>
<div className="absolute right-0 px-2 bg-base-200">
<CopyButton text={link.url} />
</div>
</div>
</div>
</>
)}
{mode === "edit" && (
<>
<br />
<div>
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("name")}
</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
</>
)}
{link.description && (
<>
<br /> <br />
<div> <div className="relative">
<p className="text-sm mb-2 text-neutral">{t("notes")}</p> <p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("collection")}
</p>
<div className="rounded-lg p-2 bg-base-200 hyphens-auto"> {mode === "view" ? (
<p>{link.description}</p> <div className="relative">
<Link
href={
isPublicRoute
? `/public/collections/${link.collection.id}`
: `/collections/${link.collection.id}`
}
className="rounded-md p-2 bg-base-200 border border-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14"
>
<p>{link.collection.name}</p>
<div className="absolute right-0 px-2 bg-base-200">
{link.collection.icon ? (
<Icon
icon={link.collection.icon}
size={30}
weight={
(link.collection.iconWeight ||
"regular") as IconWeight
}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: link.collection.color }}
></i>
)}
</div>
</Link>
</div>
) : (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.id
? { value: link.collection.id, label: link.collection.name }
: { value: null as unknown as number, label: "Unorganized" }
}
creatable={false}
/>
)}
</div>
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("tags")}
</p>
{mode === "view" ? (
<div className="flex gap-2 flex-wrap rounded-md p-2 bg-base-200 border border-base-200 w-full text-xs">
{link.tags[0] ? (
link.tags.map((tag) =>
isPublicRoute ? (
<div
key={tag.id}
className="bg-base-200 p-1 hover:bg-neutral-content rounded-md duration-100"
>
{tag.name}
</div>
) : (
<Link
href={"/tags/" + tag.id}
key={tag.id}
className="bg-base-200 p-1 hover:bg-neutral-content btn btn-xs btn-ghost rounded-md"
>
{tag.name}
</Link>
)
)
) : (
<div className="text-neutral text-base">{t("no_tags")}</div>
)}
</div>
) : (
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
)}
</div>
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("description")}
</p>
{mode === "view" ? (
<div className="rounded-md p-2 bg-base-200 hyphens-auto">
{link.description ? (
<p>{link.description}</p>
) : (
<p className="text-neutral">{t("no_description_provided")}</p>
)}
</div>
) : (
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 h-32 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
)}
</div>
{mode === "view" && (
<div>
<br />
<p
className="text-sm mb-2 text-neutral"
title={t("available_formats")}
>
{link.url ? t("preserved_formats") : t("file")}
</p>
<div className={`flex flex-col rounded-md p-3 bg-base-200`}>
{monolithAvailable(link) ? (
<>
<PreservedFormatRow
name={t("webpage")}
icon={"bi-filetype-html"}
format={ArchivedFormat.monolith}
link={link}
downloadable={true}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{screenshotAvailable(link) ? (
<>
<PreservedFormatRow
name={t("screenshot")}
icon={"bi-file-earmark-image"}
format={
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
link={link}
downloadable={true}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{pdfAvailable(link) ? (
<>
<PreservedFormatRow
name={t("pdf")}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
link={link}
downloadable={true}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{readabilityAvailable(link) ? (
<>
<PreservedFormatRow
name={t("readable")}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
link={link}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{!isReady() && !atLeastOneFormatAvailable() ? (
<div
className={`w-full h-full flex flex-col justify-center p-10`}
>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={30}
/>
<p className="text-center text-2xl">
{t("preservation_in_queue")}
</p>
<p className="text-center text-lg">
{t("check_back_later")}
</p>
</div>
) : link.url && !isReady() && atLeastOneFormatAvailable() ? (
<div
className={`w-full h-full flex flex-col justify-center p-5`}
>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={20}
/>
<p className="text-center">{t("there_are_more_formats")}</p>
<p className="text-center text-sm">
{t("check_back_later")}
</p>
</div>
) : undefined}
{link.url && (
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="text-neutral mx-auto duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">
{t("view_latest_snapshot")}
</p>
<i className="bi-box-arrow-up-right" />
</Link>
)}
</div>
</div> </div>
</div> )}
</>
)}
<br /> {mode === "view" ? (
<>
<br />
<p className="text-sm mb-2 text-neutral" title={t("available_formats")}> <p className="text-neutral text-xs text-center">
{link.url ? t("preserved_formats") : t("file")} {t("saved")}{" "}
</p> {new Date(link.createdAt || "").toLocaleDateString("en-US", {
month: "short",
<div className={`flex flex-col gap-3`}> day: "numeric",
{monolithAvailable(link) ? ( year: "numeric",
<PreservedFormatRow })}{" "}
name={t("webpage")} at{" "}
icon={"bi-filetype-html"} {new Date(link.createdAt || "").toLocaleTimeString("en-US", {
format={ArchivedFormat.monolith} hour: "numeric",
link={link} minute: "numeric",
downloadable={true} })}
/> </p>
) : undefined} </>
) : (
{screenshotAvailable(link) ? ( <>
<PreservedFormatRow <br />
name={t("screenshot")} <div className="flex justify-end items-center">
icon={"bi-file-earmark-image"} <button
format={ className={clsx(
link?.image?.endsWith("png") "btn btn-accent text-white",
? ArchivedFormat.png JSON.stringify(activeLink) === JSON.stringify(link)
: ArchivedFormat.jpeg ? "btn-disabled"
} : "dark:border-violet-400"
link={link} )}
downloadable={true} onClick={submit}
/> >
) : undefined} {t("save_changes")}
</button>
{pdfAvailable(link) ? ( </div>
<PreservedFormatRow </>
name={t("pdf")} )}
icon={"bi-file-earmark-pdf"} </div>
format={ArchivedFormat.pdf}
link={link}
downloadable={true}
/>
) : undefined}
{readabilityAvailable(link) ? (
<PreservedFormatRow
name={t("readable")}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
link={link}
/>
) : undefined}
{!isReady() && !atLeastOneFormatAvailable() ? (
<div className={`w-full h-full flex flex-col justify-center p-10`}>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={30}
/>
<p className="text-center text-2xl">{t("preservation_in_queue")}</p>
<p className="text-center text-lg">{t("check_back_later")}</p>
</div>
) : link.url && !isReady() && atLeastOneFormatAvailable() ? (
<div className={`w-full h-full flex flex-col justify-center p-5`}>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={20}
/>
<p className="text-center">{t("there_are_more_formats")}</p>
<p className="text-center text-sm">{t("check_back_later")}</p>
</div>
) : undefined}
{link.url && (
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="text-neutral mx-auto duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">{t("view_latest_snapshot")}</p>
<i className="bi-box-arrow-up-right" />
</Link>
)}
</div> </div>
</div> </div>
); );
@@ -4,15 +4,13 @@ import {
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import EditLinkModal from "@/components/ModalContent/EditLinkModal";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal"; import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import { useDeleteLink, useUpdateLink } from "@/hooks/store/links"; import { useDeleteLink, useGetLink, useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import LinkDetailModal from "@/components/ModalContent/LinkDetailModal"; import LinkModal from "@/components/ModalContent/LinkModal";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
type Props = { type Props = {
@@ -32,11 +30,11 @@ export default function LinkActions({
const { t } = useTranslation(); const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
const getLink = useGetLink();
const [editLinkModal, setEditLinkModal] = useState(false); const [editLinkModal, setEditLinkModal] = useState(false);
const [linkDetailModal, setLinkDetailModal] = useState(false); const [linkModal, setLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false); const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const { data: user = {} } = useUser(); const { data: user = {} } = useUser();
@@ -51,7 +49,7 @@ export default function LinkActions({
await updateLink.mutateAsync( await updateLink.mutateAsync(
{ {
...link, ...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }], pinnedBy: isAlreadyPinned ? [{ id: undefined }] : [{ id: user.id }],
}, },
{ {
onSettled: (data, error) => { onSettled: (data, error) => {
@@ -69,6 +67,23 @@ export default function LinkActions({
); );
}; };
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
await getLink.mutateAsync({ id: link.id as number });
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
const router = useRouter(); const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false; const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
@@ -80,7 +95,10 @@ export default function LinkActions({
className={`absolute ${position || "top-3 right-3"} ${ className={`absolute ${position || "top-3 right-3"} ${
alignToTop ? "" : "dropdown-end" alignToTop ? "" : "dropdown-end"
} z-20`} } z-20`}
onClick={() => setLinkDetailModal(true)} tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
onClick={() => setLinkModal(true)}
> >
<div className="btn btn-ghost btn-sm btn-square text-neutral"> <div className="btn btn-ghost btn-sm btn-square text-neutral">
<i title="More" className="bi-three-dots text-xl" /> <i title="More" className="bi-three-dots text-xl" />
@@ -105,31 +123,28 @@ export default function LinkActions({
alignToTop ? "" : "translate-y-10" alignToTop ? "" : "translate-y-10"
}`} }`}
> >
{permissions === true ||
(permissions?.canUpdate && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
pinLink();
}}
className="whitespace-nowrap"
>
{link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
))}
<li> <li>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setLinkDetailModal(true); pinLink();
}}
className="whitespace-nowrap"
>
{link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setLinkModal(true);
}} }}
className="whitespace-nowrap" className="whitespace-nowrap"
> >
@@ -151,18 +166,18 @@ export default function LinkActions({
</div> </div>
</li> </li>
)} )}
{link.type === "url" && ( {link.type === "url" && permissions === true && (
<li> <li>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true); updateArchive();
}} }}
className="whitespace-nowrap" className="whitespace-nowrap"
> >
{t("preserved_formats")} {t("refresh_preserved_formats")}
</div> </div>
</li> </li>
)} )}
@@ -173,8 +188,9 @@ export default function LinkActions({
tabIndex={0} tabIndex={0}
onClick={async (e) => { onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
console.log(e.shiftKey);
e.shiftKey e.shiftKey
? async () => { ? (async () => {
const load = toast.loading(t("deleting")); const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, { await deleteLink.mutateAsync(link.id as number, {
@@ -188,7 +204,7 @@ export default function LinkActions({
} }
}, },
}); });
} })()
: setDeleteLinkModal(true); : setDeleteLinkModal(true);
}} }}
className="whitespace-nowrap" className="whitespace-nowrap"
@@ -201,9 +217,13 @@ export default function LinkActions({
</div> </div>
)} )}
{editLinkModal && ( {editLinkModal && (
<EditLinkModal <LinkModal
onClose={() => setEditLinkModal(false)} onClose={() => setEditLinkModal(false)}
activeLink={link} onPin={pinLink}
onUpdateArchive={updateArchive}
onDelete={() => setDeleteLinkModal(true)}
link={link}
activeMode="edit"
/> />
)} )}
{deleteLinkModal && ( {deleteLinkModal && (
@@ -212,16 +232,12 @@ export default function LinkActions({
activeLink={link} activeLink={link}
/> />
)} )}
{preservedFormatsModal && ( {linkModal && (
<PreservedFormatsModal <LinkModal
onClose={() => setPreservedFormatsModal(false)} onClose={() => setLinkModal(false)}
link={link} onPin={pinLink}
/> onUpdateArchive={updateArchive}
)} onDelete={() => setDeleteLinkModal(true)}
{linkDetailModal && (
<LinkDetailModal
onClose={() => setLinkDetailModal(false)}
onEdit={() => setEditLinkModal(true)}
link={link} link={link}
/> />
)} )}
@@ -23,6 +23,8 @@ import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links"; import { useGetLink, useLinks } from "@/hooks/store/links";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useLocalSettingsStore from "@/store/localSettings";
import clsx from "clsx";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@@ -41,6 +43,10 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const { setSelectedLinks, selectedLinks } = useLinkStore(); const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { const {
data: { data: links = [] }, data: { data: links = [] },
} = useLinks(); } = useLinks();
@@ -143,66 +149,70 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
!editMode && window.open(generateLinkHref(link, user), "_blank") !editMode && window.open(generateLinkHref(link, user), "_blank")
} }
> >
<div> {show.image && (
<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={
link.type !== "image" ? { filter: "blur(1px)" } : undefined
}
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>
)}
{link.type !== "image" && (
<div 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 border-t border-neutral-content h-[1px]" />
</div>
<div className="flex flex-col justify-between h-full">
<div className="p-3 flex flex-col gap-2">
<p className="truncate w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
<LinkTypeBadge link={link} />
</div>
<div> <div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> <div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? (
<div className="flex justify-between text-xs text-neutral px-3 pb-1 gap-2"> <Image
<div className="cursor-pointer truncate"> src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
{collection && ( width={1280}
<LinkCollection link={link} collection={collection} /> height={720}
)} alt=""
</div> className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
<LinkDate link={link} /> style={show.icon ? { filter: "blur(1px)" } : undefined}
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>
)}
{show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center rounded-md">
<LinkIcon link={link} />
</div>
)}
</div> </div>
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
</div> </div>
)}
<div className="flex flex-col justify-between h-full min-h-24">
<div className="p-3 flex flex-col gap-2">
{show.name && (
<p className="truncate w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
)}
{show.link && <LinkTypeBadge link={link} />}
</div>
{(show.collection || show.date) && (
<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 gap-2">
{show.collection && (
<div className="cursor-pointer truncate">
<LinkCollection link={link} collection={collection} />
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div> </div>
</div> </div>
<LinkActions <LinkActions
link={link} link={link}
collection={collection} collection={collection}
position="top-[10.75rem] right-3" position={clsx(show.image ? "top-[10.75rem]" : "top-3", "right-3")}
flipDropdown={flipDropdown} flipDropdown={flipDropdown}
/> />
</div> </div>
@@ -1,7 +1,9 @@
import Icon from "@/components/Icon";
import { import {
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import { IconWeight } from "@phosphor-icons/react";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
@@ -22,10 +24,19 @@ export default function LinkCollection({
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none" className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none"
title={collection?.name} title={collection?.name}
> >
<i {link.collection.icon ? (
className="bi-folder-fill text-lg drop-shadow" <Icon
style={{ color: collection?.color }} icon={link.collection.icon}
></i> size={20}
weight={(link.collection.iconWeight || "regular") as IconWeight}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-lg"
style={{ color: link.collection.color }}
></i>
)}
<p className="truncate capitalize">{collection?.name}</p> <p className="truncate capitalize">{collection?.name}</p>
</Link> </Link>
</> </>
@@ -2,34 +2,26 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Image from "next/image"; import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import React from "react"; import React from "react";
import Icon from "@/components/Icon";
import { IconWeight } from "@phosphor-icons/react";
import clsx from "clsx";
export default function LinkIcon({ export default function LinkIcon({
link, link,
className, className,
size, hideBackground,
onClick,
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
className?: string; className?: string;
size?: "small" | "medium"; hideBackground?: boolean;
onClick?: Function;
}) { }) {
let iconClasses: string = let iconClasses: string = clsx(
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10 " + "rounded flex item-center justify-center shadow select-none z-10 w-12 h-12",
(className || ""); !hideBackground && "rounded-md bg-white backdrop-blur-lg bg-opacity-50 p-1",
className
let dimension; );
switch (size) {
case "small":
dimension = " w-8 h-8";
break;
case "medium":
dimension = " w-12 h-12";
break;
default:
size = "medium";
dimension = " w-12 h-12";
break;
}
const url = const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined; isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
@@ -37,37 +29,41 @@ export default function LinkIcon({
const [showFavicon, setShowFavicon] = React.useState<boolean>(true); const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
return ( return (
<> <div onClick={() => onClick && onClick()}>
{link.type === "url" && url ? ( {link.icon ? (
<div className={iconClasses}>
<Icon
icon={link.icon}
size={30}
weight={(link.iconWeight || "regular") as IconWeight}
color={link.color || "#006796"}
className="m-auto"
/>
</div>
) : link.type === "url" && url ? (
showFavicon ? ( showFavicon ? (
<Image <Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`} src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64} width={64}
height={64} height={64}
alt="" alt=""
className={iconClasses + dimension} className={iconClasses}
draggable="false" draggable="false"
onError={() => { onError={() => {
setShowFavicon(false); setShowFavicon(false);
}} }}
/> />
) : ( ) : (
<LinkPlaceholderIcon <LinkPlaceholderIcon iconClasses={iconClasses} icon="bi-link-45deg" />
iconClasses={iconClasses + dimension}
size={size}
icon="bi-link-45deg"
/>
) )
) : link.type === "pdf" ? ( ) : link.type === "pdf" ? (
<LinkPlaceholderIcon <LinkPlaceholderIcon
iconClasses={iconClasses + dimension} iconClasses={iconClasses}
size={size}
icon="bi-file-earmark-pdf" icon="bi-file-earmark-pdf"
/> />
) : link.type === "image" ? ( ) : link.type === "image" ? (
<LinkPlaceholderIcon <LinkPlaceholderIcon
iconClasses={iconClasses + dimension} iconClasses={iconClasses}
size={size}
icon="bi-file-earmark-image" icon="bi-file-earmark-image"
/> />
) : // : link.type === "monolith" ? ( ) : // : link.type === "monolith" ? (
@@ -78,25 +74,19 @@ export default function LinkIcon({
// /> // />
// ) // )
undefined} undefined}
</> </div>
); );
} }
const LinkPlaceholderIcon = ({ const LinkPlaceholderIcon = ({
iconClasses, iconClasses,
size,
icon, icon,
}: { }: {
iconClasses: string; iconClasses: string;
size?: "small" | "medium";
icon: string; icon: string;
}) => { }) => {
return ( return (
<div <div className={clsx(iconClasses, "aspect-square text-4xl text-[#006796]")}>
className={`${
size === "small" ? "text-2xl" : "text-4xl"
} text-black aspect-square ${iconClasses}`}
>
<i className={`${icon} m-auto`}></i> <i className={`${icon} m-auto`}></i>
</div> </div>
); );
@@ -18,6 +18,7 @@ import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections"; import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import { useLinks } from "@/hooks/store/links"; import { useLinks } from "@/hooks/store/links";
import useLocalSettingsStore from "@/store/localSettings";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@@ -39,6 +40,10 @@ export default function LinkCardCompact({
const { data: user = {} } = useUser(); const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore(); const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks(); const { links } = useLinks();
useEffect(() => { useEffect(() => {
@@ -93,9 +98,9 @@ export default function LinkCardCompact({
return ( return (
<> <>
<div <div
className={`${selectedStyle} border relative items-center flex ${ className={`${selectedStyle} rounded-md border relative items-center flex ${
!isPWA() ? "hover:bg-base-300 p-3" : "py-3" !isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1"
} duration-200 rounded-lg w-full`} } duration-200 w-full`}
onClick={() => onClick={() =>
selectable selectable
? handleCheckboxClick(link) ? handleCheckboxClick(link)
@@ -105,33 +110,31 @@ export default function LinkCardCompact({
} }
> >
<div <div
className="flex items-center cursor-pointer w-full" className="flex items-center cursor-pointer w-full min-h-12"
onClick={() => onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank") !editMode && window.open(generateLinkHref(link, user), "_blank")
} }
> >
<div className="shrink-0"> {show.icon && (
<LinkIcon link={link} className="w-12 h-12 text-4xl" /> <div className="shrink-0">
</div> <LinkIcon link={link} hideBackground />
</div>
)}
<div className="w-[calc(100%-56px)] ml-2"> <div className="w-[calc(100%-56px)] ml-2">
<p className="line-clamp-1 mr-8 text-primary select-none"> {show.name && (
{link.name ? ( <p className="line-clamp-1 mr-8 text-primary select-none">
unescapeString(link.name) {unescapeString(link.name)}
) : ( </p>
<div className="mt-2"> )}
<LinkTypeBadge link={link} />
</div>
)}
</p>
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral"> <div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
<div className="flex items-center gap-x-3 text-neutral flex-wrap"> <div className="flex items-center gap-x-3 text-neutral flex-wrap">
{collection && ( {show.link && <LinkTypeBadge link={link} />}
{show.collection && (
<LinkCollection link={link} collection={collection} /> <LinkCollection link={link} collection={collection} />
)} )}
{link.name && <LinkTypeBadge link={link} />} {show.date && <LinkDate link={link} />}
<LinkDate link={link} />
</div> </div>
</div> </div>
</div> </div>
@@ -143,12 +146,7 @@ export default function LinkCardCompact({
flipDropdown={flipDropdown} flipDropdown={flipDropdown}
/> />
</div> </div>
<div <div className="last:hidden rounded-none my-0 mx-1 border-t border-base-300 h-[1px]"></div>
className="last:hidden rounded-none"
style={{
borderTop: "1px solid var(--fallback-bc,oklch(var(--bc)/0.1))",
}}
></div>
</> </>
); );
} }
@@ -22,6 +22,8 @@ import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections"; import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links"; import { useGetLink, useLinks } from "@/hooks/store/links";
import useLocalSettingsStore from "@/store/localSettings";
import clsx from "clsx";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@@ -39,6 +41,10 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const { setSelectedLinks, selectedLinks } = useLinkStore(); const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks(); const { links } = useLinks();
const getLink = useGetLink(); const getLink = useGetLink();
@@ -129,57 +135,64 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
: undefined : undefined
} }
> >
<div <div>
className="rounded-2xl cursor-pointer" {show.image && previewAvailable(link) && (
onClick={() => <div
!editMode && window.open(generateLinkHref(link, user), "_blank") className="rounded-2xl cursor-pointer"
} onClick={() =>
> !editMode && window.open(generateLinkHref(link, user), "_blank")
<div className="relative rounded-t-2xl overflow-hidden"> }
{previewAvailable(link) ? ( >
<Image <div className="relative rounded-t-2xl overflow-hidden">
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`} {previewAvailable(link) ? (
width={1280} <Image
height={720} src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
alt="" width={1280}
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105" height={720}
style={ alt=""
link.type !== "image" ? { filter: "blur(1px)" } : undefined className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
} style={show.icon ? { filter: "blur(1px)" } : undefined}
draggable="false" draggable="false"
onError={(e) => { onError={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
target.style.display = "none"; target.style.display = "none";
}} }}
/> />
) : link.preview === "unavailable" ? null : ( ) : link.preview === "unavailable" ? null : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div> <div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)} )}
{link.type !== "image" && ( {show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> <div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center rounded-md">
<LinkIcon link={link} /> <LinkIcon link={link} />
</div>
)}
</div> </div>
)}
</div>
{link.preview !== "unavailable" && ( <hr className="divider my-0 border-t border-neutral-content h-[1px]" />
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" /> </div>
)} )}
<div className="p-3 flex flex-col gap-2"> <div className="p-3 flex flex-col gap-2 h-full min-h-14">
<p className="hyphens-auto w-full pr-9 text-primary text-sm"> {show.name && (
{unescapeString(link.name)} <p className="hyphens-auto w-full pr-9 text-primary text-sm">
</p> {unescapeString(link.name)}
</p>
)}
<LinkTypeBadge link={link} /> {show.link && <LinkTypeBadge link={link} />}
{link.description && ( {show.description && link.description && (
<p className="hyphens-auto text-sm"> <p
className={clsx(
"hyphens-auto text-sm w-full",
((!show.name && !show.link) || !link.name) && "pr-9"
)}
>
{unescapeString(link.description)} {unescapeString(link.description)}
</p> </p>
)} )}
{link.tags && link.tags[0] && ( {show.tags && link.tags && link.tags[0] && (
<div className="flex gap-1 items-center flex-wrap"> <div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => ( {link.tags.map((e, i) => (
<Link <Link
@@ -197,21 +210,29 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
)} )}
</div> </div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> {(show.collection || show.date) && (
<div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex flex-wrap justify-between text-xs text-neutral px-3 pb-1 w-full gap-x-2"> <div className="flex flex-wrap justify-between text-xs text-neutral px-3 pb-1 w-full gap-x-2">
{collection && <LinkCollection link={link} collection={collection} />} {show.collection && (
<LinkDate link={link} /> <div className="cursor-pointer truncate">
</div> <LinkCollection link={link} collection={collection} />
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div> </div>
<LinkActions <LinkActions
link={link} link={link}
collection={collection} collection={collection}
position={ position={
link.preview !== "unavailable" previewAvailable(link) && show.image
? "top-[10.75rem] right-3" ? "top-[10.75rem] right-3"
: "top-[.75rem] right-3" : "top-3 right-3"
} }
flipDropdown={flipDropdown} flipDropdown={flipDropdown}
/> />
+7 -7
View File
@@ -142,7 +142,7 @@ export function ListView({
placeHolderRef?: any; placeHolderRef?: any;
}) { }) {
return ( return (
<div className="flex gap-1 flex-col"> <div className="flex flex-col">
{links?.map((e, i) => { {links?.map((e, i) => {
return ( return (
<LinkList <LinkList
@@ -161,13 +161,13 @@ export function ListView({
<div <div
ref={e === 1 ? placeHolderRef : undefined} ref={e === 1 ? placeHolderRef : undefined}
key={i} key={i}
className="flex gap-4 p-4" className="flex gap-2 py-2 px-1"
> >
<div className="skeleton h-16 w-16"></div> <div className="skeleton h-12 w-12"></div>
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-3 w-full">
<div className="skeleton h-3 w-2/3"></div> <div className="skeleton h-2 w-2/3"></div>
<div className="skeleton h-3 w-full"></div> <div className="skeleton h-2 w-full"></div>
<div className="skeleton h-3 w-1/3"></div> <div className="skeleton h-2 w-1/3"></div>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -40,7 +40,7 @@ export default function Modal({
<ClickAwayHandler <ClickAwayHandler
onClickOutside={() => dismissible && setDrawerIsOpen(false)} onClickOutside={() => dismissible && setDrawerIsOpen(false)}
> >
<Drawer.Content className="flex flex-col rounded-t-2xl min-h-max mt-24 fixed bottom-0 left-0 right-0 z-30"> <Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30">
<div <div
className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto" className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"
data-testid="mobile-modal-container" data-testid="mobile-modal-container"
+29 -31
View File
@@ -1,11 +1,12 @@
import React, { useState } from "react"; import React, { useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import { HexColorPicker } from "react-colorful";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal"; import Modal from "../Modal";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections"; import { useUpdateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -56,10 +57,32 @@ export default function EditCollectionModal({
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col gap-3">
<div className="w-full"> <div className="flex gap-3 items-end">
<p className="mb-2">{t("name")}</p> <IconPicker
<div className="flex flex-col gap-3"> color={collection.color}
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: "#0ea5e9",
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput <TextInput
className="bg-base-200" className="bg-base-200"
value={collection.name} value={collection.name}
@@ -68,38 +91,13 @@ export default function EditCollectionModal({
setCollection({ ...collection, name: e.target.value }) setCollection({ ...collection, name: e.target.value })
} }
/> />
<div>
<p className="w-full mb-2">{t("color")}</p>
<div className="color-picker flex justify-between items-center">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32">
<i
className="bi-folder-fill text-5xl"
style={{ color: collection.color }}
></i>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
{t("reset")}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div className="w-full"> <div className="w-full">
<p className="mb-2">{t("description")}</p> <p className="mb-2">{t("description")}</p>
<textarea <textarea
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary" className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder={t("collection_description_placeholder")} placeholder={t("collection_description_placeholder")}
value={collection.description} value={collection.description}
onChange={(e) => onChange={(e) =>
-154
View File
@@ -1,154 +0,0 @@
import React, { useEffect, useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
activeLink: LinkIncludingShortenedCollectionAndTags;
};
export default function EditLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
let shortenedURL;
try {
shortenedURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
const [submitLoader, setSubmitLoader] = useState(false);
const updateLink = useUpdateLink();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
setLink(activeLink);
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("edit_link")}</p>
<div className="divider mb-3 mt-1"></div>
{link.url && (
<Link
href={link.url}
className="truncate text-neutral flex gap-2 mb-5 w-fit max-w-full"
title={link.url}
target="_blank"
>
<i className="bi-link-45deg text-xl" />
<p>{shortenedURL}</p>
</Link>
)}
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">{t("collection")}</p>
{link.collection.name && (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.id
? { value: link.collection.id, label: link.collection.name }
: { value: null as unknown as number, label: "Unorganized" }
}
creatable={false}
/>
)}
</div>
<div>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
{t("save_changes")}
</button>
</div>
</Modal>
);
}
-145
View File
@@ -1,145 +0,0 @@
import React, { useEffect, useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useGetLink } from "@/hooks/store/links";
import Drawer from "../Drawer";
import LinkDetails from "../LinkDetails";
import Link from "next/link";
import usePermissions from "@/hooks/usePermissions";
import { useRouter } from "next/router";
type Props = {
onClose: Function;
onEdit: Function;
link: LinkIncludingShortenedCollectionAndTags;
};
export default function LinkDetailModal({ onClose, onEdit, link }: Props) {
const { t } = useTranslation();
const getLink = useGetLink();
const { data: user = {} } = useUser();
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.collection.ownerId === user.id) {
setCollectionOwner({
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [link.collection.ownerId]);
const isReady = () => {
return (
link &&
(collectionOwner.archiveAsScreenshot === true
? link.pdf && link.pdf !== "pending"
: true) &&
(collectionOwner.archiveAsMonolith === true
? link.monolith && link.monolith !== "pending"
: true) &&
(collectionOwner.archiveAsPDF === true
? link.pdf && link.pdf !== "pending"
: true) &&
link.readable &&
link.readable !== "pending"
);
};
useEffect(() => {
(async () => {
await getLink.mutateAsync({
id: link.id as number,
});
})();
let interval: any;
if (!isReady()) {
interval = setInterval(async () => {
await getLink.mutateAsync({
id: link.id as number,
});
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.monolith]);
const permissions = usePermissions(link.collection.id as number);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return (
<Drawer toggleDrawer={onClose} className="sm:h-screen sm:flex relative">
<div
className="bi-x text-xl text-neutral btn btn-sm btn-square btn-ghost hidden sm:block absolute top-3 left-3"
onClick={() => onClose()}
></div>
<Link
href={isPublicRoute ? `/public/links/${link.id}` : `/links/${link.id}`}
target="_blank"
className="bi-box-arrow-up-right text-xl text-neutral btn btn-sm btn-square btn-ghost absolute top-3 right-3 select-none"
></Link>
<div className="sm:m-auto p-10 w-full max-w-xl">
<LinkDetails link={link} />
{permissions === true ||
(permissions?.canUpdate && (
<>
<br />
<br />
<div className="mx-auto text-center">
<div
className="btn btn-sm btn-ghost"
onClick={() => {
onEdit();
onClose();
}}
>
{t("edit_link")}
</div>
</div>
</>
))}
</div>
</Drawer>
);
}
+182
View File
@@ -0,0 +1,182 @@
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@/hooks/store/links";
import Drawer from "../Drawer";
import LinkDetails from "../LinkDetails";
import Link from "next/link";
import usePermissions from "@/hooks/usePermissions";
import { useRouter } from "next/router";
import { dropdownTriggerer } from "@/lib/client/utils";
import toast from "react-hot-toast";
import clsx from "clsx";
type Props = {
onClose: Function;
onDelete: Function;
onUpdateArchive: Function;
onPin: Function;
link: LinkIncludingShortenedCollectionAndTags;
activeMode?: "view" | "edit";
};
export default function LinkModal({
onClose,
onDelete,
onUpdateArchive,
onPin,
link,
activeMode,
}: Props) {
const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const deleteLink = useDeleteLink();
const [mode, setMode] = useState<"view" | "edit">(activeMode || "view");
return (
<Drawer
toggleDrawer={onClose}
className="sm:h-screen items-center relative"
>
<div className="absolute top-3 left-0 right-0 flex justify-between px-3">
<div
className="bi-x text-xl btn btn-sm btn-circle text-base-content opacity-50 hover:opacity-100 z-10"
onClick={() => onClose()}
></div>
{(permissions === true || permissions?.canUpdate) && (
<div className="flex gap-1 h-8 rounded-full bg-neutral-content bg-opacity-50 text-base-content p-1 text-xs duration-100 select-none z-10">
<div
className={clsx(
"py-1 px-2 cursor-pointer duration-100 rounded-full font-semibold",
mode === "view" && "bg-primary bg-opacity-50"
)}
onClick={() => {
setMode("view");
}}
>
View
</div>
<div
className={clsx(
"py-1 px-2 cursor-pointer duration-100 rounded-full font-semibold",
mode === "edit" && "bg-primary bg-opacity-50"
)}
onClick={() => {
setMode("edit");
}}
>
Edit
</div>
</div>
)}
<div className="flex gap-2">
<div className={`dropdown dropdown-end z-20`}>
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-circle text-base-content opacity-50 hover:opacity-100 z-10"
>
<i title="More" className="bi-three-dots text-xl" />
</div>
<ul
className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box`}
>
{
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
onPin();
}}
className="whitespace-nowrap"
>
{link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
}
{link.type === "url" && permissions === true && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
onUpdateArchive();
}}
className="whitespace-nowrap"
>
{t("refresh_preserved_formats")}
</div>
</li>
)}
{(permissions === true || permissions?.canDelete) && (
<li>
<div
role="button"
tabIndex={0}
onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur();
console.log(e.shiftKey);
if (e.shiftKey) {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
}
},
});
onClose();
} else {
onDelete();
onClose();
}
}}
className="whitespace-nowrap"
>
{t("delete")}
</div>
</li>
)}
</ul>
</div>
<Link
href={
isPublicRoute ? `/public/links/${link.id}` : `/links/${link.id}`
}
target="_blank"
className="bi-box-arrow-up-right btn-circle text-base-content opacity-50 hover:opacity-100 btn btn-sm select-none z-10"
></Link>
</div>
</div>
<div className="w-full">
<LinkDetails
activeLink={link}
className="sm:mt-0 -mt-11"
mode={mode}
setMode={(mode: "view" | "edit") => setMode(mode)}
/>
</div>
</Drawer>
);
}
+29 -31
View File
@@ -1,12 +1,13 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client"; import { Collection } from "@prisma/client";
import Modal from "../Modal"; import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@/hooks/store/collections"; import { useCreateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -72,10 +73,32 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col gap-3">
<div className="w-full"> <div className="flex gap-3 items-end">
<p className="mb-2">{t("name")}</p> <IconPicker
<div className="flex flex-col gap-2"> color={collection.color || "#0ea5e9"}
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: "#0ea5e9",
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput <TextInput
className="bg-base-200" className="bg-base-200"
value={collection.name} value={collection.name}
@@ -84,38 +107,13 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
setCollection({ ...collection, name: e.target.value }) setCollection({ ...collection, name: e.target.value })
} }
/> />
<div>
<p className="w-full mb-2">{t("color")}</p>
<div className="color-picker flex justify-between items-center">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32">
<i
className={"bi-folder-fill text-5xl"}
style={{ color: collection.color }}
></i>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
{t("reset")}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div className="w-full"> <div className="w-full">
<p className="mb-2">{t("description")}</p> <p className="mb-2">{t("description")}</p>
<textarea <textarea
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary" className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder={t("collection_description_placeholder")} placeholder={t("collection_description_placeholder")}
value={collection.description} value={collection.description}
onChange={(e) => onChange={(e) =>
+4 -1
View File
@@ -31,6 +31,9 @@ export default function NewLinkModal({ onClose }: Props) {
readable: "", readable: "",
monolith: "", monolith: "",
textContent: "", textContent: "",
icon: "",
iconWeight: "",
color: "",
collection: { collection: {
name: "", name: "",
ownerId: data?.user.id as number, ownerId: data?.user.id as number,
@@ -166,7 +169,7 @@ export default function NewLinkModal({ onClose }: Props) {
setLink({ ...link, description: e.target.value }) setLink({ ...link, description: e.target.value })
} }
placeholder={t("link_description_placeholder")} placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100" className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/> />
</div> </div>
</div> </div>
@@ -1,246 +0,0 @@
import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
AccountSettings,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import {
pdfAvailable,
readabilityAvailable,
monolithAvailable,
screenshotAvailable,
} from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow";
import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user";
import { useGetLink } from "@/hooks/store/links";
type Props = {
onClose: Function;
link: LinkIncludingShortenedCollectionAndTags;
};
export default function PreservedFormatsModal({ onClose, link }: Props) {
const { t } = useTranslation();
const session = useSession();
const getLink = useGetLink();
const { data: user = {} } = useUser();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
const [collectionOwner, setCollectionOwner] = useState<
Partial<AccountSettings>
>({});
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.collection.ownerId === user.id) {
setCollectionOwner({
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [link.collection.ownerId]);
const isReady = () => {
return (
link &&
(collectionOwner.archiveAsScreenshot === true
? link.pdf && link.pdf !== "pending"
: true) &&
(collectionOwner.archiveAsMonolith === true
? link.monolith && link.monolith !== "pending"
: true) &&
(collectionOwner.archiveAsPDF === true
? link.pdf && link.pdf !== "pending"
: true) &&
link.readable &&
link.readable !== "pending"
);
};
const atLeastOneFormatAvailable = () => {
return (
screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link)
);
};
useEffect(() => {
(async () => {
await getLink.mutateAsync({ id: link.id as number });
})();
let interval: NodeJS.Timeout | null = null;
if (!isReady()) {
interval = setInterval(async () => {
await getLink.mutateAsync({ id: link.id as number });
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.monolith]);
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
await getLink.mutateAsync({ id: link.id as number });
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("preserved_formats")}</p>
<div className="divider mb-2 mt-1"></div>
{screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link) ? (
<p className="mb-3">{t("available_formats")}</p>
) : (
""
)}
<div className={`flex flex-col gap-3`}>
{monolithAvailable(link) && (
<PreservedFormatRow
name={t("webpage")}
icon={"bi-filetype-html"}
format={ArchivedFormat.monolith}
link={link}
downloadable={true}
/>
)}
{screenshotAvailable(link) && (
<PreservedFormatRow
name={t("screenshot")}
icon={"bi-file-earmark-image"}
format={
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
link={link}
downloadable={true}
/>
)}
{pdfAvailable(link) && (
<PreservedFormatRow
name={t("pdf")}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
link={link}
downloadable={true}
/>
)}
{readabilityAvailable(link) && (
<PreservedFormatRow
name={t("readable")}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
link={link}
/>
)}
{!isReady() && !atLeastOneFormatAvailable() ? (
<div className={`w-full h-full flex flex-col justify-center p-10`}>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={30}
/>
<p className="text-center text-2xl">{t("preservation_in_queue")}</p>
<p className="text-center text-lg">{t("check_back_later")}</p>
</div>
) : (
!isReady() &&
atLeastOneFormatAvailable() && (
<div className={`w-full h-full flex flex-col justify-center p-5`}>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={20}
/>
<p className="text-center">{t("there_are_more_formats")}</p>
<p className="text-center text-sm">{t("check_back_later")}</p>
</div>
)
)}
<div
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${
isReady() ? "sm:mt " : ""
}`}
>
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">{t("view_latest_snapshot")}</p>
<i className="bi-box-arrow-up-right" />
</Link>
{link?.collection.ownerId === session.data?.user.id && (
<div className="btn btn-outline" onClick={updateArchive}>
<div>
<p>{t("refresh_preserved_formats")}</p>
<p className="text-xs">
{t("this_deletes_current_preservations")}
</p>
</div>
</div>
)}
</div>
</div>
</Modal>
);
}
+4 -1
View File
@@ -35,6 +35,9 @@ export default function UploadFileModal({ onClose }: Props) {
readable: "", readable: "",
monolith: "", monolith: "",
textContent: "", textContent: "",
icon: "",
iconWeight: "",
color: "",
collection: { collection: {
name: "", name: "",
ownerId: data?.user.id as number, ownerId: data?.user.id as number,
@@ -204,7 +207,7 @@ export default function UploadFileModal({ onClose }: Props) {
setLink({ ...link, description: e.target.value }) setLink({ ...link, description: e.target.value })
} }
placeholder={t("description_placeholder")} placeholder={t("description_placeholder")}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100" className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/> />
</div> </div>
</div> </div>
+21
View File
@@ -0,0 +1,21 @@
import React from "react";
import ClickAwayHandler from "./ClickAwayHandler";
type Props = {
children: React.ReactNode;
onClose: Function;
className?: string;
};
const Popover = ({ children, className, onClose }: Props) => {
return (
<ClickAwayHandler
onClickOutside={() => onClose()}
className={`absolute z-50 ${className || ""}`}
>
{children}
</ClickAwayHandler>
);
};
export default Popover;
+1 -1
View File
@@ -49,7 +49,7 @@ export default function PreservedFormatRow({
}; };
return ( return (
<div className="flex justify-between items-center rounded-lg p-2 bg-base-200"> <div className="flex justify-between items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<i className={`${icon} text-2xl text-primary`} /> <i className={`${icon} text-2xl text-primary`} />
<p>{name}</p> <p>{name}</p>
+17 -4
View File
@@ -16,6 +16,8 @@ import LinkActions from "./LinkViews/LinkComponents/LinkActions";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections"; import { useCollections } from "@/hooks/store/collections";
import { useGetLink } from "@/hooks/store/links"; import { useGetLink } from "@/hooks/store/links";
import { IconWeight } from "@phosphor-icons/react";
import Icon from "./Icon";
type LinkContent = { type LinkContent = {
title: string; title: string;
@@ -203,10 +205,21 @@ export default function ReadableView({ link }: Props) {
href={`/collections/${link?.collection.id}`} href={`/collections/${link?.collection.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10" className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
> >
<i {link.collection.icon ? (
className="bi-folder-fill drop-shadow text-2xl" <Icon
style={{ color: link?.collection.color }} icon={link.collection.icon}
></i> size={30}
weight={
(link.collection.iconWeight || "regular") as IconWeight
}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: link.collection.color }}
></i>
)}
<p <p
title={link?.collection.name} title={link?.collection.name}
className="text-lg truncate max-w-[12rem]" className="text-lg truncate max-w-[12rem]"
+4 -2
View File
@@ -1,12 +1,14 @@
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState, ChangeEvent } from "react"; import { useEffect, useState, ChangeEvent } from "react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import clsx from "clsx";
type Props = { type Props = {
className?: string; className?: string;
align?: "left" | "right";
}; };
export default function ToggleDarkMode({ className }: Props) { export default function ToggleDarkMode({ className, align }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore(); const { settings, updateSettings } = useLocalSettingsStore();
@@ -26,7 +28,7 @@ export default function ToggleDarkMode({ className }: Props) {
return ( return (
<div <div
className="tooltip tooltip-bottom" className={clsx("tooltip", align ? `tooltip-${align}` : "tooltip-bottom")}
data-tip={t("switch_to", { data-tip={t("switch_to", {
theme: settings.theme === "light" ? "Dark" : "Light", theme: settings.theme === "light" ? "Dark" : "Light",
})} })}
+99 -48
View File
@@ -1,7 +1,8 @@
import React, { Dispatch, SetStateAction, useEffect } from "react"; import React, { Dispatch, SetStateAction, useEffect } from "react";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@/types/global"; import { ViewMode } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
type Props = { type Props = {
viewMode: ViewMode; viewMode: ViewMode;
@@ -9,64 +10,114 @@ type Props = {
}; };
export default function ViewDropdown({ viewMode, setViewMode }: Props) { export default function ViewDropdown({ viewMode, setViewMode }: Props) {
const { updateSettings } = useLocalSettingsStore(); const { settings, updateSettings } = useLocalSettingsStore((state) => state);
const { t } = useTranslation();
const onChangeViewMode = ( const onChangeViewMode = (
e: React.MouseEvent<HTMLButtonElement>, e: React.MouseEvent<HTMLButtonElement>,
viewMode: ViewMode mode: ViewMode
) => { ) => {
setViewMode(viewMode); setViewMode(mode);
};
const toggleShowSetting = (setting: keyof typeof settings.show) => {
const newShowSettings = {
...settings.show,
[setting]: !settings.show[setting],
};
updateSettings({ show: newShowSettings });
}; };
useEffect(() => { useEffect(() => {
updateSettings({ viewMode }); updateSettings({ viewMode });
}, [viewMode]); }, [viewMode, updateSettings]);
return ( return (
<div className="p-1 flex flex-row gap-1 border border-neutral-content rounded-[0.625rem]"> <div className="dropdown dropdown-bottom dropdown-end">
<button <div
onClick={(e) => onChangeViewMode(e, ViewMode.Card)} tabIndex={0}
className={`btn btn-square btn-sm btn-ghost ${ role="button"
viewMode == ViewMode.Card onMouseDown={dropdownTriggerer}
? "bg-primary/20 hover:bg-primary/20" className="btn btn-sm btn-square btn-ghost border-none"
: "hover:bg-neutral/20"
}`}
> >
<i className="bi-grid w-4 h-4 text-neutral"></i> {viewMode === ViewMode.Card ? (
</button> <i className="bi-grid w-4 h-4 text-neutral"></i>
) : viewMode === ViewMode.Masonry ? (
<button <i className="bi-columns-gap w-4 h-4 text-neutral"></i>
onClick={(e) => onChangeViewMode(e, ViewMode.Masonry)} ) : (
className={`btn btn-square btn-sm btn-ghost ${ <i className="bi-view-stacked w-4 h-4 text-neutral"></i>
viewMode == ViewMode.Masonry )}
? "bg-primary/20 hover:bg-primary/20" </div>
: "hover:bg-neutral/20" <ul className="dropdown-content z-[30] menu shadow bg-base-200 min-w-52 border border-neutral-content rounded-xl mt-1">
}`} <p className="mb-1 text-sm text-neutral">{t("view")}</p>
> <div
<i className="bi bi-columns-gap w-4 h-4 text-neutral"></i> className="p-1 flex w-full justify-between gap-1 border border-neutral-content rounded-[0.625rem]"
</button> tabIndex={0}
role="button"
<button >
onClick={(e) => onChangeViewMode(e, ViewMode.List)} <button
className={`btn btn-square btn-sm btn-ghost ${ onClick={(e) => onChangeViewMode(e, ViewMode.Card)}
viewMode == ViewMode.List className={`btn w-[31%] btn-sm btn-ghost ${
? "bg-primary/20 hover:bg-primary/20" viewMode === ViewMode.Card
: "hover:bg-neutral/20" ? "bg-primary/20 hover:bg-primary/20"
}`} : "hover:bg-neutral/20"
> }`}
<i className="bi bi-view-stacked w-4 h-4 text-neutral"></i> >
</button> <i className="bi-grid text-lg text-neutral"></i>
</button>
{/* <button <button
onClick={(e) => onChangeViewMode(e, ViewMode.Grid)} onClick={(e) => onChangeViewMode(e, ViewMode.Masonry)}
className={`btn btn-square btn-sm btn-ghost ${ className={`btn w-[31%] btn-sm btn-ghost ${
viewMode == ViewMode.Grid viewMode === ViewMode.Masonry
? "bg-primary/20 hover:bg-primary/20" ? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
}`} }`}
> >
<i className="bi-columns-gap w-4 h-4 text-neutral"></i> <i className="bi-columns-gap text-lg text-neutral"></i>
</button> */} </button>
<button
onClick={(e) => onChangeViewMode(e, ViewMode.List)}
className={`btn w-[31%] btn-sm btn-ghost ${
viewMode === ViewMode.List
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-view-stacked text-lg text-neutral"></i>
</button>
</div>
<p className="my-1 text-sm text-neutral">{t("show")}</p>
{Object.entries(settings.show)
.filter((e) =>
settings.viewMode === ViewMode.List // Hide tags, image, icon, and description checkboxes in list view
? e[0] !== "tags" &&
e[0] !== "image" &&
e[0] !== "icon" &&
e[0] !== "description"
: settings.viewMode === ViewMode.Card // Hide tags and description checkboxes in card view
? e[0] !== "tags" && e[0] !== "description"
: true
)
.map(([key, value]) => (
<li key={key}>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
className="checkbox checkbox-primary"
checked={value}
onChange={() =>
toggleShowSetting(key as keyof typeof settings.show)
}
/>
<span className="label-text whitespace-nowrap">{t(key)}</span>
</label>
</li>
))}
</ul>
</div> </div>
); );
} }
+37
View File
@@ -13,6 +13,7 @@ import {
} from "@/types/global"; } from "@/types/global";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Jimp from "jimp";
const useLinks = (params: LinkRequestQuery = {}) => { const useLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter(); const router = useRouter();
@@ -395,6 +396,41 @@ const useUploadFile = () => {
}); });
}; };
const useUpdatePreview = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ linkId, file }: { linkId: number; file: File }) => {
const formBody = new FormData();
if (!linkId || !file)
throw new Error("Error generating preview: Invalid parameters");
formBody.append("file", file);
const res = await fetch(
`/api/v1/archives/${linkId}?format=` + ArchivedFormat.jpeg,
{
body: formBody,
method: "POST",
}
);
const data = res.json();
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["links"] });
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useBulkEditLinks = () => { const useBulkEditLinks = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -479,4 +515,5 @@ export {
useGetLink, useGetLink,
useBulkEditLinks, useBulkEditLinks,
resetInfiniteQueryPagination, resetInfiniteQueryPagination,
useUpdatePreview,
}; };
@@ -18,8 +18,6 @@ export default async function updateCollection(
if (!(collectionIsAccessible?.ownerId === userId)) if (!(collectionIsAccessible?.ownerId === userId))
return { response: "Collection is not accessible.", status: 401 }; return { response: "Collection is not accessible.", status: 401 };
console.log(data);
if (data.parentId) { if (data.parentId) {
if (data.parentId !== ("root" as any)) { if (data.parentId !== ("root" as any)) {
const findParentCollection = await prisma.collection.findUnique({ const findParentCollection = await prisma.collection.findUnique({
@@ -61,6 +59,8 @@ export default async function updateCollection(
name: data.name.trim(), name: data.name.trim(),
description: data.description, description: data.description,
color: data.color, color: data.color,
icon: data.icon,
iconWeight: data.iconWeight,
isPublic: data.isPublic, isPublic: data.isPublic,
parent: parent:
data.parentId && data.parentId !== ("root" as any) data.parentId && data.parentId !== ("root" as any)
@@ -42,6 +42,8 @@ export default async function postCollection(
name: collection.name.trim(), name: collection.name.trim(),
description: collection.description, description: collection.description,
color: collection.color, color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
parent: collection.parentId parent: collection.parentId
? { ? {
connect: { connect: {
@@ -25,15 +25,15 @@ export default async function updateLinkById(
(e: UsersAndCollections) => e.userId === userId (e: UsersAndCollections) => e.userId === userId
); );
// If the user is able to create a link, they can pin it to their dashboard only. // If the user is part of a collection, they can pin it to their dashboard
if (canPinPermission) { if (canPinPermission && data.pinnedBy && data.pinnedBy[0]) {
const updatedLink = await prisma.link.update({ const updatedLink = await prisma.link.update({
where: { where: {
id: linkId, id: linkId,
}, },
data: { data: {
pinnedBy: pinnedBy:
data?.pinnedBy && data.pinnedBy[0] data?.pinnedBy && data.pinnedBy[0].id === userId
? { connect: { id: userId } } ? { connect: { id: userId } }
: { disconnect: { id: userId } }, : { disconnect: { id: userId } },
}, },
@@ -48,7 +48,7 @@ export default async function updateLinkById(
}, },
}); });
// return { response: updatedLink, status: 200 }; return { response: updatedLink, status: 200 };
} }
const targetCollectionIsAccessible = await getPermission({ const targetCollectionIsAccessible = await getPermission({
@@ -96,6 +96,9 @@ export default async function updateLinkById(
data: { data: {
name: data.name, name: data.name,
description: data.description, description: data.description,
icon: data.icon,
iconWeight: data.iconWeight,
color: data.color,
collection: { collection: {
connect: { connect: {
id: data.collection.id, id: data.collection.id,
+3 -3
View File
@@ -11,8 +11,8 @@ export const icons: ReadonlyArray<IconEntry> = iconData.map((entry) => ({
Icon: Icons[entry.pascal_name as keyof typeof Icons] as Icons.Icon, Icon: Icons[entry.pascal_name as keyof typeof Icons] as Icons.Icon,
})); }));
if (process.env.NODE_ENV === "development") { // if (process.env.NODE_ENV === "development") {
console.log(`${icons.length} icons`); // console.log(`${icons.length} icons`);
} // }
export const iconCount = Intl.NumberFormat("en-US").format(icons.length * 6); export const iconCount = Intl.NumberFormat("en-US").format(icons.length * 6);
+2 -5
View File
@@ -88,13 +88,10 @@ function App({
{icon} {icon}
<span data-testid="toast-message">{message}</span> <span data-testid="toast-message">{message}</span>
{t.type !== "loading" && ( {t.type !== "loading" && (
<button <div
className="btn btn-xs outline-none btn-circle btn-ghost"
data-testid="close-toast-button" data-testid="close-toast-button"
onClick={() => toast.dismiss(t.id)} onClick={() => toast.dismiss(t.id)}
> ></div>
<i className="bi bi-x"></i>
</button>
)} )}
</div> </div>
)} )}
+91 -4
View File
@@ -105,8 +105,6 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
response: "Collection is not accessible.", response: "Collection is not accessible.",
}); });
// await uploadHandler(linkId, )
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER || 30000); const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER || 30000);
const numberOfLinksTheUserHas = await prisma.link.count({ const numberOfLinksTheUserHas = await prisma.link.count({
@@ -119,8 +117,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER) if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return res.status(400).json({ return res.status(400).json({
response: response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
"Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.",
}); });
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number( const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
@@ -208,4 +205,94 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
}); });
}); });
} }
// To update the link preview
else if (req.method === "PUT" && format === ArchivedFormat.jpeg) {
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 user = await verifyUser({ req, res });
if (!user) return;
const collectionPermissions = await getPermission({
userId: user.id,
linkId,
});
if (!collectionPermissions)
return res.status(400).json({
response: "Collection is not accessible.",
});
const memberHasAccess = collectionPermissions.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
);
if (!(collectionPermissions.ownerId === user.id || memberHasAccess))
return res.status(400).json({
response: "Collection is not accessible.",
});
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10
);
const form = formidable({
maxFields: 1,
maxFiles: 1,
maxFileSize: NEXT_PUBLIC_MAX_FILE_BUFFER * 1024 * 1024,
});
form.parse(req, async (err, fields, files) => {
const allowedMIMETypes = ["image/png", "image/jpg", "image/jpeg"];
if (
err ||
!files.file ||
!files.file[0] ||
!allowedMIMETypes.includes(files.file[0].mimetype || "")
) {
// Handle parsing error
return res.status(400).json({
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
});
} else {
const fileBuffer = fs.readFileSync(files.file[0].filepath);
if (
Buffer.byteLength(fileBuffer) >
1024 * 1024 * Number(NEXT_PUBLIC_MAX_FILE_BUFFER)
)
return res.status(400).json({
response: `Sorry, we couldn't process your file. Please ensure it's a PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
});
const linkStillExists = await prisma.link.update({
where: { id: linkId },
data: {
updatedAt: new Date(),
},
});
if (linkStillExists) {
const collectionId = collectionPermissions.id;
createFolder({
filePath: `archives/preview/${collectionId}`,
});
generatePreview(fileBuffer, collectionId, linkId);
}
fs.unlinkSync(files.file[0].filepath);
if (linkStillExists)
return res.status(200).json({
response: linkStillExists,
});
else return res.status(400).json({ response: "Link not found." });
}
});
}
} }
+17 -4
View File
@@ -24,6 +24,8 @@ import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import { useLinks } from "@/hooks/store/links"; import { useLinks } from "@/hooks/store/links";
import Links from "@/components/LinkViews/Links"; import Links from "@/components/LinkViews/Links";
import Icon from "@/components/Icon";
import { IconWeight } from "@phosphor-icons/react";
export default function Index() { export default function Index() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -110,10 +112,21 @@ export default function Index() {
{activeCollection && ( {activeCollection && (
<div className="flex gap-3 items-start justify-between"> <div className="flex gap-3 items-start justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<i {activeCollection.icon ? (
className="bi-folder-fill text-3xl drop-shadow" <Icon
style={{ color: activeCollection?.color }} icon={activeCollection.icon}
></i> size={45}
weight={
(activeCollection.iconWeight || "regular") as IconWeight
}
color={activeCollection.color}
/>
) : (
<i
className="bi-folder-fill text-3xl"
style={{ color: activeCollection.color }}
></i>
)}
<p className="sm:text-3xl text-2xl capitalize w-full py-1 break-words hyphens-auto font-thin"> <p className="sm:text-3xl text-2xl capitalize w-full py-1 break-words hyphens-auto font-thin">
{activeCollection?.name} {activeCollection?.name}
+4 -3
View File
@@ -17,11 +17,12 @@ const Index = () => {
}, []); }, []);
return ( return (
<div className="flex h-screen py-20"> <div className="flex h-screen">
{getLink.data ? ( {getLink.data ? (
<LinkDetails <LinkDetails
link={getLink.data} activeLink={getLink.data}
className="max-w-xl p-5 m-auto w-full" className="sm:max-w-xl sm:m-auto sm:p-5 w-full"
standalone
/> />
) : ( ) : (
<div className="max-w-xl p-5 m-auto w-full flex flex-col items-center gap-5"> <div className="max-w-xl p-5 m-auto w-full flex flex-col items-center gap-5">
+5 -6
View File
@@ -1,15 +1,13 @@
import LinkDetails from "@/components/LinkDetails"; import LinkDetails from "@/components/LinkDetails";
import { useGetLink } from "@/hooks/store/links"; import { useGetLink } from "@/hooks/store/links";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
const Index = () => { const Index = () => {
const router = useRouter(); const router = useRouter();
const { id } = router.query; const { id } = router.query;
useState;
const getLink = useGetLink(); const getLink = useGetLink();
useEffect(() => { useEffect(() => {
@@ -17,11 +15,12 @@ const Index = () => {
}, []); }, []);
return ( return (
<div className="flex h-screen py-20"> <div className="flex h-screen">
{getLink.data ? ( {getLink.data ? (
<LinkDetails <LinkDetails
link={getLink.data} activeLink={getLink.data}
className="max-w-xl p-5 m-auto w-full" className="sm:max-w-xl sm:m-auto sm:p-5 w-full"
standalone
/> />
) : ( ) : (
<div className="max-w-xl p-5 m-auto w-full flex flex-col items-center gap-5"> <div className="max-w-xl p-5 m-auto w-full flex flex-col items-center gap-5">
+23 -25
View File
@@ -146,31 +146,29 @@ export default function Index() {
<i className={"bi-hash text-primary text-3xl"} /> <i className={"bi-hash text-primary text-3xl"} />
{renameTag ? ( {renameTag ? (
<> <form onSubmit={submit} className="flex items-center gap-2">
<form onSubmit={submit} className="flex items-center gap-2"> <input
<input type="text"
type="text" autoFocus
autoFocus className="sm:text-3xl text-2xl bg-transparent h-10 w-3/4 outline-none border-b border-b-neutral-content"
className="sm:text-3xl text-2xl bg-transparent h-10 w-3/4 outline-none border-b border-b-neutral-content" value={newTagName}
value={newTagName} onChange={(e) => setNewTagName(e.target.value)}
onChange={(e) => setNewTagName(e.target.value)} />
/> <div
<div onClick={() => submit()}
onClick={() => submit()} id="expand-dropdown"
id="expand-dropdown" className="btn btn-ghost btn-square btn-sm"
className="btn btn-ghost btn-square btn-sm" >
> <i className={"bi-check2 text-neutral text-2xl"}></i>
<i className={"bi-check text-neutral text-2xl"}></i> </div>
</div> <div
<div onClick={() => cancelUpdateTag()}
onClick={() => cancelUpdateTag()} id="expand-dropdown"
id="expand-dropdown" className="btn btn-ghost btn-square btn-sm"
className="btn btn-ghost btn-square btn-sm" >
> <i className={"bi-x text-neutral text-2xl"}></i>
<i className={"bi-x text-neutral text-2xl"}></i> </div>
</div> </form>
</form>
</>
) : ( ) : (
<> <>
<p className="sm:text-3xl text-2xl capitalize"> <p className="sm:text-3xl text-2xl capitalize">
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Collection" ADD COLUMN "icon" TEXT,
ADD COLUMN "iconWeight" TEXT;
-- AlterTable
ALTER TABLE "Link" ADD COLUMN "color" TEXT,
ADD COLUMN "icon" TEXT,
ADD COLUMN "iconWeight" TEXT;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Collection" ALTER COLUMN "color" DROP NOT NULL,
ALTER COLUMN "color" DROP DEFAULT;
@@ -0,0 +1,9 @@
/*
Warnings:
- Made the column `color` on table `Collection` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "Collection" ALTER COLUMN "color" SET NOT NULL,
ALTER COLUMN "color" SET DEFAULT '#0ea5e9';
+5
View File
@@ -94,6 +94,8 @@ model Collection {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
description String @default("") description String @default("")
icon String?
iconWeight String?
color String @default("#0ea5e9") color String @default("#0ea5e9")
parentId Int? parentId Int?
parent Collection? @relation("SubCollections", fields: [parentId], references: [id]) parent Collection? @relation("SubCollections", fields: [parentId], references: [id])
@@ -133,6 +135,9 @@ model Link {
collection Collection @relation(fields: [collectionId], references: [id]) collection Collection @relation(fields: [collectionId], references: [id])
collectionId Int collectionId Int
tags Tag[] tags Tag[]
icon String?
iconWeight String?
color String?
url String? url String?
textContent String? textContent String?
preview String? preview String?
+21 -2
View File
@@ -297,7 +297,7 @@
"for_collection": "For {{name}}", "for_collection": "For {{name}}",
"create_new_collection": "Create a New Collection", "create_new_collection": "Create a New Collection",
"color": "Color", "color": "Color",
"reset": "Reset", "reset_defaults": "Reset to Defaults",
"updating_collection": "Updating Collection...", "updating_collection": "Updating Collection...",
"collection_name_placeholder": "e.g. Example Collection", "collection_name_placeholder": "e.g. Example Collection",
"collection_description_placeholder": "The purpose of this Collection...", "collection_description_placeholder": "The purpose of this Collection...",
@@ -372,5 +372,24 @@
"demo_desc": "This is only a demo instance of Linkwarden and uploads are disabled.", "demo_desc": "This is only a demo instance of Linkwarden and uploads are disabled.",
"demo_desc_2": "If you want to try out the full version, you can sign up for a free trial at:", "demo_desc_2": "If you want to try out the full version, you can sign up for a free trial at:",
"demo_button": "Login as demo user", "demo_button": "Login as demo user",
"notes": "Notes" "regular": "Regular",
"thin": "Thin",
"bold": "Bold",
"fill": "Fill",
"duotone": "Duotone",
"light_icon": "Light",
"search": "Search",
"set_custom_icon": "Set Custom Icon",
"view": "View",
"show": "Show",
"image": "Image",
"icon": "Icon",
"date": "Date",
"preview_unavailable": "Preview Unavailable",
"saved": "Saved",
"untitled": "Untitled",
"no_tags": "No tags.",
"no_description_provided": "No description provided.",
"change_icon": "Change Icon",
"upload_preview_image": "Upload Preview Image"
} }
+75 -31
View File
@@ -2,14 +2,24 @@ import { Sort } from "@/types/global";
import { create } from "zustand"; import { create } from "zustand";
type LocalSettings = { type LocalSettings = {
theme?: string; theme: string;
viewMode?: string; viewMode: string;
show: {
link: boolean;
name: boolean;
description: boolean;
image: boolean;
tags: boolean;
icon: boolean;
collection: boolean;
date: boolean;
};
sortBy?: Sort; sortBy?: Sort;
}; };
type LocalSettingsStore = { type LocalSettingsStore = {
settings: LocalSettings; settings: LocalSettings;
updateSettings: (settings: LocalSettings) => void; updateSettings: (settings: Partial<LocalSettings>) => void;
setSettings: () => void; setSettings: () => void;
}; };
@@ -17,50 +27,84 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
settings: { settings: {
theme: "", theme: "",
viewMode: "", viewMode: "",
show: {
link: true,
name: true,
description: true,
image: true,
tags: true,
icon: true,
collection: true,
date: true,
},
sortBy: Sort.DateNewestFirst, sortBy: Sort.DateNewestFirst,
}, },
updateSettings: async (newSettings) => { updateSettings: (newSettings) => {
if ( const { theme, viewMode, sortBy, show } = newSettings;
newSettings.theme !== undefined &&
newSettings.theme !== localStorage.getItem("theme")
) {
localStorage.setItem("theme", newSettings.theme);
const localTheme = localStorage.getItem("theme") || ""; if (theme !== undefined && theme !== localStorage.getItem("theme")) {
localStorage.setItem("theme", theme);
document.querySelector("html")?.setAttribute("data-theme", localTheme); document.querySelector("html")?.setAttribute("data-theme", theme);
} }
if ( if (
newSettings.viewMode !== undefined && viewMode !== undefined &&
newSettings.viewMode !== localStorage.getItem("viewMode") viewMode !== localStorage.getItem("viewMode")
) { ) {
localStorage.setItem("viewMode", newSettings.viewMode); localStorage.setItem("viewMode", viewMode);
// const localTheme = localStorage.getItem("viewMode") || "";
} }
if ( if (sortBy !== undefined) {
newSettings.sortBy !== undefined && localStorage.setItem("sortBy", sortBy.toString());
newSettings.sortBy !== Number(localStorage.getItem("sortBy"))
) {
localStorage.setItem("sortBy", newSettings.sortBy.toString());
} }
set((state) => ({ settings: { ...state.settings, ...newSettings } })); const currentShowString = localStorage.getItem("show");
}, const newShowString = show ? JSON.stringify(show) : currentShowString;
setSettings: async () => {
if (!localStorage.getItem("theme")) {
localStorage.setItem("theme", "dark");
}
const localTheme = localStorage.getItem("theme") || ""; if (newShowString !== currentShowString) {
localStorage.setItem("show", newShowString || "");
}
set((state) => ({ set((state) => ({
settings: { ...state.settings, theme: localTheme }, settings: {
...state.settings,
...newSettings,
show: show ? { ...state.settings.show, ...show } : state.settings.show,
},
})); }));
},
setSettings: () => {
const theme = localStorage.getItem("theme") || "dark";
localStorage.setItem("theme", theme);
document.querySelector("html")?.setAttribute("data-theme", localTheme); const viewMode = localStorage.getItem("viewMode") || "card";
localStorage.setItem("viewMode", viewMode);
const storedShow = localStorage.getItem("show");
const show = storedShow
? JSON.parse(storedShow)
: {
link: true,
name: true,
description: true,
image: true,
tags: true,
icon: true,
collection: true,
date: true,
};
localStorage.setItem("show", JSON.stringify(show));
set({
settings: {
theme,
viewMode,
show,
sortBy: useLocalSettingsStore.getState().settings.sortBy,
},
});
document.querySelector("html")?.setAttribute("data-theme", theme);
}, },
})); }));
+30 -8
View File
@@ -36,6 +36,14 @@
scrollbar-width: none; scrollbar-width: none;
} }
.hide-color-picker {
opacity: 0;
display: block;
width: 32px;
height: 32px;
border: none;
}
.hyphens { .hyphens {
hyphens: auto; hyphens: auto;
} }
@@ -136,15 +144,16 @@
/* For react-colorful */ /* For react-colorful */
.color-picker .react-colorful { .color-picker .react-colorful {
width: 100%; height: 7rem;
height: 7.5rem; width: 7rem;
} }
.color-picker .react-colorful__hue { .color-picker .react-colorful__hue {
height: 1rem; height: 1rem;
} }
.color-picker .react-colorful__pointer { .color-picker .react-colorful__pointer {
width: 1.3rem; width: 1rem;
height: 1.3rem; height: 1rem;
border-width: 1px;
} }
/* For the Link banner */ /* For the Link banner */
@@ -153,6 +162,19 @@
height: fit-content; height: fit-content;
} }
.react-select__indicator-separator {
display: none;
}
.react-select__control--is-focused .react-select__dropdown-indicator,
.react-select__control .react-select__dropdown-indicator,
.react-select__control .react-select__dropdown-indicator:hover,
.react-select__control .react-select__dropdown-indicator:focus,
.react-select__control--is-focused .react-select__dropdown-indicator:hover,
.react-select__control--is-focused .react-select__dropdown-indicator:focus {
color: oklch(var(--n));
}
/* Theme */ /* Theme */
@layer components { @layer components {
@@ -160,13 +182,13 @@
@apply bg-base-200 hover:border-neutral-content; @apply bg-base-200 hover:border-neutral-content;
} }
.react-select-container .react-select__control--is-focused {
@apply border-primary hover:border-primary;
}
.react-select-container .react-select__menu { .react-select-container .react-select__menu {
@apply bg-base-100 border-neutral-content border rounded-md; @apply bg-base-100 border-neutral-content border rounded-md;
} }
/*
.react-select-container .react-select__menu-list {
@apply h-20;
} */
.react-select-container .react-select__input-container, .react-select-container .react-select__input-container,
.react-select-container .react-select__single-value { .react-select-container .react-select__single-value {
+1
View File
@@ -22,6 +22,7 @@ export interface LinkIncludingShortenedCollectionAndTags
pinnedBy?: { pinnedBy?: {
id: number; id: number;
}[]; }[];
updatedAt?: string;
collection: OptionalExcluding<Collection, "name" | "ownerId">; collection: OptionalExcluding<Collection, "name" | "ownerId">;
} }