mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-11-04 22:10:12 +01:00
[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:
parent
f490c35456
commit
b985524901
97
frontend/src/components/Footer/index.jsx
Normal file
97
frontend/src/components/Footer/index.jsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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({
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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])],
|
||||
|
@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user