From b9855249015c99a8ff6f86c382c954cfff91df80 Mon Sep 17 00:00:00 2001 From: Sean Hatfield Date: Thu, 8 Feb 2024 12:17:01 -0800 Subject: [PATCH] [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 --- frontend/src/components/Footer/index.jsx | 97 ++++++++++++ .../src/components/SettingsSidebar/index.jsx | 36 +---- frontend/src/components/Sidebar/index.jsx | 71 +-------- frontend/src/models/system.js | 37 ++++- frontend/src/pages/Admin/System/index.jsx | 2 +- .../FooterCustomization/NewIconForm/index.jsx | 98 ++++++++++++ .../Appearance/FooterCustomization/index.jsx | 140 ++++++++++++++++++ .../GeneralSettings/Appearance/index.jsx | 2 + frontend/src/utils/request.js | 7 + server/endpoints/admin.js | 10 +- server/endpoints/system.js | 12 ++ server/models/systemSettings.js | 20 ++- 12 files changed, 423 insertions(+), 109 deletions(-) create mode 100644 frontend/src/components/Footer/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/NewIconForm/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/index.jsx diff --git a/frontend/src/components/Footer/index.jsx b/frontend/src/components/Footer/index.jsx new file mode 100644 index 00000000..26b271a8 --- /dev/null +++ b/frontend/src/components/Footer/index.jsx @@ -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 ( +
+
+ + + + + + + + + +
+
+ ); + } + + return ( +
+
+ {footerData.map((item, index) => ( + + {React.createElement(ICON_COMPONENTS[item.icon], { + weight: "fill", + className: "h-5 w-5", + })} + + ))} +
+
+ ); +} diff --git a/frontend/src/components/SettingsSidebar/index.jsx b/frontend/src/components/SettingsSidebar/index.jsx index 13c4abea..63f2efd9 100644 --- a/frontend/src/components/SettingsSidebar/index.jsx +++ b/frontend/src/components/SettingsSidebar/index.jsx @@ -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 ( -
-
- - - - - - - - - - {/* */} -
-
- ); -}; - const Option = ({ btnText, icon, diff --git a/frontend/src/components/Sidebar/index.jsx b/frontend/src/components/Sidebar/index.jsx index 58af4048..d14099b2 100644 --- a/frontend/src/components/Sidebar/index.jsx +++ b/frontend/src/components/Sidebar/index.jsx @@ -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() {
- {/* Footer */} -
-
- - - - - - - - - - {/* */} -
-
+
@@ -215,35 +180,7 @@ export function SidebarMobileHeader() {
- {/* Footer */} -
-
- - - - - - - - - - {/* */} -
-
+
diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 0ffbae69..9b6cc507 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -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", diff --git a/frontend/src/pages/Admin/System/index.jsx b/frontend/src/pages/Admin/System/index.jsx index 90940886..c561efd2 100644 --- a/frontend/src/pages/Admin/System/index.jsx +++ b/frontend/src/pages/Admin/System/index.jsx @@ -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({ diff --git a/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/NewIconForm/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/NewIconForm/index.jsx new file mode 100644 index 00000000..8828bbba --- /dev/null +++ b/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/NewIconForm/index.jsx @@ -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 ( +
+
+
+
+ + + + {isDropdownOpen && ( +
+ {Object.keys(ICON_COMPONENTS).map((iconName) => ( + + ))} +
+ )} +
+
+ + +
+ {selectedIcon !== "" && ( +
+ +
+ +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/index.jsx new file mode 100644 index 00000000..ee94e7c3 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/index.jsx @@ -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 ( +
+
+

+ Custom Footer Icons +

+

+ Customize the footer icons displayed on the bottom of the sidebar. +

+
+ + + +
+ ); +} + +function CurrentIcons({ footerIcons, remove }) { + if (footerIcons.length === 0) return null; + return ( +
+ {footerIcons.map((icon, index) => ( +
+
+ + {icon.url} +
+ + +
+ ))} +
+ ); +} + +const IconPreview = ({ symbol, disabled = false }) => { + const IconComponent = ICON_COMPONENTS.hasOwnProperty(symbol) + ? ICON_COMPONENTS[symbol] + : ICON_COMPONENTS.Info; + + return ( + + ); +}; diff --git a/frontend/src/pages/GeneralSettings/Appearance/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/index.jsx index a2a9ec00..99d413a4 100644 --- a/frontend/src/pages/GeneralSettings/Appearance/index.jsx +++ b/frontend/src/pages/GeneralSettings/Appearance/index.jsx @@ -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() { )} + diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js index 271c97b2..a568fb8e 100644 --- a/frontend/src/utils/request.js +++ b/frontend/src/utils/request.js @@ -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; +} diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index d9e1f9a0..885fa998 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -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); diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 823de7f1..68054503 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -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])], diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index abb93012..8a008d0f 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -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), }, }); });