Add edit and delete buttons

This commit is contained in:
dvirlabs 2025-07-04 06:26:12 +03:00
parent 9f37cf6b58
commit be3f9aa03b
12 changed files with 300 additions and 122 deletions

View File

@ -81,6 +81,58 @@ def add_app(entry: AppEntry):
return {"status": "added"}
@router.post("/edit_app")
def edit_app(entry: AppEntry):
if not APPS_FILE.exists():
return {"error": "apps.yaml not found"}
with open(APPS_FILE, "r") as f:
current = yaml.safe_load(f) or {"sections": []}
updated = False
for section in current["sections"]:
if section["name"] == entry.section:
for i, app in enumerate(section["apps"]):
if app["name"] == entry.app.name:
section["apps"][i] = entry.app.dict()
updated = True
break
break
if not updated:
return {"error": "App not found to edit"}, 404
with open(APPS_FILE, "w") as f:
yaml.safe_dump(current, f)
return {"status": "updated"}
@router.post("/delete_app")
def delete_app(entry: AppEntry):
if not APPS_FILE.exists():
return {"error": "apps.yaml not found"}
with open(APPS_FILE, "r") as f:
current = yaml.safe_load(f) or {"sections": []}
deleted = False
for section in current["sections"]:
if section["name"] == entry.section:
original_len = len(section["apps"])
section["apps"] = [a for a in section["apps"] if a["name"] != entry.app.name]
if len(section["apps"]) != original_len:
deleted = True
break
if not deleted:
return {"error": "App not found to delete"}, 404
with open(APPS_FILE, "w") as f:
yaml.safe_dump(current, f)
return {"status": "deleted"}
@router.get("/icon/{filename}")
def get_public_icon_url(filename: str):
url = f"https://{MINIO_ENDPOINT}/{BUCKET}/{filename}"

3
frontend/.gitignore vendored
View File

@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Custom files
env.js

View File

