Add "maybe" status option and long-press functionality

This update introduces a new "maybe" status for entries, represented by a distinct UI button and background. Additionally, long-press functionality has been implemented, allowing users to toggle the "confirmed" status through prolonged interaction. UI components and styles have been updated to reflect these changes, improving usability.
This commit is contained in:
2026-02-03 09:11:04 +01:00
parent 718ef2adc6
commit e8be5a5893
2 changed files with 127 additions and 62 deletions

View File

@@ -75,7 +75,7 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
if note_tag not in (None, "i", "m", "s"): if note_tag not in (None, "i", "m", "s"):
raise HTTPException(400, "invalid note_tag") raise HTTPException(400, "invalid note_tag")
if status is not None and status not in (0, 1, 2): if status is not None and status not in (0, 1, 2, 3):
raise HTTPException(400, "invalid status") raise HTTPException(400, "invalid status")
st = db.query(SheetState).filter( st = db.query(SheetState).filter(

View File

@@ -62,7 +62,6 @@ function AdminPanel() {
const closeModal = () => { const closeModal = () => {
setOpen(false); setOpen(false);
setMsg(""); setMsg("");
// optional: resetForm(); // wenn du beim Schließen leeren willst
}; };
return ( return (
@@ -87,7 +86,6 @@ function AdminPanel() {
))} ))}
</div> </div>
{/* Modal */}
{open && ( {open && (
<div style={styles.modalOverlay} onMouseDown={closeModal}> <div style={styles.modalOverlay} onMouseDown={closeModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}> <div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
@@ -128,7 +126,7 @@ function AdminPanel() {
</div> </div>
<div style={{ fontSize: 12, opacity: 0.75 }}> <div style={{ fontSize: 12, opacity: 0.75 }}>
Tipp: Am Handy ist das Modal leichter zu bedienen als die Grid-Zeile. Tipp: Tap auf Item = rot (ausschließen), Long-Press = grün (bestätigt), ? = unsicher.
</div> </div>
</div> </div>
</div> </div>
@@ -138,6 +136,38 @@ function AdminPanel() {
); );
} }
function useLongPress(onLongPress, onClick, { delay = 450 } = {}) {
const [longPressed, setLongPressed] = useState(false);
const timeoutRef = React.useRef(null);
const start = (e) => {
e.preventDefault();
setLongPressed(false);
timeoutRef.current = setTimeout(() => {
setLongPressed(true);
onLongPress();
}, delay);
};
const clear = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = null;
};
const end = () => {
clear();
if (!longPressed) onClick();
};
return {
onMouseDown: start,
onMouseUp: end,
onMouseLeave: clear,
onTouchStart: start,
onTouchEnd: end,
onTouchMove: clear,
};
}
export default function App() { export default function App() {
const [me, setMe] = useState(null); const [me, setMe] = useState(null);
@@ -154,17 +184,12 @@ export default function App() {
const gs = await api("/games"); const gs = await api("/games");
setGames(gs); setGames(gs);
// wenn noch kein game ausgewählt ist -> erstes nehmen
if (gs[0] && !gameId) setGameId(gs[0].id); if (gs[0] && !gameId) setGameId(gs[0].id);
}; };
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try { await load(); } catch {}
await load();
} catch {
// not logged in
}
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -175,9 +200,7 @@ export default function App() {
try { try {
const sh = await api(`/games/${gameId}/sheet`); const sh = await api(`/games/${gameId}/sheet`);
setSheet(sh); setSheet(sh);
} catch { } catch {}
// ignore
}
})(); })();
}, [gameId]); }, [gameId]);
@@ -222,6 +245,24 @@ export default function App() {
await reloadSheet(); await reloadSheet();
}; };
const toggleConfirmed = async (entry) => {
const next = entry.status === 2 ? 0 : 2;
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ status: next }),
});
await reloadSheet();
};
const toggleMaybe = async (entry) => {
const next = entry.status === 3 ? 0 : 3;
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
method: "PATCH",
body: JSON.stringify({ status: next }),
});
await reloadSheet();
};
const toggleTag = async (entry) => { const toggleTag = async (entry) => {
const next = cycleTag(entry.note_tag); const next = cycleTag(entry.note_tag);
await api(`/games/${gameId}/sheet/${entry.entry_id}`, { await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
@@ -231,7 +272,6 @@ export default function App() {
await reloadSheet(); await reloadSheet();
}; };
// Login Screen
if (!me) { if (!me) {
return ( return (
<div style={styles.page}> <div style={styles.page}>
@@ -275,10 +315,23 @@ export default function App() {
] ]
: []; : [];
const getRowBg = (status) => {
if (status === 1) return "rgba(255, 0, 0, 0.06)"; // crossed
if (status === 2) return "rgba(0, 170, 60, 0.07)"; // confirmed
if (status === 3) return "rgba(255, 180, 70, 0.12)"; // maybe
return "rgba(255,255,255,0.22)";
};
const getNameColor = (status) => {
if (status === 1) return "#b10000";
if (status === 2) return "#0b6a1e";
if (status === 3) return "#6b4d00";
return "#20140c";
};
return ( return (
<div style={styles.page}> <div style={styles.page}>
<div style={styles.shell}> <div style={styles.shell}>
{/* Top Bar */}
<div style={styles.topBar}> <div style={styles.topBar}>
<div> <div>
<div style={{ fontWeight: 900, color: "#20140c" }}>{me.email}</div> <div style={{ fontWeight: 900, color: "#20140c" }}>{me.email}</div>
@@ -291,10 +344,8 @@ export default function App() {
</div> </div>
</div> </div>
{/* Admin Panel */}
{me.role === "admin" && <AdminPanel />} {me.role === "admin" && <AdminPanel />}
{/* Game Selector */}
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={styles.card}> <div style={styles.card}>
<div style={styles.sectionHeader}>Spiel</div> <div style={styles.sectionHeader}>Spiel</div>
@@ -314,56 +365,65 @@ export default function App() {
</div> </div>
</div> </div>
{/* Sheet */}
<div style={{ marginTop: 14, display: "grid", gap: 14 }}> <div style={{ marginTop: 14, display: "grid", gap: 14 }}>
{sections.map((sec) => ( {sections.map((sec) => (
<div key={sec.key} style={styles.card}> <div key={sec.key} style={styles.card}>
<div style={styles.sectionHeader}>{sec.title}</div> <div style={styles.sectionHeader}>{sec.title}</div>
<div style={{ display: "grid" }}> <div style={{ display: "grid" }}>
{sec.entries.map((e) => ( {sec.entries.map((e) => {
const pressHandlers = useLongPress(
() => toggleConfirmed(e), // long press -> confirmed
() => toggleCross(e), // tap -> crossed
{ delay: 450 }
);
return (
<div <div
key={e.entry_id} key={e.entry_id}
style={{ style={{
...styles.row, ...styles.row,
background: background: getRowBg(e.status),
e.status === 1
? "rgba(255, 0, 0, 0.06)" // leicht rot
: "rgba(255,255,255,0.22)",
}} }}
> >
{/* Name: Tap toggelt crossed */} {/* Name: Tap = rot, Long-Press = grün */}
<div <div
onClick={() => toggleCross(e)} {...pressHandlers}
style={{ style={{
...styles.name, ...styles.name,
textDecoration: e.status === 1 ? "line-through" : "none", textDecoration: e.status === 1 ? "line-through" : "none",
color: getNameColor(e.status),
// ✅ Rot wenn gestrichen
color: e.status === 1 ? "#b10000" : "#20140c",
// optional etwas transparenter
opacity: e.status === 1 ? 0.75 : 1, opacity: e.status === 1 ? 0.75 : 1,
}} }}
title="Tippen = ausschließen | Lange halten = bestätigt"
> >
{e.label} {e.label}
</div> </div>
{/* Spalte 1: X */} {/* ? = maybe */}
<button
onClick={() => toggleMaybe(e)}
style={{
...styles.maybeBtn,
background: e.status === 3
? "linear-gradient(180deg, rgba(255,210,120,0.9), rgba(180,120,20,0.25))"
: styles.maybeBtn.background,
}}
title="Unsicher"
>
?
</button>
<div style={styles.cell}>{e.status === 1 ? "X" : ""}</div> <div style={styles.cell}>{e.status === 1 ? "X" : ""}</div>
{/* Spalte 2: gelbes ✓ */}
<div style={styles.cell}>{e.status === 1 ? "✓" : ""}</div> <div style={styles.cell}>{e.status === 1 ? "✓" : ""}</div>
{/* Spalte 3: confirmed (grünes ✓) aktuell nicht per UI gesetzt */}
<div style={styles.cell}>{e.status === 2 ? "✓" : ""}</div> <div style={styles.cell}>{e.status === 2 ? "✓" : ""}</div>
{/* Spalte 4: i/m/s */}
<button onClick={() => toggleTag(e)} style={styles.tagBtn} title="i → m → s → leer"> <button onClick={() => toggleTag(e)} style={styles.tagBtn} title="i → m → s → leer">
{e.note_tag || "—"} {e.note_tag || "—"}
</button> </button>
</div> </div>
))} );
})}
</div> </div>
</div> </div>
))} ))}
@@ -426,7 +486,7 @@ const styles = {
}, },
row: { row: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 40px 40px 40px 56px", gridTemplateColumns: "1fr 42px 40px 40px 40px 56px", // ✅ extra Spalte für ?
gap: 8, gap: 8,
padding: "10px 12px", padding: "10px 12px",
alignItems: "center", alignItems: "center",
@@ -444,12 +504,22 @@ const styles = {
fontWeight: 1000, fontWeight: 1000,
color: "#20140c", color: "#20140c",
}, },
maybeBtn: {
padding: "8px 0",
fontWeight: 1000,
borderRadius: 10,
border: "1px solid rgba(0,0,0,0.25)",
background: "linear-gradient(180deg, rgba(255,255,255,0.55), rgba(0,0,0,0.06))",
color: "#6b4d00",
cursor: "pointer",
},
tagBtn: { tagBtn: {
padding: "8px 0", padding: "8px 0",
fontWeight: 1000, fontWeight: 1000,
borderRadius: 10, borderRadius: 10,
border: "1px solid rgba(0,0,0,0.25)", border: "1px solid rgba(0,0,0,0.25)",
background: "linear-gradient(180deg, rgba(255,255,255,0.55), rgba(0,0,0,0.06))", background: "linear-gradient(180deg, rgba(255,255,255,0.55), rgba(0,0,0,0.06))",
cursor: "pointer",
}, },
input: { input: {
width: "100%", width: "100%",
@@ -488,11 +558,6 @@ const styles = {
color: "#20140c", color: "#20140c",
marginBottom: 8, marginBottom: 8,
}, },
adminGrid: {
display: "grid",
gridTemplateColumns: "1fr 1fr 120px 140px",
gap: 8,
},
userRow: { userRow: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 80px 90px", gridTemplateColumns: "1fr 80px 90px",