mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-10-02 08:50:11 +02: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 paths from "@/utils/paths";
|
||||||
import useLogo from "@/hooks/useLogo";
|
import useLogo from "@/hooks/useLogo";
|
||||||
import {
|
import {
|
||||||
DiscordLogo,
|
|
||||||
EnvelopeSimple,
|
EnvelopeSimple,
|
||||||
SquaresFour,
|
SquaresFour,
|
||||||
Users,
|
Users,
|
||||||
@ -13,7 +12,6 @@ import {
|
|||||||
ChatText,
|
ChatText,
|
||||||
Database,
|
Database,
|
||||||
Lock,
|
Lock,
|
||||||
GithubLogo,
|
|
||||||
House,
|
House,
|
||||||
X,
|
X,
|
||||||
List,
|
List,
|
||||||
@ -26,6 +24,7 @@ import {
|
|||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
import { USER_BACKGROUND_COLOR } from "@/utils/constants";
|
import { USER_BACKGROUND_COLOR } from "@/utils/constants";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
import Footer from "../Footer";
|
||||||
|
|
||||||
export default function SettingsSidebar() {
|
export default function SettingsSidebar() {
|
||||||
const { logo } = useLogo();
|
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 = ({
|
const Option = ({
|
||||||
btnText,
|
btnText,
|
||||||
icon,
|
icon,
|
||||||
|
@ -1,13 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { Wrench, Plus, List } from "@phosphor-icons/react";
|
||||||
Wrench,
|
|
||||||
GithubLogo,
|
|
||||||
BookOpen,
|
|
||||||
DiscordLogo,
|
|
||||||
DotsThree,
|
|
||||||
Plus,
|
|
||||||
List,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
import NewWorkspaceModal, {
|
import NewWorkspaceModal, {
|
||||||
useNewWorkspaceModal,
|
useNewWorkspaceModal,
|
||||||
} from "../Modals/NewWorkspace";
|
} from "../Modals/NewWorkspace";
|
||||||
@ -16,6 +8,7 @@ import paths from "@/utils/paths";
|
|||||||
import { USER_BACKGROUND_COLOR } from "@/utils/constants";
|
import { USER_BACKGROUND_COLOR } from "@/utils/constants";
|
||||||
import useLogo from "@/hooks/useLogo";
|
import useLogo from "@/hooks/useLogo";
|
||||||
import useUser from "@/hooks/useUser";
|
import useUser from "@/hooks/useUser";
|
||||||
|
import Footer from "../Footer";
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
@ -71,35 +64,7 @@ export default function Sidebar() {
|
|||||||
<ActiveWorkspaces />
|
<ActiveWorkspaces />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-grow justify-end mb-2">
|
<div className="flex flex-col flex-grow justify-end mb-2">
|
||||||
{/* Footer */}
|
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -215,35 +180,7 @@ export function SidebarMobileHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{/* Footer */}
|
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { API_BASE, AUTH_TIMESTAMP, fullApiUrl } from "@/utils/constants";
|
import { API_BASE, AUTH_TIMESTAMP, fullApiUrl } from "@/utils/constants";
|
||||||
import { baseHeaders } from "@/utils/request";
|
import { baseHeaders, safeJsonParse } from "@/utils/request";
|
||||||
import DataConnector from "./dataConnector";
|
import DataConnector from "./dataConnector";
|
||||||
|
|
||||||
const System = {
|
const System = {
|
||||||
|
cacheKeys: {
|
||||||
|
footerIcons: "anythingllm_footer_links",
|
||||||
|
},
|
||||||
ping: async function () {
|
ping: async function () {
|
||||||
return await fetch(`${API_BASE}/ping`)
|
return await fetch(`${API_BASE}/ping`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
@ -190,6 +193,38 @@ const System = {
|
|||||||
return { success: false, error: e.message };
|
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 () {
|
fetchLogo: async function () {
|
||||||
return await fetch(`${API_BASE}/system/logo`, {
|
return await fetch(`${API_BASE}/system/logo`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -27,7 +27,7 @@ export default function AdminSystem() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchSettings() {
|
async function fetchSettings() {
|
||||||
const { settings } = await Admin.systemPreferences();
|
const settings = (await Admin.systemPreferences())?.settings;
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
setCanDelete(settings?.users_can_delete_workspaces);
|
setCanDelete(settings?.users_can_delete_workspaces);
|
||||||
setMessageLimit({
|
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 EditingChatBubble from "@/components/EditingChatBubble";
|
||||||
import showToast from "@/utils/toast";
|
import showToast from "@/utils/toast";
|
||||||
import { Plus } from "@phosphor-icons/react";
|
import { Plus } from "@phosphor-icons/react";
|
||||||
|
import FooterCustomization from "./FooterCustomization";
|
||||||
|
|
||||||
export default function Appearance() {
|
export default function Appearance() {
|
||||||
const { logo: _initLogo, setLogo: _setLogo } = useLogo();
|
const { logo: _initLogo, setLogo: _setLogo } = useLogo();
|
||||||
@ -248,6 +249,7 @@ export default function Appearance() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<FooterCustomization />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,3 +17,10 @@ export function baseHeaders(providedToken = null) {
|
|||||||
Authorization: token ? `Bearer ${token}` : 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 { reqBody, userFromSession } = require("../utils/http");
|
||||||
const {
|
const {
|
||||||
strictMultiUserRoleValid,
|
strictMultiUserRoleValid,
|
||||||
|
flexUserRoleValid,
|
||||||
ROLES,
|
ROLES,
|
||||||
} = require("../utils/middleware/multiUserProtected");
|
} = require("../utils/middleware/multiUserProtected");
|
||||||
const { validatedRequest } = require("../utils/middleware/validatedRequest");
|
const { validatedRequest } = require("../utils/middleware/validatedRequest");
|
||||||
@ -289,8 +290,8 @@ function adminEndpoints(app) {
|
|||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/admin/system-preferences",
|
"/admin/system-preferences",
|
||||||
[validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
|
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
|
||||||
async (_request, response) => {
|
async (_, response) => {
|
||||||
try {
|
try {
|
||||||
const settings = {
|
const settings = {
|
||||||
users_can_delete_workspaces:
|
users_can_delete_workspaces:
|
||||||
@ -303,6 +304,9 @@ function adminEndpoints(app) {
|
|||||||
Number(
|
Number(
|
||||||
(await SystemSettings.get({ label: "message_limit" }))?.value
|
(await SystemSettings.get({ label: "message_limit" }))?.value
|
||||||
) || 10,
|
) || 10,
|
||||||
|
footer_data:
|
||||||
|
(await SystemSettings.get({ label: "footer_data" }))?.value ||
|
||||||
|
JSON.stringify([]),
|
||||||
};
|
};
|
||||||
response.status(200).json({ settings });
|
response.status(200).json({ settings });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -314,7 +318,7 @@ function adminEndpoints(app) {
|
|||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
"/admin/system-preferences",
|
"/admin/system-preferences",
|
||||||
[validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])],
|
[validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
|
||||||
async (request, response) => {
|
async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const updates = reqBody(request);
|
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(
|
app.get(
|
||||||
"/system/pfp/:id",
|
"/system/pfp/:id",
|
||||||
[validatedRequest, flexUserRoleValid([ROLES.all])],
|
[validatedRequest, flexUserRoleValid([ROLES.all])],
|
||||||
|
@ -12,7 +12,19 @@ const SystemSettings = {
|
|||||||
"message_limit",
|
"message_limit",
|
||||||
"logo_filename",
|
"logo_filename",
|
||||||
"telemetry_id",
|
"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 () {
|
currentSettings: async function () {
|
||||||
const llmProvider = process.env.LLM_PROVIDER;
|
const llmProvider = process.env.LLM_PROVIDER;
|
||||||
const vectorDB = process.env.VECTOR_DB;
|
const vectorDB = process.env.VECTOR_DB;
|
||||||
@ -239,14 +251,18 @@ const SystemSettings = {
|
|||||||
const updatePromises = Object.keys(updates)
|
const updatePromises = Object.keys(updates)
|
||||||
.filter((key) => this.supportedFields.includes(key))
|
.filter((key) => this.supportedFields.includes(key))
|
||||||
.map((key) => {
|
.map((key) => {
|
||||||
|
const validatedValue = this.validations.hasOwnProperty(key)
|
||||||
|
? this.validations[key](updates[key])
|
||||||
|
: updates[key];
|
||||||
|
|
||||||
return prisma.system_settings.upsert({
|
return prisma.system_settings.upsert({
|
||||||
where: { label: key },
|
where: { label: key },
|
||||||
update: {
|
update: {
|
||||||
value: updates[key] === null ? null : String(updates[key]),
|
value: validatedValue === null ? null : String(validatedValue),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
label: key,
|
label: key,
|
||||||
value: updates[key] === null ? null : String(updates[key]),
|
value: validatedValue === null ? null : String(validatedValue),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user