@ -1,19 +1,49 @@
import { useEffect, useState } from 'react';
import './App.css';
import SectionGrid from './components/SectionGrid';
import AddAppModal from './components/AddAppModal';
import AppModal from './components/AppModal';
import ConfirmDialog from './components/ConfirmDialog';
import Clock from './components/Clock';
import { fetchSections } from './services/api'; // שימוש בפונקציה הנכונה
import { IoIosAdd } from 'react-icons/io';
import { fetchSections, addAppToSection, editAppInSection, deleteAppFromSection } from './services/api';
function App() {
const [sections, setSections] = useState([]);
const [editData, setEditData] = useState(null);
const [showAdd, setShowAdd] = useState(false);
const [confirmData, setConfirmData] = useState(null);
useEffect(() => {
const loadSections = () => {
fetchSections()
.then(data => setSections(data.sections || []))
.catch(err => console.error('Failed to fetch sections:', err));
};
useEffect(() => {
loadSections();
}, []);
const handleDelete = (app) => {
setConfirmData({
message: `Are you sure you want to delete "${app.name}"?`,
app
});
};
const confirmDelete = async () => {
if (!confirmData?.app) return;
try {
await deleteAppFromSection({ section: confirmData.app.section, app: confirmData.app });
loadSections();
} catch (err) {
console.error('Failed to delete app:', err);
alert('Failed to delete app');
} finally {
setConfirmData(null);
}
};
return (
<div className="App">
<Clock />
@ -21,10 +51,50 @@ function App() {
<img src="/navix-logo.svg" alt="Navix logo" className="navix-logo" />
Navix
</h1>
<AddAppModal onAdded={() => window.location.reload()} />
{sections.map((section) => (
<SectionGrid key={section.name} section={section} />
{showAdd && (
<AppModal
mode="add"
onSubmit={addAppToSection}
onClose={() => {
setShowAdd(false);
loadSections();
}}
sections={sections}
/>
)}
{editData && (
<AppModal
mode="edit"
initialData={editData}
onSubmit={editAppInSection}
onClose={() => {
setEditData(null);
loadSections();
}}
sections={sections}
/>
)}
{confirmData && (
<ConfirmDialog
message={confirmData.message}
onConfirm={confirmDelete}
onCancel={() => setConfirmData(null)}
/>
)}
{sections.map(section => (
<SectionGrid
key={section.name}
section={section}
onEdit={setEditData}
onDelete={handleDelete}
/>
))}
<IoIosAdd className="add-button" onClick={() => setShowAdd(true)} />
</div>
);
}

View File

@ -1,91 +0,0 @@
import { useEffect, useState } from 'react';
import { addAppToSection, fetchSections } from '../services/api';
import '../style/AddAppModal.css';
import { IoIosAdd } from "react-icons/io";
function AddAppModal({ onAdded }) {
const [open, setOpen] = useState(false);
const [section, setSection] = useState('');
const [customSection, setCustomSection] = useState(false);
const [name, setName] = useState('');
const [icon, setIcon] = useState('');
const [description, setDescription] = useState('');
const [url, setUrl] = useState('');
const [sections, setSections] = useState([]);
useEffect(() => {
fetchSections().then(data => setSections(data.sections || []));
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
try {
await addAppToSection({ section, app: { name, icon, description, url } });
setOpen(false);
setSection('');
setName('');
setIcon('');
setDescription('');
setUrl('');
setCustomSection(false);
if (onAdded) onAdded();
} catch (err) {
alert('Failed to add app');
}
};
return (
<>
<IoIosAdd className="add-button" onClick={() => setOpen(true)} />
{open && (
<div className="modal-overlay" onClick={() => setOpen(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Add New App</h2>
<form onSubmit={handleSubmit}>
<select
value={customSection ? '__new__' : section}
onChange={(e) => {
const val = e.target.value;
if (val === '__new__') {
setSection('');
setCustomSection(true);
} else {
setSection(val);
setCustomSection(false);
}
}}
required={!customSection}
>
<option value="" disabled>Select a section</option>
{sections.map(s => (
<option key={s.name} value={s.name}>{s.name}</option>
))}
<option value="__new__"> Create new section...</option>
</select>
{customSection && (
<input
type="text"
placeholder="New section name"
value={section}
onChange={e => setSection(e.target.value)}
required
/>
)}
<input type="text" placeholder="App name" value={name} onChange={e => setName(e.target.value)} required />
<input type="text" placeholder="Icon 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">Add</button>
</form>
</div>
</div>
)}
</>
);
}
export default AddAppModal;

View File

@ -1,32 +1,38 @@
import { useEffect, useState } from 'react';
import '../style/AppCard.css';
import { getIconUrl } from '../services/api';
import { FaEdit, FaTrash } from 'react-icons/fa';
function AppCard({ app }) {
function AppCard({ app, section, onEdit, onDelete }) {
const [iconUrl, setIconUrl] = useState(null);
useEffect(() => {
if (app.icon) {
getIconUrl(app.icon)
.then((url) => {
console.log('Presigned icon URL for', app.name, ':', url);
setIconUrl(url);
})
.catch((err) => {
console.error(`Failed to load icon for ${app.name}:`, err);
});
.then((url) => setIconUrl(url))
.catch((err) => console.error(`Failed to load icon for ${app.name}:`, err));
}
}, [app.icon, app.name]);
}, [app.icon, app.name]);
return (
<div className="app-card-wrapper">
<a href={app.url} className="app-card" target="_blank" rel="noreferrer">
<div className="card-icons-inside">
<FaTrash className="delete-icon" onClick={(e) => {
e.preventDefault();
if (typeof onDelete === 'function') {
onDelete({ ...app, section });
}
}} />
<FaEdit className="edit-icon" onClick={(e) => {
e.preventDefault();
if (typeof onEdit === 'function') {
onEdit({ ...app, section });
}
}} />
</div>
<div className="app-icon-wrapper">
{iconUrl ? (
<img src={iconUrl} alt={app.name} className="app-icon" />
) : (
<span className="icon-placeholder"></span>
)}
{iconUrl ? <img src={iconUrl} alt={app.name} className="app-icon" /> : <span className="icon-placeholder"></span>}
</div>
<h3>{app.name}</h3>
<p>{app.description}</p>

View File

@ -1,11 +1,11 @@
import AppCard from './AppCard';
import '../style/AppGrid.css';
function AppGrid({ apps }) {
function AppGrid({ apps, section, onEdit, onDelete }) {
return (
<div className="app-grid">
{apps.map(app => (
<AppCard key={app.name} app={app} />
<AppCard key={app.name} app={app} section={section} onEdit={onEdit} onDelete={onDelete} />
))}
</div>
);

View File

@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import '../style/AddAppModal.css';
function AppModal({ mode = 'add', initialData = {}, onSubmit, onClose, sections = [] }) {
const [open, setOpen] = useState(true);
const [section, setSection] = useState(initialData.section || '');
const [customSection, setCustomSection] = useState(false);
const [name, setName] = useState(initialData.name || '');
const [icon, setIcon] = useState(initialData.icon || '');
const [description, setDescription] = useState(initialData.description || '');
const [url, setUrl] = useState(initialData.url || '');
const handleSubmit = async (e) => {
e.preventDefault();
await onSubmit({ section, app: { name, icon, description, url } });
setOpen(false);
if (onClose) onClose();
};
if (!open) return null;
return (
<div className="modal-overlay" onClick={() => { setOpen(false); if (onClose) onClose(); }}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>{mode === 'edit' ? 'Edit App' : 'Add New App'}</h2>
<form onSubmit={handleSubmit}>
<select
value={customSection ? '__new__' : section}
onChange={(e) => {
const val = e.target.value;
if (val === '__new__') {
setSection('');
setCustomSection(true);
} else {
setSection(val);
setCustomSection(false);
}
}}
required={!customSection}
disabled={mode === 'edit'}
>
<option value="" disabled>Select a section</option>
{sections.map(s => (
<option key={s.name} value={s.name}>{s.name}</option>
))}
<option value="__new__"> Create new section...</option>
</select>
{customSection && (
<input
type="text"
placeholder="New section name"
value={section}
onChange={e => setSection(e.target.value)}
required
/>
)}
<input type="text" placeholder="App name" value={name} onChange={e => setName(e.target.value)} required />
<input type="text" placeholder="Icon 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>
</form>
</div>
</div>
);
}
export default AppModal;

View File

@ -0,0 +1,18 @@
import '../style/AddAppModal.css'; // reuse the modal style
function ConfirmDialog({ message, onConfirm, onCancel }) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Confirm</h2>
<p style={{ color: '#ccc', textAlign: 'center', marginBottom: '1.5rem' }}>{message}</p>
<div style={{ display: 'flex', gap: '1rem' }}>
<button style={{ background: 'gray' }} onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Delete</button>
</div>
</div>
</div>
);
}
export default ConfirmDialog;

View File

@ -1,11 +1,11 @@
import AppGrid from './AppGrid';
import '../style/SectionGrid.css';
function SectionGrid({ section }) {
function SectionGrid({ section, onEdit, onDelete }) {
return (
<div className="section">
<h2 className="section-title">{section.name}</h2>
<AppGrid apps={section.apps} />
<AppGrid apps={section.apps} section={section.name} onEdit={onEdit} onDelete={onDelete} />
</div>
);
}

View File

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

View File

@ -50,3 +50,31 @@
color: #ccc;
}
.app-card-wrapper {
position: relative;
}
.card-icons-inside {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
z-index: 2;
}
.edit-icon,
.delete-icon {
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
padding: 4px;
color: white;
cursor: pointer;
font-size: 0.8rem;
transition: transform 0.2s ease;
}
.edit-icon:hover,
.delete-icon:hover {
transform: scale(1.2);
}