better edit view

This commit is contained in:
daniel31x13 2024-08-30 02:38:58 -04:00
parent 820d686c37
commit aee10fa406
22 changed files with 596 additions and 256 deletions

View File

@ -273,13 +273,13 @@ const renderItem = (
icon={collection.icon} icon={collection.icon}
size={30} size={30}
weight={(collection.iconWeight || "regular") as IconWeight} weight={(collection.iconWeight || "regular") as IconWeight}
color={collection.color || "#0ea5e9"} color={collection.color}
className="-mr-[0.15rem]" className="-mr-[0.15rem]"
/> />
) : ( ) : (
<i <i
className="bi-folder-fill text-2xl" className="bi-folder-fill text-2xl"
style={{ color: collection.color || "#0ea5e9" }} style={{ color: collection.color }}
></i> ></i>
)} )}

25
components/EditButton.tsx Normal file
View File

@ -0,0 +1,25 @@
import React from "react";
import { useTranslation } from "next-i18next";
import clsx from "clsx";
type Props = {
onClick: Function;
className?: string;
};
function EditButton({ onClick, className }: Props) {
const { t } = useTranslation();
return (
<span
onClick={() => onClick()}
className={clsx(
"group-hover:opacity-100 opacity-0 duration-100 btn-square btn-xs btn btn-ghost absolute bi-pencil-fill text-neutral cursor-pointer -right-7 text-xs",
className
)}
title={t("edit")}
></span>
);
}
export default EditButton;

View File

@ -6,6 +6,8 @@ import { useTranslation } from "next-i18next";
import Icon from "./Icon"; import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react"; import { IconWeight } from "@phosphor-icons/react";
import IconGrid from "./IconGrid"; import IconGrid from "./IconGrid";
import IconPopover from "./IconPopover";
import clsx from "clsx";
type Props = { type Props = {
alignment?: string; alignment?: string;
@ -33,7 +35,6 @@ const IconPicker = ({
reset, reset,
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [query, setQuery] = useState("");
const [iconPicker, setIconPicker] = useState(false); const [iconPicker, setIconPicker] = useState(false);
return ( return (
@ -59,57 +60,21 @@ const IconPicker = ({
)} )}
</div> </div>
{iconPicker && ( {iconPicker && (
<Popover <IconPopover
onClose={() => setIconPicker(false)} alignment={alignment}
className={
className +
" fade-in bg-base-200 border border-neutral-content p-2 h-44 w-[22.5rem] rounded-lg backdrop-blur-sm bg-opacity-90 " +
(alignment || " lg:-translate-x-1/3 top-20 left-0 ")
}
>
<div className="flex gap-2 h-full w-full">
<div className="flex flex-col gap-2 h-full w-fit color-picker">
<div
className="btn btn-ghost btn-xs"
onClick={reset as React.MouseEventHandler<HTMLDivElement>}
>
{t("reset")}
</div>
<select
className="border border-neutral-content bg-base-100 focus:outline-none focus:border-primary duration-100 w-full rounded-md h-7"
value={weight}
onChange={(e) => setWeight(e.target.value)}
>
<option value="regular">{t("regular")}</option>
<option value="thin">{t("thin")}</option>
<option value="light">{t("light_icon")}</option>
<option value="bold">{t("bold")}</option>
<option value="fill">{t("fill")}</option>
<option value="duotone">{t("duotone")}</option>
</select>
<HexColorPicker color={color} onChange={(e) => setColor(e)} />
</div>
<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-4 gap-1 w-full overflow-y-auto h-32 border border-neutral-content bg-base-100 rounded-md p-2">
<IconGrid
query={query}
color={color} color={color}
weight={weight} setColor={setColor}
iconName={iconName} iconName={iconName}
setIconName={setIconName} 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>
</div>
</Popover>
)} )}
</div> </div>
); );

View File

@ -0,0 +1,91 @@
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 h-44 w-[22.5rem] rounded-lg"
)}
>
<div className="flex gap-2 h-full w-full">
<div className="flex flex-col gap-2 h-full w-fit color-picker">
<div
className="btn btn-ghost btn-xs"
onClick={reset as React.MouseEventHandler<HTMLDivElement>}
>
{t("reset")}
</div>
<select
className="border border-neutral-content bg-base-100 focus:outline-none focus:border-primary duration-100 w-full rounded-md h-7"
value={weight}
onChange={(e) => setWeight(e.target.value)}
>
<option value="regular">{t("regular")}</option>
<option value="thin">{t("thin")}</option>
<option value="light">{t("light_icon")}</option>
<option value="bold">{t("bold")}</option>
<option value="fill">{t("fill")}</option>
<option value="duotone">{t("duotone")}</option>
</select>
<HexColorPicker color={color} onChange={(e) => setColor(e)} />
</div>
<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-4 gap-1 w-full overflow-y-auto h-32 border border-neutral-content bg-base-100 rounded-md p-2">
<IconGrid
query={query}
color={color}
weight={weight}
iconName={iconName}
setIconName={setIconName}
/>
</div>
</div>
</div>
</Popover>
);
};
export default IconPopover;

