[FEAT] Customizable footer icon links in Appearance Settings (#694)

* WIP custom footer icons

* UI for updating footer icons complete and backend to save/modify

* add backend for unprotected footer fetch

* break out footer into separate component and render footer items using a cache for 1 hour

* wip review

* refactor & cleanup

* Optimize footer form component
Optimize caching for footer icons
Add validation on SystemSetting upserts
Normalize fallback items for footer_data

* Adjust max icons to 3

* fix success message on remove

* fix success message on remove

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-02-08 12:17:01 -08:00 committed by GitHub
parent f490c35456
commit b985524901
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 423 additions and 109 deletions

View File

@ -0,0 +1,97 @@
import System from "@/models/system";
import paths from "@/utils/paths";
import { safeJsonParse } from "@/utils/request";
import {
BookOpen,
DiscordLogo,
GithubLogo,
Briefcase,
Envelope,
Globe,
HouseLine,
Info,
LinkSimple,
} from "@phosphor-icons/react";
import React, { useEffect, useState } from "react";
export const MAX_ICONS = 3;
export const ICON_COMPONENTS = {
BookOpen: BookOpen,
DiscordLogo: DiscordLogo,
GithubLogo: GithubLogo,
Envelope: Envelope,
LinkSimple: LinkSimple,
HouseLine: HouseLine,
Globe: Globe,
Briefcase: Briefcase,
Info: Info,
};
export default function Footer() {
const [footerData, setFooterData] = useState(false);
useEffect(() => {
async function fetchFooterData() {
const { footerData } = await System.fetchCustomFooterIcons();
setFooterData(footerData);
}
fetchFooterData();
}, []);
// wait for some kind of non-false response from footer data first
// to prevent pop-in.
if (footerData === false) return null;
if (!Array.isArray(footerData) || footerData.length === 0) {
return (
<div className="flex justify-center mt-2">
<div className="flex space-x-4">
<a
href={paths.github()}
target="_blank"
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<GithubLogo weight="fill" className="h-5 w-5 " />
</a>
<a
href={paths.docs()}
target="_blank"
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<BookOpen weight="fill" className="h-5 w-5 " />
</a>
<a
href={paths.discord()}
target="_blank"
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<DiscordLogo
weight="fill"
className="h-5 w-5 stroke-slate-200 group-hover:stroke-slate-200"
/>
</a>
</div>
</div>
);
}
return (
<div className="flex justify-center mt-2">
<div className="flex space-x-4">
{footerData.map((item, index) => (
<a
key={index}
href={item.url}
target="_blank"
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
{React.createElement(ICON_COMPONENTS[item.icon], {
weight: "fill",
className: "h-5 w-5",
})}
</a>
))}
</div>
</div>
);
}

View File

@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from "react";
import paths from "@/utils/paths";
import useLogo from "@/hooks/useLogo";
import {
DiscordLogo,
EnvelopeSimple,
SquaresFour,
Users,
@ -13,7 +12,6 @@ import {
ChatText,
Database,
Lock,
GithubLogo,
House,
X,
List,
@ -26,6 +24,7 @@ import {
import useUser from "@/hooks/useUser";
import { USER_BACKGROUND_COLOR } from "@/utils/constants";
import { isMobile } from "react-device-detect";
import Footer from "../Footer";
export default function SettingsSidebar() {
const { logo } = useLogo();
@ -172,39 +171,6 @@ export default function SettingsSidebar() {
);
}
const Footer = () => {
return (
<div className="flex justify-center mt-2">
<div className="flex space-x-4">
<a
href={paths.github()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<GithubLogo weight="fill" className="h-5 w-5 " />
</a>
<a
href={paths.docs()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<BookOpen weight="fill" className="h-5 w-5 " />
</a>
<a
href={paths.discord()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<DiscordLogo
weight="fill"
className="h-5 w-5 stroke-slate-200 group-hover:stroke-slate-200"
/>
</a>
{/* <button className="invisible transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border">
<DotsThree className="h-5 w-5 group-hover:stroke-slate-200" />
</button> */}
</div>
</div>
);
};
const Option = ({
btnText,
icon,

View File

@ -1,13 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import {
Wrench,
GithubLogo,
BookOpen,
DiscordLogo,
DotsThree,
Plus,
List,
} from "@phosphor-icons/react";
import { Wrench, Plus, List } from "@phosphor-icons/react";
import NewWorkspaceModal, {
useNewWorkspaceModal,
} from "../Modals/NewWorkspace";
@ -16,6 +8,7 @@ import paths from "@/utils/paths";
import { USER_BACKGROUND_COLOR } from "@/utils/constants";
import useLogo from "@/hooks/useLogo";
import useUser from "@/hooks/useUser";
import Footer from "../Footer";
export default function Sidebar() {
const { user } = useUser();
@ -71,35 +64,7 @@ export default function Sidebar() {
<ActiveWorkspaces />
</div>
<div className="flex flex-col flex-grow justify-end mb-2">
{/* Footer */}
<div className="flex justify-center mt-2">
<div className="flex space-x-4">
<a
href={paths.github()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<GithubLogo weight="fill" className="h-5 w-5 " />
</a>
<a
href={paths.docs()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<BookOpen weight="fill" className="h-5 w-5 " />
</a>
<a
href={paths.discord()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<DiscordLogo
weight="fill"
className="h-5 w-5 stroke-slate-200 group-hover:stroke-slate-200"
/>
</a>
{/* <button className="invisible transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border">
<DotsThree className="h-5 w-5 group-hover:stroke-slate-200" />
</button> */}
</div>
</div>
<Footer />
</div>
</div>
</div>
@ -215,35 +180,7 @@ export function SidebarMobileHeader() {
</div>
</div>
<div>
{/* Footer */}
<div className="flex justify-center mt-2">
<div className="flex space-x-4">
<a
href={paths.github()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<GithubLogo weight="fill" className="h-5 w-5 " />
</a>
<a
href={paths.docs()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<BookOpen weight="fill" className="h-5 w-5 " />
</a>
<a
href={paths.discord()}
className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"
>
<DiscordLogo
weight="fill"
className="h-5 w-5 stroke-slate-200 group-hover:stroke-slate-200"
/>
</a>
{/* <button className="invisible transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border">
<DotsThree className="h-5 w-5 group-hover:stroke-slate-200" />
</button> */}
</div>
</div>
<Footer />
</div>
</div>
</div>

View File

@ -1,8 +1,11 @@
import { API_BASE, AUTH_TIMESTAMP, fullApiUrl } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";
import { baseHeaders, safeJsonParse } from "@/utils/request";
import DataConnector from "./dataConnector";
const System = {
cacheKeys: {
footerIcons: "anythingllm_footer_links",
},
ping: async function () {
return await fetch(`${API_BASE}/ping`)
.then((res) => res.json())
@ -190,6 +193,38 @@ const System = {
return { success: false, error: e.message };
});
},
fetchCustomFooterIcons: async function () {
const cache = window.localStorage.getItem(this.cacheKeys.footerIcons);
const { data, lastFetched } = cache
? safeJsonParse(cache, { data: [], lastFetched: 0 })
: { data: [], lastFetched: 0 };
if (!!data && Date.now() - lastFetched < 3_600_000)
return { footerData: data, error: null };
const { footerData, error } = await fetch(
`${API_BASE}/system/footer-data`,
{
method: "GET",
cache: "no-cache",
headers: baseHeaders(),
}
)
.then((res) => res.json())
.catch((e) => {
console.log(e);
return { footerData: [], error: e.message };
});
if (!footerData || !!error) return { footerData: [], error: null };
const newData = safeJsonParse(footerData, []);
window.localStorage.setItem(
this.cacheKeys.footerIcons,
JSON.stringify({ data: newData, lastFetched: Date.now() })
);
return { footerData: newData, error: null };
},
fetchLogo: async function () {
return await fetch(`${API_BASE}/system/logo`, {
method: "GET",

View File

@ -27,7 +27,7 @@ export default function AdminSystem() {
useEffect(() => {
async function fetchSettings() {
const { settings } = await Admin.systemPreferences();
const settings = (await Admin.systemPreferences())?.settings;
if (!settings) return;
setCanDelete(settings?.users_can_delete_workspaces);
setMessageLimit({

View File

@ -0,0 +1,98 @@
import { ICON_COMPONENTS } from "@/components/Footer";
import React, { useEffect, useRef, useState } from "react";
export default function NewIconForm({ handleSubmit, showing }) {
const [selectedIcon, setSelectedIcon] = useState("Info");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef(null);
useEffect(() => {
function handleClickOutside(event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [dropdownRef]);
if (!showing) return null;
return (
<form onSubmit={handleSubmit} className="flex justify-start">
<div className="mt-6 mb-6 flex flex-col bg-zinc-900 rounded-lg px-6 py-4">
<div className="flex gap-x-4 items-center">
<div
className="relative flex flex-col items-center gap-y-4"
ref={dropdownRef}
>
<input type="hidden" name="icon" value={selectedIcon} />
<label className="text-sm font-medium text-white">Icon</label>
<button
type="button"
className={`${
isDropdownOpen
? "bg-menu-item-selected-gradient border-slate-100/50"
: ""
}border-transparent transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border`}
onClick={(e) => {
e.preventDefault();
setIsDropdownOpen(!isDropdownOpen);
}}
>
{React.createElement(ICON_COMPONENTS[selectedIcon], {
className: "h-5 w-5 text-white",
weight: "fill",
})}
</button>
{isDropdownOpen && (
<div className="absolute z-10 grid grid-cols-4 gap-4 bg-zinc-800 -mt-20 ml-44 p-1 rounded-md w-56 h-28 overflow-y-auto border border-slate-100/10">
{Object.keys(ICON_COMPONENTS).map((iconName) => (
<button
key={iconName}
type="button"
className="flex justify-center items-center border border-transparent hover:bg-menu-item-selected-gradient hover:border-slate-100 rounded-full"
onClick={() => {
setSelectedIcon(iconName);
setIsDropdownOpen(false);
}}
>
{React.createElement(ICON_COMPONENTS[iconName], {
className: "h-5 w-5 text-white m-2.5",
weight: "fill",
})}
</button>
))}
</div>
)}
</div>
<div className="flex flex-col gap-y-4">
<label className="text-sm font-medium text-white">Link</label>
<input
type="url"
name="url"
required={true}
placeholder="https://example.com"
className="bg-sidebar text-white placeholder-white/60 rounded-md p-2"
/>
</div>
{selectedIcon !== "" && (
<div className="flex flex-col gap-y-4">
<label className="text-sm font-medium text-white invisible">
Submit
</label>
<div className="flex justify-center">
<button
type="submit"
className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
>
Save
</button>
</div>
</div>
)}
</div>
</div>
</form>
);
}

View File

@ -0,0 +1,140 @@
import React, { useState, useEffect } from "react";
import showToast from "@/utils/toast";
import { Plus, X } from "@phosphor-icons/react";
import { ICON_COMPONENTS, MAX_ICONS } from "@/components/Footer";
import { safeJsonParse } from "@/utils/request";
import NewIconForm from "./NewIconForm";
import Admin from "@/models/admin";
import System from "@/models/system";
export default function FooterCustomization() {
const [loading, setLoading] = useState(true);
const [footerIcons, setFooterIcons] = useState([]);
const [showForm, setShowForm] = useState(false);
useEffect(() => {
async function fetchFooterIcons() {
const settings = (await Admin.systemPreferences())?.settings;
if (settings && settings.footer_data) {
setFooterIcons(safeJsonParse(settings.footer_data, []));
}
setLoading(false);
}
fetchFooterIcons();
}, []);
const removeFooterIcon = async (index) => {
const updatedIcons = footerIcons.filter((_, i) => i !== index);
const { success, error } = await Admin.updateSystemPreferences({
footer_data: JSON.stringify(updatedIcons),
});
if (!success) {
showToast(`Failed to remove footer icon - ${error}`, "error", {
clear: true,
});
return;
}
window.localStorage.removeItem(System.cacheKeys.footerIcons);
setFooterIcons(updatedIcons);
showToast("Successfully removed footer icon.", "success", { clear: true });
};
const onSubmit = async (e) => {
e.preventDefault();
const form = new FormData(e.target);
const icon = form.get("icon");
const url = form.get("url");
const newIcon = { icon, url };
setFooterIcons([...footerIcons, newIcon]);
const { success, error } = await Admin.updateSystemPreferences({
footer_data: JSON.stringify([...footerIcons, newIcon]),
});
if (!success) {
showToast(`Failed to add footer icon - ${error}`, "error", {
clear: true,
});
return;
}
window.localStorage.removeItem(System.cacheKeys.footerIcons);
setShowForm(false);
showToast("Successfully added footer icon.", "success", { clear: true });
};
return (
<div className="mb-6">
<div className="flex flex-col gap-y-2">
<h2 className="leading-tight font-medium text-white">
Custom Footer Icons
</h2>
<p className="text-sm font-base text-white/60">
Customize the footer icons displayed on the bottom of the sidebar.
</p>
</div>
<CurrentIcons footerIcons={footerIcons} remove={removeFooterIcon} />
<NewIconForm
handleSubmit={onSubmit}
showing={footerIcons.length < MAX_ICONS && showForm}
/>
<div hidden={!(!showForm && footerIcons.length < MAX_ICONS) || loading}>
<div className="flex gap-2 mt-6">
<button
onClick={() => setShowForm(true)}
className="flex gap-x-2 items-center justify-center text-white text-sm hover:text-sky-400 transition-all duration-300"
>
Add new footer icon
<Plus className="" size={24} weight="fill" />
</button>
</div>
</div>
</div>
);
}
function CurrentIcons({ footerIcons, remove }) {
if (footerIcons.length === 0) return null;
return (
<div className="flex flex-col w-fit gap-y-2 mt-4">
{footerIcons.map((icon, index) => (
<div
key={index}
className="flex items-center justify-between bg-zinc-900 p-2 rounded-md gap-x-4"
>
<div className="flex items-center gap-x-2">
<IconPreview symbol={icon.icon} disabled={true} />
<span className="text-white/60">{icon.url}</span>
</div>
<button
type="button"
className="transition-all duration-300 text-neutral-700 bg-transparent rounded-full hover:bg-zinc-600 hover:border-zinc-600 hover:text-white border-transparent border shadow-lg mr-2"
onClick={() => remove(index)}
>
<X className="m-[1px]" size={20} />
</button>
</div>
))}
</div>
);
}
const IconPreview = ({ symbol, disabled = false }) => {
const IconComponent = ICON_COMPONENTS.hasOwnProperty(symbol)
? ICON_COMPONENTS[symbol]
: ICON_COMPONENTS.Info;
return (
<button
type="button"
disabled={disabled}
className="disabled:pointer-events-none border-transparent transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border mx-1"
>
<IconComponent className="h-5 w-5 text-white" weight="fill" />
</button>
);
};

View File

@ -7,6 +7,7 @@ import System from "@/models/system";
import EditingChatBubble from "@/components/EditingChatBubble";
import showToast from "@/utils/toast";
import { Plus } from "@phosphor-icons/react";
import FooterCustomization from "./FooterCustomization";
export default function Appearance() {
const { logo: _initLogo, setLogo: _setLogo } = useLogo();
@ -248,6 +249,7 @@ export default function Appearance() {
</div>
)}
</div>
<FooterCustomization />
</div>
</div>
</div>

View File

@ -17,3 +17,10 @@ export function baseHeaders(providedToken = null) {
Authorization: token ? `Bearer ${token}` : null,
};
}
export function safeJsonParse(jsonString, fallback = null) {
try {
return JSON.parse(jsonString);
} catch {}
return fallback;
}

View File

@ -17,6 +17,7 @@ const {
const { reqBody, userFromSession } = require("../utils/http");
const {
strictMultiUserRoleValid,
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
@ -289,8 +290,8 @@ function adminEndpoints(app) {
app.get(
"/admin/system-preferences",
[validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
async (_request, response) => {
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
async (_, response) => {
try {
const settings = {
users_can_delete_workspaces:
@ -303,6 +304,9 @@ function adminEndpoints(app) {
Number(
(await SystemSettings.get({ label: "message_limit" }))?.value
) || 10,
footer_data:
(await SystemSettings.get({ label: "footer_data" }))?.value ||
JSON.stringify([]),
};
response.status(200).json({ settings });
} catch (e) {
@ -314,7 +318,7 @@ function adminEndpoints(app) {
app.post(
"/admin/system-preferences",
[validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
async (request, response) => {
try {
const updates = reqBody(request);

View File

@ -460,6 +460,18 @@ function systemEndpoints(app) {
}
});
app.get("/system/footer-data", [validatedRequest], async (_, response) => {
try {
const footerData =
(await SystemSettings.get({ label: "footer_data" }))?.value ??
JSON.stringify([]);
response.status(200).json({ footerData: footerData });
} catch (error) {
console.error("Error fetching footer data:", error);
response.status(500).json({ message: "Internal server error" });
}
});
app.get(
"/system/pfp/:id",
[validatedRequest, flexUserRoleValid([ROLES.all])],

View File

@ -12,7 +12,19 @@ const SystemSettings = {
"message_limit",
"logo_filename",
"telemetry_id",
"footer_data",
],
validations: {
footer_data: (updates) => {
try {
const array = JSON.parse(updates);
return JSON.stringify(array.slice(0, 3)); // max of 3 items in footer.
} catch (e) {
console.error(`Failed to run validation function on footer_data`);
return JSON.stringify([]);
}
},
},
currentSettings: async function () {
const llmProvider = process.env.LLM_PROVIDER;
const vectorDB = process.env.VECTOR_DB;
@ -239,14 +251,18 @@ const SystemSettings = {
const updatePromises = Object.keys(updates)
.filter((key) => this.supportedFields.includes(key))
.map((key) => {
const validatedValue = this.validations.hasOwnProperty(key)
? this.validations[key](updates[key])
: updates[key];
return prisma.system_settings.upsert({
where: { label: key },
update: {
value: updates[key] === null ? null : String(updates[key]),
value: validatedValue === null ? null : String(validatedValue),
},
create: {
label: key,
value: updates[key] === null ? null : String(updates[key]),
value: validatedValue === null ? null : String(validatedValue),
},
});
});