improved settings UX

This commit is contained in:
daniel31x13
2026-02-05 20:05:54 -05:00
parent 4743aa8144
commit 7bbdec0f85
5 changed files with 210 additions and 95 deletions

View File

@@ -99,7 +99,7 @@ const Page: NextPageWithLayout = () => {
<Button
variant="ghost"
size="icon"
className="hover:text-error"
className="hover:text-error ml-auto block"
onClick={() => openRevokeModal(token as AccessToken)}
>
<i className="bi-x text-lg"></i>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, ChangeEvent, ReactElement } from "react";
import { useState, useEffect, ChangeEvent, ReactElement, useMemo } from "react";
import { AccountSettings } from "@linkwarden/types";
import { toast } from "react-hot-toast";
import SettingsLayout from "@/layouts/SettingsLayout";
@@ -63,9 +63,10 @@ const Page: NextPageWithLayout = () => {
setUser({
...account,
whitelistedUsers: stringToArray(whitelistedUsersTextbox),
whitelistedUsers: account?.whitelistedUsers || [],
});
}, [account, whitelistedUsersTextbox]);
setWhiteListedUsersTextbox(account?.whitelistedUsers?.join(", ") || "");
}, [account]);
const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -92,6 +93,8 @@ const Page: NextPageWithLayout = () => {
};
const submit = async (password?: string) => {
if (!account?.id || !hasAccountChanges) return;
if (!/^[a-z0-9_-]{3,50}$/.test(user.username || "")) {
return toast.error(t("username_invalid_guide"));
}
@@ -108,7 +111,14 @@ const Page: NextPageWithLayout = () => {
await updateUser.mutateAsync(
{
...user,
id: account.id,
name: user.name,
username: user.username,
email: user.email,
locale: user.locale,
image: user.image,
isPrivate: user.isPrivate,
whitelistedUsers: stringToArray(whitelistedUsersTextbox),
password: password ? password : undefined,
},
{
@@ -137,14 +147,43 @@ const Page: NextPageWithLayout = () => {
}
};
useEffect(() => {
setWhiteListedUsersTextbox(account?.whitelistedUsers?.join(", ") || "");
}, [account]);
const stringToArray = (str: string) => {
return str?.replace(/\s+/g, "").split(",");
};
const normalizeUserList = (list: string[] = []) =>
list
.map((value) => value.trim())
.filter(Boolean)
.sort();
const hasAccountChanges = useMemo(() => {
if (!account?.id) return false;
const currentWhitelist = normalizeUserList(
stringToArray(whitelistedUsersTextbox)
);
const originalWhitelist = normalizeUserList(
account?.whitelistedUsers || []
);
const whitelistChanged =
currentWhitelist.length !== originalWhitelist.length ||
currentWhitelist.some(
(value, index) => value !== originalWhitelist[index]
);
return (
(user.name || "") !== (account.name || "") ||
(user.username || "") !== (account.username || "") ||
(user.email || "") !== (account.email || "") ||
(user.locale || "") !== (account.locale || "") ||
(user.image || "") !== (account.image || "") ||
Boolean(user.isPrivate) !== Boolean(account.isPrivate) ||
whitelistChanged
);
}, [account, user, whitelistedUsersTextbox]);
return (
<>
<p className="capitalize text-3xl font-thin inline">
@@ -154,7 +193,7 @@ const Page: NextPageWithLayout = () => {
<Separator className="my-3" />
<div className="flex flex-col gap-5">
<div className="grid sm:grid-cols-2 gap-3 auto-rows-auto">
<div className="grid sm:grid-cols-2 gap-3 auto-rows-auto max-w-screen-lg">
<div className="flex flex-col gap-3">
<div>
<p className="mb-2">{t("display_name")}</p>
@@ -282,7 +321,14 @@ const Page: NextPageWithLayout = () => {
className="w-full resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder={t("whitelisted_users_placeholder")}
value={whitelistedUsersTextbox}
onChange={(e) => setWhiteListedUsersTextbox(e.target.value)}
onChange={(e) => {
const value = e.target.value;
setWhiteListedUsersTextbox(value);
setUser((prev: AccountSettings) => ({
...prev,
whitelistedUsers: stringToArray(value),
}));
}}
/>
</div>
)}
@@ -297,7 +343,7 @@ const Page: NextPageWithLayout = () => {
submit();
}
}}
disabled={submitLoader}
disabled={submitLoader || !hasAccountChanges}
className="mt-2 w-full sm:w-fit"
>
{t("save_changes")}

View File

@@ -222,6 +222,7 @@ const Page: NextPageWithLayout = () => {
size="icon"
onMouseDown={(e) => e.preventDefault()}
title={t("more")}
className="ml-auto block"
>
<i
className={"bi bi-three-dots text-lg text-neutral"}

View File

@@ -19,6 +19,8 @@ const Page: NextPageWithLayout = () => {
const updateUser = useUpdateUser();
const submit = async () => {
if (!account?.id) return;
if (newPassword === "" || oldPassword === "") {
return toast.error(t("fill_all_fields"));
}
@@ -30,7 +32,10 @@ const Page: NextPageWithLayout = () => {
await updateUser.mutateAsync(
{
...account,
id: account.id,
username: account.username,
email: account.email,
whitelistedUsers: account.whitelistedUsers || [],
newPassword,
oldPassword,
},
@@ -61,7 +66,7 @@ const Page: NextPageWithLayout = () => {
<Separator className="my-3" />
<p className="mb-3">{t("password_change_instructions")}</p>
<div className="w-full flex flex-col gap-2 justify-between">
<div className="w-full flex flex-col gap-2 justify-between max-w-screen-lg">
<p>{t("old_password")}</p>
<TextInput
@@ -84,7 +89,7 @@ const Page: NextPageWithLayout = () => {
<Button
onClick={submit}
disabled={submitLoader}
disabled={submitLoader || !oldPassword || !newPassword}
variant="accent"
className="mt-3 w-full sm:w-fit"
>

View File

@@ -30,7 +30,6 @@ const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const updateUserPreference = useUpdateUserPreference();
const [submitLoader, setSubmitLoader] = useState(false);
const { data: account } = useUser() as any;
const { data: tags } = useTags();
const upsertTags = useUpsertTags();
@@ -43,7 +42,9 @@ const Page: NextPageWithLayout = () => {
removeTag,
} = useArchivalTags(tags ? tags : []);
const updateUser = useUpdateUser();
const [user, setUser] = useState(account);
const [aiSubmitLoader, setAiSubmitLoader] = useState(false);
const [archiveSubmitLoader, setArchiveSubmitLoader] = useState(false);
const [linkSubmitLoader, setLinkSubmitLoader] = useState(false);
const [preventDuplicateLinks, setPreventDuplicateLinks] = useState<boolean>(
account.preventDuplicateLinks || false
@@ -60,9 +61,6 @@ const Page: NextPageWithLayout = () => {
const [archiveAsReadable, setArchiveAsReadable] = useState<boolean>(false);
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
useState<boolean>(account.archiveAsWaybackMachine || false);
const [dashboardPinnedLinks, setDashboardPinnedLinks] = useState<boolean>(
account.dashboardPinnedLinks || false
);
const [linksRouteTo, setLinksRouteTo] = useState(account.linksRouteTo);
const [aiTaggingMethod, setAiTaggingMethod] = useState<AiTaggingMethod>(
account.aiTaggingMethod
@@ -71,77 +69,28 @@ const Page: NextPageWithLayout = () => {
const [aiTagExistingLinks, setAiTagExistingLinks] = useState<boolean>(
account.aiTagExistingLinks ?? false
);
const [hasAccountChanges, setHasAccountChanges] = useState(false);
const [hasTagChanges, setHasTagChanges] = useState(false);
const [hasArchiveTagChanges, setHasArchiveTagChanges] = useState(false);
const { data: config } = useConfig();
useEffect(() => {
setUser({
...account,
archiveAsScreenshot,
archiveAsMonolith,
archiveAsPDF,
archiveAsReadable,
archiveAsWaybackMachine,
linksRouteTo,
preventDuplicateLinks,
aiTaggingMethod,
aiPredefinedTags,
aiTagExistingLinks,
dashboardPinnedLinks,
});
}, [
account,
archiveAsScreenshot,
archiveAsMonolith,
archiveAsPDF,
archiveAsReadable,
archiveAsWaybackMachine,
linksRouteTo,
preventDuplicateLinks,
aiTaggingMethod,
aiPredefinedTags,
aiTagExistingLinks,
]);
function objectIsEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
useEffect(() => {
if (!objectIsEmpty(account)) {
setArchiveAsScreenshot(account.archiveAsScreenshot);
setArchiveAsMonolith(account.archiveAsMonolith);
setArchiveAsPDF(account.archiveAsPDF);
setArchiveAsReadable(account.archiveAsReadable);
setArchiveAsWaybackMachine(account.archiveAsWaybackMachine);
setArchiveAsScreenshot(account.archiveAsScreenshot ?? false);
setArchiveAsMonolith(account.archiveAsMonolith ?? false);
setArchiveAsPDF(account.archiveAsPDF ?? false);
setArchiveAsReadable(account.archiveAsReadable ?? false);
setArchiveAsWaybackMachine(account.archiveAsWaybackMachine ?? false);
setLinksRouteTo(account.linksRouteTo);
setPreventDuplicateLinks(account.preventDuplicateLinks);
setPreventDuplicateLinks(account.preventDuplicateLinks ?? false);
setAiTaggingMethod(account.aiTaggingMethod);
setAiPredefinedTags(account.aiPredefinedTags);
setAiTagExistingLinks(account.aiTagExistingLinks);
setAiTagExistingLinks(account.aiTagExistingLinks ?? false);
}
}, [account]);
useEffect(() => {
const relevantKeys = [
"archiveAsScreenshot",
"archiveAsMonolith",
"archiveAsPDF",
"archiveAsReadable",
"archiveAsWaybackMachine",
"linksRouteTo",
"preventDuplicateLinks",
"aiTaggingMethod",
"aiPredefinedTags",
"aiTagExistingLinks",
];
const hasChanges = relevantKeys.some((key) => account[key] !== user[key]);
setHasAccountChanges(hasChanges);
}, [account, user]);
useEffect(() => {
if (!tags || !archivalTags) return;
@@ -161,28 +110,127 @@ const Page: NextPageWithLayout = () => {
);
});
setHasTagChanges(hasChanges);
setHasArchiveTagChanges(hasChanges);
}, [archivalTags, tags]);
const submit = async () => {
setSubmitLoader(true);
const areStringArraysEqual = (a: string[] = [], b: string[] = []) =>
a.length === b.length && a.every((value, index) => value === b[index]);
const baseUserPayload = () => ({
id: account?.id,
username: account?.username,
email: account?.email,
whitelistedUsers: account?.whitelistedUsers || [],
});
const hasAiChanges =
!!account?.id &&
(aiTaggingMethod !== account.aiTaggingMethod ||
aiTagExistingLinks !== (account.aiTagExistingLinks ?? false) ||
!areStringArraysEqual(
aiPredefinedTags || [],
account.aiPredefinedTags || []
));
const hasArchivePreferenceChanges =
!!account?.id &&
(archiveAsScreenshot !== (account.archiveAsScreenshot ?? false) ||
archiveAsMonolith !== (account.archiveAsMonolith ?? false) ||
archiveAsPDF !== (account.archiveAsPDF ?? false) ||
archiveAsReadable !== (account.archiveAsReadable ?? false) ||
archiveAsWaybackMachine !== (account.archiveAsWaybackMachine ?? false));
const hasArchiveChanges = hasArchivePreferenceChanges || hasArchiveTagChanges;
const hasLinkChanges =
!!account?.id &&
(preventDuplicateLinks !== (account.preventDuplicateLinks ?? false) ||
linksRouteTo !== account.linksRouteTo);
const saveAiSection = async () => {
if (!account?.id || !hasAiChanges) return;
setAiSubmitLoader(true);
const load = toast.loading(t("applying_settings"));
try {
const payload: any = {
...baseUserPayload(),
aiTaggingMethod,
aiTagExistingLinks,
};
if (aiPredefinedTags !== undefined) {
payload.aiPredefinedTags = aiPredefinedTags;
}
await updateUser.mutateAsync(payload);
toast.success(t("settings_applied"));
} catch (error: any) {
toast.error(error.message);
} finally {
setAiSubmitLoader(false);
toast.dismiss(load);
}
};
const saveArchiveSection = async () => {
if (!account?.id || !hasArchiveChanges) return;
setArchiveSubmitLoader(true);
const load = toast.loading(t("applying_settings"));
try {
const promises = [];
if (hasAccountChanges) promises.push(updateUser.mutateAsync({ ...user }));
if (hasTagChanges) promises.push(upsertTags.mutateAsync(archivalTags));
if (hasArchivePreferenceChanges) {
promises.push(
updateUser.mutateAsync({
...baseUserPayload(),
archiveAsScreenshot,
archiveAsMonolith,
archiveAsPDF,
archiveAsReadable,
archiveAsWaybackMachine,
})
);
}
if (hasArchiveTagChanges) {
promises.push(upsertTags.mutateAsync(archivalTags));
}
if (promises.length > 0) {
await Promise.all(promises);
toast.success(t("settings_applied"));
}
toast.success(t("settings_applied"));
} catch (error: any) {
toast.error(error.message);
} finally {
setSubmitLoader(false);
setArchiveSubmitLoader(false);
toast.dismiss(load);
}
};
const saveLinkSection = async () => {
if (!account?.id || !hasLinkChanges) return;
setLinkSubmitLoader(true);
const load = toast.loading(t("applying_settings"));
try {
await updateUser.mutateAsync({
...baseUserPayload(),
preventDuplicateLinks,
linksRouteTo,
});
toast.success(t("settings_applied"));
} catch (error: any) {
toast.error(error.message);
} finally {
setLinkSubmitLoader(false);
toast.dismiss(load);
}
};
@@ -266,7 +314,7 @@ const Page: NextPageWithLayout = () => {
<p>{t("ai_tagging_method")}</p>
<div className="p-3">
<div className="p-3 max-w-screen-sm">
<label
className="label cursor-pointer flex gap-2 justify-start w-fit"
tabIndex={0}
@@ -376,6 +424,14 @@ const Page: NextPageWithLayout = () => {
disabled={aiTaggingMethod === AiTaggingMethod.DISABLED}
/>
</div>
<Button
onClick={saveAiSection}
disabled={aiSubmitLoader || !hasAiChanges}
className="mt-2 w-full sm:w-fit"
variant="accent"
>
{t("save_changes")}
</Button>
</div>
)}
@@ -423,7 +479,7 @@ const Page: NextPageWithLayout = () => {
<div className="max-w-full">
<p>{t("tag_preservation_rule_label")}</p>
</div>
<div className="p-3">
<div className="p-3 max-w-screen-sm">
<TagSelection
isArchivalSelection
onChange={addTags}
@@ -483,6 +539,14 @@ const Page: NextPageWithLayout = () => {
))}
</div>
</div>
<Button
onClick={saveArchiveSection}
disabled={archiveSubmitLoader || !hasArchiveChanges}
className="mt-2 w-full sm:w-fit"
variant="accent"
>
{t("save_changes")}
</Button>
</div>
<div>
@@ -603,16 +667,15 @@ const Page: NextPageWithLayout = () => {
</span>
</label>
</div>
<Button
onClick={saveLinkSection}
disabled={linkSubmitLoader || !hasLinkChanges}
className="mt-2 w-full sm:w-fit"
variant="accent"
>
{t("save_changes")}
</Button>
</div>
<Button
onClick={submit}
disabled={submitLoader}
className="mt-2 w-full sm:w-fit"
variant="accent"
>
{t("save_changes")}
</Button>
</div>
</>
);