client side i18n fully implemented

This commit is contained in:
daniel31x13 2024-06-09 09:27:16 -04:00
parent d261bd39ec
commit 71678ba9dd
41 changed files with 677 additions and 637 deletions

View File

@ -1,5 +1,6 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import { Trans } from "next-i18next";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
@ -13,15 +14,17 @@ export default function Announcement({ toggleAnnouncementBar }: Props) {
<div className="mx-auto w-full p-2 flex justify-between gap-2 items-center border border-primary shadow-xl rounded-xl bg-base-300 backdrop-blur-sm bg-opacity-80 max-w-md">
<i className="bi-stars text-2xl text-yellow-600 dark:text-yellow-500"></i>
<p className="w-4/5 text-center text-sm sm:text-base">
See what&apos;s new in{" "}
<Link
href={`https://blog.linkwarden.app/releases/${announcementId}`}
target="_blank"
className="underline"
>
Linkwarden {announcementId}
</Link>
!
<Trans
i18nKey="new_version_announcement"
values={{ version: announcementId }}
components={[
<Link
href={`https://blog.linkwarden.app/releases/${announcementId}`}
target="_blank"
className="underline"
/>,
]}
/>
</p>
<button
onClick={toggleAnnouncementBar}

View File

@ -10,6 +10,7 @@ import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
@ -17,6 +18,7 @@ type Props = {
};
export default function CollectionCard({ collection, className }: Props) {
const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const { account } = useAccountStore();
@ -76,8 +78,8 @@ export default function CollectionCard({ collection, className }: Props) {
>
<i className="bi-three-dots text-xl" title="More"></i>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
{permissions === true ? (
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
{permissions === true && (
<li>
<div
role="button"
@ -87,10 +89,10 @@ export default function CollectionCard({ collection, className }: Props) {
setEditCollectionModal(true);
}}
>
Edit Collection Info
{t("edit_collection_info")}
</div>
</li>
) : undefined}
)}
<li>
<div
role="button"
@ -100,7 +102,9 @@ export default function CollectionCard({ collection, className }: Props) {
setEditCollectionSharingModal(true);
}}
>
{permissions === true ? "Share and Collaborate" : "View Team"}
{permissions === true
? t("share_and_collaborate")
: t("view_team")}
</div>
</li>
<li>
@ -112,7 +116,9 @@ export default function CollectionCard({ collection, className }: Props) {
setDeleteCollectionModal(true);
}}
>
{permissions === true ? "Delete Collection" : "Leave Collection"}
{permissions === true
? t("delete_collection")
: t("leave_collection")}
</div>
</li>
</ul>

View File

@ -16,12 +16,14 @@ import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router";
import useAccountStore from "@/store/account";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
}
const CollectionListing = () => {
const { t } = useTranslation();
const { collections, updateCollection } = useCollectionStore();
const { account, updateAccount } = useAccountStore();
@ -141,9 +143,7 @@ const CollectionListing = () => {
(destinationCollection?.ownerId !== account.id &&
destination.parentId !== "root")
) {
return toast.error(
"You can't make change to a collection you don't own."
);
return toast.error(t("cant_change_collection_you_dont_own"));
}
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
@ -203,7 +203,7 @@ const CollectionListing = () => {
if (!tree) {
return (
<p className="text-neutral text-xs font-semibold truncate w-full px-2 mt-5 mb-8">
You Have No Collections...
{t("you_have_no_collections")}
</p>
);
} else

View File

@ -1,5 +1,6 @@
import { dropdownTriggerer } from "@/lib/client/utils";
import React from "react";
import { useTranslation } from "next-i18next";
type Props = {
setSearchFilter: Function;
@ -16,6 +17,8 @@ export default function FilterSearchDropdown({
setSearchFilter,
searchFilter,
}: Props) {
const { t } = useTranslation();
return (
<div className="dropdown dropdown-bottom dropdown-end">
<div
@ -38,11 +41,11 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.name}
onChange={() => {
setSearchFilter({ ...searchFilter, name: !searchFilter.name });
}}
onChange={() =>
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
}
/>
<span className="label-text">Name</span>
<span className="label-text">{t("name")}</span>
</label>
</li>
<li>
@ -56,11 +59,11 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.url}
onChange={() => {
setSearchFilter({ ...searchFilter, url: !searchFilter.url });
}}
onChange={() =>
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
}
/>
<span className="label-text">Link</span>
<span className="label-text">{t("link")}</span>
</label>
</li>
<li>
@ -74,14 +77,14 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.description}
onChange={() => {
onChange={() =>
setSearchFilter({
...searchFilter,
description: !searchFilter.description,
});
}}
})
}
/>
<span className="label-text">Description</span>
<span className="label-text">{t("description")}</span>
</label>
</li>
<li>
@ -95,14 +98,11 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.tags}
onChange={() => {
setSearchFilter({
...searchFilter,
tags: !searchFilter.tags,
});
}}
onChange={() =>
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
}
/>
<span className="label-text">Tags</span>
<span className="label-text">{t("tags")}</span>
</label>
</li>
<li>
@ -116,16 +116,17 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
onChange={() =>
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
})
}
/>
<span className="label-text">Full Content</span>
<div className="ml-auto badge badge-sm badge-neutral">Slower</div>
<span className="label-text">{t("full_content")}</span>
<div className="ml-auto badge badge-sm badge-neutral">
{t("slower")}
</div>
</label>
</li>
</ul>

View File

@ -1,5 +1,6 @@
import { isPWA } from "@/lib/client/utils";
import React, { useState } from "react";
import { Trans } from "next-i18next";
type Props = {};
@ -25,15 +26,16 @@ const InstallApp = (props: Props) => {
/>
</svg>
<p className="w-4/5 text-[0.92rem]">
Install Linkwarden to your home screen for a faster access and
enhanced experience.{" "}
<a
className="underline"
target="_blank"
href="https://docs.linkwarden.app/getting-started/pwa-installation"
>
Learn more
</a>
<Trans
i18nKey="pwa_install_prompt"
components={[
<a
className="underline"
target="_blank"
href="https://docs.linkwarden.app/getting-started/pwa-installation"
/>,
]}
/>
</p>
<button
onClick={() => setIsOpen(false)}

View File

@ -20,6 +20,7 @@ import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
import { useTranslation } from "next-i18next";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@ -30,6 +31,8 @@ type Props = {
};
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const viewMode = localStorage.getItem("viewMode") || "card";
const { collections } = useCollectionStore();
const { account } = useAccountStore();
@ -121,9 +124,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(
"You don't have permission to edit or delete this item."
)
? toast.error(t("link_selection_error"))
: undefined
}
>
@ -167,7 +168,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
<div className="flex flex-col justify-between h-full">
<div className="p-3 flex flex-col gap-2">
<p className="truncate w-full pr-8 text-primary text-sm">
<p className="truncate w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
@ -197,7 +198,9 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">Description</p>
<p className="text-neutral text-lg font-semibold">
{t("description")}
</p>
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
<p>
@ -205,13 +208,15 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
No description provided.
{t("no_description")}
</span>
)}
</p>
{link.tags && link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
<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]" />

View File

