Merge branch 'dev' into patch-1

This commit is contained in:
Daniel 2024-09-13 01:57:39 -04:00 committed by GitHub
commit 01826b1634
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 6079 additions and 1746 deletions

View File

@ -33,6 +33,7 @@ SCREENSHOT_MAX_BUFFER=
READABILITY_MAX_BUFFER= READABILITY_MAX_BUFFER=
PREVIEW_MAX_BUFFER= PREVIEW_MAX_BUFFER=
IMPORT_LIMIT= IMPORT_LIMIT=
MAX_WORKERS=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=

View File

@ -59,10 +59,10 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: "18" node-version: "18"
cache: 'yarn' cache: 'yarn'
@ -135,7 +135,7 @@ jobs:
- name: Run Tests - name: Run Tests
run: npx playwright test --grep ${{ matrix.test_case }} run: npx playwright test --grep ${{ matrix.test_case }}
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: playwright-report name: playwright-report

View File

@ -27,7 +27,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@ -40,7 +40,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v3 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true push: true

View File

@ -1,4 +1,16 @@
FROM node:18.18-bullseye-slim # Stage: monolith-builder
# Purpose: Uses the Rust image to build monolith
# Notes:
# - Fine to leave extra here, as only the resulting binary is copied out
FROM docker.io/rust:1.80-bullseye AS monolith-builder
RUN set -eux && cargo install --locked monolith
# Stage: main-app
# Purpose: Compiles the frontend and
# Notes:
# - Nothing extra should be left here. All commands should cleanup
FROM node:18.18-bullseye-slim AS main-app
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@ -8,30 +20,18 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./ COPY ./package.json ./yarn.lock ./playwright.config.ts ./
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000 RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
set -eux && \
yarn install --network-timeout 10000000
RUN apt-get update # Copy the compiled monolith binary from the builder stage
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
RUN apt-get install -y \ RUN set -eux && \
build-essential \ npx playwright install --with-deps chromium && \
curl \
libssl-dev \
pkg-config
RUN apt-get update
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install monolith
RUN npx playwright install-deps && \
apt-get clean && \ apt-get clean && \
yarn cache clean yarn cache clean
RUN yarn playwright install
COPY . . COPY . .
RUN yarn prisma generate && \ RUN yarn prisma generate && \

View File

@ -1,5 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
} from "@/types/global";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import ProfilePhoto from "./ProfilePhoto"; import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
@ -12,12 +15,11 @@ 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";
type Props = { export default function CollectionCard({
collection,
}: {
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
className?: string; }) {
};
export default function CollectionCard({ collection, className }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useLocalSettingsStore(); const { settings } = useLocalSettingsStore();
const { data: user = {} } = useUser(); const { data: user = {} } = useUser();
@ -33,15 +35,9 @@ export default function CollectionCard({ collection, className }: Props) {
const permissions = usePermissions(collection.id as number); const permissions = usePermissions(collection.id as number);
const [collectionOwner, setCollectionOwner] = useState({ const [collectionOwner, setCollectionOwner] = useState<
id: null as unknown as number, Partial<AccountSettings>
name: "", >({});
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => { useEffect(() => {
const fetchOwner = async () => { const fetchOwner = async () => {
@ -132,12 +128,12 @@ export default function CollectionCard({ collection, className }: Props) {
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full" className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
onClick={() => setEditCollectionSharingModal(true)} onClick={() => setEditCollectionSharingModal(true)}
> >
{collectionOwner.id ? ( {collectionOwner.id && (
<ProfilePhoto <ProfilePhoto
src={collectionOwner.image || undefined} src={collectionOwner.image || undefined}
name={collectionOwner.name} name={collectionOwner.name}
/> />
) : undefined} )}
{collection.members {collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number)) .sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => { .map((e, i) => {
@ -151,13 +147,13 @@ export default function CollectionCard({ collection, className }: Props) {
); );
}) })
.slice(0, 3)} .slice(0, 3)}
{collection.members.length - 3 > 0 ? ( {collection.members.length - 3 > 0 && (
<div className={`avatar drop-shadow-md placeholder -ml-3`}> <div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content"> <div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span> <span>+{collection.members.length - 3}</span>
</div> </div>
</div> </div>
) : null} )}
</div> </div>
<Link <Link
href={`/collections/${collection.id}`} href={`/collections/${collection.id}`}
@ -181,12 +177,12 @@ export default function CollectionCard({ collection, className }: Props) {
<div className="flex justify-end items-center"> <div className="flex justify-end items-center">
<div className="text-right"> <div className="text-right">
<div className="font-bold text-sm flex justify-end gap-1 items-center"> <div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? ( {collection.isPublic && (
<i <i
className="bi-globe2 drop-shadow text-neutral" className="bi-globe2 drop-shadow text-neutral"
title="This collection is being shared publicly." title="This collection is being shared publicly."
></i> ></i>
) : undefined} )}
<i <i
className="bi-link-45deg text-lg text-neutral" className="bi-link-45deg text-lg text-neutral"
title="This collection is being shared publicly." title="This collection is being shared publicly."
@ -206,24 +202,24 @@ export default function CollectionCard({ collection, className }: Props) {
</div> </div>
</div> </div>
</Link> </Link>
{editCollectionModal ? ( {editCollectionModal && (
<EditCollectionModal <EditCollectionModal
onClose={() => setEditCollectionModal(false)} onClose={() => setEditCollectionModal(false)}
activeCollection={collection} activeCollection={collection}
/> />
) : undefined} )}
{editCollectionSharingModal ? ( {editCollectionSharingModal && (
<EditCollectionSharingModal <EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)} onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection} activeCollection={collection}
/> />
) : undefined} )}
{deleteCollectionModal ? ( {deleteCollectionModal && (
<DeleteCollectionModal <DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)} onClose={() => setDeleteCollectionModal(false)}
activeCollection={collection} activeCollection={collection}
/> />
) : undefined} )}
</div> </div>
); );
} }

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;
@ -43,6 +45,7 @@ const CollectionListing = () => {
return buildTreeFromCollections( return buildTreeFromCollections(
collections, collections,
router, router,
tree,
user.collectionOrder user.collectionOrder
); );
} else return undefined; } else return undefined;
@ -256,7 +259,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,18 +269,29 @@ 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 && (
<i <i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow" className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly." title="This collection is being shared publicly."
></i> ></i>
) : undefined} )}
<div className="drop-shadow text-neutral text-xs"> <div className="drop-shadow text-neutral text-xs">
{collection._count?.links} {collection._count?.links}
</div> </div>
@ -288,7 +302,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
@ -311,6 +325,7 @@ const Icon = (
const buildTreeFromCollections = ( const buildTreeFromCollections = (
collections: CollectionIncludingMembersAndLinkCount[], collections: CollectionIncludingMembersAndLinkCount[],
router: ReturnType<typeof useRouter>, router: ReturnType<typeof useRouter>,
tree?: TreeData,
order?: number[] order?: number[]
): TreeData => { ): TreeData => {
if (order) { if (order) {
@ -325,13 +340,15 @@ const buildTreeFromCollections = (
id: collection.id, id: collection.id,
children: [], children: [],
hasChildren: false, hasChildren: false,
isExpanded: false, isExpanded: tree?.items[collection.id as number]?.isExpanded || false,
data: { data: {
id: collection.id, id: collection.id,
parentId: collection.parentId, parentId: collection.parentId,
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,

32
components/CopyButton.tsx Normal file
View File

@ -0,0 +1,32 @@
import React, { useState } from "react";
type Props = {
text: string;
};
const CopyButton: React.FC<Props> = ({ text }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (err) {
console.log(err);
}
};
return (
<div
className={`text-xl text-neutral btn btn-sm btn-square btn-ghost ${
copied ? "bi-check2 text-success" : "bi-copy"
}`}
onClick={handleCopy}
></div>
);
};
export default CopyButton;

View File

@ -14,7 +14,7 @@ export default function dashboardItem({
</div> </div>
<div className="ml-4 flex flex-col justify-center"> <div className="ml-4 flex flex-col justify-center">
<p className="text-neutral text-xs tracking-wider">{name}</p> <p className="text-neutral text-xs tracking-wider">{name}</p>
<p className="font-thin text-5xl text-primary mt-0.5">{value}</p> <p className="font-thin text-5xl text-primary mt-0.5">{value || 0}</p>
</div> </div>
</div> </div>
); );

88
components/Drawer.tsx Normal file
View File

@ -0,0 +1,88 @@
import React, { ReactNode, useEffect } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { Drawer as D } from "vaul";
type Props = {
toggleDrawer: Function;
children: ReactNode;
className?: string;
dismissible?: boolean;
};
export default function Drawer({
toggleDrawer,
className,
children,
dismissible = true,
}: Props) {
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
useEffect(() => {
if (window.innerWidth >= 640) {
document.body.style.overflow = "hidden";
document.body.style.position = "relative";
return () => {
document.body.style.overflow = "auto";
document.body.style.position = "";
};
}
}, []);
if (window.innerWidth < 640) {
return (
<D.Root
open={drawerIsOpen}
onClose={() => dismissible && setTimeout(() => toggleDrawer(), 350)}
dismissible={dismissible}
>
<D.Portal>
<D.Overlay className="fixed inset-0 bg-black/40" />
<ClickAwayHandler
onClickOutside={() => dismissible && setDrawerIsOpen(false)}
>
<D.Content className="flex flex-col rounded-t-2xl mt-24 fixed bottom-0 left-0 right-0 z-30 h-[90%]">
<div
className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"
data-testid="mobile-modal-container"
>
<div
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"
/>
{children}
</div>
</D.Content>
</ClickAwayHandler>
</D.Portal>
</D.Root>
);
} else {
return (
<D.Root
open={drawerIsOpen}
onClose={() => dismissible && setTimeout(() => toggleDrawer(), 350)}
dismissible={dismissible}
direction="right"
>
<D.Portal>
<D.Overlay className="fixed inset-0 bg-black/10 z-20" />
<ClickAwayHandler
onClickOutside={() => dismissible && setDrawerIsOpen(false)}
className="z-30"
>
<D.Content className="bg-white flex flex-col h-full w-2/5 min-w-[30rem] mt-24 fixed bottom-0 right-0 z-40 !select-auto">
<div
className={
"p-4 bg-base-100 flex-1 border-neutral-content border-l overflow-y-auto " +
className
}
>
{children}
</div>
</D.Content>
</ClickAwayHandler>
</D.Portal>
</D.Root>
);
}
}

View File

@ -60,47 +60,49 @@ export default function Dropdown({
} }
}, [points, dropdownHeight]); }, [points, dropdownHeight]);
return !points || pos ? ( return (
<ClickAwayHandler (!points || pos) && (
onMount={(e) => { <ClickAwayHandler
setDropdownHeight(e.height); onMount={(e) => {
setDropdownWidth(e.width); setDropdownHeight(e.height);
}} setDropdownWidth(e.width);
style={ }}
points style={
? { points
position: "fixed", ? {
top: `${pos?.y}px`, position: "fixed",
left: `${pos?.x}px`, top: `${pos?.y}px`,
} left: `${pos?.x}px`,
: undefined }
} : undefined
onClickOutside={onClickOutside} }
className={`${ onClickOutside={onClickOutside}
className || "" className={`${
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`} className || ""
> } py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
{items.map((e, i) => { >
const inner = e && ( {items.map((e, i) => {
<div className="cursor-pointer rounded-md"> const inner = e && (
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100"> <div className="cursor-pointer rounded-md">
<p className="select-none">{e.name}</p> <div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
<p className="select-none">{e.name}</p>
</div>
</div> </div>
</div> );
);
return e && e.href ? ( return e && e.href ? (
<Link key={i} href={e.href}> <Link key={i} href={e.href}>
{inner}
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
{inner} {inner}
</div> </Link>
) ) : (
); e && (
})} <div key={i} onClick={e.onClick}>
</ClickAwayHandler> {inner}
) : null; </div>
)
);
})}
</ClickAwayHandler>
)
);
} }

18
components/Icon.tsx Normal file
View File

@ -0,0 +1,18 @@
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} />;
});
Icon.displayName = "Icon";
export default Icon;

49
components/IconGrid.tsx Normal file
View File

@ -0,0 +1,49 @@
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;

83
components/IconPicker.tsx Normal file
View File

@ -0,0 +1,83 @@
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";
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;
};
const IconPicker = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
hideDefaultIcon,
className,
reset,
}: Props) => {
const { t } = useTranslation();
const [iconPicker, setIconPicker] = useState(false);
return (
<div className="relative">
<div
onClick={() => setIconPicker(!iconPicker)}
className="btn btn-square w-20 h-20"
>
{iconName ? (
<Icon
icon={iconName}
size={60}
weight={(weight || "regular") as IconWeight}
color={color || "#0ea5e9"}
/>
) : !iconName && hideDefaultIcon ? (
<p className="p-1">{t("set_custom_icon")}</p>
) : (
<i
className="bi-folder-fill text-6xl"
style={{ color: color || "#0ea5e9" }}
></i>
)}
</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>
);
};
export default IconPicker;

142
components/IconPopover.tsx Normal file
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-ghost btn-sm mt-2 w-fit mx-auto"
onClick={reset as React.MouseEventHandler<HTMLDivElement>}
>
{t("reset_defaults")}
</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[]>([]);
@ -34,8 +41,9 @@ export default function TagSelection({ onChange, defaultValue }: Props) {
options={options} options={options}
styles={styles} styles={styles}
defaultValue={defaultValue} defaultValue={defaultValue}
// menuPosition="fixed"
isMulti isMulti
autoFocus={autoFocus}
onBlur={onBlur}
/> />
); );
} }

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

View File

@ -8,7 +8,7 @@ const InstallApp = (props: Props) => {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
return isOpen && !isPWA() ? ( return isOpen && !isPWA() ? (
<div className="fixed left-0 right-0 bottom-10 w-full p-5"> <div className="fixed left-0 right-0 bottom-10 w-full">
<div className="mx-auto w-fit p-2 flex justify-between gap-2 items-center border border-neutral-content rounded-xl bg-base-300 backdrop-blur-md bg-opacity-80 max-w-md"> <div className="mx-auto w-fit p-2 flex justify-between gap-2 items-center border border-neutral-content rounded-xl bg-base-300 backdrop-blur-md bg-opacity-80 max-w-md">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

663
components/LinkDetails.tsx Normal file
View File

@ -0,0 +1,663 @@
import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import Link from "next/link";
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,
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;
activeLink: LinkIncludingShortenedCollectionAndTags;
standalone?: boolean;
mode?: "view" | "edit";
setMode?: Function;
};
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 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"
);
};
const atLeastOneFormatAvailable = () => {
return (
screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link)
);
};
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 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 (
<div className={clsx(className)} data-vaul-no-drag>
<div
className={clsx(
standalone && "sm:border sm:border-neutral-content sm:rounded-2xl p-5"
)}
>
<div
className={clsx(
"overflow-hidden select-none relative group h-40 opacity-80",
standalone
? "sm:max-w-xl -mx-5 -mt-5 sm:rounded-t-2xl"
: "-mx-4 -mt-4"
)}
>
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className="object-cover scale-105 object-center h-full"
style={{
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-none"></div>
)}
{!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">
<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;
const load = toast.loading(t("updating"));
await updatePreview.mutateAsync(
{
linkId: link.id as number,
file,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
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 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>
)}
{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.url && mode === "view" ? (
<>
<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>
</>
) : activeLink.url ? (
<>
<br />
<div>
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("link")}
</p>
<TextInput
value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
</>
) : undefined}
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("collection")}
</p>
{mode === "view" ? (
<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 && 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>
)}
{mode === "view" ? (
<>
<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>
</>
) : (
<>
<br />
<div className="flex justify-end items-center">
<button
className={clsx(
"btn btn-accent text-white",
JSON.stringify(activeLink) === JSON.stringify(link)
? "btn-disabled"
: "dark:border-violet-400"
)}
onClick={submit}
>
{t("save_changes")}
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -4,207 +4,205 @@ 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 { useDeleteLink, useGetLink } from "@/hooks/store/links";
import { useDeleteLink, useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import LinkModal from "@/components/ModalContent/LinkModal";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
position?: string; btnStyle?: string;
toggleShowInfo?: () => void;
linkInfo?: boolean;
alignToTop?: boolean;
flipDropdown?: boolean;
}; };
export default function LinkActions({ export default function LinkActions({ link, btnStyle }: Props) {
link,
toggleShowInfo,
position,
linkInfo,
alignToTop,
flipDropdown,
}: Props) {
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 pinLink = usePinLink();
const [editLinkModal, setEditLinkModal] = useState(false); const [editLinkModal, setEditLinkModal] = 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 updateLink = useUpdateLink();
const deleteLink = useDeleteLink(); const deleteLink = useDeleteLink();
const pinLink = async () => { const updateArchive = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false; const load = toast.loading(t("sending_request"));
const load = toast.loading(t("updating")); const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
await updateLink.mutateAsync( const data = await response.json();
{ toast.dismiss(load);
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }],
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) { if (response.ok) {
toast.error(error.message); await getLink.mutateAsync({ id: link.id as number });
} else {
toast.success( toast.success(t("link_being_archived"));
isAlreadyPinned ? t("link_unpinned") : t("link_pinned") } else toast.error(data.response);
);
}
},
}
);
}; };
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return ( return (
<> <>
<div {isPublicRoute ? (
className={`dropdown dropdown-left absolute ${
position || "top-3 right-3"
} ${alignToTop ? "" : "dropdown-end"} z-20`}
>
<div <div
tabIndex={0} className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
role="button"
onMouseDown={dropdownTriggerer} onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral" onClick={() => setLinkModal(true)}
> >
<i title="More" className="bi-three-dots text-xl" /> <div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
<i title="More" className="bi-three-dots text-xl" />
</div>
</div> </div>
<ul ) : (
className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box mr-1 ${ <div
alignToTop ? "" : "translate-y-10" className={`dropdown dropdown-end absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 z-20`}
}`}
> >
<li> <div
<div tabIndex={0}
role="button" role="button"
tabIndex={0} onMouseDown={dropdownTriggerer}
onClick={() => { className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}
(document?.activeElement as HTMLElement)?.blur(); >
pinLink(); <i title="More" className="bi-three-dots text-xl" />
}} </div>
className="whitespace-nowrap" <ul
> className={
{link?.pinnedBy && link.pinnedBy[0] "dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1"
? t("unpin") }
: t("pin_to_dashboard")} >
</div>
</li>
{linkInfo !== undefined && toggleShowInfo ? (
<li> <li>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
toggleShowInfo(); pinLink(link);
}} }}
className="whitespace-nowrap" className="whitespace-nowrap"
> >
{!linkInfo ? t("show_link_details") : t("hide_link_details")} {link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div> </div>
</li> </li>
) : undefined}
{permissions === true || permissions?.canUpdate ? (
<li> <li>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setEditLinkModal(true); setLinkModal(true);
}} }}
className="whitespace-nowrap" className="whitespace-nowrap"
> >
{t("edit_link")} {t("show_link_details")}
</div> </div>
</li> </li>
) : undefined} {(permissions === true || permissions?.canUpdate) && (
{link.type === "url" && ( <li>
<li> <div
<div role="button"
role="button" tabIndex={0}
tabIndex={0} onClick={() => {
onClick={() => { (document?.activeElement as HTMLElement)?.blur();
(document?.activeElement as HTMLElement)?.blur(); setEditLinkModal(true);
setPreservedFormatsModal(true); }}
}} className="whitespace-nowrap"
className="whitespace-nowrap" >
> {t("edit_link")}
{t("preserved_formats")} </div>
</div> </li>
</li> )}
)} {link.type === "url" &&
{permissions === true || permissions?.canDelete ? ( (permissions === true || permissions?.canUpdate) && (
<li> <li>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={async (e) => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
e.shiftKey updateArchive();
? async () => { }}
const load = toast.loading(t("deleting")); 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);
e.shiftKey
? (async () => {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, { await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => { onSettled: (data, error) => {
toast.dismiss(load); toast.dismiss(load);
if (error) { if (error) {
toast.error(error.message); toast.error(error.message);
} else { } else {
toast.success(t("deleted")); toast.success(t("deleted"));
} }
}, },
}); });
} })()
: setDeleteLinkModal(true); : setDeleteLinkModal(true);
}} }}
className="whitespace-nowrap" className="whitespace-nowrap"
> >
{t("delete")} {t("delete")}
</div> </div>
</li> </li>
) : undefined} )}
</ul> </ul>
</div> </div>
)}
{editLinkModal ? ( {editLinkModal && (
<EditLinkModal <LinkModal
onClose={() => setEditLinkModal(false)} onClose={() => setEditLinkModal(false)}
activeLink={link} onPin={() => pinLink(link)}
onUpdateArchive={updateArchive}
onDelete={() => setDeleteLinkModal(true)}
link={link}
activeMode="edit"
/> />
) : undefined} )}
{deleteLinkModal ? ( {deleteLinkModal && (
<DeleteLinkModal <DeleteLinkModal
onClose={() => setDeleteLinkModal(false)} onClose={() => setDeleteLinkModal(false)}
activeLink={link} activeLink={link}
/> />
) : undefined} )}
{preservedFormatsModal ? ( {linkModal && (
<PreservedFormatsModal <LinkModal
onClose={() => setPreservedFormatsModal(false)} onClose={() => setLinkModal(false)}
onPin={() => pinLink(link)}
onUpdateArchive={updateArchive}
onDelete={() => setDeleteLinkModal(true)}
link={link} link={link}
/> />
) : undefined} )}
{/* {expandedLink ? (
<ExpandedLink onClose={() => setExpandedLink(false)} link={link} />
) : undefined} */}
</> </>
); );
} }

