mirror of
https://github.com/ParisNeo/lollms-webui.git
synced 2025-02-20 09:16:15 +00:00
Upgraded database classes
This commit is contained in:
parent
29dc3ff5b1
commit
eead6bec64
59
app.py
59
app.py
@ -5,7 +5,7 @@ import traceback
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import sys
|
||||
from db import Discussion, export_to_json, check_discussion_db, last_discussion_has_messages
|
||||
from db import DiscussionsDB, Discussion
|
||||
from flask import (
|
||||
Flask,
|
||||
Response,
|
||||
@ -28,6 +28,9 @@ class Gpt4AllWebUI:
|
||||
self.current_discussion = None
|
||||
self.app = _app
|
||||
self.db_path = args.db_path
|
||||
self.db = DiscussionsDB(self.db_path)
|
||||
# If the database is empty, populate it with tables
|
||||
self.db.populate()
|
||||
|
||||
# workaround for non interactive mode
|
||||
self.full_message = ""
|
||||
@ -35,17 +38,21 @@ class Gpt4AllWebUI:
|
||||
# This is the queue used to stream text to the ui as the bot spits out its response
|
||||
self.text_queue = Queue(0)
|
||||
|
||||
self.add_endpoint(
|
||||
"/list_models", "list_models", self.list_models, methods=["GET"]
|
||||
)
|
||||
self.add_endpoint(
|
||||
"/list_discussions", "list_discussions", self.list_discussions, methods=["GET"]
|
||||
)
|
||||
|
||||
|
||||
self.add_endpoint("/", "", self.index, methods=["GET"])
|
||||
self.add_endpoint("/export_discussion", "export_discussion", self.export_discussion, methods=["GET"])
|
||||
self.add_endpoint("/export", "export", self.export, methods=["GET"])
|
||||
self.add_endpoint(
|
||||
"/new_discussion", "new_discussion", self.new_discussion, methods=["GET"]
|
||||
)
|
||||
self.add_endpoint("/bot", "bot", self.bot, methods=["POST"])
|
||||
self.add_endpoint(
|
||||
"/discussions", "discussions", self.discussions, methods=["GET"]
|
||||
)
|
||||
self.add_endpoint("/rename", "rename", self.rename, methods=["POST"])
|
||||
self.add_endpoint(
|
||||
"/load_discussion", "load_discussion", self.load_discussion, methods=["POST"]
|
||||
@ -64,11 +71,6 @@ class Gpt4AllWebUI:
|
||||
"/update_model_params", "update_model_params", self.update_model_params, methods=["POST"]
|
||||
)
|
||||
|
||||
self.add_endpoint(
|
||||
"/list_models", "list_models", self.list_models, methods=["GET"]
|
||||
)
|
||||
|
||||
|
||||
self.add_endpoint(
|
||||
"/get_args", "get_args", self.get_args, methods=["GET"]
|
||||
)
|
||||
@ -80,6 +82,22 @@ class Gpt4AllWebUI:
|
||||
models = [f.name for f in models_dir.glob('*.bin')]
|
||||
return jsonify(models)
|
||||
|
||||
def list_discussions(self):
|
||||
try:
|
||||
discussions = self.db.get_discussions()
|
||||
return jsonify(discussions)
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
return jsonify({
|
||||
"status":"Error",
|
||||
"content": "<b style='color:red;'>Exception :<b>"
|
||||
+ str(ex)
|
||||
+ "<br>"
|
||||
+ traceback.format_exc()
|
||||
+ "<br>Please report exception"
|
||||
})
|
||||
|
||||
|
||||
def prepare_a_new_chatbot(self):
|
||||
# Create chatbot
|
||||
self.chatbot_bindings = self.create_chatbot()
|
||||
@ -164,8 +182,11 @@ GPT4All:Welcome! I'm here to assist you with anything you need. What can I do fo
|
||||
return message
|
||||
|
||||
def export(self):
|
||||
return jsonify(export_to_json(self.db_path))
|
||||
return jsonify(self.db.export_to_json())
|
||||
|
||||
def export_discussion(self):
|
||||
return jsonify(self.full_message)
|
||||
|
||||
def generate_message(self):
|
||||
self.generating=True
|
||||
self.text_queue=Queue()
|
||||
@ -231,7 +252,7 @@ GPT4All:Welcome! I'm here to assist you with anything you need. What can I do fo
|
||||
self.stop = True
|
||||
|
||||
try:
|
||||
if self.current_discussion is None or not last_discussion_has_messages(
|
||||
if self.current_discussion is None or not self.db.does_last_discussion_have_messages(
|
||||
self.db_path
|
||||
):
|
||||
self.current_discussion = Discussion.create_discussion(self.db_path)
|
||||
@ -258,19 +279,6 @@ GPT4All:Welcome! I'm here to assist you with anything you need. What can I do fo
|
||||
+ "<br>Please report exception"
|
||||
)
|
||||
|
||||
def discussions(self):
|
||||
try:
|
||||
discussions = Discussion.get_discussions(self.db_path)
|
||||
return jsonify(discussions)
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
return (
|
||||
"<b style='color:red;'>Exception :<b>"
|
||||
+ str(ex)
|
||||
+ "<br>"
|
||||
+ traceback.format_exc()
|
||||
+ "<br>Please report exception"
|
||||
)
|
||||
|
||||
def rename(self):
|
||||
data = request.get_json()
|
||||
@ -295,7 +303,7 @@ GPT4All:Welcome! I'm here to assist you with anything you need. What can I do fo
|
||||
def load_discussion(self):
|
||||
data = request.get_json()
|
||||
discussion_id = data["id"]
|
||||
self.current_discussion = Discussion(discussion_id, self.db_path)
|
||||
self.current_discussion = Discussion(discussion_id, self.db)
|
||||
messages = self.current_discussion.get_messages()
|
||||
|
||||
self.full_message = ""
|
||||
@ -426,7 +434,6 @@ if __name__ == "__main__":
|
||||
parser.set_defaults(debug=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
check_discussion_db(args.db_path)
|
||||
executor = ThreadPoolExecutor(max_workers=2)
|
||||
app.config['executor'] = executor
|
||||
|
||||
|
270
db.py
270
db.py
@ -1,98 +1,109 @@
|
||||
|
||||
import sqlite3
|
||||
# =================================== Database ==================================================================
|
||||
class Discussion:
|
||||
def __init__(self, discussion_id, db_path="database.db"):
|
||||
self.discussion_id = discussion_id
|
||||
class DiscussionsDB:
|
||||
def __init__(self, db_path="database.db"):
|
||||
self.db_path = db_path
|
||||
|
||||
@staticmethod
|
||||
def create_discussion(db_path="database.db", title="untitled"):
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("INSERT INTO discussion (title) VALUES (?)", (title,))
|
||||
discussion_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return Discussion(discussion_id, db_path)
|
||||
|
||||
@staticmethod
|
||||
def get_discussion(db_path="database.db", discussion_id=0):
|
||||
return Discussion(discussion_id, db_path)
|
||||
|
||||
def add_message(self, sender, content):
|
||||
def populate(self):
|
||||
"""
|
||||
create database schema
|
||||
"""
|
||||
print("Checking discussions database...")
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO message (sender, content, discussion_id) VALUES (?, ?, ?)",
|
||||
(sender, content, self.discussion_id),
|
||||
)
|
||||
message_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return message_id
|
||||
|
||||
@staticmethod
|
||||
def get_discussions(db_path):
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM discussion")
|
||||
rows = cursor.fetchall()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS discussion (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT
|
||||
)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
discussion_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (discussion_id) REFERENCES discussion(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def select(self, query, fetch_all=True):
|
||||
"""
|
||||
Execute the specified SQL select query on the database,
|
||||
with optional parameters.
|
||||
Returns the cursor object for further processing.
|
||||
"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query)
|
||||
if fetch_all:
|
||||
return cursor.fetchall()
|
||||
else:
|
||||
return cursor.fetchone()
|
||||
|
||||
|
||||
def delete(self, query, fetch_all=True):
|
||||
"""
|
||||
Execute the specified SQL delete query on the database,
|
||||
with optional parameters.
|
||||
Returns the cursor object for further processing.
|
||||
"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query)
|
||||
conn.commit()
|
||||
|
||||
def insert(self, query, params=None):
|
||||
"""
|
||||
Execute the specified INSERT SQL query on the database,
|
||||
with optional parameters.
|
||||
Returns the ID of the newly inserted row.
|
||||
"""
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
self.conn = conn
|
||||
cursor = self.execute(query, params)
|
||||
rowid = cursor.lastrowid
|
||||
conn.commit()
|
||||
self.conn = None
|
||||
return rowid
|
||||
|
||||
|
||||
def create_discussion(self, title="untitled"):
|
||||
"""Creates a new discussion
|
||||
|
||||
Args:
|
||||
title (str, optional): The title of the discussion. Defaults to "untitled".
|
||||
|
||||
Returns:
|
||||
Discussion: A Discussion instance
|
||||
"""
|
||||
discussion_id = self.insert(f"INSERT INTO discussion (title) VALUES ({title})")
|
||||
return Discussion(discussion_id, self)
|
||||
|
||||
def build_discussion(self, discussion_id=0):
|
||||
return Discussion(discussion_id, self)
|
||||
|
||||
def get_discussions(self):
|
||||
rows = self.select("SELECT * FROM discussion")
|
||||
|
||||
return [{"id": row[0], "title": row[1]} for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def rename(db_path, discussion_id, title):
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE discussion SET title=? WHERE id=?", (title, discussion_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def delete_discussion(self):
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM message WHERE discussion_id=?", (self.discussion_id,)
|
||||
)
|
||||
cur.execute("DELETE FROM discussion WHERE id=?", (self.discussion_id,))
|
||||
conn.commit()
|
||||
|
||||
def get_messages(self):
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT * FROM message WHERE discussion_id=?", (self.discussion_id,)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [{"sender": row[1], "content": row[2], "id": row[0]} for row in rows]
|
||||
|
||||
def update_message(self, message_id, new_content):
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE message SET content = ? WHERE id = ?", (new_content, message_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def remove_discussion(self):
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.cursor().execute(
|
||||
"DELETE FROM discussion WHERE id=?", (self.discussion_id,)
|
||||
)
|
||||
conn.commit()
|
||||
def does_last_discussion_have_messages(self):
|
||||
last_message = self.select("SELECT * FROM message ORDER BY id DESC LIMIT 1", fetch_all=False)
|
||||
return last_message is not None
|
||||
|
||||
def remove_discussions(self):
|
||||
self.delete("DELETE FROM message")
|
||||
self.delete("DELETE FROM discussion")
|
||||
|
||||
|
||||
def last_discussion_has_messages(db_path="database.db"):
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM message ORDER BY id DESC LIMIT 1")
|
||||
last_message = cursor.fetchone()
|
||||
return last_message is not None
|
||||
|
||||
|
||||
def export_to_json(db_path="database.db"):
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM discussion")
|
||||
def export_to_json(self):
|
||||
cur = self.execute("SELECT * FROM discussion")
|
||||
discussions = []
|
||||
for row in cur.fetchall():
|
||||
discussion_id = row[0]
|
||||
@ -106,41 +117,72 @@ def export_to_json(db_path="database.db"):
|
||||
return discussions
|
||||
|
||||
|
||||
def remove_discussions(db_path="database.db"):
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM message")
|
||||
cur.execute("DELETE FROM discussion")
|
||||
conn.commit()
|
||||
class Discussion:
|
||||
def __init__(self, discussion_id, discussions_db:DiscussionsDB):
|
||||
self.discussion_id = discussion_id
|
||||
self.discussions_db = discussions_db
|
||||
|
||||
def add_message(self, sender, content):
|
||||
"""Adds a new message to the discussion
|
||||
|
||||
# create database schema
|
||||
def check_discussion_db(db_path):
|
||||
print("Checking discussions database...")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS discussion (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT
|
||||
)
|
||||
Args:
|
||||
sender (str): The sender name
|
||||
content (str): The text sent by the sender
|
||||
|
||||
Returns:
|
||||
int: The added message id
|
||||
"""
|
||||
self.discussions_db.execute(
|
||||
f"INSERT INTO message (sender, content, discussion_id) VALUES ({sender}, {content}, {self.discussion_id})",
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
discussion_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (discussion_id) REFERENCES discussion(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
message_id = self.discussions_db.conn.cursor().lastrowid
|
||||
self.discussions_db.commit()
|
||||
return message_id
|
||||
|
||||
print("Ok")
|
||||
def rename(self, new_title):
|
||||
"""Renames the discussion
|
||||
|
||||
Args:
|
||||
new_title (str): The nex discussion name
|
||||
"""
|
||||
self.discussions_db.execute(
|
||||
f"UPDATE discussion SET title={new_title} WHERE id={self.discussion_id}"
|
||||
)
|
||||
self.discussions_db.commit()
|
||||
|
||||
def delete_discussion(self):
|
||||
"""Deletes the discussion
|
||||
"""
|
||||
self.discussions_db.execute(
|
||||
f"DELETE FROM message WHERE discussion_id={self.discussion_id}"
|
||||
)
|
||||
self.discussions_db.execute(
|
||||
f"DELETE FROM discussion WHERE id={self.discussion_id}"
|
||||
)
|
||||
self.discussions_db.commit()
|
||||
|
||||
def get_messages(self):
|
||||
"""Gets a list of messages information
|
||||
|
||||
Returns:
|
||||
list: List of entries in the format {"sender":sender name, "content":message content,"id":message id}
|
||||
"""
|
||||
rows = self.discussions_db.select(
|
||||
f"SELECT * FROM message WHERE discussion_id={self.discussion_id}"
|
||||
)
|
||||
return [{"sender": row[1], "content": row[2], "id": row[0]} for row in rows]
|
||||
|
||||
def update_message(self, message_id, new_content):
|
||||
"""Updates the content of a message
|
||||
|
||||
Args:
|
||||
message_id (int): The id of the message to be changed
|
||||
new_content (str): The nex message content
|
||||
"""
|
||||
self.discussions_db.execute(
|
||||
f"UPDATE message SET content = {new_content} WHERE id = {message_id}"
|
||||
)
|
||||
self.discussions_db.commit()
|
||||
|
||||
|
||||
# ========================================================================================================================
|
||||
|
@ -110,6 +110,18 @@ function addMessage(sender, message, id, can_edit=false) {
|
||||
messageElement.appendChild(messageTextElement);
|
||||
if(can_edit)
|
||||
{
|
||||
// Create buttons container
|
||||
const buttonsContainer = document.createElement('div');
|
||||
// Add the 'flex' class to the div
|
||||
buttonsContainer.classList.add('flex');
|
||||
|
||||
// Add the 'justify-end' class to the div
|
||||
buttonsContainer.classList.add('justify-end');
|
||||
|
||||
// Set the width and height of the container to 100%
|
||||
buttonsContainer.style.width = '100%';
|
||||
buttonsContainer.style.height = '100%';
|
||||
|
||||
const editButton = document.createElement('button');
|
||||
editButton.classList.add('my-1','mx-1','outline-none','px-4','bg-accent','text-black','rounded-md','hover:bg-[#7ba0ea]','active:bg-[#3d73e1]','transition-colors','ease-in-out');
|
||||
editButton.style.float = 'right'; // set the float property to right
|
||||
@ -121,7 +133,7 @@ function addMessage(sender, message, id, can_edit=false) {
|
||||
inputField.classList.add('font-medium', 'text-md', 'border', 'border-gray-300', 'p-1');
|
||||
inputField.value = messageTextElement.innerHTML;
|
||||
|
||||
editButton.style.display="none"
|
||||
buttonsContainer.style.display="none"
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.classList.add('bg-green-500', 'hover:bg-green-700', 'text-white', 'font-bold', 'py-2', 'px-4', 'rounded', 'my-2', 'ml-2');
|
||||
@ -143,10 +155,10 @@ function addMessage(sender, message, id, can_edit=false) {
|
||||
.catch(error => {
|
||||
console.error('There was a problem updating the message:', error);
|
||||
});
|
||||
editButton.style.display='inline-block'
|
||||
messageElement.replaceChild(messageTextElement, inputField);
|
||||
//messageElement.removeChild(inputField);
|
||||
messageElement.removeChild(saveButton);
|
||||
buttonsContainer.style.display='inline-block'
|
||||
messageElement.replaceChild(messageTextElement, inputField);
|
||||
//messageElement.removeChild(inputField);
|
||||
messageElement.removeChild(saveButton);
|
||||
});
|
||||
|
||||
messageElement.replaceChild(inputField, messageTextElement);
|
||||
@ -154,7 +166,8 @@ function addMessage(sender, message, id, can_edit=false) {
|
||||
inputField.focus();
|
||||
});
|
||||
|
||||
messageElement.appendChild(editButton);
|
||||
buttonsContainer.appendChild(editButton);
|
||||
messageElement.appendChild(buttonsContainer);
|
||||
}
|
||||
chatWindow.appendChild(messageElement);
|
||||
chatWindow.appendChild(hiddenElement);
|
||||
|
@ -4,7 +4,7 @@ function populate_discussions_list()
|
||||
// Populate discussions list
|
||||
const discussionsList = document.querySelector('#discussions-list');
|
||||
discussionsList.innerHTML = "";
|
||||
fetch('/discussions')
|
||||
fetch('/list_discussions')
|
||||
.then(response => response.json())
|
||||
.then(discussions => {
|
||||
discussions.forEach(discussion => {
|
||||
@ -162,7 +162,39 @@ function populate_discussions_list()
|
||||
// First time we populate the discussions list
|
||||
populate_discussions_list()
|
||||
|
||||
// adding export discussion button
|
||||
const exportDiscussionButton = document.createElement('button');
|
||||
exportDiscussionButton.classList.add(
|
||||
'my-1',
|
||||
'mx-1',
|
||||
'outline-none',
|
||||
'px-4',
|
||||
'bg-accent',
|
||||
'text-black',
|
||||
'rounded-md',
|
||||
'hover:bg-[#7ba0ea]',
|
||||
'active:bg-[#3d73e1]',
|
||||
'transition-colors',
|
||||
'ease-in-out'
|
||||
);
|
||||
exportDiscussionButton.style.float = 'right'; // set the float property to right
|
||||
exportDiscussionButton.style.display='inline-block'
|
||||
exportDiscussionButton.innerHTML = 'Export discussion to text';
|
||||
exportDiscussionButton.addEventListener('click', () => {
|
||||
fetch('/bot', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ message })
|
||||
}).then(function(response) {
|
||||
|
||||
}).catch(function(error){
|
||||
|
||||
});
|
||||
});
|
||||
const actionBtns = document.querySelector('#action-buttons');
|
||||
actionBtns.appendChild(exportDiscussionButton);
|
||||
|
||||
const newDiscussionBtn = document.querySelector('#new-discussion-btn');
|
||||
|
||||
|
@ -19,6 +19,10 @@
|
||||
<div id="discussions-list" class="h-96 overflow-y-auto">
|
||||
|
||||
</div>
|
||||
<div id="action-buttons" class="h-96 overflow-y-auto">
|
||||
<input type="button" value="New Discussion" id="new-discussion-btn" class="my-1 mx-1 outline-none px-4 bg-accent text-black rounded-md hover:bg-[#7ba0ea] active:bg-[#3d73e1] transition-colors ease-in-out">
|
||||
<input type="button" value="Export" id="export-button" class="my-1 mx-1 outline-none px-4 bg-accent text-black rounded-md hover:bg-[#7ba0ea] active:bg-[#3d73e1] transition-colors ease-in-out">
|
||||
</div>
|
||||
</section>
|
||||
<section class="md:h-1/2 md:border-b border-accent flex flex md:flex-col">
|
||||
<div>
|
||||
@ -72,8 +76,6 @@
|
||||
</section>
|
||||
</main>
|
||||
<footer class="border-t border-accent flex">
|
||||
<input type="button" value="New Chat" id="new-discussion-btn" class="my-1 mx-1 outline-none px-4 bg-accent text-black rounded-md hover:bg-[#7ba0ea] active:bg-[#3d73e1] transition-colors ease-in-out">
|
||||
<input type="button" value="Export" id="export-button" class="my-1 mx-1 outline-none px-4 bg-accent text-black rounded-md hover:bg-[#7ba0ea] active:bg-[#3d73e1] transition-colors ease-in-out">
|
||||
<form id="chat-form" class="flex w-full">
|
||||
<input type="text" id="user-input" placeholder="Type your message..." class="bg-secondary my-1 mx-1 outline-none drop-shadow-sm w-full rounded-md p-2">
|
||||
<input type="submit" value="Send" id="submit-input" class="my-1 mx-1 outline-none px-4 bg-accent text-black rounded-md hover:bg-[#7ba0ea] active:bg-[#3d73e1] transition-colors ease-in-out">
|
||||
|
Loading…
x
Reference in New Issue
Block a user