diff --git a/components/CollectionListing.tsx b/components/CollectionListing.tsx index f3a722ea..0bc2c9d5 100644 --- a/components/CollectionListing.tsx +++ b/components/CollectionListing.tsx @@ -17,6 +17,8 @@ import toast from "react-hot-toast"; import { useTranslation } from "next-i18next"; import { useCollections, useUpdateCollection } from "@/hooks/store/collections"; import { useUpdateUser, useUser } from "@/hooks/store/user"; +import Icon from "./Icon"; +import { IconWeight } from "@phosphor-icons/react"; interface ExtendedTreeItem extends TreeItem { data: Collection; @@ -256,7 +258,7 @@ const renderItem = ( : "hover:bg-neutral/20" } 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)} - + {collection.icon ? ( + + ) : ( + + )} +

{collection.name}

{collection.isPublic && ( @@ -288,7 +301,7 @@ const renderItem = ( ); }; -const Icon = ( +const Dropdown = ( item: ExtendedTreeItem, onExpand: (id: ItemId) => void, onCollapse: (id: ItemId) => void @@ -332,6 +345,8 @@ const buildTreeFromCollections = ( name: collection.name, description: collection.description, color: collection.color, + icon: collection.icon, + iconWeight: collection.iconWeight, isPublic: collection.isPublic, ownerId: collection.ownerId, createdAt: collection.createdAt, diff --git a/components/Drawer.tsx b/components/Drawer.tsx index 12f931ec..49cd9eb2 100644 --- a/components/Drawer.tsx +++ b/components/Drawer.tsx @@ -40,13 +40,13 @@ export default function Drawer({ dismissible && setDrawerIsOpen(false)} > - +
{children} diff --git a/components/Icon.tsx b/components/Icon.tsx new file mode 100644 index 00000000..b1790669 --- /dev/null +++ b/components/Icon.tsx @@ -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(({ icon, ...rest }, ref) => { + const IconComponent: any = Icons[icon as keyof typeof Icons]; + + if (!IconComponent) { + return null; + } else return ; +}); + +export default Icon; diff --git a/components/IconGrid.tsx b/components/IconGrid.tsx new file mode 100644 index 00000000..edb2362d --- /dev/null +++ b/components/IconGrid.tsx @@ -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 ( +
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" + : "" + }`} + > + +
+ ); + }); +}; + +export default IconGrid; diff --git a/components/IconPicker.tsx b/components/IconPicker.tsx index 994a5af0..82643a8c 100644 --- a/components/IconPicker.tsx +++ b/components/IconPicker.tsx @@ -1,44 +1,81 @@ -import { icons } from "@/lib/client/icons"; -import React, { useMemo, useState } from "react"; -import Fuse from "fuse.js"; +import React, { useState } from "react"; 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, { - keys: [{ name: "name", weight: 4 }, "tags", "categories"], - threshold: 0.2, - useExtendedSearch: true, -}); +type Props = { + alignment?: string; + color: string; + 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 = (props: Props) => { - const [query, setQuery] = useState(""); - - const filteredQueryResultsSelector = useMemo(() => { - if (!query) { - return icons; - } - return fuse.search(query).map((result) => result.item); - }, [query]); +const IconPicker = ({ + alignment, + color, + setColor, + iconName, + setIconName, + weight, + setWeight, + hideDefaultIcon, + className, + reset, +}: Props) => { + const { t } = useTranslation(); + const [iconPicker, setIconPicker] = useState(false); return ( -
- setQuery(e.target.value)} - /> -
- {filteredQueryResultsSelector.map((icon) => { - const IconComponent = icon.Icon; - return ( -
console.log(icon.name)}> - -
- ); - })} +
+
setIconPicker(!iconPicker)} + className="btn btn-square w-20 h-20" + > + {iconName ? ( + + ) : !iconName && hideDefaultIcon ? ( +

{t("set_custom_icon")}

+ ) : ( + + )}
+ {iconPicker && ( + setIconPicker(false)} + className={clsx( + className, + alignment || "lg:-translate-x-1/3 top-20 left-0" + )} + /> + )}
); }; diff --git a/components/IconPopover.tsx b/components/IconPopover.tsx new file mode 100644 index 00000000..12086617 --- /dev/null +++ b/components/IconPopover.tsx @@ -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 ( + onClose()} + className={clsx( + className, + "fade-in bg-base-200 border border-neutral-content p-2 w-[22.5rem] rounded-lg shadow-md" + )} + > +
+
+ setQuery(e.target.value)} + /> + +
+ +
+ +
+ setColor(e)} /> + +
+ + + + + + +
+
+
} + > + {t("reset_defaults")} +
+
+
+
+ ); +}; + +export default IconPopover; diff --git a/components/InputSelect/CollectionSelection.tsx b/components/InputSelect/CollectionSelection.tsx index 81bef390..f53df37f 100644 --- a/components/InputSelect/CollectionSelection.tsx +++ b/components/InputSelect/CollectionSelection.tsx @@ -16,6 +16,8 @@ type Props = { } | undefined; creatable?: boolean; + autoFocus?: boolean; + onBlur?: any; }; export default function CollectionSelection({ @@ -23,6 +25,8 @@ export default function CollectionSelection({ defaultValue, showDefaultValue = true, creatable = true, + autoFocus, + onBlur, }: Props) { const { data: collections = [] } = useCollections(); @@ -76,7 +80,7 @@ export default function CollectionSelection({ return (
{data.label} @@ -104,6 +108,8 @@ export default function CollectionSelection({ onChange={onChange} options={options} styles={styles} + autoFocus={autoFocus} + onBlur={onBlur} defaultValue={showDefaultValue ? defaultValue : null} components={{ Option: customOption, @@ -120,7 +126,9 @@ export default function CollectionSelection({ onChange={onChange} options={options} styles={styles} + autoFocus={autoFocus} defaultValue={showDefaultValue ? defaultValue : null} + onBlur={onBlur} components={{ Option: customOption, }} diff --git a/components/InputSelect/TagSelection.tsx b/components/InputSelect/TagSelection.tsx index 65e74997..08460ea3 100644 --- a/components/InputSelect/TagSelection.tsx +++ b/components/InputSelect/TagSelection.tsx @@ -10,9 +10,16 @@ type Props = { value: number; 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 [options, setOptions] = useState([]); @@ -35,6 +42,8 @@ export default function TagSelection({ onChange, defaultValue }: Props) { styles={styles} defaultValue={defaultValue} isMulti + autoFocus={autoFocus} + onBlur={onBlur} /> ); } diff --git a/components/InputSelect/styles.ts b/components/InputSelect/styles.ts index 96aad6d9..f05f4a51 100644 --- a/components/InputSelect/styles.ts +++ b/components/InputSelect/styles.ts @@ -14,7 +14,7 @@ export const styles: StylesConfig = { ? "oklch(var(--p))" : "oklch(var(--nc))", }, - transition: "all 50ms", + transition: "all 100ms", }), control: (styles, state) => ({ ...styles, @@ -50,19 +50,28 @@ export const styles: StylesConfig = { multiValue: (styles) => { return { ...styles, - backgroundColor: "#0ea5e9", - color: "white", + backgroundColor: "oklch(var(--b2))", + color: "oklch(var(--bc))", + display: "flex", + alignItems: "center", + gap: "0.1rem", + marginRight: "0.4rem", }; }, multiValueLabel: (styles) => ({ ...styles, - color: "white", + color: "oklch(var(--bc))", }), multiValueRemove: (styles) => ({ ...styles, + height: "1.2rem", + width: "1.2rem", + borderRadius: "100px", + transition: "all 100ms", + color: "oklch(var(--w))", ":hover": { - color: "white", - backgroundColor: "#38bdf8", + color: "red", + backgroundColor: "oklch(var(--nc))", }, }), menuPortal: (base) => ({ ...base, zIndex: 9999 }), diff --git a/components/LinkDetails.tsx b/components/LinkDetails.tsx index 0f237ca2..5c71ff78 100644 --- a/components/LinkDetails.tsx +++ b/components/LinkDetails.tsx @@ -4,31 +4,63 @@ import { ArchivedFormat, } from "@/types/global"; import Link from "next/link"; -import { useSession } from "next-auth/react"; import { pdfAvailable, readabilityAvailable, monolithAvailable, screenshotAvailable, + previewAvailable, } 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"; +import { + useGetLink, + useUpdateLink, + useUpdatePreview, +} from "@/hooks/store/links"; import LinkIcon from "./LinkViews/LinkComponents/LinkIcon"; import CopyButton from "./CopyButton"; 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 = { 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(activeLink); + + useEffect(() => { + setLink(activeLink); + }, [activeLink]); + + const permissions = usePermissions(link.collection.id as number); + const { t } = useTranslation(); - const session = useSession(); const getLink = useGetLink(); const { data: user = {} } = useUser(); @@ -117,188 +149,498 @@ export default function LinkDetails({ className, link }: Props) { clearInterval(interval); } }; - }, [link?.monolith]); + }, [link.monolith]); const router = useRouter(); 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 ( -
- - - {link.name &&

{link.name}

} - - {link.url && ( - <> -
- -

{t("link")}

- -
-
- - {link.url} - -
- -
-
-
- - )} - -
- -

{t("collection")}

- -
- +
+
-

{link.collection.name}

-
- -
- -
+ {previewAvailable(link) ? ( + { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + ) : link.preview === "unavailable" ? ( +
+ ) : ( +
+ )} - {link.tags[0] && ( - <> -
+ {!standalone && (permissions === true || permissions?.canUpdate) && ( +
+
+ + {!standalone && (permissions === true || permissions?.canUpdate) ? ( +
+
+ setIconPopover(true)} + /> +
+ {iconPopover && ( + 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(); + }} + /> )}
- - )} + ) : ( +
+ setIconPopover(true)} /> +
+ )} + +
+ {mode === "view" && ( +
+

+ {link.name || t("untitled")} +

+
+ )} + + {link.url && ( + <> +
+ +

{t("link")}

+ +
+
+ + {link.url} + +
+ +
+
+
+ + )} + + {mode === "edit" && ( + <> +
+ +
+

+ {t("name")} +

+ setLink({ ...link, name: e.target.value })} + placeholder={t("placeholder_example_link")} + className="bg-base-200" + /> +
+ + )} - {link.description && ( - <>
-
-

{t("notes")}

+
+

+ {t("collection")} +

-
-

{link.description}

+ {mode === "view" ? ( +
+ +

{link.collection.name}

+
+ {link.collection.icon ? ( + + ) : ( + + )} +
+ +
+ ) : ( + + )} +
+ +
+ +
+

+ {t("tags")} +

+ + {mode === "view" ? ( +
+ {link.tags[0] ? ( + link.tags.map((tag) => + isPublicRoute ? ( +
+ {tag.name} +
+ ) : ( + + {tag.name} + + ) + ) + ) : ( +
{t("no_tags")}
+ )} +
+ ) : ( + ({ + label: e.name, + value: e.id, + }))} + /> + )} +
+ +
+ +
+

+ {t("description")} +

+ + {mode === "view" ? ( +
+ {link.description ? ( +

{link.description}

+ ) : ( +

{t("no_description_provided")}

+ )} +
+ ) : ( +