View File

@ -3,7 +3,7 @@ import {
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
@ -11,7 +11,6 @@ import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import Image from "next/image"; import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity"; import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link";
import LinkIcon from "./LinkIcon"; import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen"; import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref"; import { generateLinkHref } from "@/lib/client/generateLinkHref";
@ -22,24 +21,47 @@ 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 { useRouter } from "next/router";
import useLocalSettingsStore from "@/store/localSettings";
import LinkPin from "./LinkPin";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; count: number;
columns: number;
className?: string; className?: string;
flipDropdown?: boolean;
editMode?: boolean; editMode?: boolean;
}; };
export default function LinkCard({ link, flipDropdown, editMode }: Props) { export default function LinkCard({ link, columns, editMode }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
[columns]
);
const { data: collections = [] } = useCollections(); const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser(); const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore(); const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { const {
data: { data: links = [] }, data: { data: links = [] },
} = useLinks(); } = useLinks();
@ -90,8 +112,12 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const isVisible = useOnScreen(ref); const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number); const permissions = usePermissions(collection?.id as number);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
useEffect(() => { useEffect(() => {
let interval: any; let interval: NodeJS.Timeout | null = null;
if ( if (
isVisible && isVisible &&
@ -99,7 +125,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
link.preview !== "unavailable" link.preview !== "unavailable"
) { ) {
interval = setInterval(async () => { interval = setInterval(async () => {
getLink.mutateAsync(link.id as number); getLink.mutateAsync({ id: link.id as number, isPublicRoute: isPublic });
}, 5000); }, 5000);
} }
@ -110,8 +136,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
}; };
}, [isVisible, link.preview]); }, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some( const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id (selectedLink) => selectedLink.id === link.id
) )
@ -125,7 +149,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
return ( return (
<div <div
ref={ref} ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`} className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative group`}
onClick={() => onClick={() =>
selectable selectable
? handleCheckboxClick(link) ? handleCheckboxClick(link)
@ -140,121 +164,76 @@ 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 ${imageHeightClass} overflow-hidden`}
<div className="flex justify-between text-xs text-neutral px-3 pb-1 gap-2"> >
<div className="cursor-pointer truncate"> {previewAvailable(link) ? (
{collection && ( <Image
<LinkCollection link={link} collection={collection} /> src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
)} width={1280}
</div> height={720}
<LinkDate link={link} /> alt=""
className={`rounded-t-2xl select-none object-cover z-10 ${imageHeightClass} w-full shadow opacity-80 scale-105`}
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 ${imageHeightClass} bg-opacity-80`}
></div>
) : (
<div
className={`${imageHeightClass} 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 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 items-center 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>
{showInfo && ( {/* Overlay on hover */}
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-[0.9rem] fade-in overflow-y-auto"> <div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-2xl duration-100"></div>
<div <LinkActions link={link} collection={collection} />
onClick={() => setShowInfo(!showInfo)} <LinkPin link={link} />
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">
{t("description")}
</p>
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
<p>
{link.description ? (
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
{t("no_description")}
</span>
)}
</p>
{link.tags && link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
{t("tags")}
</p>
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</Link>
))}
</div>
</div>
</>
)}
</div>
)}
<LinkActions
link={link}
collection={collection}
position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
/>
</div> </div>
); );
} }

View File

@ -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>
</> </>

View File

@ -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>
); );

View File

@ -18,20 +18,17 @@ 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";
import LinkPin from "./LinkPin";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; count: number;
className?: string; className?: string;
flipDropdown?: boolean;
editMode?: boolean; editMode?: boolean;
}; };
export default function LinkCardCompact({ export default function LinkCardCompact({ link, editMode }: Props) {
link,
flipDropdown,
editMode,
}: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: collections = [] } = useCollections(); const { data: collections = [] } = useCollections();
@ -39,6 +36,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(() => {
@ -80,8 +81,6 @@ export default function LinkCardCompact({
const permissions = usePermissions(collection?.id as number); const permissions = usePermissions(collection?.id as number);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some( const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id (selectedLink) => selectedLink.id === link.id
) )
@ -95,9 +94,9 @@ export default function LinkCardCompact({
return ( return (
<> <>
<div <div
className={`${selectedStyle} border relative items-center flex ${ className={`${selectedStyle} rounded-md border relative group items-center flex ${
!showInfo && !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)
@ -106,67 +105,40 @@ export default function LinkCardCompact({
: undefined : undefined
} }
> >
{/* {showCheckbox &&
editMode &&
(permissions === true ||
permissions?.canCreate ||
permissions?.canDelete) && (
<input
type="checkbox"
className="checkbox checkbox-primary my-auto mr-2"
checked={selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)}
onChange={() => handleCheckboxClick(link)}
/>
)} */}
<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} />
) : undefined} )}
{link.name && <LinkTypeBadge link={link} />} {show.date && <LinkDate link={link} />}
<LinkDate link={link} />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<LinkActions <LinkPin link={link} btnStyle="btn-ghost" />
link={link} <LinkActions link={link} collection={collection} btnStyle="btn-ghost" />
collection={collection}
position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo}
/>
</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>
</> </>
); );
} }

View File

@ -3,7 +3,7 @@ import {
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
@ -22,23 +22,44 @@ 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";
import LinkPin from "./LinkPin";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; columns: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean; editMode?: boolean;
}; };
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { export default function LinkMasonry({ link, editMode, columns }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
[columns]
);
const { data: collections = [] } = useCollections(); const { data: collections = [] } = useCollections();
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();
const getLink = useGetLink(); const getLink = useGetLink();
@ -88,7 +109,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const permissions = usePermissions(collection?.id as number); const permissions = usePermissions(collection?.id as number);
useEffect(() => { useEffect(() => {
let interval: any; let interval: NodeJS.Timeout | null = null;
if ( if (
isVisible && isVisible &&
@ -96,7 +117,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
link.preview !== "unavailable" link.preview !== "unavailable"
) { ) {
interval = setInterval(async () => { interval = setInterval(async () => {
getLink.mutateAsync(link.id as number); getLink.mutateAsync({ id: link.id as number });
}, 5000); }, 5000);
} }
@ -107,8 +128,6 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
}; };
}, [isVisible, link.preview]); }, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some( const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id (selectedLink) => selectedLink.id === link.id
) )
@ -122,7 +141,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
return ( return (
<div <div
ref={ref} ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`} className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative group`}
onClick={() => onClick={() =>
selectable selectable
? handleCheckboxClick(link) ? handleCheckboxClick(link)
@ -131,57 +150,61 @@ 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 ${imageHeightClass} 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 ${imageHeightClass} bg-opacity-80 skeleton rounded-none`}
{link.type !== "image" && ( ></div>
<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} /> {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>
)}
</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 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")}>
{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
@ -199,77 +222,26 @@ 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 items-center 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>
{showInfo && ( {/* Overlay on hover */}
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto"> <div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-2xl duration-100"></div>
<div <LinkActions link={link} collection={collection} />
onClick={() => setShowInfo(!showInfo)} <LinkPin link={link} />
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">
{t("description")}
</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<p>
{link.description ? (
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
{t("no_description")}
</span>
)}
</p>
{link.tags && link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
{t("tags")}
</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</Link>
))}
</div>
</div>
</>
)}
</div>
)}
<LinkActions
link={link}
collection={collection}
position={
link.preview !== "unavailable"
? "top-[10.75rem] right-3"
: "top-[.75rem] right-3"
}
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
/>
</div> </div>
); );
} }

