Board: Eingabe-Fix (ItemRow außerhalb), Promote-Button Wunsch→Roadmap
This commit is contained in:
@@ -288,15 +288,20 @@ for (const [k, v] of loginDefaults) {
|
|||||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_items'").get()) {
|
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_items'").get()) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE board_items (
|
CREATE TABLE board_items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
type TEXT NOT NULL CHECK(type IN ('roadmap','wish')),
|
type TEXT NOT NULL CHECK(type IN ('roadmap','wish')),
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME DEFAULT NULL
|
promoted_from_wish INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
// Migration: promoted_from_wish Spalte
|
||||||
|
const boardCols = db.prepare("PRAGMA table_info(board_items)").all().map(r => r.name);
|
||||||
|
if (boardCols.length && !boardCols.includes('promoted_from_wish'))
|
||||||
|
db.exec("ALTER TABLE board_items ADD COLUMN promoted_from_wish INTEGER NOT NULL DEFAULT 0");
|
||||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_reads'").get()) {
|
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='board_reads'").get()) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE board_reads (
|
CREATE TABLE board_reads (
|
||||||
|
|||||||
@@ -189,4 +189,18 @@ router.delete('/board/:id', authenticate, (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wunsch zur Roadmap befördern (Admin only)
|
||||||
|
router.post('/board/:id/promote', authenticate, (req, res) => {
|
||||||
|
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
const item = db.prepare('SELECT * FROM board_items WHERE id=?').get(req.params.id);
|
||||||
|
if (!item) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
if (item.type !== 'wish') return res.status(400).json({ error: 'Nur Wünsche können befördert werden' });
|
||||||
|
db.prepare('UPDATE board_items SET type=?, promoted_from_wish=1 WHERE id=?').run('roadmap', item.id);
|
||||||
|
const updated = db.prepare(`
|
||||||
|
SELECT b.*, u.username as author FROM board_items b
|
||||||
|
JOIN users u ON u.id=b.user_id WHERE b.id=?
|
||||||
|
`).get(item.id);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -822,13 +822,64 @@ function PushoverSettings({ toast }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Ideen-Board Modal ─────────────────────────────────────────────────────────
|
// ── Ideen-Board Modal ─────────────────────────────────────────────────────────
|
||||||
|
// ── Board ItemRow (außerhalb BoardModal damit Inputs stabil bleiben) ───────────
|
||||||
|
function BoardItemRow({ item, isAdmin, userId, editId, editForm, setEditId, setEditForm, onSave, onDelete, onPromote }) {
|
||||||
|
const isEditing = editId === item.id;
|
||||||
|
const canEdit = isAdmin || (item.user_id === userId && item.type === 'wish');
|
||||||
|
return (
|
||||||
|
<div style={{padding:'10px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||||
|
{isEditing ? (
|
||||||
|
<div>
|
||||||
|
<input value={editForm.title} onChange={e=>setEditForm(p=>({...p,title:e.target.value}))}
|
||||||
|
style={{...S.inp,marginBottom:6,fontSize:13}} autoFocus/>
|
||||||
|
<input value={editForm.description} onChange={e=>setEditForm(p=>({...p,description:e.target.value}))}
|
||||||
|
placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:12}}/>
|
||||||
|
<div style={{display:'flex',gap:6}}>
|
||||||
|
<button onClick={()=>onSave(item.id)} style={{...S.btn('#4ecdc4'),fontSize:11,padding:'5px 12px'}}>✓</button>
|
||||||
|
<button onClick={()=>setEditId(null)} style={{...S.btn('#ff6b9d',true),fontSize:11,padding:'5px 10px'}}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{display:'flex',alignItems:'flex-start',gap:8}}>
|
||||||
|
<div style={{flex:1,minWidth:0}}>
|
||||||
|
<div style={{display:'flex',alignItems:'center',gap:5,flexWrap:'wrap'}}>
|
||||||
|
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{item.title}</div>
|
||||||
|
{item.promoted_from_wish && (
|
||||||
|
<span style={{background:'rgba(255,230,109,0.1)',border:'1px solid rgba(255,230,109,0.25)',
|
||||||
|
borderRadius:5,padding:'1px 6px',color:'#ffe66d',fontSize:9,fontFamily:'monospace'}}>↑ aus Wünschen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.description && <div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,marginTop:2}}>{item.description}</div>}
|
||||||
|
<div style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9,marginTop:3}}>
|
||||||
|
{item.author} · {item.created_at ? new Date(item.created_at.replace(' ','T')).toLocaleDateString('de-DE') : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex',gap:4,flexShrink:0}}>
|
||||||
|
{isAdmin && item.type==='wish' && (
|
||||||
|
<button onClick={()=>onPromote(item.id)} title="Zur Roadmap befördern"
|
||||||
|
style={{...S.btn('#ffe66d',true),padding:'3px 8px',fontSize:11}}>↑</button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<button onClick={()=>{setEditId(item.id);setEditForm({title:item.title,description:item.description||''});}}
|
||||||
|
style={{...S.btn('#4ecdc4',true),padding:'3px 8px',fontSize:11}}>✎</button>
|
||||||
|
<button onClick={()=>onDelete(item.id)} style={{...S.btn('#ff6b9d',true),padding:'3px 8px',fontSize:11}}>✕</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BoardModal({ user, toast, onClose, onRead }) {
|
function BoardModal({ user, toast, onClose, onRead }) {
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [form, setForm] = useState({ type:'wish', title:'', description:'' });
|
const [form, setForm] = useState({ type:'wish', title:'', description:'' });
|
||||||
const [editId, setEditId] = useState(null);
|
const [editId, setEditId] = useState(null);
|
||||||
const [editForm,setEditForm]= useState({});
|
const [editForm, setEditForm] = useState({ title:'', description:'' });
|
||||||
const isMobile = window.innerWidth < 768;
|
const isMobile = window.innerWidth < 768;
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
@@ -857,46 +908,17 @@ function BoardModal({ user, toast, onClose, onRead }) {
|
|||||||
catch(e){ toast(e.message,'error'); }
|
catch(e){ toast(e.message,'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const promote = async id => {
|
||||||
|
try {
|
||||||
|
const r = await api(`/dashboard/board/${id}/promote`,{method:'POST'});
|
||||||
|
setItems(p=>p.map(i=>i.id===id?r:i)); toast('Zur Roadmap befördert ✓');
|
||||||
|
} catch(e){ toast(e.message,'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
const roadmap = items.filter(i=>i.type==='roadmap');
|
const roadmap = items.filter(i=>i.type==='roadmap');
|
||||||
const wishes = items.filter(i=>i.type==='wish');
|
const wishes = items.filter(i=>i.type==='wish');
|
||||||
|
const rowProps = { isAdmin, userId:user?.id, editId, editForm, setEditId, setEditForm,
|
||||||
const ItemRow = ({item}) => {
|
onSave:save, onDelete:del, onPromote:promote };
|
||||||
const canEdit = isAdmin || item.user_id === user?.id;
|
|
||||||
const isEditing = editId === item.id;
|
|
||||||
return (
|
|
||||||
<div style={{padding:'10px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
|
||||||
{isEditing ? (
|
|
||||||
<div>
|
|
||||||
<input value={editForm.title} onChange={e=>setEditForm(p=>({...p,title:e.target.value}))}
|
|
||||||
style={{...S.inp,marginBottom:6,fontSize:13}}/>
|
|
||||||
<input value={editForm.description} onChange={e=>setEditForm(p=>({...p,description:e.target.value}))}
|
|
||||||
placeholder="Beschreibung (optional)" style={{...S.inp,marginBottom:8,fontSize:12}}/>
|
|
||||||
<div style={{display:'flex',gap:6}}>
|
|
||||||
<button onClick={()=>save(item.id)} style={{...S.btn('#4ecdc4'),fontSize:11,padding:'5px 12px'}}>✓</button>
|
|
||||||
<button onClick={()=>setEditId(null)} style={{...S.btn('#ff6b9d',true),fontSize:11,padding:'5px 10px'}}>✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{display:'flex',alignItems:'flex-start',gap:8}}>
|
|
||||||
<div style={{flex:1,minWidth:0}}>
|
|
||||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700}}>{item.title}</div>
|
|
||||||
{item.description && <div style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,marginTop:2}}>{item.description}</div>}
|
|
||||||
<div style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9,marginTop:3}}>
|
|
||||||
{item.author} · {item.created_at ? new Date(item.created_at.replace(' ','T')).toLocaleDateString('de-DE') : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<div style={{display:'flex',gap:4,flexShrink:0}}>
|
|
||||||
<button onClick={()=>{setEditId(item.id);setEditForm({title:item.title,description:item.description});}}
|
|
||||||
style={{...S.btn('#4ecdc4',true),padding:'3px 8px',fontSize:11}}>✎</button>
|
|
||||||
<button onClick={()=>del(item.id)} style={{...S.btn('#ff6b9d',true),padding:'3px 8px',fontSize:11}}>✕</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:7000,
|
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:7000,
|
||||||
@@ -906,21 +928,20 @@ function BoardModal({ user, toast, onClose, onRead }) {
|
|||||||
border:'1px solid rgba(255,255,255,0.12)',display:'flex',flexDirection:'column',
|
border:'1px solid rgba(255,255,255,0.12)',display:'flex',flexDirection:'column',
|
||||||
maxHeight:isMobile?'85vh':'80vh',overflow:'hidden'}}>
|
maxHeight:isMobile?'85vh':'80vh',overflow:'hidden'}}>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{padding:'16px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,
|
<div style={{padding:'16px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',flexShrink:0,
|
||||||
display:'flex',justifyContent:'space-between',alignItems:'center'}}>
|
display:'flex',justifyContent:'space-between',alignItems:'center',position:'relative'}}>
|
||||||
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,position:'absolute',top:8,left:'50%',transform:'translateX(-50%)'}}/>}
|
{isMobile&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,
|
||||||
|
position:'absolute',top:8,left:'50%',transform:'translateX(-50%)'}}/>}
|
||||||
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>📋 Ideen & Roadmap</div>
|
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>📋 Ideen & Roadmap</div>
|
||||||
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{overflowY:'auto',flex:1,padding:'0 20px'}}>
|
<div style={{overflowY:'auto',flex:1,padding:'0 20px'}}>
|
||||||
{/* Roadmap */}
|
|
||||||
<div style={{padding:'14px 0 4px'}}>
|
<div style={{padding:'14px 0 4px'}}>
|
||||||
<div style={{...S.head,marginBottom:6}}>🗺 ROADMAP{isAdmin&&<span style={{color:'rgba(255,255,255,0.3)',fontWeight:400}}> (Admin)</span>}</div>
|
<div style={{...S.head,marginBottom:6}}>🗺 ROADMAP{isAdmin&&<span style={{color:'rgba(255,255,255,0.3)',fontWeight:400}}> (Admin)</span>}</div>
|
||||||
{loading ? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Lädt…</div>
|
{loading ? <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Lädt…</div>
|
||||||
: roadmap.length===0 ? <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Noch keine Einträge.</div>
|
: roadmap.length===0 ? <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Noch keine Einträge.</div>
|
||||||
: roadmap.map(i=><ItemRow key={i.id} item={i}/>)}
|
: roadmap.map(i=><BoardItemRow key={i.id} item={i} {...rowProps}/>)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div style={{marginTop:10}}>
|
<div style={{marginTop:10}}>
|
||||||
<input value={form.type==='roadmap'?form.title:''} placeholder="+ Roadmap-Eintrag hinzufügen…"
|
<input value={form.type==='roadmap'?form.title:''} placeholder="+ Roadmap-Eintrag hinzufügen…"
|
||||||
@@ -940,12 +961,12 @@ function BoardModal({ user, toast, onClose, onRead }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wishes */}
|
|
||||||
<div style={{padding:'14px 0 20px',borderTop:'1px solid rgba(255,255,255,0.06)',marginTop:8}}>
|
<div style={{padding:'14px 0 20px',borderTop:'1px solid rgba(255,255,255,0.06)',marginTop:8}}>
|
||||||
<div style={{...S.head,marginBottom:6}}>💡 WÜNSCHE & IDEEN</div>
|
<div style={{...S.head,marginBottom:2}}>💡 WÜNSCHE & IDEEN</div>
|
||||||
|
{isAdmin && <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginBottom:8}}>↑ befördert Wunsch zur Roadmap</div>}
|
||||||
{loading ? null : wishes.length===0
|
{loading ? null : wishes.length===0
|
||||||
? <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Noch keine Wünsche.</div>
|
? <div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Noch keine Wünsche.</div>
|
||||||
: wishes.map(i=><ItemRow key={i.id} item={i}/>)}
|
: wishes.map(i=><BoardItemRow key={i.id} item={i} {...rowProps}/>)}
|
||||||
<div style={{marginTop:10}}>
|
<div style={{marginTop:10}}>
|
||||||
<input value={form.type==='wish'?form.title:''} placeholder="+ Wunsch oder Idee einreichen…"
|
<input value={form.type==='wish'?form.title:''} placeholder="+ Wunsch oder Idee einreichen…"
|
||||||
onFocus={()=>setForm(f=>({...f,type:'wish'}))}
|
onFocus={()=>setForm(f=>({...f,type:'wish'}))}
|
||||||
@@ -968,6 +989,7 @@ function BoardModal({ user, toast, onClose, onRead }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, user }) {
|
function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, user }) {
|
||||||
const [now, setNow] = useState(new Date());
|
const [now, setNow] = useState(new Date());
|
||||||
const [showBoard, setShowBoard] = useState(false);
|
const [showBoard, setShowBoard] = useState(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user