@ -11,6 +11,7 @@ import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast";
import useAccountStore from "@/store/account";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@ -30,6 +31,8 @@ export default function LinkActions({
alignToTop,
flipDropdown,
}: Props) {
const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number);
const [editLinkModal, setEditLinkModal] = useState(false);
@ -43,7 +46,7 @@ export default function LinkActions({
const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
const load = toast.loading("Applying...");
const load = toast.loading(t("applying"));
const response = await updateLink({
...link,
@ -53,17 +56,17 @@ export default function LinkActions({
toast.dismiss(load);
response.ok &&
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
toast.success(isAlreadyPinned ? t("link_unpinned") : t("link_unpinned"));
};
const deleteLink = async () => {
const load = toast.loading("Deleting...");
const load = toast.loading(t("deleting"));
const response = await removeLink(link.id as number);
toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`);
response.ok && toast.success(t("deleted"));
};
return (
@ -96,8 +99,8 @@ export default function LinkActions({
}}
>
{link?.pinnedBy && link.pinnedBy[0]
? "Unpin"
: "Pin to Dashboard"}
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
{linkInfo !== undefined && toggleShowInfo ? (
@ -110,7 +113,7 @@ export default function LinkActions({
toggleShowInfo();
}}
>
{!linkInfo ? "Show" : "Hide"} Link Details
{!linkInfo ? t("show_link_details") : t("hide_link_details")}
</div>
</li>
) : undefined}
@ -124,7 +127,7 @@ export default function LinkActions({
setEditLinkModal(true);
}}
>
Edit Link
{t("edit_link")}
</div>
</li>
) : undefined}
@ -138,7 +141,7 @@ export default function LinkActions({
setPreservedFormatsModal(true);
}}
>
Preserved Formats
{t("preserved_formats")}
</div>
</li>
)}
@ -152,7 +155,7 @@ export default function LinkActions({
e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
}}
>
Delete
{t("delete")}
</div>
</li>
) : undefined}

View File

@ -1,53 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl";
import React from "react";
import Link from "next/link";
export default function LinkGroupedIconURL({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
let shortendURL;
try {
shortendURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
return (
<Link href={link.url || ""} target="_blank">
<div className="bg-white shadow-md rounded-md border-[2px] flex gap-1 item-center justify-center border-white select-none z-10 max-w-full">
{link.url && url && showFavicon ? (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64}
height={64}
alt=""
className="w-5 h-5 rounded"
draggable="false"
onError={() => {
setShowFavicon(false);
}}
/>
) : showFavicon === false ? (
<i className="bi-link-45deg text-xl leading-none text-black"></i>
) : link.type === "pdf" ? (
<i className={`bi-file-earmark-pdf`}></i>
) : link.type === "image" ? (
<i className={`bi-file-earmark-image`}></i>
) : undefined}
<p className="truncate bg-white text-black mr-1">
<p className="text-sm">{shortendURL}</p>
</p>
</div>
</Link>
);
}

View File

@ -10,13 +10,13 @@ import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import Link from "next/link";
import { isPWA } from "@/lib/client/utils";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
import { useTranslation } from "next-i18next";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@ -31,6 +31,8 @@ export default function LinkCardCompact({
flipDropdown,
editMode,
}: Props) {
const { t } = useTranslation();
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
@ -96,9 +98,7 @@ export default function LinkCardCompact({
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(
"You don't have permission to edit or delete this item."
)
? toast.error(t("link_selection_error"))
: undefined
}
>

View File

@ -20,6 +20,7 @@ import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
import { useTranslation } from "next-i18next";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@ -30,6 +31,8 @@ type Props = {
};
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const { collections } = useCollectionStore();
const { account } = useAccountStore();
@ -120,9 +123,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(
"You don't have permission to edit or delete this item."
)
? toast.error(t("link_selection_error"))
: undefined
}
>
@ -164,7 +165,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
)}
<div className="p-3 flex flex-col gap-2">
<p className="hyphens-auto w-full pr-8 text-primary text-sm">
<p className="hyphens-auto w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
@ -210,7 +211,9 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">Description</p>
<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>
@ -218,13 +221,15 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
No description provided.
{t("no_description")}
</span>
)}
</p>
{link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
<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]" />

View File

@ -1,7 +0,0 @@
export default function Loading() {
return (
<div>
<p>Loading...</p>
</div>
);
}

View File

@ -5,10 +5,12 @@ import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal";
import UploadFileModal from "./ModalContent/UploadFileModal";
import MobileNavigationButton from "./MobileNavigationButton";
import { useTranslation } from "next-i18next";
type Props = {};
export default function MobileNavigation({}: Props) {
const { t } = useTranslation();
const [newLinkModal, setNewLinkModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
@ -49,21 +51,21 @@ export default function MobileNavigation({}: Props) {
tabIndex={0}
role="button"
>
New Link
{t("new_link")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
{t("upload_file")}
</div>
</li>
{/* <li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
Upload File
</div>
</li> */}
<li>
<div
onClick={() => {
@ -73,7 +75,7 @@ export default function MobileNavigation({}: Props) {
tabIndex={0}
role="button"
>
New Collection
{t("new_collection")}
</div>
</li>
</ul>

View File

@ -3,20 +3,18 @@ import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
};
export default function BulkDeleteLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore();
const deleteLink = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const load = toast.loading(t("deleting"));
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
@ -25,12 +23,7 @@ export default function BulkDeleteLinksModal({ onClose }: Props) {
toast.dismiss(load);
if (response.ok) {
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}`
);
toast.success(t("deleted"));
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
@ -39,33 +32,32 @@ export default function BulkDeleteLinksModal({ onClose }: Props) {
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
Delete {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
{selectedLinks.length === 1
? t("delete_link")
: t("delete_links", { count: selectedLinks.length })}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
{selectedLinks.length > 1 ? (
<p>Are you sure you want to delete {selectedLinks.length} links?</p>
) : (
<p>Are you sure you want to delete this link?</p>
)}
<p>
{selectedLinks.length === 1
? t("link_deletion_confirmation_message")
: t("links_deletion_confirmation_message", {
count: selectedLinks.length,
})}
</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>Warning:</b> This action is irreversible!
</span>
<span>{t("warning_irreversible")}</span>
</div>
<p>
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
&apos;Delete&apos; to bypass this confirmation in the future.
</p>
<p>{t("shift_key_tip")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<i className="bi-trash text-xl" />
Delete
{t("delete")}
</Button>
</div>
</Modal>

View File

@ -5,12 +5,14 @@ import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
};
export default function BulkEditLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [removePreviousTags, setRemovePreviousTags] = useState(false);
@ -20,7 +22,6 @@ export default function BulkEditLinksModal({ onClose }: Props) {
const setCollection = (e: any) => {
const collectionId = e?.value || null;
console.log(updatedValues);
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
};
@ -33,7 +34,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading("Updating...");
const load = toast.loading(t("updating"));
const response = await updateLinks(
selectedLinks,
@ -44,7 +45,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
toast.success(t("updated"));
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
@ -57,13 +58,15 @@ export default function BulkEditLinksModal({ onClose }: Props) {
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
Edit {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
{selectedLinks.length === 1
? t("edit_link")
: t("edit_links", { count: selectedLinks.length })}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Move to Collection</p>
<p className="mb-2">{t("move_to_collection")}</p>
<CollectionSelection
showDefaultValue={false}
onChange={setCollection}
@ -72,7 +75,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
</div>
<div>
<p className="mb-2">Add Tags</p>
<p className="mb-2">{t("add_tags")}</p>
<TagSelection onChange={setTags} />
</div>
</div>
@ -84,7 +87,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
checked={removePreviousTags}
onChange={(e) => setRemovePreviousTags(e.target.checked)}
/>
Remove previous tags
{t("remove_previous_tags")}
</label>
</div>
</div>
@ -94,7 +97,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Save Changes
{t("save_changes")}
</button>
</div>
</Modal>

View File

@ -7,6 +7,7 @@ import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
@ -17,42 +18,40 @@ export default function DeleteCollectionModal({
onClose,
activeCollection,
}: Props) {
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const { removeCollection } = useCollectionStore();
const router = useRouter();
const [inputField, setInputField] = useState("");
const permissions = usePermissions(collection.id as number);
useEffect(() => {
setCollection(activeCollection);
}, []);
const [submitLoader, setSubmitLoader] = useState(false);
const { removeCollection } = useCollectionStore();
const router = useRouter();
const [inputField, setInputField] = useState("");
const permissions = usePermissions(collection.id as number);
const submit = async () => {
if (permissions === true) if (collection.name !== inputField) return null;
if (permissions === true && collection.name !== inputField) return;
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading("Deleting...");
const load = toast.loading(t("deleting_collection"));
let response;
response = await removeCollection(collection.id as any);
let response = await removeCollection(collection.id as number);
toast.dismiss(load);
if (response.ok) {
toast.success(`Deleted.`);
toast.success(t("deleted"));
onClose();
router.push("/collections");
} else toast.error(response.data as string);
} else {
toast.error(response.data as string);
}
setSubmitLoader(false);
}
@ -61,7 +60,7 @@ export default function DeleteCollectionModal({
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{permissions === true ? "Delete" : "Leave"} Collection
{permissions === true ? t("delete_collection") : t("leave_collection")}
</p>
<div className="divider mb-3 mt-1"></div>
@ -69,32 +68,26 @@ export default function DeleteCollectionModal({
<div className="flex flex-col gap-3">
{permissions === true ? (
<>
<div className="flex flex-col gap-3">
<p>
To confirm, type &quot;
<span className="font-bold">{collection.name}</span>
&quot; in the box below:
</p>
<TextInput
value={inputField}
onChange={(e) => setInputField(e.target.value)}
placeholder={`Type "${collection.name}" Here.`}
className="w-3/4 mx-auto"
/>
</div>
<p>{t("confirm_deletion_prompt", { name: collection.name })}</p>
<TextInput
value={inputField}
onChange={(e) => setInputField(e.target.value)}
placeholder={t("type_name_placeholder", {
name: collection.name,
})}
className="w-3/4 mx-auto"
/>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl"></i>
<span>
<b>Warning:</b> Deleting this collection will permanently erase
all its contents, and it will become inaccessible to everyone,
including members with previous access.
<b>{t("warning")}: </b>
{t("deletion_warning")}
</span>
</div>
</>
) : (
<p>Click the button below to leave the current collection.</p>
<p>{t("leave_prompt")}</p>
)}
<Button
@ -104,7 +97,7 @@ export default function DeleteCollectionModal({
className="ml-auto"
>
<i className="bi-trash text-xl"></i>
{permissions === true ? "Delete" : "Leave"} Collection
{permissions === true ? t("delete") : t("leave")}
</Button>
</div>
</Modal>

View File

@ -5,6 +5,7 @@ import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
@ -12,11 +13,10 @@ type Props = {
};
export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const { removeLink } = useLinkStore();
const router = useRouter();
useEffect(() => {
@ -24,13 +24,13 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
}, []);
const deleteLink = async () => {
const load = toast.loading("Deleting...");
const load = toast.loading(t("deleting"));
const response = await removeLink(link.id as number);
toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`);
response.ok && toast.success(t("deleted"));
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard");
@ -41,28 +41,25 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Delete Link</p>
<p className="text-xl font-thin text-red-500">{t("delete_link")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>Are you sure you want to delete this Link?</p>
<p>{t("link_deletion_confirmation_message")}</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>Warning:</b> This action is irreversible!
<b>{t("warning")}:</b> {t("irreversible_warning")}
</span>
</div>
<p>
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
&apos;Delete&apos; to bypass this confirmation in the future.
</p>
<p>{t("shift_key_tip")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<i className="bi-trash text-xl" />
Delete
{t("delete")}
</Button>
</div>
</Modal>

View File

@ -2,6 +2,7 @@ import toast from "react-hot-toast";
import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
@ -9,39 +10,40 @@ type Props = {
};
export default function DeleteUserModal({ onClose, userId }: Props) {
const { t } = useTranslation();
const { removeUser } = useUserStore();
const deleteUser = async () => {
const load = toast.loading("Deleting...");
const load = toast.loading(t("deleting_user"));
const response = await removeUser(userId);
toast.dismiss(load);
response.ok && toast.success(`User Deleted.`);
response.ok && toast.success(t("user_deleted"));
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Delete User</p>
<p className="text-xl font-thin text-red-500">{t("delete_user")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>Are you sure you want to remove this user?</p>
<p>{t("confirm_user_deletion")}</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>Warning:</b> This action is irreversible!
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
</span>
</div>
<Button className="ml-auto" intent="destructive" onClick={deleteUser}>
<i className="bi-trash text-xl" />
Delete, I know what I&apos;m doing
{t("delete_confirmation")}
</Button>
</div>
</Modal>

View File

@ -5,6 +5,7 @@ import toast from "react-hot-toast";
import { HexColorPicker } from "react-colorful";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
@ -15,6 +16,7 @@ export default function EditCollectionModal({
onClose,
activeCollection,
}: Props) {
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
@ -28,16 +30,14 @@ export default function EditCollectionModal({
setSubmitLoader(true);
const load = toast.loading("Updating...");
const load = toast.loading(t("updating_collection"));
let response;
response = await updateCollection(collection as any);
let response = await updateCollection(collection as any);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
toast.success(t("updated"));
onClose();
} else toast.error(response.data as string);
@ -47,29 +47,35 @@ export default function EditCollectionModal({
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Edit Collection Info</p>
<p className="text-xl font-thin">{t("edit_collection_info")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">Name</p>
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-3">
<TextInput
className="bg-base-200"
value={collection.name}
placeholder="e.g. Example Collection"
placeholder={t("collection_name_placeholder")}
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">Color</p>
<div className="color-picker flex justify-between">
<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 drop-shadow"
className="bi-folder-fill text-5xl"
style={{ color: collection.color }}
></i>
<div
@ -78,29 +84,22 @@ export default function EditCollectionModal({
setCollection({ ...collection, color: "#0ea5e9" })
}
>
Reset
{t("reset")}
</div>
</div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">Description</p>
<p className="mb-2">{t("description")}</p>
<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"
placeholder="The purpose of this Collection..."
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>
setCollection({
...collection,
description: e.target.value,
})
setCollection({ ...collection, description: e.target.value })
}
/>
</div>
@ -110,7 +109,7 @@ export default function EditCollectionModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit}
>
Save Changes
{t("save_changes")}
</button>
</div>
</Modal>

View File

@ -10,6 +10,7 @@ import ProfilePhoto from "../ProfilePhoto";
import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Modal from "../Modal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
@ -20,6 +21,8 @@ export default function EditCollectionSharingModal({
onClose,
activeCollection,
}: Props) {
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
@ -33,7 +36,7 @@ export default function EditCollectionSharingModal({
setSubmitLoader(true);
const load = toast.loading("Updating...");
const load = toast.loading(t("updating"));
let response;
@ -42,7 +45,7 @@ export default function EditCollectionSharingModal({
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
toast.success(t("updated"));
onClose();
} else toast.error(response.data as string);
@ -93,7 +96,7 @@ export default function EditCollectionSharingModal({
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{permissions === true ? "Share and Collaborate" : "Team"}
{permissions === true ? t("share_and_collaborate") : t("team")}
</p>
<div className="divider mb-3 mt-1"></div>
@ -101,7 +104,7 @@ export default function EditCollectionSharingModal({
<div className="flex flex-col gap-3">
{permissions === true && (
<div>
<p>Make Public</p>
<p>{t("make_collection_public")}</p>
<label className="label cursor-pointer justify-start gap-2">
<input
@ -115,25 +118,26 @@ export default function EditCollectionSharingModal({
}
className="checkbox checkbox-primary"
/>
<span className="label-text">Make this a public collection</span>
<span className="label-text">
{t("make_collection_public_checkbox")}
</span>
</label>
<p className="text-neutral text-sm">
This will let <b>Anyone</b> to view this collection and it&apos;s
users.
{t("make_collection_public_desc")}
</p>
</div>
)}
{collection.isPublic ? (
<div className={permissions === true ? "pl-5" : ""}>
<p className="mb-2">Sharable Link (Click to copy)</p>
<p className="mb-2">{t("sharable_link_guide")}</p>
<div
onClick={() => {
try {
navigator.clipboard
.writeText(publicCollectionURL)
.then(() => toast.success("Copied!"));
.then(() => toast.success(t("copied")));
} catch (err) {
console.log(err);
}
@ -149,13 +153,13 @@ export default function EditCollectionSharingModal({
{permissions === true && (
<>
<p>Members</p>
<p>{t("members")}</p>
<div className="flex items-center gap-2">
<TextInput
value={memberUsername || ""}
className="bg-base-200"
placeholder="Username (without the '@')"
placeholder={t("members_username_placeholder")}
onChange={(e) => setMemberUsername(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
@ -163,7 +167,8 @@ export default function EditCollectionSharingModal({
account.username as string,
memberUsername || "",
collection,
setMemberState
setMemberState,
t
)
}
/>
@ -174,7 +179,8 @@ export default function EditCollectionSharingModal({
account.username as string,
memberUsername || "",
collection,
setMemberState
setMemberState,
t
)
}
className="btn btn-accent dark:border-violet-400 text-white btn-square btn-sm h-10 w-10"
@ -214,7 +220,7 @@ export default function EditCollectionSharingModal({
</div>
</div>
<div>
<p className="text-sm font-bold">Owner</p>
<p className="text-sm font-bold">{t("owner")}</p>
</div>
</div>
</div>
@ -226,11 +232,11 @@ export default function EditCollectionSharingModal({
.map((e, i) => {
const roleLabel =
e.canCreate && e.canUpdate && e.canDelete
? "Admin"
? t("admin")
: e.canCreate && !e.canUpdate && !e.canDelete
? "Contributor"
? t("contributor")
: !e.canCreate && !e.canUpdate && !e.canDelete
? "Viewer"
? t("viewer")
: undefined;
return (
@ -307,8 +313,10 @@ export default function EditCollectionSharingModal({
}}
/>
<div>
<p className="font-bold">Viewer</p>
<p>Read-only access</p>
<p className="font-bold">
{t("viewer")}
</p>
<p>{t("viewer_desc")}</p>
</div>
</label>
</li>
@ -350,8 +358,10 @@ export default function EditCollectionSharingModal({
}}
/>
<div>
<p className="font-bold">Contributor</p>
<p>Can view and create Links</p>
<p className="font-bold">
{t("contributor")}
</p>
<p>{t("contributor_desc")}</p>
</div>
</label>
</li>
@ -393,8 +403,10 @@ export default function EditCollectionSharingModal({
}}
/>
<div>
<p className="font-bold">Admin</p>
<p>Full access to all Links</p>
<p className="font-bold">
{t("admin")}
</p>
<p>{t("admin_desc")}</p>
</div>
</label>
</li>
@ -411,7 +423,7 @@ export default function EditCollectionSharingModal({
className={
"bi-x text-xl btn btn-sm btn-square btn-ghost text-neutral hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"
}
title="Remove Member"
title={t("remove_member")}
onClick={() => {
const updatedMembers =
collection.members.filter((member) => {
@ -442,7 +454,7 @@ export default function EditCollectionSharingModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3"
onClick={submit}
>
Save Changes
{t("save_changes")}
</button>
)}
</div>

View File

@ -8,6 +8,7 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
@ -15,13 +16,13 @@ type Props = {
};
export default function EditLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
let shortendURL;
let shortenedURL;
try {
shortendURL = new URL(link.url || "").host.toLowerCase();
shortenedURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
@ -31,7 +32,6 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
@ -39,10 +39,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
@ -53,29 +50,25 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
let response;
const load = toast.loading("Updating...");
response = await updateLink(link);
const load = toast.loading(t("updating"));
let response = await updateLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
toast.success(t("updated"));
onClose();
} else toast.error(response.data as string);
} else {
toast.error(response.data as string);
}
setSubmitLoader(false);
return response;
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Edit Link</p>
<p className="text-xl font-thin">{t("edit_link")}</p>
<div className="divider mb-3 mt-1"></div>
@ -87,42 +80,31 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
target="_blank"
>
<i className="bi-link-45deg text-xl" />
<p>{shortendURL}</p>
<p>{shortenedURL}</p>
</Link>
) : undefined}
<div className="w-full">
<p className="mb-2">Name</p>
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
<div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Collection</p>
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
// defaultValue={{
// label: link.collection.name,
// value: link.collection.id,
// }}
defaultValue={
link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
? { value: link.collection.id, label: link.collection.name }
: { value: null as unknown as number, label: "Unorganized" }
}
creatable={false}
/>
@ -130,23 +112,24 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
</div>
<div>
<p className="mb-2">Tags</p>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder="Will be auto generated if nothing is provided."
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>
@ -158,7 +141,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Save Changes
{t("save_changes")}
</button>
</div>
</Modal>

View File

@ -1,6 +1,7 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
@ -15,39 +16,40 @@ export default function EmailChangeVerificationModal({
oldEmail,
newEmail,
}: Props) {
const { t } = useTranslation();
const [password, setPassword] = useState("");
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Confirm Password</p>
<p className="text-xl font-thin">{t("confirm_password")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-5">
<p>
Please confirm your password before changing your email address.{" "}
{process.env.NEXT_PUBLIC_STRIPE === "true" &&
"Updating this field will change your billing email on Stripe as well."}
{t("password_change_warning")}
{process.env.NEXT_PUBLIC_STRIPE === "true" && t("stripe_update_note")}
</p>
<p>
If you change your email address, any existing{" "}
{process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true" && "Google"} SSO
connections will be removed.
{t("sso_will_be_removed_warning", {
service:
process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true" ? "Google" : "",
})}
</p>
<div>
<p>Old Email</p>
<p>{t("old_email")}</p>
<p className="text-neutral">{oldEmail}</p>
</div>
<div>
<p>New Email</p>
<p>{t("new_email")}</p>
<p className="text-neutral">{newEmail}</p>
</div>
<div className="w-full">
<p className="mb-2">Password</p>
<p className="mb-2">{t("password")}</p>
<TextInput
value={password}
onChange={(e) => setPassword(e.target.value)}
@ -63,7 +65,7 @@ export default function EmailChangeVerificationModal({
className="btn btn-accent dark:border-violet-400 text-white"
onClick={() => onSubmit(password)}
>
Confirm
{t("confirm")}
</button>
</div>
</div>

View File

@ -8,6 +8,7 @@ import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useAccountStore from "@/store/account";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
@ -15,6 +16,7 @@ type Props = {
};
export default function NewCollectionModal({ onClose, parent }: Props) {
const { t } = useTranslation();
const initial = {
parentId: parent?.id,
name: "",
@ -39,15 +41,14 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
setSubmitLoader(true);
const load = toast.loading("Creating...");
const load = toast.loading(t("creating"));
let response = await addCollection(collection as any);
toast.dismiss(load);
if (response.ok) {
toast.success("Created!");
toast.success(t("created"));
if (response.data) {
// If the collection was created successfully, we need to get the new collection order
setAccount(data?.user.id as number);
onClose();
}
@ -60,11 +61,13 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
<Modal toggleModal={onClose}>
{parent?.id ? (
<>
<p className="text-xl font-thin">New Sub-Collection</p>
<p className="capitalize text-sm">For {parent.name}</p>
<p className="text-xl font-thin">{t("new_sub_collection")}</p>
<p className="capitalize text-sm">
{t("for_collection", { name: parent.name })}
</p>
</>
) : (
<p className="text-xl font-thin">Create a New Collection</p>
<p className="text-xl font-thin">{t("create_new_collection")}</p>
)}
<div className="divider mb-3 mt-1"></div>
@ -72,19 +75,25 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">Name</p>
<div className="flex flex-col gap-3">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-2">
<TextInput
className="bg-base-200"
value={collection.name}
placeholder="e.g. Example Collection"
placeholder={t("collection_name_placeholder")}
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">Color</p>
<div className="color-picker flex justify-between">
<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"}
@ -96,29 +105,22 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
setCollection({ ...collection, color: "#0ea5e9" })
}
>
Reset
{t("reset")}
</div>
</div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">Description</p>
<p className="mb-2">{t("description")}</p>
<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"
placeholder="The purpose of this Collection..."
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>
setCollection({
...collection,
description: e.target.value,
})
setCollection({ ...collection, description: e.target.value })
}
/>
</div>
@ -128,7 +130,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit}
>
Create Collection
{t("create_collection_button")}
</button>
</div>
</Modal>

View File

@ -11,14 +11,15 @@ import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
};
export default function NewLinkModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession();
const initial = {
name: "",
url: "",
@ -38,18 +39,14 @@ export default function NewLinkModal({ onClose }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const { addLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { collections } = useCollectionStore();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
@ -57,10 +54,7 @@ export default function NewLinkModal({ onClose }: Props) {
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
@ -69,7 +63,6 @@ export default function NewLinkModal({ onClose }: Props) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
@ -86,53 +79,42 @@ export default function NewLinkModal({ onClose }: Props) {
} else
setLink({
...initial,
collection: {
name: "Unorganized",
ownerId: data?.user.id as number,
},
collection: { name: "Unorganized", ownerId: data?.user.id as number },
});
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
let response;
const load = toast.loading("Creating...");
response = await addLink(link);
const load = toast.loading(t("creating_link"));
const response = await addLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(`Created!`);
toast.success(t("link_created"));
onClose();
} else toast.error(response.data as string);
} else {
toast.error(response.data as string);
}
setSubmitLoader(false);
return response;
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Create a New Link</p>
<p className="text-xl font-thin">{t("create_new_link")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5">
<p className="mb-2">Link</p>
<p className="mb-2">{t("link")}</p>
<TextInput
value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder="e.g. http://example.com/"
placeholder={t("link_url_placeholder")}
className="bg-base-200"
/>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">Collection</p>
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
@ -144,40 +126,37 @@ export default function NewLinkModal({ onClose }: Props) {
) : null}
</div>
</div>
<div className={"mt-2"}>
{optionsExpanded ? (
<div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Name</p>
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="Will be auto generated if left empty."
placeholder={t("link_name_placeholder")}
className="bg-base-200"
/>
</div>
<div>
<p className="mb-2">Tags</p>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder="Notes, thoughts, etc."
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"
/>
</div>
@ -185,27 +164,19 @@ export default function NewLinkModal({ onClose }: Props) {
</div>
) : undefined}
</div>
<div className="flex justify-between items-center mt-5">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
>
<p className="font-normal">
{optionsExpanded ? "Hide" : "More"} Options
</p>
<i
className={`${
optionsExpanded ? "bi-chevron-up" : "bi-chevron-down"
}`}
></i>
<p>{optionsExpanded ? t("hide_options") : t("more_options")}</p>
<i className={`bi-chevron-${optionsExpanded ? "up" : "down"}`}></i>
</div>
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Create Link
{t("create_link")}
</button>
</div>
</Modal>

View File

@ -6,14 +6,15 @@ import Modal from "../Modal";
import useTokenStore from "@/store/tokens";
import { dropdownTriggerer } from "@/lib/client/utils";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
};
export default function NewTokenModal({ onClose }: Props) {
const { t } = useTranslation();
const [newToken, setNewToken] = useState("");
const { addToken } = useTokenStore();
const initial = {
@ -22,21 +23,19 @@ export default function NewTokenModal({ onClose }: Props) {
};
const [token, setToken] = useState(initial as any);
const [submitLoader, setSubmitLoader] = useState(false);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading("Creating...");
const load = toast.loading(t("creating_token"));
const { ok, data } = await addToken(token);
toast.dismiss(load);
if (ok) {
toast.success(`Created!`);
toast.success(t("token_created"));
setNewToken((data as any).secretKey);
} else toast.error(data as string);
@ -44,15 +43,27 @@ export default function NewTokenModal({ onClose }: Props) {
}
};
const getLabel = (expiry: TokenExpiry) => {
switch (expiry) {
case TokenExpiry.sevenDays:
return t("7_days");
case TokenExpiry.oneMonth:
return t("30_days");
case TokenExpiry.twoMonths:
return t("60_days");
case TokenExpiry.threeMonths:
return t("90_days");
case TokenExpiry.never:
return t("no_expiration");
}
};
return (
<Modal toggleModal={onClose}>
{newToken ? (
<div className="flex flex-col justify-center space-y-4">
<p className="text-xl font-thin">Access Token Created</p>
<p>
Your new token has been created. Please copy it and store it
somewhere safe. You will not be able to see it again.
</p>
<p className="text-xl font-thin">{t("access_token_created")}</p>
<p>{t("token_creation_notice")}</p>
<TextInput
spellCheck={false}
value={newToken}
@ -62,33 +73,33 @@ export default function NewTokenModal({ onClose }: Props) {
<button
onClick={() => {
navigator.clipboard.writeText(newToken);
toast.success("Copied to clipboard!");
toast.success(t("copied_to_clipboard"));
}}
className="btn btn-primary w-fit mx-auto"
>
Copy to Clipboard
{t("copy_to_clipboard")}
</button>
</div>
) : (
<>
<p className="text-xl font-thin">Create an Access Token</p>
<p className="text-xl font-thin">{t("create_access_token")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex sm:flex-row flex-col gap-2 items-center">
<div className="w-full">
<p className="mb-2">Name</p>
<p className="mb-2">{t("name")}</p>
<TextInput
value={token.name}
onChange={(e) => setToken({ ...token, name: e.target.value })}
placeholder="e.g. For the iOS shortcut"
placeholder={t("token_name_placeholder")}
className="bg-base-200"
/>
</div>
<div className="w-full sm:w-fit">
<p className="mb-2">Expires in</p>
<p className="mb-2">{t("expires_in")}</p>
<div className="dropdown dropdown-bottom dropdown-end w-full">
<Button
@ -98,11 +109,7 @@ export default function NewTokenModal({ onClose }: Props) {
onMouseDown={dropdownTriggerer}
className="whitespace-nowrap w-32"
>
{token.expires === TokenExpiry.sevenDays && "7 Days"}
{token.expires === TokenExpiry.oneMonth && "30 Days"}
{token.expires === TokenExpiry.twoMonths && "60 Days"}
{token.expires === TokenExpiry.threeMonths && "90 Days"}
{token.expires === TokenExpiry.never && "No Expiration"}
{getLabel(token.expires)}
</Button>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-full sm:w-52 mt-1">
<li>
@ -124,7 +131,7 @@ export default function NewTokenModal({ onClose }: Props) {
});
}}
/>
<span className="label-text">7 Days</span>
<span className="label-text">{t("7_days")}</span>
</label>
</li>
<li>
@ -143,7 +150,7 @@ export default function NewTokenModal({ onClose }: Props) {
setToken({ ...token, expires: TokenExpiry.oneMonth });
}}
/>
<span className="label-text">30 Days</span>
<span className="label-text">{t("30_days")}</span>
</label>
</li>
<li>
@ -165,7 +172,7 @@ export default function NewTokenModal({ onClose }: Props) {
});
}}
/>
<span className="label-text">60 Days</span>
<span className="label-text">{t("60_days")}</span>
</label>
</li>
<li>
@ -187,7 +194,7 @@ export default function NewTokenModal({ onClose }: Props) {
});
}}
/>
<span className="label-text">90 Days</span>
<span className="label-text">{t("90_days")}</span>
</label>
</li>
<li>
@ -206,7 +213,7 @@ export default function NewTokenModal({ onClose }: Props) {
setToken({ ...token, expires: TokenExpiry.never });
}}
/>
<span className="label-text">No Expiration</span>
<span className="label-text">{t("no_expiration")}</span>
</label>
</li>
</ul>
@ -219,7 +226,7 @@ export default function NewTokenModal({ onClose }: Props) {
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Create Access Token
{t("create_token")}
</button>
</div>
</>

View File

@ -3,6 +3,7 @@ import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
import TextInput from "../TextInput";
import { FormEvent, useState } from "react";
import { useTranslation, Trans } from "next-i18next";
type Props = {
onClose: Function;
@ -18,15 +19,14 @@ type FormData = {
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
export default function NewUserModal({ onClose }: Props) {
const { t } = useTranslation();
const { addUser } = useUserStore();
const [form, setForm] = useState<FormData>({
name: "",
username: "",
email: emailEnabled ? "" : undefined,
password: "",
});
const [submitLoader, setSubmitLoader] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
@ -45,11 +45,11 @@ export default function NewUserModal({ onClose }: Props) {
if (checkFields()) {
if (form.password.length < 8)
return toast.error("Passwords must be at least 8 characters.");
return toast.error(t("password_length_error"));
setSubmitLoader(true);
const load = toast.loading("Creating Account...");
const load = toast.loading(t("creating_account"));
const response = await addUser(form);
@ -57,29 +57,29 @@ export default function NewUserModal({ onClose }: Props) {
setSubmitLoader(false);
if (response.ok) {
toast.success("User Created!");
toast.success(t("user_created"));
onClose();
} else {
toast.error(response.data as string);
}
} else {
toast.error("Please fill out all the fields.");
toast.error(t("fill_all_fields_error"));
}
}
}
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Create New User</p>
<p className="text-xl font-thin">{t("create_new_user")}</p>
<div className="divider mb-3 mt-1"></div>
<form onSubmit={submit}>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Display Name</p>
<p className="mb-2">{t("display_name")}</p>
<TextInput
placeholder="Johnny"
placeholder={t("placeholder_johnny")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.name}
@ -88,9 +88,9 @@ export default function NewUserModal({ onClose }: Props) {
{emailEnabled ? (
<div>
<p className="mb-2">Email</p>
<p className="mb-2">{t("email")}</p>
<TextInput
placeholder="johnny@example.com"
placeholder={t("placeholder_email")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, email: e.target.value })}
value={form.email}
@ -100,13 +100,13 @@ export default function NewUserModal({ onClose }: Props) {
<div>
<p className="mb-2">
Username{" "}
{t("username")}{" "}
{emailEnabled && (
<span className="text-xs text-neutral">(Optional)</span>
<span className="text-xs text-neutral">{t("optional")}</span>
)}
</p>
<TextInput
placeholder="john"
placeholder={t("placeholder_john")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, username: e.target.value })}
value={form.username}
@ -114,7 +114,7 @@ export default function NewUserModal({ onClose }: Props) {
</div>
<div>
<p className="mb-2">Password</p>
<p className="mb-2">{t("password")}</p>
<TextInput
placeholder="••••••••••••••"
className="bg-base-200"
@ -127,8 +127,7 @@ export default function NewUserModal({ onClose }: Props) {
<div role="note" className="alert alert-note mt-5">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>Note:</b> Please make sure you inform the user that they need to
change their password.
<Trans i18nKey="password_change_note" components={[<b />]} />
</span>
</div>
@ -137,7 +136,7 @@ export default function NewUserModal({ onClose }: Props) {
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
type="submit"
>
Create User
{t("create_user")}
</button>
</div>
</form>

View File

@ -1,8 +1,8 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
@ -17,6 +17,7 @@ import {
import PreservedFormatRow from "@/components/PreserverdFormatRow";
import useAccountStore from "@/store/account";
import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
@ -24,14 +25,12 @@ type Props = {
};
export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const session = useSession();
const { getLink } = useLinkStore();
const { account } = useAccountStore();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
@ -109,17 +108,16 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
clearInterval(interval);
}
};
}, [link?.image, link?.pdf, link?.readable]);
}, [link, getLink]);
const updateArchive = async () => {
const load = toast.loading("Sending request...");
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) {
@ -127,33 +125,29 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
setLink(
(newLink as any).response as LinkIncludingShortenedCollectionAndTags
);
toast.success(`Link is being archived...`);
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Preserved Formats</p>
<p className="text-xl font-thin">{t("preserved_formats")}</p>
<div className="divider mb-2 mt-1"></div>
{isReady() &&
(screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link)) ? (
<p className="mb-3">
The following formats are available for this link:
</p>
<p className="mb-3">{t("available_formats")}</p>
) : (
""
)}
<div className={`flex flex-col gap-3`}>
<div className="flex flex-col gap-3">
{isReady() ? (
<>
{screenshotAvailable(link) ? (
<PreservedFormatRow
name={"Screenshot"}
name={t("screenshot")}
icon={"bi-file-earmark-image"}
format={
link?.image?.endsWith("png")
@ -164,37 +158,29 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
downloadable={true}
/>
) : undefined}
{pdfAvailable(link) ? (
<PreservedFormatRow
name={"PDF"}
icon={"bi-file-earmark-pdf"}
name={t("pdf")}
icon="bi-file-earmark-pdf"
format={ArchivedFormat.pdf}
activeLink={link}
downloadable={true}
/>
) : undefined}
{readabilityAvailable(link) ? (
<PreservedFormatRow
name={"Readable"}
icon={"bi-file-earmark-text"}
name={t("readable")}
icon="bi-file-earmark-text"
format={ArchivedFormat.readability}
activeLink={link}
/>
) : undefined}
</>
) : (
<div
className={`w-full h-full flex flex-col justify-center p-10 skeleton bg-base-200`}
>
<div className="w-full h-full flex flex-col justify-center p-10 skeleton bg-base-200">
<i className="bi-stack drop-shadow text-primary text-8xl mx-auto mb-5"></i>
<p className="text-center text-2xl">
Link preservation is in the queue
</p>
<p className="text-center text-lg">
Please check back later to see the result
</p>
<p className="text-center text-2xl">{t("preservation_in_queue")}</p>
<p className="text-center text-lg">{t("check_back_later")}</p>
</div>
)}
@ -209,23 +195,21 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
""
)}`}
target="_blank"
className={`text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm`}
className="text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">
View latest snapshot on archive.org
</p>
<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()}>
{link?.collection.ownerId === session.data?.user.id && (
<div className="btn btn-outline" onClick={updateArchive}>
<div>
<p>Refresh Preserved Formats</p>
<p>{t("refresh_preserved_formats")}</p>
<p className="text-xs">
This deletes the current preservations
{t("this_deletes_current_preservations")}
</p>
</div>
</div>
) : undefined}
)}
</div>
</div>
</Modal>

View File

@ -1,11 +1,10 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import useTokenStore from "@/store/tokens";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { AccessToken } from "@prisma/client";
type Props = {
onClose: Function;
@ -13,46 +12,41 @@ type Props = {
};
export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const { t } = useTranslation();
const [token, setToken] = useState<AccessToken>(activeToken);
const { revokeToken } = useTokenStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
useEffect(() => {
setToken(activeToken);
}, []);
}, [activeToken]);
const deleteLink = async () => {
console.log(token);
const load = toast.loading("Deleting...");
const load = toast.loading(t("deleting"));
const response = await revokeToken(token.id as number);
toast.dismiss(load);
response.ok && toast.success(`Token Revoked.`);
if (response.ok) {
toast.success(t("token_revoked"));
}
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Revoke Token</p>
<p className="text-xl font-thin text-red-500">{t("revoke_token")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>
Are you sure you want to revoke this Access Token? Any apps or
services using this token will no longer be able to access Linkwarden
using it.
</p>
<p>{t("revoke_confirmation")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<i className="bi-trash text-xl" />
Revoke
{t("revoke")}
</Button>
</div>
</Modal>

View File

@ -5,20 +5,19 @@ import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
};
export default function UploadFileModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession();
const initial = {
@ -40,14 +39,11 @@ export default function UploadFileModal({ onClose }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const [file, setFile] = useState<File>();
const { uploadFile } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { collections } = useCollectionStore();
@ -74,7 +70,6 @@ export default function UploadFileModal({ onClose }: Props) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
@ -91,30 +86,26 @@ export default function UploadFileModal({ onClose }: Props) {
} else
setLink({
...initial,
collection: {
name: "Unorganized",
ownerId: data?.user.id as number,
},
collection: { name: "Unorganized", ownerId: data?.user.id as number },
});
}, []);
}, [router, collections]);
const submit = async () => {
if (!submitLoader && file) {
setSubmitLoader(true);
const load = toast.loading("Creating...");
const load = toast.loading(t("creating"));
const response = await uploadFile(link, file);
toast.dismiss(load);
if (response.ok) {
toast.success(`Created!`);
toast.success(t("created"));
onClose();
} else toast.error(response.data as string);
} else {
toast.error(response.data as string);
}
setSubmitLoader(false);
return response;
}
};
@ -122,12 +113,12 @@ export default function UploadFileModal({ onClose }: Props) {
return (
<Modal toggleModal={onClose}>
<div className="flex gap-2 items-start">
<p className="text-xl font-thin">Upload File</p>
<p className="text-xl font-thin">{t("upload_file")}</p>
</div>
<div className="divider mb-3 mt-1"></div>
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5">
<p className="mb-2">File</p>
<p className="mb-2">{t("file")}</p>
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
<input
type="file"
@ -137,12 +128,13 @@ export default function UploadFileModal({ onClose }: Props) {
/>
</label>
<p className="text-xs font-semibold mt-2">
PDF, PNG, JPG (Up to {process.env.NEXT_PUBLIC_MAX_FILE_SIZE || 30}
MB)
{t("file_types", {
size: process.env.NEXT_PUBLIC_MAX_FILE_SIZE || 30,
})}
</p>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">Collection</p>
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
@ -156,36 +148,34 @@ export default function UploadFileModal({ onClose }: Props) {
</div>
{optionsExpanded ? (
<div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Name</p>
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
placeholder={t("example_link")}
className="bg-base-200"
/>
</div>
<div>
<p className="mb-2">Tags</p>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder="Will be auto generated if nothing is provided."
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"
/>
</div>
@ -197,14 +187,15 @@ export default function UploadFileModal({ onClose }: Props) {
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
>
<p>{optionsExpanded ? "Hide" : "More"} Options</p>
<p>
{optionsExpanded ? t("hide") : t("more")} {t("options")}
</p>
</div>
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Upload File
{t("upload_file")}
</button>
</div>
</Modal>

View File

@ -11,8 +11,10 @@ import UploadFileModal from "./ModalContent/UploadFileModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import MobileNavigation from "./MobileNavigation";
import ProfileDropdown from "./ProfileDropdown";
import { useTranslation } from "next-i18next";
export default function Navbar() {
const { t } = useTranslation();
const router = useRouter();
const [sidebar, setSidebar] = useState(false);
@ -49,7 +51,7 @@ export default function Navbar() {
<ToggleDarkMode className="hidden sm:inline-grid" />
<div className="dropdown dropdown-end sm:inline-block hidden">
<div className="tooltip tooltip-bottom" data-tip="Create New...">
<div className="tooltip tooltip-bottom" data-tip={t("create_new")}>
<div
tabIndex={0}
role="button"
@ -74,7 +76,7 @@ export default function Navbar() {
tabIndex={0}
role="button"
>
New Link
{t("new_link")}
</div>
</li>
<li>
@ -86,7 +88,7 @@ export default function Navbar() {
tabIndex={0}
role="button"
>
Upload File
{t("upload_file")}
</div>
</li>
<li>
@ -98,7 +100,7 @@ export default function Navbar() {
tabIndex={0}
role="button"
>
New Collection
{t("new_collection")}
</div>
</li>
</ul>

View File

@ -1,11 +1,13 @@
import React, { useState } from "react";
import NewLinkModal from "./ModalContent/NewLinkModal";
import { useTranslation } from "next-i18next";
type Props = {
text?: string;
};
export default function NoLinksFound({ text }: Props) {
const { t } = useTranslation();
const [newLinkModal, setNewLinkModal] = useState(false);
return (
@ -23,9 +25,7 @@ export default function NoLinksFound({ text }: Props) {
<p className="text-center text-xl sm:text-2xl">
{text || "You haven't created any Links Here"}
</p>
<p className="text-center text-sm sm:text-base">
Start your journey by creating a new Link!
</p>
<p className="text-center text-sm sm:text-base">{t("start_journey")}</p>
<div className="text-center w-full mt-4">
<div
onClick={() => {
@ -35,7 +35,7 @@ export default function NoLinksFound({ text }: Props) {
>
<i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i>
<span className="group-hover:opacity-0 text-right w-full duration-100">
Create New Link
{t("create_new_link")}
</span>
</div>
</div>

View File

@ -4,7 +4,6 @@ import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";

View File

@ -4,17 +4,16 @@ import ProfilePhoto from "./ProfilePhoto";
import useAccountStore from "@/store/account";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { useTranslation } from "next-i18next";
export default function ProfileDropdown() {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore();
const handleToggle = () => {
if (settings.theme === "dark") {
updateSettings({ theme: "light" });
} else {
updateSettings({ theme: "dark" });
}
const newTheme = settings.theme === "dark" ? "light" : "dark";
updateSettings({ theme: newTheme });
};
return (
@ -38,7 +37,7 @@ export default function ProfileDropdown() {
tabIndex={0}
role="button"
>
Settings
{t("settings")}
</Link>
</li>
<li className="block sm:hidden">
@ -50,7 +49,9 @@ export default function ProfileDropdown() {
tabIndex={0}
role="button"
>
Switch to {settings.theme === "light" ? "Dark" : "Light"}
{t("switch_to", {
theme: settings.theme === "light" ? t("dark") : t("light"),
})}
</div>
</li>
<li>
@ -62,7 +63,7 @@ export default function ProfileDropdown() {
tabIndex={0}
role="button"
>
Logout
{t("logout")}
</div>
</li>
</ul>

View File

@ -16,14 +16,6 @@ export default function RadioButton({ label, state, onClick }: Props) {
checked={state}
onChange={onClick}
/>
{/*<FontAwesomeIcon*/}
{/* icon={faCircleCheck}*/}
{/* className="w-5 h-5 text-primary peer-checked:block hidden"*/}
{/*/>*/}
{/*<FontAwesomeIcon*/}
{/* icon={faCircle}*/}
{/* className="w-5 h-5 text-primary peer-checked:hidden block"*/}
{/*/>*/}
<span className="rounded select-none">{label}</span>
</label>
);

View File

@ -15,6 +15,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useMemo, useState } from "react";
import LinkActions from "./LinkViews/LinkComponents/LinkActions";
import useCollectionStore from "@/store/collections";
import { useTranslation } from "next-i18next";
type LinkContent = {
title: string;
@ -33,6 +34,7 @@ type Props = {
};
export default function ReadableView({ link }: Props) {
const { t } = useTranslation();
const [linkContent, setLinkContent] = useState<LinkContent>();
const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
@ -271,11 +273,9 @@ export default function ReadableView({ link }: Props) {
<path d="m14.12 6.576 1.715.858c.22.11.22.424 0 .534l-7.568 3.784a.598.598 0 0 1-.534 0L.165 7.968a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.659z" />
</svg>
<p className="text-center text-2xl">
The Link preservation is currently in the queue
</p>
<p className="text-center text-lg mt-2">
Please check back later to see the result
{t("link_preservation_in_queue")}
</p>
<p className="text-center text-lg mt-2">{t("check_back_later")}</p>
</div>
)}
</div>

View File

@ -1,20 +1,18 @@
import useCollectionStore from "@/store/collections";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
export default function SettingsSidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const LINKWARDEN_VERSION = process.env.version;
const { collections } = useCollectionStore();
const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => {
setActive(router.asPath);
}, [router, collections]);
}, [router]);
return (
<div
@ -26,71 +24,69 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<Link href="/settings/account">
<div
className={`${
active === `/settings/account`
active === "/settings/account"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-person text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Account</p>
<p className="truncate w-full pr-7">{t("account")}</p>
</div>
</Link>
<Link href="/settings/preference">
<div
className={`${
active === `/settings/preference`
active === "/settings/preference"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-sliders text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Preference</p>
<p className="truncate w-full pr-7">{t("preference")}</p>
</div>
</Link>
<Link href="/settings/access-tokens">
<div
className={`${
active === `/settings/access-tokens`
active === "/settings/access-tokens"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-key text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Access Tokens</p>
<p className="truncate w-full pr-7">{t("access_tokens")}</p>
</div>
</Link>
<Link href="/settings/password">
<div
className={`${
active === `/settings/password`
active === "/settings/password"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-lock text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Password</p>
<p className="truncate w-full pr-7">{t("password")}</p>
</div>
</Link>
{process.env.NEXT_PUBLIC_STRIPE ? (
{process.env.NEXT_PUBLIC_STRIPE && (
<Link href="/settings/billing">
<div
className={`${
active === `/settings/billing`
active === "/settings/billing"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-credit-card text-primary text-xl"></i>
<p className="truncate w-full pr-7">Billing</p>
<p className="truncate w-full pr-7">{t("billing")}</p>
</div>
</Link>
) : undefined}
)}
</div>
<div className="flex flex-col gap-1">
@ -99,42 +95,38 @@ export default function SettingsSidebar({ className }: { className?: string }) {
target="_blank"
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
>
Linkwarden {LINKWARDEN_VERSION}
{t("linkwarden_version", { version: LINKWARDEN_VERSION })}
</Link>
<Link href="https://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-question-circle text-primary text-xl"></i>
<p className="truncate w-full pr-7">Help</p>
<p className="truncate w-full pr-7">{t("help")}</p>
</div>
</Link>
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-github text-primary text-xl"></i>
<p className="truncate w-full pr-7">GitHub</p>
<p className="truncate w-full pr-7">{t("github")}</p>
</div>
</Link>
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-twitter-x text-primary text-xl"></i>
<p className="truncate w-full pr-7">Twitter</p>
<p className="truncate w-full pr-7">{t("twitter")}</p>
</div>
</Link>
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-mastodon text-primary text-xl"></i>
<p className="truncate w-full pr-7">Mastodon</p>
<p className="truncate w-full pr-7">{t("mastodon")}</p>
</div>
</Link>
</div>

View File

@ -6,8 +6,10 @@ import { useEffect, useState } from "react";
import { Disclosure, Transition } from "@headlessui/react";
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
import CollectionListing from "@/components/CollectionListing";
import { useTranslation } from "next-i18next";
export default function Sidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
const storedValue = localStorage.getItem("tagDisclosure");
return storedValue ? storedValue === "true" : true;
@ -82,7 +84,7 @@ export default function Sidebar({ className }: { className?: string }) {
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">Collections</p>
<p className="text-sm">{t("collections")}</p>
<i
className={`bi-chevron-down ${
collectionDisclosure ? "rotate-reverse" : "rotate"
@ -109,7 +111,7 @@ export default function Sidebar({ className }: { className?: string }) {
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">Tags</p>
<p className="text-sm">{t("tags")}</p>
<i
className={`bi-chevron-down ${
tagDisclosure ? "rotate-reverse" : "rotate"
@ -152,7 +154,7 @@ export default function Sidebar({ className }: { className?: string }) {
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
You Have No Tags...
{t("you_have_no_tags")}
</p>
</div>
)}

View File

@ -1,11 +1,13 @@
import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
type Props = {
className?: string;
};
export default function ToggleDarkMode({ className }: Props) {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const [theme, setTheme] = useState(localStorage.getItem("theme"));
@ -21,7 +23,9 @@ export default function ToggleDarkMode({ className }: Props) {
return (
<div
className="tooltip tooltip-bottom"
data-tip={`Switch to ${settings.theme === "light" ? "Dark" : "Light"}`}
data-tip={t("switch_to", {
theme: settings.theme === "light" ? "Dark" : "Light",
})}
>
<label
className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}

View File

@ -1,7 +1,6 @@
import { ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import Loader from "../components/Loader";
import useInitialData from "@/hooks/useInitialData";
import useAccountStore from "@/store/account";

View File

@ -2,6 +2,7 @@ import useLocalSettingsStore from "@/store/localSettings";
import Image from "next/image";
import Link from "next/link";
import React, { ReactNode } from "react";
import { Trans } from "next-i18next";
interface Props {
text?: string;
@ -40,11 +41,13 @@ export default function CenteredForm({
) : undefined}
{children}
<p className="text-center text-xs text-neutral mb-5">
© {new Date().getFullYear()}{" "}
<Link href="https://linkwarden.app" className="font-semibold">
Linkwarden
</Link>
. All rights reserved.
<Trans
values={{ date: new Date().getFullYear() }}
i18nKey="all_rights_reserved"
components={[
<Link href="https://linkwarden.app" className="font-semibold" />,
]}
/>
</p>
</div>
</div>

View File

@ -258,16 +258,12 @@ export default function Register({
href="https://linkwarden.app/tos"
className="font-semibold"
data-testid="terms-of-service-link"
>
Terms of Services
</Link>,
/>,
<Link
href="https://linkwarden.app/privacy-policy"
className="font-semibold"
data-testid="privacy-policy-link"
>
Privacy Policy
</Link>,
/>,
]}
/>
</p>

View File

@ -92,7 +92,7 @@
"access_tokens_description": "Access Tokens can be used to access Linkwarden from other apps and services without giving away your Username and Password.",
"new_token": "New Access Token",
"name": "Name",
"created": "Created",
"created": "Created!",
"expires": "Expires",
"accountSettings": "Account Settings",
"language": "Language",
@ -188,6 +188,7 @@
"edit_collection_info": "Edit Collection Info",
"share_and_collaborate": "Share and Collaborate",
"view_team": "View Team",
"team": "Team",
"create_subcollection": "Create Sub-Collection",
"delete_collection": "Delete Collection",
"leave_collection": "Leave Collection",
@ -207,5 +208,156 @@
"name_az": "Name (A-Z)",
"name_za": "Name (Z-A)",
"description_az": "Description (A-Z)",
"description_za": "Description (Z-A)"
"description_za": "Description (Z-A)",
"all_rights_reserved": "© {{date}} <0>Linkwarden</0>. All rights reserved.",
"you_have_no_collections": "You have no Collections...",
"you_have_no_tags": "You have no Tags...",
"cant_change_collection_you_dont_own": "You can't make changes to a collection you don't own.",
"account": "Account",
"billing": "Billing",
"linkwarden_version": "Linkwarden {{version}}",
"help": "Help",
"github": "GitHub",
"twitter": "Twitter",
"mastodon": "Mastodon",
"link_preservation_in_queue": "LThe Link preservation is currently in the queue",
"check_back_later": "Please check back later to see the result",
"settings": "Settings",
"switch_to": "Switch to {{theme}}",
"logout": "Logout",
"start_journey": "Start your journey by creating a new Link!",
"create_new_link": "Create New Link",
"new_link": "New Link",
"create_new": "Create New...",
"pwa_install_prompt": "Install Linkwarden to your home screen for a faster access and enhanced experience. <0>Learn more</0>",
"full_content": "Full Content",
"slower": "Slower",
"new_version_announcement": "See what's new in <0>Linkwarden {{version}}!</0>",
"creating": "Creating...",
"upload_file": "Upload File",
"file": "File",
"file_types": "PDF, PNG, JPG (Up to {{size}} MB)",
"description": "Description",
"auto_generated": "Will be auto generated if nothing is provided.",
"example_link": "e.g. Example Link",
"hide": "Hide",
"more": "More",
"options": "Options",
"description_placeholder": "Notes, thoughts, etc.",
"deleting": "Deleting...",
"token_revoked": "Token Revoked.",
"revoke_token": "Revoke Token",
"revoke_confirmation": "Are you sure you want to revoke this Access Token? Any apps or services using this token will no longer be able to access Linkwarden using it.",
"revoke": "Revoke",
"sending_request": "Sending request...",
"link_being_archived": "Link is being archived...",
"preserved_formats": "Preserved Formats",
"available_formats": "The following formats are available for this link:",
"readable": "Readable",
"preservation_in_queue": "Link preservation is in the queue",
"view_latest_snapshot": "View latest snapshot on archive.org",
"refresh_preserved_formats": "Refresh Preserved Formats",
"this_deletes_current_preservations": "This deletes the current preservations",
"create_new_user": "Create New User",
"placeholder_johnny": "Johnny",
"placeholder_email": "johnny@example.com",
"placeholder_john": "john",
"user_created": "User Created!",
"fill_all_fields_error": "Please fill out all the fields.",
"password_change_note": "<0>Note:</0> Please make sure you inform the user that they need to change their password.",
"create_user": "Create User",
"creating_token": "Creating Token...",
"token_created": "Token Created!",
"access_token_created": "Access Token Created",
"token_creation_notice": "Your new token has been created. Please copy it and store it somewhere safe. You will not be able to see it again.",
"copied_to_clipboard": "Copied to clipboard!",
"copy_to_clipboard": "Copy to Clipboard",
"create_access_token": "Create an Access Token",
"expires_in": "Expires in",
"token_name_placeholder": "e.g. For the iOS shortcut",
"create_token": "Create Access Token",
"7_days": "7 Days",
"30_days": "30 Days",
"60_days": "60 Days",
"90_days": "90 Days",
"no_expiration": "No Expiration",
"creating_link": "Creating link...",
"link_created": "Link created!",
"link_name_placeholder": "Will be auto generated if left empty.",
"link_url_placeholder": "e.g. http://example.com/",
"link_description_placeholder": "Notes, thoughts, etc.",
"more_options": "More Options",
"hide_options": "Hide Options",
"create_link": "Create Link",
"new_sub_collection": "New Sub-Collection",
"for_collection": "For {{name}}",
"create_new_collection": "Create a New Collection",
"color": "Color",
"reset": "Reset",
"collection_name_placeholder": "e.g. Example Collection",
"collection_description_placeholder": "The purpose of this Collection...",
"create_collection_button": "Create Collection",
"password_change_warning": "Please confirm your password before changing your email address.",
"stripe_update_note": " Updating this field will change your billing email on Stripe as well.",
"sso_will_be_removed_warning": "If you change your email address, any existing {{service}} SSO connections will be removed.",
"old_email": "Old Email",
"new_email": "New Email",
"confirm": "Confirm",
"edit_link": "Edit Link",
"updating": "Updating...",
"updated": "Updated!",
"placeholder_example_link": "e.g. Example Link",
"make_collection_public": "Make Collection Public",
"make_collection_public_checkbox": "Make this a public collection",
"make_collection_public_desc": "This will allow anyone to view this collection and it's users.",
"sharable_link_guide": "Sharable Link (Click to copy)",
"copied": "Copied!",
"members": "Members",
"members_username_placeholder": "Username (without the '@')",
"owner": "Owner",
"admin": "Admin",
"contributor": "Contributor",
"viewer": "Viewer",
"viewer_desc": "Read-only access",
"contributor_desc": "Can view and create Links",
"admin_desc": "Full access to all Links",
"remove_member": "Remove Member",
"placeholder_example_collection": "e.g. Example Collection",
"placeholder_collection_purpose": "The purpose of this Collection...",
"deleting_user": "Deleting...",
"user_deleted": "User Deleted.",
"delete_user": "Delete User",
"confirm_user_deletion": "Are you sure you want to remove this user?",
"irreversible_action_warning": "This action is irreversible!",
"delete_confirmation": "Delete, I know what I'm doing",
"delete_link": "Delete Link",
"deleted": "Deleted.",
"link_deletion_confirmation_message": "Are you sure you want to delete this Link?",
"warning": "Warning",
"irreversible_warning": "This action is irreversible!",
"shift_key_tip": "Hold the Shift key while clicking 'Delete' to bypass this confirmation in the future.",
"deleting_collection": "Deleting...",
"collection_deleted": "Collection Deleted.",
"confirm_deletion_prompt": "To confirm, type \"{{name}}\" in the box below:",
"type_name_placeholder": "Type \"{{name}}\" Here.",
"deletion_warning": "Deleting this collection will permanently erase all its contents, and it will become inaccessible to everyone, including members with previous access.",
"leave_prompt": "Click the button below to leave the current collection.",
"leave": "Leave",
"edit_links": "Edit {{count}} Links",
"move_to_collection": "Move to Collection",
"add_tags": "Add Tags",
"remove_previous_tags": "Remove previous tags",
"delete_links": "Delete {{count}} Links",
"links_deletion_confirmation_message": "Are you sure you want to delete {{count}} Links? ",
"warning_irreversible": "Warning: This action is irreversible!",
"shift_key_instruction": "Hold the 'Shift' key while clicking 'Delete' to bypass this confirmation in the future.",
"link_selection_error": "You don't have permission to edit or delete this item.",
"no_description": "No description provided.",
"applying": "Applying...",
"unpin": "Unpin",
"pin_to_dashboard": "Pin to Dashboard",
"show_link_details": "Show Link Details",
"hide_link_details": "Hide Link Details",
"link_pinned": "Link Pinned!",
"link_unpinned": "Link Unpinned!"
}