View File

@ -0,0 +1,34 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
btnStyle?: string;
};
export default function LinkPin({ link, btnStyle }: Props) {
const pinLink = usePinLink();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
return (
<div
className="absolute top-3 right-[3.25rem] group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
onClick={() => pinLink(link)}
>
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
<i
title="Pin"
className={clsx(
"text-xl",
isAlreadyPinned ? "bi-pin-fill" : "bi-pin"
)}
/>
</div>
</div>
);
}

View File

@ -3,7 +3,7 @@ import {
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
ViewMode, ViewMode,
} from "@/types/global"; } from "@/types/global";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry"; import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
import Masonry from "react-masonry-css"; import Masonry from "react-masonry-css";
@ -11,6 +11,7 @@ import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../tailwind.config.js"; import tailwindConfig from "../../tailwind.config.js";
import { useMemo } from "react"; import { useMemo } from "react";
import LinkList from "@/components/LinkViews/LinkComponents/LinkList"; import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
import useLocalSettingsStore from "@/store/localSettings";
export function CardView({ export function CardView({
links, links,
@ -27,16 +28,68 @@ export function CardView({
hasNextPage?: boolean; hasNextPage?: boolean;
placeHolderRef?: any; placeHolderRef?: any;
}) { }) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
return ( return (
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"> <div className={`${gridColClass} grid gap-5 pb-5`}>
{links?.map((e, i) => { {links?.map((e, i) => {
return ( return (
<LinkCard <LinkCard
key={i} key={i}
link={e} link={e}
count={i} count={i}
flipDropdown={i === links.length - 1}
editMode={editMode} editMode={editMode}
columns={columnCount}
/> />
); );
})} })}
@ -76,6 +129,58 @@ export function MasonryView({
hasNextPage?: boolean; hasNextPage?: boolean;
placeHolderRef?: any; placeHolderRef?: any;
}) { }) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
const fullConfig = resolveConfig(tailwindConfig as any); const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => { const breakpointColumnsObj = useMemo(() => {
@ -90,18 +195,19 @@ export function MasonryView({
return ( return (
<Masonry <Masonry
breakpointCols={breakpointColumnsObj} breakpointCols={
settings.columns === 0 ? breakpointColumnsObj : columnCount
}
columnClassName="flex flex-col gap-5 !w-full" columnClassName="flex flex-col gap-5 !w-full"
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5" className={`${gridColClass} grid gap-5 pb-5`}
> >
{links?.map((e, i) => { {links?.map((e, i) => {
return ( return (
<LinkMasonry <LinkMasonry
key={i} key={i}
link={e} link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode} editMode={editMode}
columns={columnCount}
/> />
); );
})} })}
@ -142,17 +248,9 @@ 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 key={i} link={e} count={i} editMode={editMode} />;
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})} })}
{(hasNextPage || isLoading) && {(hasNextPage || isLoading) &&
@ -161,13 +259,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>
); );

View File

@ -87,15 +87,13 @@ export default function MobileNavigation({}: Props) {
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} /> <MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div> </div>
</div> </div>
{newLinkModal ? ( {newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
<NewLinkModal onClose={() => setNewLinkModal(false)} /> {newCollectionModal && (
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} /> <NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined} )}
{uploadFileModal ? ( {uploadFileModal && (
<UploadFileModal onClose={() => setUploadFileModal(false)} /> <UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined} )}
</> </>
); );
} }

View File

@ -32,7 +32,7 @@ export default function Modal({
return ( return (
<Drawer.Root <Drawer.Root
open={drawerIsOpen} open={drawerIsOpen}
onClose={() => dismissible && setTimeout(() => toggleModal(), 100)} onClose={() => dismissible && setTimeout(() => toggleModal(), 350)}
dismissible={dismissible} dismissible={dismissible}
> >
<Drawer.Portal> <Drawer.Portal>
@ -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"

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) =>

View File

@ -1,7 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global"; import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
Member,
} from "@/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto"; import ProfilePhoto from "../ProfilePhoto";
@ -11,6 +15,7 @@ import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections"; import { useUpdateCollection } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import CopyButton from "../CopyButton";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -64,15 +69,9 @@ export default function EditCollectionSharingModal({
const [memberUsername, setMemberUsername] = useState(""); const [memberUsername, setMemberUsername] = useState("");
const [collectionOwner, setCollectionOwner] = useState({ const [collectionOwner, setCollectionOwner] = useState<
id: null as unknown as number, Partial<AccountSettings>
name: "", >({});
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => { useEffect(() => {
const fetchOwner = async () => { const fetchOwner = async () => {
@ -132,25 +131,15 @@ export default function EditCollectionSharingModal({
</div> </div>
)} )}
{collection.isPublic ? ( {collection.isPublic && (
<div className={permissions === true ? "pl-5" : ""}> <div>
<p className="mb-2">{t("sharable_link_guide")}</p> <p className="mb-2">{t("sharable_link")}</p>
<div <div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between">
onClick={() => {
try {
navigator.clipboard
.writeText(publicCollectionURL)
.then(() => toast.success(t("copied")));
} catch (err) {
console.log(err);
}
}}
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border outline-none hover:border-primary dark:hover:border-primary duration-100 cursor-text"
>
{publicCollectionURL} {publicCollectionURL}
<CopyButton text={publicCollectionURL} />
</div> </div>
</div> </div>
) : null} )}
{permissions === true && <div className="divider my-3"></div>} {permissions === true && <div className="divider my-3"></div>}

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

View File

@ -0,0 +1,183 @@
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 || permissions?.canUpdate) && (
<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.url && (
<Link
href={link.url}
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>
);
}

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) =>

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,
@ -61,7 +64,7 @@ export default function NewLinkModal({ onClose }: Props) {
}; };
useEffect(() => { useEffect(() => {
if (router.query.id) { if (router.pathname.startsWith("/collections/") && router.query.id) {
const currentCollection = collections.find( const currentCollection = collections.find(
(e) => e.id == Number(router.query.id) (e) => e.id == Number(router.query.id)
); );
@ -124,7 +127,7 @@ export default function NewLinkModal({ onClose }: Props) {
</div> </div>
<div className="sm:col-span-2 col-span-5"> <div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p> <p className="mb-2">{t("collection")}</p>
{link.collection.name ? ( {link.collection.name && (
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
defaultValue={{ defaultValue={{
@ -132,11 +135,11 @@ export default function NewLinkModal({ onClose }: Props) {
value: link.collection.id, value: link.collection.id,
}} }}
/> />
) : null} )}
</div> </div>
</div> </div>
<div className={"mt-2"}> <div className={"mt-2"}>
{optionsExpanded ? ( {optionsExpanded && (
<div className="mt-5"> <div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
<div> <div>
@ -166,12 +169,12 @@ 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>
</div> </div>
) : undefined} )}
</div> </div>
<div className="flex justify-between items-center mt-5"> <div className="flex justify-between items-center mt-5">
<div <div

View File

@ -7,6 +7,7 @@ import { dropdownTriggerer } from "@/lib/client/utils";
import Button from "../ui/Button"; import Button from "../ui/Button";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useAddToken } from "@/hooks/store/tokens"; import { useAddToken } from "@/hooks/store/tokens";
import CopyButton from "../CopyButton";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -68,21 +69,14 @@ export default function NewTokenModal({ onClose }: Props) {
<div className="flex flex-col justify-center space-y-4"> <div className="flex flex-col justify-center space-y-4">
<p className="text-xl font-thin">{t("access_token_created")}</p> <p className="text-xl font-thin">{t("access_token_created")}</p>
<p>{t("token_creation_notice")}</p> <p>{t("token_creation_notice")}</p>
<TextInput <div className="relative">
spellCheck={false} <div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between pr-14">
value={newToken} {newToken}
onChange={() => {}} <div className="absolute right-0 px-2 border-neutral-content border-solid border-r bg-base-200">
className="w-full" <CopyButton text={newToken} />
/> </div>
<button </div>
onClick={() => { </div>
navigator.clipboard.writeText(newToken);
toast.success(t("copied_to_clipboard"));
}}
className="btn btn-primary w-fit mx-auto"
>
{t("copy_to_clipboard")}
</button>
</div> </div>
) : ( ) : (
<> <>

View File

@ -79,7 +79,7 @@ export default function NewUserModal({ onClose }: Props) {
/> />
</div> </div>
{emailEnabled ? ( {emailEnabled && (
<div> <div>
<p className="mb-2">{t("email")}</p> <p className="mb-2">{t("email")}</p>
<TextInput <TextInput
@ -89,7 +89,7 @@ export default function NewUserModal({ onClose }: Props) {
value={form.email} value={form.email}
/> />
</div> </div>
) : undefined} )}
<div> <div>
<p className="mb-2"> <p className="mb-2">

View File

@ -1,248 +0,0 @@
import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} 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({
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"
);
};
const atLeastOneFormatAvailable = () => {
return (
screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link)
);
};
useEffect(() => {
(async () => {
await getLink.mutateAsync(link.id as number);
})();
let interval: any;
if (!isReady()) {
interval = setInterval(async () => {
await getLink.mutateAsync(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(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}
/>
) : 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}
/>
) : undefined}
{pdfAvailable(link) ? (
<PreservedFormatRow
name={t("pdf")}
icon={"bi-file-earmark-pdf"}
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>
) : !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}
<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>
);
}

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,
@ -70,7 +73,7 @@ export default function UploadFileModal({ onClose }: Props) {
useEffect(() => { useEffect(() => {
setOptionsExpanded(false); setOptionsExpanded(false);
if (router.query.id) { if (router.pathname.startsWith("/collections/") && router.query.id) {
const currentCollection = collections.find( const currentCollection = collections.find(
(e) => e.id == Number(router.query.id) (e) => e.id == Number(router.query.id)
); );
@ -150,7 +153,7 @@ export default function UploadFileModal({ onClose }: Props) {
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between"> <label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
<input <input
type="file" type="file"
accept=".pdf,.png,.jpg,.jpeg,.html" accept=".pdf,.png,.jpg,.jpeg"
className="cursor-pointer custom-file-input" className="cursor-pointer custom-file-input"
onChange={(e) => e.target.files && setFile(e.target.files[0])} onChange={(e) => e.target.files && setFile(e.target.files[0])}
/> />
@ -163,7 +166,7 @@ export default function UploadFileModal({ onClose }: Props) {
</div> </div>
<div className="sm:col-span-2 col-span-5"> <div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p> <p className="mb-2">{t("collection")}</p>
{link.collection.name ? ( {link.collection.name && (
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
defaultValue={{ defaultValue={{
@ -171,10 +174,10 @@ export default function UploadFileModal({ onClose }: Props) {
value: link.collection.id, value: link.collection.id,
}} }}
/> />
) : null} )}
</div> </div>
</div> </div>
{optionsExpanded ? ( {optionsExpanded && (
<div className="mt-5"> <div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
<div> <div>
@ -204,12 +207,12 @@ 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>
</div> </div>
) : undefined} )}
<div className="flex justify-between items-center mt-5"> <div className="flex justify-between items-center mt-5">
<div <div
onClick={() => setOptionsExpanded(!optionsExpanded)} onClick={() => setOptionsExpanded(!optionsExpanded)}

View File

@ -114,7 +114,7 @@ export default function Navbar() {
<MobileNavigation /> <MobileNavigation />
{sidebar ? ( {sidebar && (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40"> <div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}> <ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg"> <div className="slide-right h-full shadow-lg">
@ -122,16 +122,14 @@ export default function Navbar() {
</div> </div>
</ClickAwayHandler> </ClickAwayHandler>
</div> </div>
) : null} )}
{newLinkModal ? ( {newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
<NewLinkModal onClose={() => setNewLinkModal(false)} /> {newCollectionModal && (
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} /> <NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined} )}
{uploadFileModal ? ( {uploadFileModal && (
<UploadFileModal onClose={() => setUploadFileModal(false)} /> <UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined} )}
</div> </div>
); );
} }

View File

@ -39,9 +39,7 @@ export default function NoLinksFound({ text }: Props) {
</span> </span>
</div> </div>
</div> </div>
{newLinkModal ? ( {newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
</div> </div>
); );
} }

21
components/Popover.tsx Normal file
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;

View File

@ -4,7 +4,6 @@ import {
} from "@/types/global"; } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useGetLink } from "@/hooks/store/links";
type Props = { type Props = {
name: string; name: string;
@ -21,8 +20,6 @@ export default function PreservedFormatRow({
link, link,
downloadable, downloadable,
}: Props) { }: Props) {
const getLink = useGetLink();
const router = useRouter(); const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined; let isPublic = router.pathname.startsWith("/public") ? true : undefined;
@ -52,11 +49,9 @@ export default function PreservedFormatRow({
}; };
return ( return (
<div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md"> <div className="flex justify-between items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="bg-primary text-primary-content p-2 rounded-l-md"> <i className={`${icon} text-2xl text-primary`} />
<i className={`${icon} text-2xl`} />
</div>
<p>{name}</p> <p>{name}</p>
</div> </div>
@ -64,7 +59,7 @@ export default function PreservedFormatRow({
{downloadable || false ? ( {downloadable || false ? (
<div <div
onClick={() => handleDownload()} onClick={() => handleDownload()}
className="btn btn-sm btn-square" className="btn btn-sm btn-square btn-ghost"
> >
<i className="bi-cloud-arrow-down text-xl text-neutral" /> <i className="bi-cloud-arrow-down text-xl text-neutral" />
</div> </div>
@ -75,9 +70,9 @@ export default function PreservedFormatRow({
isPublic ? "/public" : "" isPublic ? "/public" : ""
}/preserved/${link?.id}?format=${format}`} }/preserved/${link?.id}?format=${format}`}
target="_blank" target="_blank"
className="btn btn-sm btn-square" className="btn btn-sm btn-square btn-ghost"
> >
<i className="bi-box-arrow-up-right text-xl text-neutral" /> <i className="bi-box-arrow-up-right text-lg text-neutral" />
</Link> </Link>
</div> </div>
</div> </div>

View File

@ -60,7 +60,7 @@ export default function ProfileDropdown() {
})} })}
</div> </div>
</li> </li>
{isAdmin ? ( {isAdmin && (
<li> <li>
<Link <Link
href="/admin" href="/admin"
@ -72,7 +72,7 @@ export default function ProfileDropdown() {
{t("server_administration")} {t("server_administration")}
</Link> </Link>
</li> </li>
) : null} )}
<li> <li>
<div <div
onClick={() => { onClick={() => {

View File

@ -3,7 +3,6 @@ import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import { import {
ArchivedFormat, ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import ColorThief, { RGBColor } from "colorthief"; import ColorThief, { RGBColor } from "colorthief";
@ -11,11 +10,11 @@ import DOMPurify from "dompurify";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useState } from "react";
import LinkActions from "./LinkViews/LinkComponents/LinkActions";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
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;
@ -46,13 +45,6 @@ export default function ReadableView({ link }: Props) {
const router = useRouter(); const router = useRouter();
const getLink = useGetLink(); const getLink = useGetLink();
const { data: collections = [] } = useCollections();
const collection = useMemo(() => {
return collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount;
}, [collections, link]);
useEffect(() => { useEffect(() => {
const fetchLinkContent = async () => { const fetchLinkContent = async () => {
@ -73,9 +65,9 @@ export default function ReadableView({ link }: Props) {
}, [link]); }, [link]);
useEffect(() => { useEffect(() => {
if (link) getLink.mutateAsync(link?.id as number); if (link) getLink.mutateAsync({ id: link.id as number });
let interval: any; let interval: NodeJS.Timeout | null = null;
if ( if (
link && link &&
(link?.image === "pending" || (link?.image === "pending" ||
@ -88,7 +80,10 @@ export default function ReadableView({ link }: Props) {
!link?.monolith) !link?.monolith)
) { ) {
interval = setInterval( interval = setInterval(
() => getLink.mutateAsync(link.id as number), () =>
getLink.mutateAsync({
id: link.id as number,
}),
5000 5000
); );
} else { } else {
@ -186,7 +181,7 @@ export default function ReadableView({ link }: Props) {
link?.name || link?.description || link?.url || "" link?.name || link?.description || link?.url || ""
)} )}
</p> </p>
{link?.url ? ( {link?.url && (
<Link <Link
href={link?.url || ""} href={link?.url || ""}
title={link?.url} title={link?.url}
@ -195,11 +190,10 @@ export default function ReadableView({ link }: Props) {
> >
<i className="bi-link-45deg"></i> <i className="bi-link-45deg"></i>
{isValidUrl(link?.url || "") {isValidUrl(link?.url || "") &&
? new URL(link?.url as string).host new URL(link?.url as string).host}
: undefined}
</Link> </Link>
) : undefined} )}
</div> </div>
</div> </div>
@ -208,10 +202,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]"
@ -243,13 +248,6 @@ export default function ReadableView({ link }: Props) {
{link?.name ? <p>{unescapeString(link?.description)}</p> : undefined} {link?.name ? <p>{unescapeString(link?.description)}</p> : undefined}
</div> </div>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
alignToTop
/>
</div> </div>
<div className="flex flex-col gap-5 h-full"> <div className="flex flex-col gap-5 h-full">

View File

@ -1,28 +1,34 @@
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState } 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();
const [theme, setTheme] = useState(localStorage.getItem("theme")); const [theme, setTheme] = useState<string | null>(
localStorage.getItem("theme")
);
const handleToggle = (e: any) => { const handleToggle = (e: ChangeEvent<HTMLInputElement>) => {
setTheme(e.target.checked ? "dark" : "light"); setTheme(e.target.checked ? "dark" : "light");
}; };
useEffect(() => { useEffect(() => {
updateSettings({ theme: theme as string }); if (theme) {
updateSettings({ theme });
}
}, [theme]); }, [theme]);
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",
})} })}
@ -34,7 +40,7 @@ export default function ToggleDarkMode({ className }: Props) {
type="checkbox" type="checkbox"
onChange={handleToggle} onChange={handleToggle}
className="theme-controller" className="theme-controller"
checked={localStorage.getItem("theme") === "light" ? false : true} checked={theme === "dark"}
/> />
<i className="bi-sun-fill text-xl swap-on"></i> <i className="bi-sun-fill text-xl swap-on"></i>
<i className="bi-moon-fill text-xl swap-off"></i> <i className="bi-moon-fill text-xl swap-off"></i>

View File

@ -74,12 +74,12 @@ const UserListing = (
</tbody> </tbody>
</table> </table>
{deleteUserModal.isOpen && deleteUserModal.userId ? ( {deleteUserModal.isOpen && deleteUserModal.userId && (
<DeleteUserModal <DeleteUserModal
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })} onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
userId={deleteUserModal.userId} userId={deleteUserModal.userId}
/> />
) : null} )}
</div> </div>
); );
}; };

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,141 @@ 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 = (
e: React.MouseEvent<HTMLButtonElement>,
viewMode: ViewMode
) => {
setViewMode(viewMode);
};
useEffect(() => { useEffect(() => {
updateSettings({ viewMode }); updateSettings({ viewMode });
}, [viewMode]); }, [viewMode, updateSettings]);
const onChangeViewMode = (mode: ViewMode) => {
setViewMode(mode);
updateSettings({ viewMode });
};
const toggleShowSetting = (setting: keyof typeof settings.show) => {
const newShowSettings = {
...settings.show,
[setting]: !settings.show[setting],
};
updateSettings({ show: newShowSettings });
};
const onColumnsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
updateSettings({ columns: Number(e.target.value) });
};
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
}`} tabIndex={0}
className="dropdown-content z-[30] menu shadow bg-base-200 min-w-52 border border-neutral-content rounded-xl mt-1"
> >
<i className="bi bi-columns-gap w-4 h-4 text-neutral"></i> <p className="mb-1 text-sm text-neutral">{t("view")}</p>
</button> <div className="p-1 flex w-full justify-between gap-1 border border-neutral-content rounded-[0.625rem]">
<button
<button onClick={(e) => onChangeViewMode(ViewMode.Card)}
onClick={(e) => onChangeViewMode(e, ViewMode.List)} className={`btn w-[31%] btn-sm btn-ghost ${
className={`btn btn-square btn-sm btn-ghost ${ viewMode === ViewMode.Card
viewMode == ViewMode.List ? "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-grid text-lg text-neutral"></i>
<i className="bi bi-view-stacked w-4 h-4 text-neutral"></i> </button>
</button> <button
onClick={(e) => onChangeViewMode(ViewMode.Masonry)}
{/* <button className={`btn w-[31%] btn-sm btn-ghost ${
onClick={(e) => onChangeViewMode(e, ViewMode.Grid)} viewMode === ViewMode.Masonry
className={`btn btn-square btn-sm btn-ghost ${ ? "bg-primary/20 hover:bg-primary/20"
viewMode == ViewMode.Grid : "hover:bg-neutral/20"
? "bg-primary/20 hover:bg-primary/20" }`}
: "hover:bg-neutral/20" >
}`} <i className="bi-columns-gap text-lg text-neutral"></i>
> </button>
<i className="bi-columns-gap w-4 h-4 text-neutral"></i> <button
</button> */} onClick={(e) => onChangeViewMode(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="mb-1 mt-2 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">
<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>
))}
{settings.viewMode !== ViewMode.List && (
<>
<p className="mb-1 mt-2 text-sm text-neutral">
{t("columns")}:{" "}
{settings.columns === 0 ? t("default") : settings.columns}
</p>
<div>
<input
type="range"
min={0}
max="8"
value={settings.columns}
onChange={(e) => onColumnsChange(e)}
className="range range-xs range-primary"
step="1"
/>
<div className="flex w-full justify-between px-2 text-xs text-neutral select-none">
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
</div>
</div>
</>
)}
</ul>
</div> </div>
); );
} }

View File

@ -2,19 +2,17 @@ import axios, { AxiosError } from "axios"
axios.defaults.baseURL = "http://localhost:3000" axios.defaults.baseURL = "http://localhost:3000"
export async function seedUser (username?: string, password?: string, name?: string) { export async function seedUser(username?: string, password?: string, name?: string) {
try { try {
return await axios.post("/api/v1/users", { return await axios.post("/api/v1/users", {
username: username || "test", username: username || "test",
password: password || "password", password: password || "password",
name: name || "Test User", name: name || "Test User",
}) })
} catch (e: any) { } catch (error) {
if (e instanceof AxiosError) { const axiosError = error as AxiosError;
if (e.response?.status === 400) { if (axiosError && axiosError.response?.status === 400) return
return
} throw error
}
throw e
} }
} }

View File

@ -1,4 +1,3 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
@ -7,11 +6,11 @@ const useDashboardData = () => {
return useQuery({ return useQuery({
queryKey: ["dashboardData"], queryKey: ["dashboardData"],
queryFn: async (): Promise<LinkIncludingShortenedCollectionAndTags[]> => { queryFn: async () => {
const response = await fetch("/api/v1/dashboard"); const response = await fetch("/api/v2/dashboard");
const data = await response.json(); const data = await response.json();
return data.response; return data.data;
}, },
enabled: status === "authenticated", enabled: status === "authenticated",
}); });

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();
@ -120,8 +121,11 @@ const useAddLink = () => {
}, },
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => { queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined; if (!oldData?.links) return undefined;
return [data, ...oldData]; return {
...oldData,
links: [data, ...oldData.links],
};
}); });
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@ -160,8 +164,8 @@ const useUpdateLink = () => {
}, },
onSuccess: (data) => { onSuccess: (data) => {
// queryClient.setQueryData(["dashboardData"], (oldData: any) => { // queryClient.setQueryData(["dashboardData"], (oldData: any) => {
// if (!oldData) return undefined; // if (!oldData?.links) return undefined;
// return oldData.map((e: any) => (e.id === data.id ? data : e)); // return oldData.links.map((e: any) => (e.id === data.id ? data : e));
// }); // });
// queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { // queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@ -201,8 +205,11 @@ const useDeleteLink = () => {
}, },
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => { queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined; if (!oldData?.links) return undefined;
return oldData.filter((e: any) => e.id !== data.id); return {
...oldData,
links: oldData.links.filter((e: any) => e.id !== data.id),
};
}); });
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@ -225,9 +232,21 @@ const useDeleteLink = () => {
const useGetLink = () => { const useGetLink = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
return useMutation({ return useMutation({
mutationFn: async (id: number) => { mutationFn: async ({
const response = await fetch(`/api/v1/links/${id}`); id,
isPublicRoute = router.pathname.startsWith("/public") ? true : undefined,
}: {
id: number;
isPublicRoute?: boolean;
}) => {
const path = isPublicRoute
? `/api/v1/public/links/${id}`
: `/api/v1/links/${id}`;
const response = await fetch(path);
const data = await response.json(); const data = await response.json();
if (!response.ok) throw new Error(data.response); if (!response.ok) throw new Error(data.response);
@ -236,8 +255,11 @@ const useGetLink = () => {
}, },
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => { queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined; if (!oldData?.links) return undefined;
return oldData.map((e: any) => (e.id === data.id ? data : e)); return {
...oldData,
links: oldData.links.map((e: any) => (e.id === data.id ? data : e)),
};
}); });
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@ -250,7 +272,20 @@ const useGetLink = () => {
}; };
}); });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] }); queryClient.setQueriesData(
{ queryKey: ["publicLinks"] },
(oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) => (item.id === data.id ? data : item))
),
pageParams: oldData.pageParams,
};
}
);
// queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
}, },
}); });
}; };
@ -276,8 +311,8 @@ const useBulkDeleteLinks = () => {
}, },
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => { queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined; if (!oldData.links) return undefined;
return oldData.filter((e: any) => !data.includes(e.id)); return oldData.links.filter((e: any) => !data.includes(e.id));
}); });
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@ -351,8 +386,11 @@ const useUploadFile = () => {
}, },
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => { queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined; if (!oldData?.links) return undefined;
return [data, ...oldData]; return {
...oldData,
links: [data, ...oldData.links],
};
}); });
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => { queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@ -370,6 +408,67 @@ 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: "PUT",
}
);
const data = res.json();
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.links) return undefined;
return {
...oldData,
links: oldData.links.map((e: any) =>
e.id === data.response.id
? {
...e,
preview: `archives/preview/${e.collectionId}/${e.id}.jpeg`,
}
: e
),
};
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) =>
item.id === data.response.id
? {
...item,
preview: `archives/preview/${item.collectionId}/${item.id}.jpeg`,
updatedAt: new Date().toISOString(),
}
: item
)
),
pageParams: oldData.pageParams,
};
});
},
});
};
const useBulkEditLinks = () => { const useBulkEditLinks = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -403,8 +502,8 @@ const useBulkEditLinks = () => {
onSuccess: (data, { links, newData, removePreviousTags }) => { onSuccess: (data, { links, newData, removePreviousTags }) => {
// TODO: Fix these // TODO: Fix these
// queryClient.setQueryData(["dashboardData"], (oldData: any) => { // queryClient.setQueryData(["dashboardData"], (oldData: any) => {
// if (!oldData) return undefined; // if (!oldData?.links) return undefined;
// return oldData.map((e: any) => // return oldData.links.map((e: any) =>
// data.find((d: any) => d.id === e.id) ? data : e // data.find((d: any) => d.id === e.id) ? data : e
// ); // );
// }); // });
@ -454,4 +553,5 @@ export {
useGetLink, useGetLink,
useBulkEditLinks, useBulkEditLinks,
resetInfiniteQueryPagination, resetInfiniteQueryPagination,
useUpdatePreview,
}; };

View File

@ -23,7 +23,7 @@ export default function CenteredForm({
data-testid={dataTestId} data-testid={dataTestId}
> >
<div className="m-auto flex flex-col gap-2 w-full"> <div className="m-auto flex flex-col gap-2 w-full">
{settings.theme ? ( {settings.theme && (
<Image <Image
src={`/linkwarden_${ src={`/linkwarden_${
settings.theme === "dark" ? "dark" : "light" settings.theme === "dark" ? "dark" : "light"
@ -33,12 +33,12 @@ export default function CenteredForm({
alt="Linkwarden" alt="Linkwarden"
className="h-12 w-fit mx-auto" className="h-12 w-fit mx-auto"
/> />
) : undefined} )}
{text ? ( {text && (
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold px-2 text-center"> <p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold px-2 text-center">
{text} {text}
</p> </p>
) : undefined} )}
{children} {children}
<p className="text-center text-xs text-neutral mb-5"> <p className="text-center text-xs text-neutral mb-5">
<Trans <Trans

View File

@ -34,9 +34,9 @@ export default function MainLayout({ children }: Props) {
return ( return (
<div className="flex" data-testid="dashboard-wrapper"> <div className="flex" data-testid="dashboard-wrapper">
{showAnnouncement ? ( {showAnnouncement && (
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} /> <Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
) : undefined} )}
<div className="hidden lg:block"> <div className="hidden lg:block">
<Sidebar className={`fixed top-0`} /> <Sidebar className={`fixed top-0`} />
</div> </div>

View File

@ -54,7 +54,7 @@ export default function SettingsLayout({ children }: Props) {
{children} {children}
{sidebar ? ( {sidebar && (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30"> <div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler <ClickAwayHandler
className="h-full" className="h-full"
@ -65,7 +65,7 @@ export default function SettingsLayout({ children }: Props) {
</div> </div>
</ClickAwayHandler> </ClickAwayHandler>
</div> </div>
) : null} )}
</div> </div>
</div> </div>
</> </>

View File

@ -57,8 +57,8 @@ export default async function deleteCollection(
}, },
}); });
await removeFolder({ filePath: `archives/${collectionId}` }); removeFolder({ filePath: `archives/${collectionId}` });
await removeFolder({ filePath: `archives/preview/${collectionId}` }); removeFolder({ filePath: `archives/preview/${collectionId}` });
await removeFromOrders(userId, collectionId); await removeFromOrders(userId, collectionId);
@ -100,8 +100,8 @@ async function deleteSubCollections(collectionId: number) {
where: { id: subCollection.id }, where: { id: subCollection.id },
}); });
await removeFolder({ filePath: `archives/${subCollection.id}` }); removeFolder({ filePath: `archives/${subCollection.id}` });
await removeFolder({ filePath: `archives/preview/${subCollection.id}` }); removeFolder({ filePath: `archives/preview/${subCollection.id}` });
} }
} }

View File

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

View File

@ -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: {

View File

@ -1,11 +1,11 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global"; import { LinkRequestQuery, Order, Sort } from "@/types/global";
export default async function getDashboardData( export default async function getDashboardData(
userId: number, userId: number,
query: LinkRequestQuery query: LinkRequestQuery
) { ) {
let order: any = { id: "desc" }; let order: Order = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" };

View File

@ -1,5 +1,5 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global"; import { LinkRequestQuery, Order, Sort } from "@/types/global";
type Response<D> = type Response<D> =
| { | {
@ -17,7 +17,7 @@ export default async function getDashboardData(
userId: number, userId: number,
query: LinkRequestQuery query: LinkRequestQuery
): Promise<Response<any>> { ): Promise<Response<any>> {
let order: any; let order: Order = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" };
@ -48,7 +48,7 @@ export default async function getDashboardData(
}); });
const pinnedLinks = await prisma.link.findMany({ const pinnedLinks = await prisma.link.findMany({
take: 10, take: 16,
where: { where: {
AND: [ AND: [
{ {
@ -80,7 +80,7 @@ export default async function getDashboardData(
}); });
const recentlyAddedLinks = await prisma.link.findMany({ const recentlyAddedLinks = await prisma.link.findMany({
take: 10, take: 16,
where: { where: {
collection: { collection: {
OR: [ OR: [
@ -105,12 +105,17 @@ export default async function getDashboardData(
}); });
const links = [...recentlyAddedLinks, ...pinnedLinks].sort( const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any) (a, b) => new Date(b.id).getTime() - new Date(a.id).getTime()
);
// Make sure links are unique
const uniqueLinks = links.filter(
(link, index, self) => index === self.findIndex((t) => t.id === link.id)
); );
return { return {
data: { data: {
links, links: uniqueLinks,
numberOfPinnedLinks, numberOfPinnedLinks,
}, },
message: "Dashboard data fetched successfully.", message: "Dashboard data fetched successfully.",

View File

@ -1,11 +1,11 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global"; import { LinkRequestQuery, Order, Sort } from "@/types/global";
export default async function getLink(userId: number, query: LinkRequestQuery) { export default async function getLink(userId: number, query: LinkRequestQuery) {
const POSTGRES_IS_ENABLED = const POSTGRES_IS_ENABLED =
process.env.DATABASE_URL?.startsWith("postgresql"); process.env.DATABASE_URL?.startsWith("postgresql");
let order: any = { id: "desc" }; let order: Order = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" };

View File

@ -2,7 +2,8 @@ import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { UsersAndCollections } from "@prisma/client"; import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import { moveFiles } from "@/lib/api/manageLinkFiles"; import { moveFiles, removeFiles } from "@/lib/api/manageLinkFiles";
import isValidUrl from "@/lib/shared/isValidUrl";
export default async function updateLinkById( export default async function updateLinkById(
userId: number, userId: number,
@ -25,15 +26,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 +49,7 @@ export default async function updateLinkById(
}, },
}); });
// return { response: updatedLink, status: 200 }; return { response: updatedLink, status: 200 };
} }
const targetCollectionIsAccessible = await getPermission({ const targetCollectionIsAccessible = await getPermission({
@ -89,13 +90,41 @@ export default async function updateLinkById(
status: 401, status: 401,
}; };
else { else {
const oldLink = await prisma.link.findUnique({
where: {
id: linkId,
},
});
if (
data.url &&
oldLink &&
oldLink?.url !== data.url &&
isValidUrl(data.url)
) {
await removeFiles(oldLink.id, oldLink.collectionId);
} else if (oldLink?.url !== data.url)
return {
response: "Invalid URL.",
status: 401,
};
const updatedLink = await prisma.link.update({ const updatedLink = await prisma.link.update({
where: { where: {
id: linkId, id: linkId,
}, },
data: { data: {
name: data.name, name: data.name,
url: data.url,
description: data.description, description: data.description,
icon: data.icon,
iconWeight: data.iconWeight,
color: data.color,
image: oldLink?.url !== data.url ? null : undefined,
pdf: oldLink?.url !== data.url ? null : undefined,
readable: oldLink?.url !== data.url ? null : undefined,
monolith: oldLink?.url !== data.url ? null : undefined,
preview: oldLink?.url !== data.url ? null : undefined,
collection: { collection: {
connect: { connect: {
id: data.collection.id, id: data.collection.id,
@ -121,7 +150,7 @@ export default async function updateLinkById(
})), })),
}, },
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 } },
}, },

View File

@ -22,18 +22,5 @@ export default async function exportData(userId: number) {
const { password, id, ...userData } = user; const { password, id, ...userData } = user;
function redactIds(obj: any) {
if (Array.isArray(obj)) {
obj.forEach((o) => redactIds(o));
} else if (obj !== null && typeof obj === "object") {
delete obj.id;
for (let key in obj) {
redactIds(obj[key]);
}
}
}
redactIds(userData);
return { response: userData, status: 200 }; return { response: userData, status: 200 };
} }

View File

@ -1,5 +1,4 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { Backup } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
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;

View File

@ -1,5 +1,5 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global"; import { LinkRequestQuery, Order, Sort } from "@/types/global";
export default async function getLink( export default async function getLink(
query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly"> query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly">
@ -7,7 +7,7 @@ export default async function getLink(
const POSTGRES_IS_ENABLED = const POSTGRES_IS_ENABLED =
process.env.DATABASE_URL?.startsWith("postgresql"); process.env.DATABASE_URL?.startsWith("postgresql");
let order: any; let order: Order = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" };

View File

@ -29,7 +29,7 @@ export default async function createSession(
secret: process.env.NEXTAUTH_SECRET as string, secret: process.env.NEXTAUTH_SECRET as string,
}); });
const createToken = await prisma.accessToken.create({ await prisma.accessToken.create({
data: { data: {
name: sessionName || "Unknown Device", name: sessionName || "Unknown Device",
userId, userId,

View File

@ -24,10 +24,7 @@ export default async function deleteUserById(
if (!isServerAdmin) { if (!isServerAdmin) {
if (user.password) { if (user.password) {
const isPasswordValid = bcrypt.compareSync( const isPasswordValid = bcrypt.compareSync(body.password, user.password);
body.password,
user.password as string
);
if (!isPasswordValid && !isServerAdmin) { if (!isPasswordValid && !isServerAdmin) {
return { return {

View File

@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = export const prisma =
globalForPrisma.prisma || globalForPrisma.prisma ||
new PrismaClient({ new PrismaClient({
log: ["query"], log: process.env.DEBUG === "true" ? ["query", "info", "warn", "error"] : ["warn", "error"]
}); });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@ -16,7 +16,7 @@ const generatePreview = async (
return; return;
} }
image.resize(1280, Jimp.AUTO).quality(20); image.resize(1000, Jimp.AUTO).quality(20);
const processedBuffer = await image.getBufferAsync(Jimp.MIME_JPEG); const processedBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
if ( if (

View File

@ -14,7 +14,7 @@ export default async function moveFile(from: string, to: string) {
}; };
try { try {
s3Client.copyObject(copyParams, async (err: any) => { s3Client.copyObject(copyParams, async (err: unknown) => {
if (err) { if (err) {
console.error("Error copying the object:", err); console.error("Error copying the object:", err);
} else { } else {

18
lib/client/icons.ts Normal file
View File

@ -0,0 +1,18 @@
import * as Icons from "@phosphor-icons/react";
import { icons as iconData } from "@phosphor-icons/core";
import { IconEntry as CoreEntry } from "@phosphor-icons/core";
interface IconEntry extends CoreEntry {
Icon: Icons.Icon;
}
export const icons: ReadonlyArray<IconEntry> = iconData.map((entry) => ({
...entry,
Icon: Icons[entry.pascal_name as keyof typeof Icons] as Icons.Icon,
}));
// if (process.env.NODE_ENV === "development") {
// console.log(`${icons.length} icons`);
// }
export const iconCount = Intl.NumberFormat("en-US").format(icons.length * 6);

47
lib/client/pinLink.ts Normal file
View File

@ -0,0 +1,47 @@
import { useUpdateLink } from "@/hooks/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
const usePinLink = () => {
const { t } = useTranslation();
const updateLink = useUpdateLink();
const { data: user = {} } = useUser();
// Return a function that can be used to pin/unpin the link
const pinLink = async (link: LinkIncludingShortenedCollectionAndTags) => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
const load = toast.loading(t("updating"));
try {
await updateLink.mutateAsync(
{
...link,
pinnedBy: isAlreadyPinned ? [{ id: undefined }] : [{ id: user.id }],
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(
isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
);
}
},
}
);
} catch (e) {
toast.dismiss(load);
console.error(e);
}
};
return pinLink;
};
export default usePinLink;

View File

@ -9,7 +9,7 @@ export const resizeImage = (file: File): Promise<Blob> =>
"JPEG", // output format "JPEG", // output format
100, // quality 100, // quality
0, // rotation 0, // rotation
(uri: any) => { (uri) => {
resolve(uri as Blob); resolve(uri as Blob);
}, },
"blob" // output type "blob" // output type

View File

@ -7,10 +7,15 @@ export function isPWA() {
} }
export function isIphone() { export function isIphone() {
return /iPhone/.test(navigator.userAgent) && !(window as any).MSStream; return (
/iPhone/.test(navigator.userAgent) &&
!(window as unknown as { MSStream?: any }).MSStream
);
} }
export function dropdownTriggerer(e: any) { export function dropdownTriggerer(
e: React.FocusEvent<HTMLElement> | React.MouseEvent<HTMLElement>
) {
let targetEl = e.currentTarget; let targetEl = e.currentTarget;
if (targetEl && targetEl.matches(":focus")) { if (targetEl && targetEl.matches(":focus")) {
setTimeout(function () { setTimeout(function () {

View File

@ -39,7 +39,9 @@ export function monolithAvailable(
); );
} }
export function previewAvailable(link: any) { export function previewAvailable(
link: LinkIncludingShortenedCollectionAndTags
) {
return ( return (
link && link &&
link.preview && link.preview &&

View File

@ -2,7 +2,19 @@
module.exports = { module.exports = {
i18n: { i18n: {
defaultLocale: "en", defaultLocale: "en",
locales: ["en", "it", "fr", "zh"], locales: [
"en",
"it",
"fr",
"zh",
"uk",
"pt-BR",
"ja",
"es",
"de",
"nl",
"tr",
],
}, },
reloadOnPrerender: process.env.NODE_ENV === "development", reloadOnPrerender: process.env.NODE_ENV === "development",
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "linkwarden", "name": "linkwarden",
"version": "v2.7.1", "version": "v2.8.0",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git", "repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>", "author": "Daniel31X13 <daniel31x13@gmail.com>",
@ -25,6 +25,8 @@
"@aws-sdk/client-s3": "^3.379.1", "@aws-sdk/client-s3": "^3.379.1",
"@headlessui/react": "^1.7.15", "@headlessui/react": "^1.7.15",
"@mozilla/readability": "^0.4.4", "@mozilla/readability": "^0.4.4",
"@phosphor-icons/core": "^2.1.1",
"@phosphor-icons/react": "^2.1.7",
"@prisma/client": "^4.16.2", "@prisma/client": "^4.16.2",
"@stripe/stripe-js": "^1.54.1", "@stripe/stripe-js": "^1.54.1",
"@tanstack/react-query": "^5.51.15", "@tanstack/react-query": "^5.51.15",
@ -50,6 +52,7 @@
"eslint-config-next": "13.4.9", "eslint-config-next": "13.4.9",
"formidable": "^3.5.1", "formidable": "^3.5.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"fuse.js": "^7.0.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"himalaya": "^1.1.0", "himalaya": "^1.1.0",
"i18next": "^23.11.5", "i18next": "^23.11.5",
@ -76,7 +79,7 @@
"socks-proxy-agent": "^8.0.2", "socks-proxy-agent": "^8.0.2",
"stripe": "^12.13.0", "stripe": "^12.13.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"vaul": "^0.8.8", "vaul": "^0.9.1",
"zustand": "^4.3.8" "zustand": "^4.3.8"
}, },
"devDependencies": { "devDependencies": {
@ -92,7 +95,7 @@
"postcss": "^8.4.26", "postcss": "^8.4.26",
"prettier": "3.1.1", "prettier": "3.1.1",
"prisma": "^4.16.2", "prisma": "^4.16.2",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.4.10",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "4.9.4" "typescript": "4.9.4"
} }

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

View File

@ -100,9 +100,7 @@ export default function Admin() {
<p>{t("no_users_found")}</p> <p>{t("no_users_found")}</p>
)} )}
{newUserModal ? ( {newUserModal && <NewUserModal onClose={() => setNewUserModal(false)} />}
<NewUserModal onClose={() => setNewUserModal(false)} />
) : null}
</div> </div>
); );
} }

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(
@ -166,8 +163,12 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
where: { id: linkId }, where: { id: linkId },
}); });
if (linkStillExists && files.file[0].mimetype?.includes("image")) { const { mimetype } = files.file[0];
const collectionId = collectionPermissions.id as number; const isPDF = mimetype?.includes("pdf");
const isImage = mimetype?.includes("image");
if (linkStillExists && isImage) {
const collectionId = collectionPermissions.id;
createFolder({ createFolder({
filePath: `archives/preview/${collectionId}`, filePath: `archives/preview/${collectionId}`,
}); });
@ -184,13 +185,11 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
await prisma.link.update({ await prisma.link.update({
where: { id: linkId }, where: { id: linkId },
data: { data: {
preview: files.file[0].mimetype?.includes("pdf") preview: isPDF ? "unavailable" : undefined,
? "unavailable" image: isImage
: undefined,
image: files.file[0].mimetype?.includes("image")
? `archives/${collectionPermissions.id}/${linkId + suffix}` ? `archives/${collectionPermissions.id}/${linkId + suffix}`
: null, : null,
pdf: files.file[0].mimetype?.includes("pdf") pdf: isPDF
? `archives/${collectionPermissions.id}/${linkId + suffix}` ? `archives/${collectionPermissions.id}/${linkId + suffix}`
: null, : null,
lastPreserved: new Date().toISOString(), lastPreserved: new Date().toISOString(),
@ -206,4 +205,94 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
}); });
}); });
} }
// To update the link preview
else if (req.method === "PUT") {
if (process.env.NEXT_PUBLIC_DEMO === "true")
return res.status(400).json({
response:
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const 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}`,
});
await 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." });
}
});
}
} }

View File

@ -1186,10 +1186,42 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
providerAccountId: account?.providerAccountId, providerAccountId: account?.providerAccountId,
}, },
}); });
if (!existingUser && newSsoUsersDisabled) { if (!existingUser && newSsoUsersDisabled) {
return false; return false;
} }
// If user is already registered, link the provider
if (user.email && account) {
const findUser = await prisma.user.findFirst({
where: {
email: user.email,
},
include: {
accounts: true,
},
});
if (findUser && findUser.accounts.length === 0) {
await prisma.account.create({
data: {
userId: findUser.id,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
id_token: account.id_token,
access_token: account.access_token,
refresh_token: account.refresh_token,
expires_at: account.expires_at,
token_type: account.token_type,
scope: account.scope,
session_state: account.session_state,
},
});
}
}
} }
return true; return true;
}, },
async jwt({ token, trigger, user }) { async jwt({ token, trigger, user }) {
@ -1198,13 +1230,28 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
token.id = user?.id as number; token.id = user?.id as number;
if (trigger === "signUp") { if (trigger === "signUp") {
const checkIfUserExists = await prisma.user.findUnique({ const userExists = await prisma.user.findUnique({
where: { where: {
id: token.id, id: token.id,
}, },
include: {
accounts: true,
},
}); });
if (checkIfUserExists && !checkIfUserExists.username) { // Verify SSO user email
if (userExists && userExists.accounts.length > 0) {
await prisma.user.update({
where: {
id: userExists.id,
},
data: {
emailVerified: new Date(),
},
});
}
if (userExists && !userExists.username) {
const autoGeneratedUsername = const autoGeneratedUsername =
"user" + Math.round(Math.random() * 1000000000); "user" + Math.round(Math.random() * 1000000000);
@ -1217,6 +1264,22 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
}, },
}); });
} }
} else if (trigger === "signIn") {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
});
if (user && !user.username) {
const autoGeneratedUsername =
"user" + Math.round(Math.random() * 1000000000);
await prisma.user.update({
where: { id: user.id },
data: { username: autoGeneratedUsername },
});
}
} }
return token; return token;
@ -1224,6 +1287,8 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
async session({ session, token }) { async session({ session, token }) {
session.user.id = token.id; session.user.id = token.id;
console.log("session", session);
if (STRIPE_SECRET_KEY) { if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
@ -1235,6 +1300,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
}); });
if (user) { if (user) {
//
const subscribedUser = await verifySubscription(user); const subscribedUser = await verifySubscription(user);
} }
} }

View File

@ -2,8 +2,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import { Collection, Link } from "@prisma/client"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { removeFiles } from "@/lib/api/manageLinkFiles"; import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import { moveFiles, removeFiles } from "@/lib/api/manageLinkFiles";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@ -23,7 +25,16 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
response: "Link not found.", response: "Link not found.",
}); });
if (link.collection.ownerId !== user.id) const collectionIsAccessible = await getPermission({
userId: user.id,
collectionId: link.collectionId,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canUpdate
);
if (!(collectionIsAccessible?.ownerId === user.id || memberHasAccess))
return res.status(401).json({ return res.status(401).json({
response: "Permission denied.", response: "Permission denied.",
}); });
@ -54,7 +65,20 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
response: "Invalid URL.", response: "Invalid URL.",
}); });
await deleteArchivedFiles(link); await prisma.link.update({
where: {
id: link.id,
},
data: {
image: null,
pdf: null,
readable: null,
monolith: null,
preview: null,
},
});
await removeFiles(link.id, link.collection.id);
return res.status(200).json({ return res.status(200).json({
response: "Link is being archived.", response: "Link is being archived.",
@ -72,20 +96,3 @@ const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
return diffInMinutes; return diffInMinutes;
}; };
const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
await prisma.link.update({
where: {
id: link.id,
},
data: {
image: null,
pdf: null,
readable: null,
monolith: null,
preview: null,
},
});
await removeFiles(link.id, link.collection.id);
};

View File

@ -1,4 +1,5 @@
import { import {
AccountSettings,
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
Sort, Sort,
ViewMode, ViewMode,
@ -23,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();
@ -54,15 +57,9 @@ export default function Index() {
const { data: user = {} } = useUser(); const { data: user = {} } = useUser();
const [collectionOwner, setCollectionOwner] = useState({ const [collectionOwner, setCollectionOwner] = useState<
id: null as unknown as number, Partial<AccountSettings>
name: "", >({});
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => { useEffect(() => {
const fetchOwner = async () => { const fetchOwner = async () => {
@ -115,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}
@ -207,14 +215,14 @@ export default function Index() {
className="flex items-center btn px-2 btn-ghost rounded-full w-fit" className="flex items-center btn px-2 btn-ghost rounded-full w-fit"
onClick={() => setEditCollectionSharingModal(true)} onClick={() => setEditCollectionSharingModal(true)}
> >
{collectionOwner.id ? ( {collectionOwner.id && (
<ProfilePhoto <ProfilePhoto
src={collectionOwner.image || undefined} src={collectionOwner.image || undefined}
name={collectionOwner.name} name={collectionOwner.name}
/> />
) : undefined} )}
{activeCollection.members {activeCollection.members
.sort((a, b) => (a.userId as number) - (b.userId as number)) .sort((a, b) => a.userId - b.userId)
.map((e, i) => { .map((e, i) => {
return ( return (
<ProfilePhoto <ProfilePhoto
@ -226,13 +234,13 @@ export default function Index() {
); );
}) })
.slice(0, 3)} .slice(0, 3)}
{activeCollection.members.length - 3 > 0 ? ( {activeCollection.members.length - 3 > 0 && (
<div className={`avatar drop-shadow-md placeholder -ml-3`}> <div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content"> <div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{activeCollection.members.length - 3}</span> <span>+{activeCollection.members.length - 3}</span>
</div> </div>
</div> </div>
) : null} )}
</div> </div>
<p className="text-neutral text-sm"> <p className="text-neutral text-sm">

View File

@ -10,6 +10,7 @@ import PageHeader from "@/components/PageHeader";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections"; import { useCollections } from "@/hooks/store/collections";
import { dropdownTriggerer } from "@/lib/client/utils";
export default function Collections() { export default function Collections() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -29,12 +30,37 @@ export default function Collections() {
<MainLayout> <MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full"> <div className="p-5 flex flex-col gap-5 w-full h-full">
<div className="flex justify-between"> <div className="flex justify-between">
<PageHeader <div className="flex items-center gap-3">
icon={"bi-folder"} <PageHeader
title={t("collections")} icon={"bi-folder"}
description={t("collections_you_own")} title={t("collections")}
/> description={t("collections_you_own")}
/>
<div className="relative">
<div className={"dropdown dropdown-bottom font-normal"}>
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral"
>
<i className={"bi-three-dots text-neutral text-2xl"}></i>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
<li>
<div
role="button"
tabIndex={0}
onClick={() => setNewCollectionModal(true)}
className="whitespace-nowrap"
>
{t("new_collection")}
</div>
</li>
</ul>
</div>
</div>
</div>
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<div className="relative mt-2"> <div className="relative mt-2">
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} /> <SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} />
@ -60,7 +86,7 @@ export default function Collections() {
</div> </div>
</div> </div>
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] ? ( {sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] && (
<> <>
<PageHeader <PageHeader
icon={"bi-folder"} icon={"bi-folder"}
@ -76,11 +102,11 @@ export default function Collections() {
})} })}
</div> </div>
</> </>
) : undefined} )}
</div> </div>
{newCollectionModal ? ( {newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} /> <NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined} )}
</MainLayout> </MainLayout>
); );
} }