View File

@ -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,
}} }}

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}
/> />
); );
} }

View File

@ -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 }),

View File

@ -17,7 +17,7 @@ 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 } 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";
@ -25,14 +25,27 @@ import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react"; import { IconWeight } from "@phosphor-icons/react";
import Image from "next/image"; import Image from "next/image";
import clsx from "clsx"; import clsx from "clsx";
import toast from "react-hot-toast";
import EditButton from "./EditButton";
import CollectionSelection from "./InputSelect/CollectionSelection";
import TagSelection from "./InputSelect/TagSelection";
import unescapeString from "@/lib/client/unescapeString";
import IconPopover from "./IconPopover";
type Props = { type Props = {
className?: string; className?: string;
link: LinkIncludingShortenedCollectionAndTags; activeLink: LinkIncludingShortenedCollectionAndTags;
standalone?: boolean; standalone?: boolean;
}; };
export default function LinkDetails({ className, link, standalone }: Props) { export default function LinkDetails({
className,
activeLink,
standalone,
}: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const getLink = useGetLink(); const getLink = useGetLink();
@ -129,6 +142,59 @@ export default function LinkDetails({ className, link, standalone }: Props) {
const isPublicRoute = router.pathname.startsWith("/public") ? true : false; const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const updateLink = useUpdateLink();
const [fieldToEdit, setFieldToEdit] = useState<
"name" | "collection" | "tags" | "description" | null
>(null);
const submit = async (e?: any) => {
e?.preventDefault();
const { updatedAt: b, ...oldLink } = activeLink;
const { updatedAt: a, ...newLink } = link;
console.log(oldLink);
console.log(newLink);
if (JSON.stringify(oldLink) === JSON.stringify(newLink)) {
setFieldToEdit(null);
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"));
setFieldToEdit(null);
console.log(data);
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={clsx(className)} data-vaul-no-drag> <div className={clsx(className)} data-vaul-no-drag>
<div <div
@ -138,7 +204,7 @@ export default function LinkDetails({ className, link, standalone }: Props) {
> >
<div <div
className={clsx( className={clsx(
"overflow-hidden select-none relative h-32 opacity-80 bg-opacity-80", "overflow-hidden select-none relative h-32 opacity-80 group",
standalone standalone
? "sm:max-w-xl -mx-5 -mt-5 sm:rounded-t-2xl" ? "sm:max-w-xl -mx-5 -mt-5 sm:rounded-t-2xl"
: "-mx-4 -mt-4" : "-mx-4 -mt-4"
@ -164,13 +230,116 @@ export default function LinkDetails({ className, link, standalone }: Props) {
) : ( ) : (
<div className="duration-100 h-32 skeleton rounded-b-none"></div> <div className="duration-100 h-32 skeleton rounded-b-none"></div>
)} )}
<div className="absolute top-0 left-0 right-0 bottom-0 flex items-center justify-center rounded-md">
<LinkIcon link={link} /> <div className="absolute top-0 bottom-0 left-0 right-0 opacity-0 group-hover:opacity-100 duration-100 flex justify-end items-end">
<label className="btn btn-xs mb-2 mr-12">
{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;
const formData = new FormData();
formData.append("file", file);
const load = toast.loading(t("uploading"));
try {
const res = await fetch(
`/api/v1/archives/${link.id}/preview`,
{
method: "POST",
body: formData,
}
);
if (!res.ok) {
throw new Error(await res.text());
}
const data = await res.json();
setLink({
...link,
preview: data.preview,
});
toast.success(t("uploaded"));
} catch (error) {
console.error(error);
} finally {
toast.dismiss(load);
}
}}
className="hidden"
/>
</label>
</div> </div>
</div> </div>
<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>
{/* #006796 */}
{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 className="max-w-xl sm:px-8 p-5 pb-8 pt-2"> <div className="max-w-xl sm:px-8 p-5 pb-8 pt-2">
{link.name && <p className="text-xl text-center mt-2">{link.name}</p>} {fieldToEdit !== "name" ? (
<div className="text-xl mt-2 group pr-7">
<p
className={clsx("relative w-fit", !link.name && "text-neutral")}
>
{link.name || t("untitled")}
<EditButton
onClick={() => setFieldToEdit("name")}
className="top-0"
/>
</p>
</div>
) : fieldToEdit === "name" ? (
<form onSubmit={submit} className="flex items-center gap-2">
<input
type="text"
autoFocus
onBlur={submit}
className="text-xl bg-transparent h-9 w-full outline-none border-b border-b-neutral-content"
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
/>
</form>
) : undefined}
{link.url && ( {link.url && (
<> <>
@ -179,7 +348,7 @@ export default function LinkDetails({ className, link, standalone }: Props) {
<p className="text-sm mb-2 text-neutral">{t("link")}</p> <p className="text-sm mb-2 text-neutral">{t("link")}</p>
<div className="relative"> <div className="relative">
<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"> <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 href={link.url} title={link.url} target="_blank">
{link.url} {link.url}
</Link> </Link>
@ -193,8 +362,18 @@ export default function LinkDetails({ className, link, standalone }: Props) {
<br /> <br />
<p className="text-sm mb-2 text-neutral">{t("collection")}</p> <div className="group relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("collection")}
{fieldToEdit !== "collection" && (
<EditButton
onClick={() => setFieldToEdit("collection")}
className="bottom-0"
/>
)}
</p>
{fieldToEdit !== "collection" ? (
<div className="relative"> <div className="relative">
<Link <Link
href={ href={
@ -202,7 +381,7 @@ export default function LinkDetails({ className, link, standalone }: Props) {
? `/public/collections/${link.collection.id}` ? `/public/collections/${link.collection.id}`
: `/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" 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> <p>{link.collection.name}</p>
<div className="absolute right-0 px-2 bg-base-200"> <div className="absolute right-0 px-2 bg-base-200">
@ -211,34 +390,56 @@ export default function LinkDetails({ className, link, standalone }: Props) {
icon={link.collection.icon} icon={link.collection.icon}
size={30} size={30}
weight={ weight={
(link.collection.iconWeight || "regular") as IconWeight (link.collection.iconWeight ||
"regular") as IconWeight
} }
color={link.collection.color || "#0ea5e9"} color={link.collection.color}
/> />
) : ( ) : (
<i <i
className="bi-folder-fill text-2xl" className="bi-folder-fill text-2xl"
style={{ color: link.collection.color || "#0ea5e9" }} style={{ color: link.collection.color }}
></i> ></i>
)} )}
</div> </div>
</Link> </Link>
</div> </div>
) : fieldToEdit === "collection" ? (
{link.tags[0] && ( <CollectionSelection
<> onChange={setCollection}
<br /> defaultValue={
link.collection.id
<div> ? { value: link.collection.id, label: link.collection.name }
<p className="text-sm mb-2 text-neutral">{t("tags")}</p> : { value: null as unknown as number, label: "Unorganized" }
}
creatable={false}
autoFocus
onBlur={submit}
/>
) : undefined}
</div> </div>
<div className="flex gap-2 flex-wrap"> <br />
{link.tags.map((tag) =>
<div className="group relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("tags")}
{fieldToEdit !== "tags" && (
<EditButton
onClick={() => setFieldToEdit("tags")}
className="bottom-0"
/>
)}
</p>
{fieldToEdit !== "tags" ? (
<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 ? ( isPublicRoute ? (
<div <div
key={tag.id} key={tag.id}
className="rounded-lg px-3 py-1 bg-base-200" className="bg-base-200 p-1 hover:bg-neutral-content rounded-md duration-100"
> >
{tag.name} {tag.name}
</div> </div>
@ -246,29 +447,63 @@ export default function LinkDetails({ className, link, standalone }: Props) {
<Link <Link
href={"/tags/" + tag.id} href={"/tags/" + tag.id}
key={tag.id} key={tag.id}
className="rounded-lg px-3 py-1 bg-base-200" className="bg-base-200 p-1 hover:bg-neutral-content btn btn-xs btn-ghost rounded-md"
> >
{tag.name} {tag.name}
</Link> </Link>
) )
)
) : (
<div className="text-neutral text-base">{t("no_tags")}</div>
)} )}
</div> </div>
</> ) : (
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
autoFocus
onBlur={submit}
/>
)} )}
</div>
{link.description && (
<>
<br /> <br />
<div> <div className="relative group">
<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("description")}
<div className="rounded-lg p-2 bg-base-200 hyphens-auto"> {fieldToEdit !== "description" && (
<p>{link.description}</p> <EditButton
</div> onClick={() => setFieldToEdit("description")}
</div> className="bottom-0"
</> />
)} )}
</p>
{fieldToEdit !== "description" ? (
<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"
autoFocus
onBlur={submit}
/>
)}
</div>
<br /> <br />
@ -279,8 +514,9 @@ export default function LinkDetails({ className, link, standalone }: Props) {
{link.url ? t("preserved_formats") : t("file")} {link.url ? t("preserved_formats") : t("file")}
</p> </p>
<div className={`flex flex-col gap-3`}> <div className={`flex flex-col rounded-md p-3 bg-base-200`}>
{monolithAvailable(link) ? ( {monolithAvailable(link) ? (
<>
<PreservedFormatRow <PreservedFormatRow
name={t("webpage")} name={t("webpage")}
icon={"bi-filetype-html"} icon={"bi-filetype-html"}
@ -288,9 +524,12 @@ export default function LinkDetails({ className, link, standalone }: Props) {
link={link} link={link}
downloadable={true} downloadable={true}
/> />
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined} ) : undefined}
{screenshotAvailable(link) ? ( {screenshotAvailable(link) ? (
<>
<PreservedFormatRow <PreservedFormatRow
name={t("screenshot")} name={t("screenshot")}
icon={"bi-file-earmark-image"} icon={"bi-file-earmark-image"}
@ -302,9 +541,12 @@ export default function LinkDetails({ className, link, standalone }: Props) {
link={link} link={link}
downloadable={true} downloadable={true}
/> />
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined} ) : undefined}
{pdfAvailable(link) ? ( {pdfAvailable(link) ? (
<>
<PreservedFormatRow <PreservedFormatRow
name={t("pdf")} name={t("pdf")}
icon={"bi-file-earmark-pdf"} icon={"bi-file-earmark-pdf"}
@ -312,15 +554,20 @@ export default function LinkDetails({ className, link, standalone }: Props) {
link={link} link={link}
downloadable={true} downloadable={true}
/> />
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined} ) : undefined}
{readabilityAvailable(link) ? ( {readabilityAvailable(link) ? (
<>
<PreservedFormatRow <PreservedFormatRow
name={t("readable")} name={t("readable")}
icon={"bi-file-earmark-text"} icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability} format={ArchivedFormat.readability}
link={link} link={link}
/> />
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined} ) : undefined}
{!isReady() && !atLeastOneFormatAvailable() ? ( {!isReady() && !atLeastOneFormatAvailable() ? (
@ -364,6 +611,22 @@ export default function LinkDetails({ className, link, standalone }: Props) {
</Link> </Link>
)} )}
</div> </div>
<br />
<p className="text-neutral text-xs text-center">
{t("saved")}{" "}
{new Date(link.createdAt || "").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}{" "}
at{" "}
{new Date(link.createdAt || "").toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
})}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -29,12 +29,12 @@ export default function LinkCollection({
icon={link.collection.icon} icon={link.collection.icon}
size={20} size={20}
weight={(link.collection.iconWeight || "regular") as IconWeight} weight={(link.collection.iconWeight || "regular") as IconWeight}
color={link.collection.color || "#0ea5e9"} color={link.collection.color}
/> />
) : ( ) : (
<i <i
className="bi-folder-fill text-lg" className="bi-folder-fill text-lg"
style={{ color: link.collection.color || "#0ea5e9" }} style={{ color: link.collection.color }}
></i> ></i>
)} )}
<p className="truncate capitalize">{collection?.name}</p> <p className="truncate capitalize">{collection?.name}</p>

View File

@ -10,10 +10,12 @@ export default function LinkIcon({
link, link,
className, className,
hideBackground, hideBackground,
onClick,
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
className?: string; className?: string;
hideBackground?: boolean; hideBackground?: boolean;
onClick?: Function;
}) { }) {
let iconClasses: string = clsx( let iconClasses: string = clsx(
"rounded flex item-center justify-center shadow select-none z-10 w-12 h-12", "rounded flex item-center justify-center shadow select-none z-10 w-12 h-12",
@ -27,14 +29,14 @@ 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.icon ? ( {link.icon ? (
<div className={iconClasses}> <div className={iconClasses}>
<Icon <Icon
icon={link.icon} icon={link.icon}
size={30} size={30}
weight={(link.iconWeight || "regular") as IconWeight} weight={(link.iconWeight || "regular") as IconWeight}
color={link.color || "#0ea5e9"} color={link.color || "#006796"}
className="m-auto" className="m-auto"
/> />
</div> </div>
@ -72,7 +74,7 @@ export default function LinkIcon({
// /> // />
// ) // )
undefined} undefined}
</> </div>
); );
} }
@ -84,7 +86,7 @@ const LinkPlaceholderIcon = ({
icon: string; icon: string;
}) => { }) => {
return ( return (
<div className={clsx(iconClasses, "aspect-square text-4xl text-[#000000]")}> <div className={clsx(iconClasses, "aspect-square text-4xl text-[#006796]")}>
<i className={`${icon} m-auto`}></i> <i className={`${icon} m-auto`}></i>
</div> </div>
); );

View File

@ -60,7 +60,7 @@ export default function EditCollectionModal({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex gap-3 items-end"> <div className="flex gap-3 items-end">
<IconPicker <IconPicker
color={collection.color || "#0ea5e9"} color={collection.color}
setColor={(color: string) => setColor={(color: string) =>
setCollection({ ...collection, color }) setCollection({ ...collection, color })
} }

View File

@ -145,54 +145,6 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
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" 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> </div>
<div>
<p className="mb-2">{t("icon_and_preview")}</p>
<div className="flex gap-3">
<IconPicker
hideDefaultIcon
color={link.color || "#0ea5e9"}
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: "",
})
}
alignment="-top-10 translate-x-20"
/>
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
width={1280}
height={720}
alt=""
className="object-cover h-20 w-32 rounded-lg opacity-80"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-20 w-32 bg-opacity-80 rounded-lg flex flex-col justify-center">
<p className="text-black text-sm text-center">
{t("preview_unavailable")}
</p>
</div>
) : (
<div className="duration-100 h-20 w-32 bg-opacity-80 skeleton rounded-lg"></div>
)}
</div>
</div>
</div> </div>
</div> </div>

View File

@ -233,7 +233,7 @@ export default function LinkDetailModal({
></Link> ></Link>
<div className="w-full"> <div className="w-full">
<LinkDetails link={link} className="sm:mt-0 -mt-11" /> <LinkDetails activeLink={link} className="sm:mt-0 -mt-11" />
</div> </div>
</Drawer> </Drawer>
); );

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>

View File

@ -212,12 +212,12 @@ export default function ReadableView({ link }: Props) {
weight={ weight={
(link.collection.iconWeight || "regular") as IconWeight (link.collection.iconWeight || "regular") as IconWeight
} }
color={link.collection.color || "#0ea5e9"} color={link.collection.color}
/> />
) : ( ) : (
<i <i
className="bi-folder-fill text-2xl" className="bi-folder-fill text-2xl"
style={{ color: link.collection.color || "#0ea5e9" }} style={{ color: link.collection.color }}
></i> ></i>
)} )}
<p <p

View File

@ -119,12 +119,12 @@ export default function Index() {
weight={ weight={
(activeCollection.iconWeight || "regular") as IconWeight (activeCollection.iconWeight || "regular") as IconWeight
} }
color={activeCollection.color || "#0ea5e9"} color={activeCollection.color}
/> />
) : ( ) : (
<i <i
className="bi-folder-fill text-3xl" className="bi-folder-fill text-3xl"
style={{ color: activeCollection.color || "#0ea5e9" }} style={{ color: activeCollection.color }}
></i> ></i>
)} )}

View File

@ -20,7 +20,7 @@ const Index = () => {
<div className="flex h-screen"> <div className="flex h-screen">
{getLink.data ? ( {getLink.data ? (
<LinkDetails <LinkDetails
link={getLink.data} activeLink={getLink.data}
className="sm:max-w-xl sm:m-auto sm:p-5 w-full" className="sm:max-w-xl sm:m-auto sm:p-5 w-full"
standalone standalone
/> />

View File

@ -18,7 +18,7 @@ const Index = () => {
<div className="flex h-screen"> <div className="flex h-screen">
{getLink.data ? ( {getLink.data ? (
<LinkDetails <LinkDetails
link={getLink.data} activeLink={getLink.data}
className="sm:max-w-xl sm:m-auto sm:p-5 w-full" className="sm:max-w-xl sm:m-auto sm:p-5 w-full"
standalone standalone
/> />

View File

@ -146,7 +146,6 @@ 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"
@ -160,7 +159,7 @@ export default function Index() {
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-check text-neutral text-2xl"}></i> <i className={"bi-check2 text-neutral text-2xl"}></i>
</div> </div>
<div <div
onClick={() => cancelUpdateTag()} onClick={() => cancelUpdateTag()}
@ -170,7 +169,6 @@ export default function Index() {
<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">

View File

@ -372,7 +372,6 @@
"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", "regular": "Regular",
"thin": "Thin", "thin": "Thin",
"bold": "Bold", "bold": "Bold",
@ -387,5 +386,10 @@
"icon": "Icon", "icon": "Icon",
"date": "Date", "date": "Date",
"preview_unavailable": "Preview Unavailable", "preview_unavailable": "Preview Unavailable",
"icon_and_preview": "Icon & Preview" "saved": "Saved",
"untitled": "Untitled",
"no_tags": "No tags.",
"no_description_provided": "No description provided.",
"change_icon": "Change Icon",
"upload_preview_image": "Upload Preview Image"
} }

View File

@ -162,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 {
@ -169,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 {

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">;
} }