Add toast alerts and fix bugs

This commit is contained in:
dvirlabs 2025-07-04 07:26:24 +03:00
parent 0945e7c7c2
commit c0c3c92b55
8 changed files with 165 additions and 41 deletions

View File

@ -35,3 +35,7 @@ sections:
name: Vault name: Vault
url: https://vault.dvirlabs.com url: https://vault.dvirlabs.com
name: Dev-tools name: Dev-tools
- apps: []
name: fgbhn
- apps: []
name: dfgb

View File

@ -20,7 +20,6 @@ app.add_middleware(
load_dotenv() load_dotenv()
# ENV
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT") MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY") MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY") MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY")
@ -34,12 +33,13 @@ minio_client = Minio(
) )
BUCKET = MINIO_BUCKET or "navix-icons" BUCKET = MINIO_BUCKET or "navix-icons"
APPS_FILE = Path(__file__).parent / "apps.yaml"
@router.get("/") @router.get("/")
def root(): def root():
return {"message": "Welcome to the FastAPI application!"} return {"message": "Welcome to the FastAPI application!"}
APPS_FILE = Path(__file__).parent / "apps.yaml"
@router.get("/apps") @router.get("/apps")
def get_apps(): def get_apps():
@ -48,15 +48,19 @@ def get_apps():
with open(APPS_FILE, "r") as f: with open(APPS_FILE, "r") as f:
return yaml.safe_load(f) return yaml.safe_load(f)
class AppData(BaseModel): class AppData(BaseModel):
name: str name: str
icon: str icon: str
description: str description: str
url: str url: str
class AppEntry(BaseModel): class AppEntry(BaseModel):
section: str section: str
app: AppData app: AppData
original_name: str | None = None
@router.post("/add_app") @router.post("/add_app")
def add_app(entry: AppEntry): def add_app(entry: AppEntry):
@ -81,6 +85,7 @@ def add_app(entry: AppEntry):
return {"status": "added"} return {"status": "added"}
@router.post("/edit_app") @router.post("/edit_app")
def edit_app(entry: AppEntry): def edit_app(entry: AppEntry):
if not APPS_FILE.exists(): if not APPS_FILE.exists():
@ -93,20 +98,21 @@ def edit_app(entry: AppEntry):
for section in current["sections"]: for section in current["sections"]:
if section["name"] == entry.section: if section["name"] == entry.section:
for i, app in enumerate(section["apps"]): for i, app in enumerate(section["apps"]):
if app["name"] == entry.app.name: if app["name"] == (entry.original_name or entry.app.name):
section["apps"][i] = entry.app.dict() section["apps"][i] = entry.app.dict()
updated = True updated = True
break break
break break
if not updated: if not updated:
return {"error": "App not found to edit"}, 404 return JSONResponse(status_code=404, content={"error": "App not found to edit"})
with open(APPS_FILE, "w") as f: with open(APPS_FILE, "w") as f:
yaml.safe_dump(current, f) yaml.safe_dump(current, f)
return {"status": "updated"} return {"status": "updated"}
@router.post("/delete_app") @router.post("/delete_app")
def delete_app(entry: AppEntry): def delete_app(entry: AppEntry):
if not APPS_FILE.exists(): if not APPS_FILE.exists():
@ -125,7 +131,7 @@ def delete_app(entry: AppEntry):
break break
if not deleted: if not deleted:
return {"error": "App not found to delete"}, 404 return JSONResponse(status_code=404, content={"error": "App not found to delete"})
with open(APPS_FILE, "w") as f: with open(APPS_FILE, "w") as f:
yaml.safe_dump(current, f) yaml.safe_dump(current, f)
@ -138,10 +144,11 @@ def get_public_icon_url(filename: str):
url = f"https://{MINIO_ENDPOINT}/{BUCKET}/{filename}" url = f"https://{MINIO_ENDPOINT}/{BUCKET}/{filename}"
return JSONResponse(content={"url": url}) return JSONResponse(content={"url": url})
app.include_router(router, prefix="/api") app.include_router(router, prefix="/api")
# ✅ This is the missing part:
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@ -11,7 +11,8 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0" "react-icons": "^5.5.0",
"react-toastify": "^11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
@ -1488,6 +1489,14 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2440,6 +2449,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-toastify": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/redux": { "node_modules/redux": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",

View File

@ -13,7 +13,8 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0" "react-icons": "^5.5.0",
"react-toastify": "^11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",

View File

@ -4,8 +4,15 @@ import SectionGrid from './components/SectionGrid';
import AppModal from './components/AppModal'; import AppModal from './components/AppModal';
import ConfirmDialog from './components/ConfirmDialog'; import ConfirmDialog from './components/ConfirmDialog';
import Clock from './components/Clock'; import Clock from './components/Clock';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { IoIosAdd } from 'react-icons/io'; import { IoIosAdd } from 'react-icons/io';
import { fetchSections, addAppToSection, editAppInSection, deleteAppFromSection } from './services/api'; import {
fetchSections,
addAppToSection,
editAppInSection,
deleteAppFromSection,
} from './services/api';
function App() { function App() {
const [sections, setSections] = useState([]); const [sections, setSections] = useState([]);
@ -14,10 +21,13 @@ function App() {
const [confirmData, setConfirmData] = useState(null); const [confirmData, setConfirmData] = useState(null);
const loadSections = () => { const loadSections = () => {
fetchSections() fetchSections()
.then(data => setSections(data.sections || [])) .then(data => {
.catch(err => console.error('Failed to fetch sections:', err)); const filtered = (data.sections || []).filter(section => (section.apps?.length ?? 0) > 0);
}; setSections(filtered);
})
.catch(err => console.error('Failed to fetch sections:', err));
};
useEffect(() => { useEffect(() => {
loadSections(); loadSections();
@ -26,7 +36,7 @@ function App() {
const handleDelete = (app) => { const handleDelete = (app) => {
setConfirmData({ setConfirmData({
message: `Are you sure you want to delete "${app.name}"?`, message: `Are you sure you want to delete "${app.name}"?`,
app app,
}); });
}; };
@ -34,16 +44,48 @@ function App() {
if (!confirmData?.app) return; if (!confirmData?.app) return;
try { try {
await deleteAppFromSection({ section: confirmData.app.section, app: confirmData.app }); await deleteAppFromSection({
section: confirmData.app.section,
app: confirmData.app,
});
toast.error(`App "${confirmData.app.name}" deleted successfully!`);
loadSections(); loadSections();
} catch (err) { } catch (err) {
console.error('Failed to delete app:', err); console.error('Failed to delete app:', err);
alert('Failed to delete app'); toast.error('Failed to delete app');
} finally { } finally {
setConfirmData(null); setConfirmData(null);
} }
}; };
const handleAddSubmit = async (data) => {
try {
await addAppToSection(data);
toast.success(`App "${data.app.name}" added successfully!`);
setShowAdd(false);
loadSections();
} catch (err) {
console.error('Failed to add app:', err);
toast.error('Failed to add app');
}
};
const handleEditSubmit = async (data) => {
try {
await editAppInSection({
section: data.section,
app: data.app,
original_name: data.original_name || data.app.name,
});
toast.warning(`App "${data.app.name}" updated successfully!`);
setEditData(null);
loadSections();
} catch (err) {
console.error('Failed to update app:', err);
toast.error('Failed to update app');
}
};
return ( return (
<div className="App"> <div className="App">
<Clock /> <Clock />
@ -55,11 +97,8 @@ function App() {
{showAdd && ( {showAdd && (
<AppModal <AppModal
mode="add" mode="add"
onSubmit={addAppToSection} onSubmit={handleAddSubmit}
onClose={() => { onClose={() => setShowAdd(false)}
setShowAdd(false);
loadSections();
}}
sections={sections} sections={sections}
/> />
)} )}
@ -68,11 +107,8 @@ function App() {
<AppModal <AppModal
mode="edit" mode="edit"
initialData={editData} initialData={editData}
onSubmit={editAppInSection} onSubmit={handleEditSubmit}
onClose={() => { onClose={() => setEditData(null)}
setEditData(null);
loadSections();
}}
sections={sections} sections={sections}
/> />
)} )}
@ -85,16 +121,30 @@ function App() {
/> />
)} )}
{sections.map(section => ( {sections.map((section) => (
<SectionGrid <SectionGrid
key={section.name} key={section.name}
section={section} section={section}
onEdit={setEditData} onEdit={(app) =>
setEditData({ ...app, section: section.name, original_name: app.name })
}
onDelete={handleDelete} onDelete={handleDelete}
/> />
))} ))}
<IoIosAdd className="add-button" onClick={() => setShowAdd(true)} /> <IoIosAdd className="add-button" onClick={() => setShowAdd(true)} />
<ToastContainer
position="bottom-right"
autoClose={3000}
hideProgressBar={false}
newestOnTop
closeOnClick
pauseOnFocusLoss
draggable
pauseOnHover
theme="colored"
/>
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import '../style/AddAppModal.css'; import '../style/AddAppModal.css';
function AppModal({ mode = 'add', initialData = {}, onSubmit, onClose, sections = [] }) { function AppModal({ mode = 'add', initialData = {}, onSubmit, onClose, sections = [] }) {
@ -12,7 +12,24 @@ function AppModal({ mode = 'add', initialData = {}, onSubmit, onClose, sections
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
await onSubmit({ section, app: { name, icon, description, url } });
const appData = {
name,
icon,
description,
url,
};
const payload = {
section,
app: appData,
};
if (mode === 'edit') {
payload.original_name = initialData.original_name || initialData.name;
}
await onSubmit(payload);
setOpen(false); setOpen(false);
if (onClose) onClose(); if (onClose) onClose();
}; };
@ -40,8 +57,10 @@ function AppModal({ mode = 'add', initialData = {}, onSubmit, onClose, sections
disabled={mode === 'edit'} disabled={mode === 'edit'}
> >
<option value="" disabled>Select a section</option> <option value="" disabled>Select a section</option>
{sections.map(s => ( {sections.map((s) => (
<option key={s.name} value={s.name}>{s.name}</option> <option key={s.name} value={s.name}>
{s.name}
</option>
))} ))}
<option value="__new__"> Create new section...</option> <option value="__new__"> Create new section...</option>
</select> </select>
@ -51,15 +70,37 @@ function AppModal({ mode = 'add', initialData = {}, onSubmit, onClose, sections
type="text" type="text"
placeholder="New section name" placeholder="New section name"
value={section} value={section}
onChange={e => setSection(e.target.value)} onChange={(e) => setSection(e.target.value)}
required required
/> />
)} )}
<input type="text" placeholder="App name" value={name} onChange={e => setName(e.target.value)} required /> <input
<input type="text" placeholder="Icon name (e.g. grafana.svg) or full URL" value={icon} onChange={e => setIcon(e.target.value)} /> type="text"
<input type="text" placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} /> placeholder="App name"
<input type="text" placeholder="App URL" value={url} onChange={e => setUrl(e.target.value)} required /> value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
type="text"
placeholder="Icon name (e.g. grafana.svg) or full URL"
value={icon}
onChange={(e) => setIcon(e.target.value)}
/>
<input
type="text"
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<input
type="text"
placeholder="App URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
/>
<button type="submit">{mode === 'edit' ? 'Update' : 'Add'}</button> <button type="submit">{mode === 'edit' ? 'Update' : 'Add'}</button>
</form> </form>
</div> </div>

View File

@ -23,11 +23,11 @@ export async function getIconUrl(filename) {
return data.url; return data.url;
} }
export async function editAppInSection({ section, app }) { export async function editAppInSection({ section, app, original_name }) {
const res = await fetch(`${API_BASE}/edit_app`, { const res = await fetch(`${API_BASE}/edit_app`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, app }) body: JSON.stringify({ section, app, original_name })
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());