View File

@ -1,7 +1,6 @@
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import React from "react"; import React from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { MigrationFormat, MigrationRequest, ViewMode } from "@/types/global"; import { MigrationFormat, MigrationRequest, ViewMode } from "@/types/global";
@ -16,16 +15,20 @@ import { useCollections } from "@/hooks/store/collections";
import { useTags } from "@/hooks/store/tags"; import { useTags } from "@/hooks/store/tags";
import { useDashboardData } from "@/hooks/store/dashboardData"; import { useDashboardData } from "@/hooks/store/dashboardData";
import Links from "@/components/LinkViews/Links"; import Links from "@/components/LinkViews/Links";
import useLocalSettingsStore from "@/store/localSettings";
export default function Dashboard() { export default function Dashboard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: collections = [] } = useCollections(); const { data: collections = [] } = useCollections();
const dashboardData = useDashboardData(); const {
data: { links = [], numberOfPinnedLinks } = { links: [] },
...dashboardData
} = useDashboardData();
const { data: tags = [] } = useTags(); const { data: tags = [] } = useTags();
const [numberOfLinks, setNumberOfLinks] = useState(0); const [numberOfLinks, setNumberOfLinks] = useState(0);
const [showLinks, setShowLinks] = useState(3); const { settings } = useLocalSettingsStore();
useEffect(() => { useEffect(() => {
setNumberOfLinks( setNumberOfLinks(
@ -37,29 +40,28 @@ export default function Dashboard() {
); );
}, [collections]); }, [collections]);
const handleNumberOfLinksToShow = () => { const numberOfLinksToShow = useMemo(() => {
if (window.innerWidth > 1900) { if (window.innerWidth > 1900) {
setShowLinks(10); return 10;
} else if (window.innerWidth > 1500) { } else if (window.innerWidth > 1500) {
setShowLinks(8); return 8;
} else if (window.innerWidth > 880) { } else if (window.innerWidth > 880) {
setShowLinks(6); return 6;
} else if (window.innerWidth > 550) { } else if (window.innerWidth > 550) {
setShowLinks(4); return 4;
} else setShowLinks(2); } else {
}; return 2;
}
}, []);
const { width } = useWindowDimensions(); const importBookmarks = async (
e: React.ChangeEvent<HTMLInputElement>,
useEffect(() => { format: MigrationFormat
handleNumberOfLinksToShow(); ) => {
}, [width]); const file: File | null = e.target.files && e.target.files[0];
const importBookmarks = async (e: any, format: MigrationFormat) => {
const file: File = e.target.files[0];
if (file) { if (file) {
var reader = new FileReader(); const reader = new FileReader();
reader.readAsText(file, "UTF-8"); reader.readAsText(file, "UTF-8");
reader.onload = async function (e) { reader.onload = async function (e) {
const load = toast.loading("Importing..."); const load = toast.loading("Importing...");
@ -110,32 +112,30 @@ export default function Dashboard() {
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} /> <ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div> </div>
<div> <div className="xl:flex flex flex-col sm:grid grid-cols-2 gap-5 xl:flex-row xl:justify-evenly xl:w-full h-full rounded-2xl p-5 bg-base-200 border border-neutral-content">
<div className="flex justify-evenly flex-col xl:flex-row xl:items-center gap-2 xl:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200"> <DashboardItem
<DashboardItem name={numberOfLinks === 1 ? t("link") : t("links")}
name={numberOfLinks === 1 ? t("link") : t("links")} value={numberOfLinks}
value={numberOfLinks} icon={"bi-link-45deg"}
icon={"bi-link-45deg"} />
/>
<div className="divider xl:divider-horizontal"></div> <DashboardItem
name={collections.length === 1 ? t("collection") : t("collections")}
value={collections.length}
icon={"bi-folder"}
/>
<DashboardItem <DashboardItem
name={ name={tags.length === 1 ? t("tag") : t("tags")}
collections.length === 1 ? t("collection") : t("collections") value={tags.length}
} icon={"bi-hash"}
value={collections.length} />
icon={"bi-folder"}
/>
<div className="divider xl:divider-horizontal"></div> <DashboardItem
name={t("pinned")}
<DashboardItem value={numberOfPinnedLinks}
name={tags.length === 1 ? t("tag") : t("tags")} icon={"bi-pin-angle"}
value={tags.length} />
icon={"bi-hash"}
/>
</div>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@ -157,10 +157,7 @@ export default function Dashboard() {
<div <div
style={{ style={{
flex: flex: links || dashboardData.isLoading ? "0 1 auto" : "1 1 auto",
dashboardData.data || dashboardData.isLoading
? "0 1 auto"
: "1 1 auto",
}} }}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2" className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
> >
@ -168,16 +165,17 @@ export default function Dashboard() {
<div className="w-full"> <div className="w-full">
<Links <Links
layout={viewMode} layout={viewMode}
placeholderCount={showLinks / 2} placeholderCount={settings.columns || 1}
useData={dashboardData} useData={dashboardData}
/> />
</div> </div>
) : dashboardData.data && ) : links && links[0] && !dashboardData.isLoading ? (
dashboardData.data[0] &&
!dashboardData.isLoading ? (
<div className="w-full"> <div className="w-full">
<Links <Links
links={dashboardData.data.slice(0, showLinks)} links={links.slice(
0,
settings.columns ? settings.columns * 2 : numberOfLinksToShow
)}
layout={viewMode} layout={viewMode}
/> />
</div> </div>
@ -310,16 +308,21 @@ export default function Dashboard() {
<div className="w-full"> <div className="w-full">
<Links <Links
layout={viewMode} layout={viewMode}
placeholderCount={showLinks / 2} placeholderCount={settings.columns || 1}
useData={dashboardData} useData={dashboardData}
/> />
</div> </div>
) : dashboardData.data?.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( ) : links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="w-full"> <div className="w-full">
<Links <Links
links={dashboardData.data links={links
.filter((e) => e.pinnedBy && e.pinnedBy[0]) .filter((e: any) => e.pinnedBy && e.pinnedBy[0])
.slice(0, showLinks)} .slice(
0,
settings.columns
? settings.columns * 2
: numberOfLinksToShow
)}
layout={viewMode} layout={viewMode}
/> />
</div> </div>
@ -339,9 +342,7 @@ export default function Dashboard() {
)} )}
</div> </div>
</div> </div>
{newLinkModal ? ( {newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
</MainLayout> </MainLayout>
); );
} }

42
pages/links/[id].tsx Normal file
View File

@ -0,0 +1,42 @@
import LinkDetails from "@/components/LinkDetails";
import { useGetLink } from "@/hooks/store/links";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import getServerSideProps from "@/lib/client/getServerSideProps";
const Index = () => {
const router = useRouter();
const { id } = router.query;
useState;
const getLink = useGetLink();
useEffect(() => {
getLink.mutate({ id: Number(id) });
}, []);
return (
<div className="flex h-screen">
{getLink.data ? (
<LinkDetails
activeLink={getLink.data}
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="w-20 h-20 skeleton rounded-xl"></div>
<div className="w-full h-10 skeleton rounded-xl"></div>
<div className="w-full h-10 skeleton rounded-xl"></div>
<div className="w-full h-10 skeleton rounded-xl"></div>
<div className="w-full h-10 skeleton rounded-xl"></div>
</div>
)}
</div>
);
};
export default Index;
export { getServerSideProps };

View File

@ -203,9 +203,9 @@ export default function Login({
{t("login")} {t("login")}
</Button> </Button>
{availableLogins.buttonAuths.length > 0 ? ( {availableLogins.buttonAuths.length > 0 && (
<div className="divider my-1">{t("or_continue_with")}</div> <div className="divider my-1">{t("or_continue_with")}</div>
) : undefined} )}
</> </>
); );
} }
@ -224,9 +224,9 @@ export default function Login({
loading={submitLoader} loading={submitLoader}
> >
{value.name.toLowerCase() === "google" || {value.name.toLowerCase() === "google" ||
value.name.toLowerCase() === "apple" ? ( (value.name.toLowerCase() === "apple" && (
<i className={"bi-" + value.name.toLowerCase()}></i> <i className={"bi-" + value.name.toLowerCase()}></i>
) : undefined} ))}
{value.name} {value.name}
</Button> </Button>
</React.Fragment> </React.Fragment>

View File

@ -20,7 +20,7 @@ export default function Index() {
useEffect(() => { useEffect(() => {
const fetchLink = async () => { const fetchLink = async () => {
if (router.query.id) { if (router.query.id) {
await getLink.mutateAsync(Number(router.query.id)); await getLink.mutateAsync({ id: Number(router.query.id) });
} }
}; };

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import getPublicCollectionData from "@/lib/client/getPublicCollectionData"; import getPublicCollectionData from "@/lib/client/getPublicCollectionData";
import { import {
AccountSettings,
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
Sort, Sort,
ViewMode, ViewMode,
@ -29,15 +30,9 @@ export default function PublicCollections() {
const router = useRouter(); const router = useRouter();
const [collectionOwner, setCollectionOwner] = useState({ const [collectionOwner, setCollectionOwner] = useState<
id: null as unknown as number, Partial<AccountSettings>
name: "", >({});
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
const [searchFilter, setSearchFilter] = useState({ const [searchFilter, setSearchFilter] = useState({
name: true, name: true,
@ -93,160 +88,162 @@ export default function PublicCollections() {
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
); );
return collection ? ( if (!collection) return <></>;
<div else
className="h-96" return (
style={{ <div
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${ className="h-96"
settings.theme === "dark" ? "#262626" : "#f3f4f6" style={{
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
}} settings.theme === "dark" ? "#262626" : "#f3f4f6"
> } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
{collection ? ( }}
<Head> >
<title>{collection.name} | Linkwarden</title> {collection && (
<meta <Head>
property="og:title" <title>{collection.name} | Linkwarden</title>
content={`${collection.name} | Linkwarden`} <meta
key="title" property="og:title"
/> content={`${collection.name} | Linkwarden`}
</Head> key="title"
) : undefined} />
<div className="lg:w-3/4 w-full mx-auto p-5 bg"> </Head>
<div className="flex items-center justify-between"> )}
<p className="text-4xl font-thin mb-2 capitalize mt-10"> <div className="lg:w-3/4 w-full mx-auto p-5 bg">
{collection.name} <div className="flex items-center justify-between">
</p> <p className="text-4xl font-thin mb-2 capitalize mt-10">
<div className="flex gap-2 items-center mt-8 min-w-fit"> {collection.name}
<ToggleDarkMode /> </p>
<div className="flex gap-2 items-center mt-8 min-w-fit">
<ToggleDarkMode />
<Link href="https://linkwarden.app/" target="_blank"> <Link href="https://linkwarden.app/" target="_blank">
<Image <Image
src={`/icon.png`} src={`/icon.png`}
width={551} width={551}
height={551} height={551}
alt="Linkwarden" alt="Linkwarden"
title={t("list_created_with_linkwarden")} title={t("list_created_with_linkwarden")}
className="h-8 w-fit mx-auto rounded" className="h-8 w-fit mx-auto rounded"
/> />
</Link> </Link>
</div>
</div> </div>
</div>
<div className="mt-3"> <div className="mt-3">
<div className={`min-w-[15rem]`}> <div className={`min-w-[15rem]`}>
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit"> <div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
<div <div
className="flex items-center btn px-2 btn-ghost rounded-full" className="flex items-center btn px-2 btn-ghost rounded-full"
onClick={() => setEditCollectionSharingModal(true)} onClick={() => setEditCollectionSharingModal(true)}
> >
{collectionOwner.id ? ( {collectionOwner.id && (
<ProfilePhoto <ProfilePhoto
src={collectionOwner.image || undefined} src={collectionOwner.image || undefined}
name={collectionOwner.name} name={collectionOwner.name}
/> />
) : undefined} )}
{collection.members {collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number)) .sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => { .map((e, i) => {
return ( return (
<ProfilePhoto <ProfilePhoto
key={i} key={i}
src={e.user.image ? e.user.image : undefined} src={e.user.image ? e.user.image : undefined}
className="-ml-3" className="-ml-3"
name={e.user.name} name={e.user.name}
/> />
); );
})
.slice(0, 3)}
{collection.members.length - 3 > 0 ? (
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span>
</div>
</div>
) : null}
</div>
<p className="text-neutral text-sm">
{collection.members.length > 0 &&
collection.members.length === 1
? t("by_author_and_other", {
author: collectionOwner.name,
count: collection.members.length,
}) })
: collection.members.length > 0 && .slice(0, 3)}
collection.members.length !== 1 {collection.members.length - 3 > 0 && (
? t("by_author_and_others", { <div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span>
</div>
</div>
)}
</div>
<p className="text-neutral text-sm">
{collection.members.length > 0 &&
collection.members.length === 1
? t("by_author_and_other", {
author: collectionOwner.name, author: collectionOwner.name,
count: collection.members.length, count: collection.members.length,
}) })
: t("by_author", { : collection.members.length > 0 &&
author: collectionOwner.name, collection.members.length !== 1
})} ? t("by_author_and_others", {
</p> author: collectionOwner.name,
count: collection.members.length,
})
: t("by_author", {
author: collectionOwner.name,
})}
</p>
</div>
</div> </div>
</div> </div>
</div>
<p className="mt-5">{collection.description}</p> <p className="mt-5">{collection.description}</p>
<div className="divider mt-5 mb-0"></div> <div className="divider mt-5 mb-0"></div>
<div className="flex mb-5 mt-10 flex-col gap-5"> <div className="flex mb-5 mt-10 flex-col gap-5">
<LinkListOptions <LinkListOptions
t={t} t={t}
viewMode={viewMode} viewMode={viewMode}
setViewMode={setViewMode} setViewMode={setViewMode}
sortBy={sortBy} sortBy={sortBy}
setSortBy={setSortBy} setSortBy={setSortBy}
searchFilter={searchFilter} searchFilter={searchFilter}
setSearchFilter={setSearchFilter} setSearchFilter={setSearchFilter}
> >
<SearchBar <SearchBar
placeholder={ placeholder={
collection._count?.links === 1 collection._count?.links === 1
? t("search_count_link", { ? t("search_count_link", {
count: collection._count?.links, count: collection._count?.links,
}) })
: t("search_count_links", { : t("search_count_links", {
count: collection._count?.links, count: collection._count?.links,
}) })
}
/>
</LinkListOptions>
<Links
links={
links?.map((e, i) => {
const linkWithCollectionData = {
...e,
collection: collection, // Append collection data
};
return linkWithCollectionData;
}) as any
} }
layout={viewMode}
placeholderCount={1}
useData={data}
/> />
</LinkListOptions> {!data.isLoading && links && !links[0] && (
<p>{t("nothing_found")}</p>
)}
<Links {/* <p className="text-center text-neutral">
links={
links?.map((e, i) => {
const linkWithCollectionData = {
...e,
collection: collection, // Append collection data
};
return linkWithCollectionData;
}) as any
}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
{!data.isLoading && links && !links[0] && <p>{t("nothing_found")}</p>}
{/* <p className="text-center text-neutral">
List created with <span className="text-black">Linkwarden.</span> List created with <span className="text-black">Linkwarden.</span>
</p> */} </p> */}
</div>
</div> </div>
{editCollectionSharingModal && (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
)}
</div> </div>
{editCollectionSharingModal ? ( );
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
) : undefined}
</div>
) : (
<></>
);
} }
export { getServerSideProps }; export { getServerSideProps };

View File

@ -0,0 +1,40 @@
import LinkDetails from "@/components/LinkDetails";
import { useGetLink } from "@/hooks/store/links";
import { useRouter } from "next/router";
import { useEffect } from "react";
import getServerSideProps from "@/lib/client/getServerSideProps";
const Index = () => {
const router = useRouter();
const { id } = router.query;
const getLink = useGetLink();
useEffect(() => {
getLink.mutate({ id: Number(id) });
}, []);
return (
<div className="flex h-screen">
{getLink.data ? (
<LinkDetails
activeLink={getLink.data}
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="w-20 h-20 skeleton rounded-xl"></div>
<div className="w-full h-10 skeleton rounded-xl"></div>
<div className="w-full h-10 skeleton rounded-xl"></div>
<div className="w-full h-10 skeleton rounded-xl"></div>
<div className="w-full h-10 skeleton rounded-xl"></div>
</div>
)}
</div>
);
};
export default Index;
export { getServerSideProps };

View File

@ -6,10 +6,9 @@ import {
} from "@/types/global"; } from "@/types/global";
import ReadableView from "@/components/ReadableView"; import ReadableView from "@/components/ReadableView";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useGetLink, useLinks } from "@/hooks/store/links"; import { useGetLink } from "@/hooks/store/links";
export default function Index() { export default function Index() {
const { links } = useLinks();
const getLink = useGetLink(); const getLink = useGetLink();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>(); const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
@ -19,18 +18,14 @@ export default function Index() {
useEffect(() => { useEffect(() => {
const fetchLink = async () => { const fetchLink = async () => {
if (router.query.id) { if (router.query.id) {
await getLink.mutateAsync(Number(router.query.id)); const get = await getLink.mutateAsync({ id: Number(router.query.id) });
setLink(get);
} }
}; };
fetchLink(); fetchLink();
}, []); }, []);
useEffect(() => {
if (links && links[0])
setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
return ( return (
<div className="relative"> <div className="relative">
{/* <div className="fixed left-1/2 transform -translate-x-1/2 w-fit py-1 px-3 bg-base-200 border border-neutral-content rounded-md"> {/* <div className="fixed left-1/2 transform -translate-x-1/2 w-fit py-1 px-3 bg-base-200 border border-neutral-content rounded-md">
@ -39,6 +34,12 @@ export default function Index() {
{link && Number(router.query.format) === ArchivedFormat.readability && ( {link && Number(router.query.format) === ArchivedFormat.readability && (
<ReadableView link={link} /> <ReadableView link={link} />
)} )}
{link && Number(router.query.format) === ArchivedFormat.monolith && (
<iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.monolith}`}
className="w-full h-screen border-none"
></iframe>
)}
{link && Number(router.query.format) === ArchivedFormat.pdf && ( {link && Number(router.query.format) === ArchivedFormat.pdf && (
<iframe <iframe
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}`} src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}`}

View File

@ -133,9 +133,9 @@ export default function Register({
loading={submitLoader} loading={submitLoader}
> >
{value.name.toLowerCase() === "google" || {value.name.toLowerCase() === "google" ||
value.name.toLowerCase() === "apple" ? ( (value.name.toLowerCase() === "apple" && (
<i className={"bi-" + value.name.toLowerCase()}></i> <i className={"bi-" + value.name.toLowerCase()}></i>
) : undefined} ))}
{value.name} {value.name}
</Button> </Button>
</React.Fragment> </React.Fragment>
@ -201,7 +201,7 @@ export default function Register({
</div> </div>
)} )}
{emailEnabled ? ( {emailEnabled && (
<div> <div>
<p className="text-sm w-fit font-semibold mb-1">{t("email")}</p> <p className="text-sm w-fit font-semibold mb-1">{t("email")}</p>
@ -214,7 +214,7 @@ export default function Register({
onChange={(e) => setForm({ ...form, email: e.target.value })} onChange={(e) => setForm({ ...form, email: e.target.value })}
/> />
</div> </div>
) : undefined} )}
<div className="w-full"> <div className="w-full">
<p className="text-sm w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">
@ -248,7 +248,7 @@ export default function Register({
/> />
</div> </div>
{process.env.NEXT_PUBLIC_STRIPE ? ( {process.env.NEXT_PUBLIC_STRIPE && (
<div className="text-xs text-neutral mb-3"> <div className="text-xs text-neutral mb-3">
<p> <p>
<Trans <Trans
@ -270,7 +270,7 @@ export default function Register({
/> />
</p> </p>
</div> </div>
) : undefined} )}
<Button <Button
type="submit" type="submit"
@ -282,9 +282,9 @@ export default function Register({
{t("sign_up")} {t("sign_up")}
</Button> </Button>
{availableLogins.buttonAuths.length > 0 ? ( {availableLogins.buttonAuths.length > 0 && (
<div className="divider my-1">{t("or_continue_with")}</div> <div className="divider my-1">{t("or_continue_with")}</div>
) : undefined} )}
{displayLoginExternalButton()} {displayLoginExternalButton()}
<div> <div>
@ -298,7 +298,7 @@ export default function Register({
{t("login")} {t("login")}
</Link> </Link>
</div> </div>
{process.env.NEXT_PUBLIC_STRIPE ? ( {process.env.NEXT_PUBLIC_STRIPE && (
<div className="text-neutral text-center flex items-baseline gap-1 justify-center"> <div className="text-neutral text-center flex items-baseline gap-1 justify-center">
<p>{t("need_help")}</p> <p>{t("need_help")}</p>
<Link <Link
@ -309,7 +309,7 @@ export default function Register({
{t("get_in_touch")} {t("get_in_touch")}
</Link> </Link>
</div> </div>
) : undefined} )}
</div> </div>
</div> </div>
</form> </form>

View File

@ -40,7 +40,7 @@ export default function AccessTokens() {
{t("new_token")} {t("new_token")}
</button> </button>
{tokens.length > 0 ? ( {tokens.length > 0 && (
<table className="table mt-2 overflow-x-auto"> <table className="table mt-2 overflow-x-auto">
<thead> <thead>
<tr> <tr>
@ -85,12 +85,12 @@ export default function AccessTokens() {
))} ))}
</tbody> </tbody>
</table> </table>
) : undefined} )}
</div> </div>
{newTokenModal ? ( {newTokenModal && (
<NewTokenModal onClose={() => setNewTokenModal(false)} /> <NewTokenModal onClose={() => setNewTokenModal(false)} />
) : undefined} )}
{revokeTokenModal && selectedToken && ( {revokeTokenModal && selectedToken && (
<RevokeTokenModal <RevokeTokenModal
onClose={() => { onClose={() => {

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, ChangeEvent } from "react";
import { AccountSettings } from "@/types/global"; import { AccountSettings } from "@/types/global";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import SettingsLayout from "@/layouts/SettingsLayout"; import SettingsLayout from "@/layouts/SettingsLayout";
@ -55,8 +55,10 @@ export default function Account() {
if (!objectIsEmpty(account)) setUser({ ...account }); if (!objectIsEmpty(account)) setUser({ ...account });
}, [account]); }, [account]);
const handleImageUpload = async (e: any) => { const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file: File = e.target.files[0]; const file = e.target.files?.[0];
if (!file) return toast.error(t("image_upload_no_file_error"));
const fileExtension = file.name.split(".").pop()?.toLowerCase(); const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["png", "jpeg", "jpg"]; const allowedExtensions = ["png", "jpeg", "jpg"];
if (allowedExtensions.includes(fileExtension as string)) { if (allowedExtensions.includes(fileExtension as string)) {
@ -114,9 +116,13 @@ export default function Account() {
setSubmitLoader(false); setSubmitLoader(false);
}; };
const importBookmarks = async (e: any, format: MigrationFormat) => { const importBookmarks = async (
e: ChangeEvent<HTMLInputElement>,
format: MigrationFormat
) => {
setSubmitLoader(true); setSubmitLoader(true);
const file: File = e.target.files[0]; const file = e.target.files?.[0];
if (file) { if (file) {
var reader = new FileReader(); var reader = new FileReader();
reader.readAsText(file, "UTF-8"); reader.readAsText(file, "UTF-8");
@ -190,7 +196,7 @@ export default function Account() {
onChange={(e) => setUser({ ...user, username: e.target.value })} onChange={(e) => setUser({ ...user, username: e.target.value })}
/> />
</div> </div>
{emailEnabled ? ( {emailEnabled && (
<div> <div>
<p className="mb-2">{t("email")}</p> <p className="mb-2">{t("email")}</p>
<TextInput <TextInput
@ -199,7 +205,7 @@ export default function Account() {
onChange={(e) => setUser({ ...user, email: e.target.value })} onChange={(e) => setUser({ ...user, email: e.target.value })}
/> />
</div> </div>
) : undefined} )}
<div> <div>
<p className="mb-2">{t("language")}</p> <p className="mb-2">{t("language")}</p>
<select <select
@ -437,9 +443,8 @@ export default function Account() {
<p> <p>
{t("delete_account_warning")} {t("delete_account_warning")}
{process.env.NEXT_PUBLIC_STRIPE {process.env.NEXT_PUBLIC_STRIPE &&
? " " + t("cancel_subscription_notice") " " + t("cancel_subscription_notice")}
: undefined}
</p> </p>
</div> </div>
@ -448,14 +453,14 @@ export default function Account() {
</Link> </Link>
</div> </div>
{emailChangeVerificationModal ? ( {emailChangeVerificationModal && (
<EmailChangeVerificationModal <EmailChangeVerificationModal
onClose={() => setEmailChangeVerificationModal(false)} onClose={() => setEmailChangeVerificationModal(false)}
onSubmit={submit} onSubmit={submit}
oldEmail={account.email || ""} oldEmail={account.email || ""}
newEmail={user.email || ""} newEmail={user.email || ""}
/> />
) : undefined} )}
</SettingsLayout> </SettingsLayout>
); );
} }

View File

@ -83,7 +83,7 @@ export default function Delete() {
/> />
</div> </div>
{process.env.NEXT_PUBLIC_STRIPE ? ( {process.env.NEXT_PUBLIC_STRIPE && (
<fieldset className="border rounded-md p-2 border-primary"> <fieldset className="border rounded-md p-2 border-primary">
<legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-primary"> <legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-primary">
<b>{t("optional")}</b> <i>{t("feedback_help")}</i> <b>{t("optional")}</b> <i>{t("feedback_help")}</i>
@ -123,7 +123,7 @@ export default function Delete() {
/> />
</div> </div>
</fieldset> </fieldset>
) : undefined} )}
<Button <Button
className="mx-auto" className="mx-auto"

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

View File

@ -15,7 +15,7 @@ export default defineConfig({
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : Number(process.env.MAX_WORKERS) || undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html", reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@ -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;

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Collection" ALTER COLUMN "color" DROP NOT NULL,
ALTER COLUMN "color" DROP DEFAULT;

View File

@ -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';

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?

Some files were not shown because too many files have changed in this diff Show More