redesign
This commit is contained in:
parent
baa18484ca
commit
f6d40b8f2e
@ -34,47 +34,108 @@
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover, .nav-link.active {
|
||||
color: var(--accent-color);
|
||||
.nav-link.active {
|
||||
color: var(--text-color);
|
||||
border-bottom: 2px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
/* Toast Styles */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.9rem;
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
background-color: var(--secondary-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
min-width: 300px;
|
||||
animation: slideInRight 0.3s ease forwards;
|
||||
}
|
||||
|
||||
.toast-success { border-left: 4px solid #4caf50; }
|
||||
.toast-error { border-left: 4px solid #f44336; }
|
||||
.toast-info { border-left: 4px solid var(--accent-color); }
|
||||
|
||||
.toast-icon { font-size: 1.2rem; }
|
||||
.toast-message { flex: 1; font-weight: 500; }
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary-alt-bg);
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Loading Bar */
|
||||
.loading-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent-color), #4da3ff);
|
||||
z-index: 2000;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Theme Switcher */
|
||||
.theme-switcher {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: var(--secondary-alt-bg);
|
||||
padding: 0.25rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background-color: var(--secondary-alt-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
.theme-btn:not(.game-btn) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent-color);
|
||||
.theme-btn:hover { transform: scale(1.1); }
|
||||
.theme-btn.active { border-color: var(--text-color); }
|
||||
|
||||
.theme-default { background: #23283d; }
|
||||
.theme-blackhole { background: #000000; }
|
||||
.theme-star { background: #0a0a2a; }
|
||||
.theme-ball { background: #1a1a1a; }
|
||||
.theme-reflect { background: #333333; }
|
||||
.theme-clouds { background: #23283d; }
|
||||
|
||||
.game-entry {
|
||||
gap: 0.5rem;
|
||||
background-color: var(--secondary-alt-bg);
|
||||
margin-bottom: 10px;
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
.game-entry:hover {
|
||||
background-color: var(--primary-bg);
|
||||
}
|
||||
|
||||
@ -7,9 +7,17 @@ import { GameList } from "./GameList";
|
||||
import { GameFilter } from "./GameFilter";
|
||||
import { GameDetails } from "./GameDetails";
|
||||
import { ShaderBackground } from "./ShaderBackground";
|
||||
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||
import "./App.css";
|
||||
import { apiFetch } from "./api";
|
||||
import { Toast } from "./Toast";
|
||||
import type { ToastType } from "./Toast";
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
@ -17,6 +25,8 @@ function App() {
|
||||
localStorage.getItem("token") || ""
|
||||
);
|
||||
const [theme, setTheme] = useState<string>("default");
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== "default") {
|
||||
@ -26,8 +36,18 @@ function App() {
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const addToast = (message: string, type: ToastType = "info") => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
};
|
||||
|
||||
const removeToast = (id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
const fetchPeople = () => {
|
||||
if (!token) return;
|
||||
setIsLoading(true);
|
||||
|
||||
apiFetch("/api")
|
||||
.then((res) => res.arrayBuffer())
|
||||
@ -35,7 +55,11 @@ function App() {
|
||||
const list = PersonListProto.decode(new Uint8Array(buffer));
|
||||
setPeople(list.person);
|
||||
})
|
||||
.catch((err) => console.error("Failed to fetch people:", err));
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch people:", err);
|
||||
addToast("Failed to fetch people list", "error");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -46,49 +70,80 @@ function App() {
|
||||
const handleLogin = (newToken: string) => {
|
||||
setToken(newToken);
|
||||
localStorage.setItem("token", newToken);
|
||||
addToast("Welcome back!", "success");
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setToken("");
|
||||
setPeople([]);
|
||||
localStorage.removeItem("token");
|
||||
addToast("Logged out successfully", "info");
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return <Login onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
const themes = [
|
||||
{ id: "default", label: "Default", icon: "🏠" },
|
||||
{ id: "blackhole", label: "Blackhole", icon: "🕳️" },
|
||||
{ id: "star", label: "Star", icon: "⭐" },
|
||||
{ id: "ball", label: "Ball", icon: "⚽" },
|
||||
{ id: "reflect", label: "Reflect", icon: "🪞" },
|
||||
{ id: "clouds", label: "Clouds", icon: "☁️" },
|
||||
];
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{isLoading && <div className="loading-bar" style={{ width: "50%" }} />}
|
||||
<div className="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="navbar">
|
||||
<div className="nav-links">
|
||||
<Link to="/" className="nav-link">
|
||||
People List
|
||||
</Link>
|
||||
<Link to="/games" className="nav-link">
|
||||
<NavLink to="/" className="nav-link">
|
||||
People
|
||||
</NavLink>
|
||||
<NavLink to="/games" className="nav-link">
|
||||
Games
|
||||
</Link>
|
||||
<Link to="/filter" className="nav-link">
|
||||
</NavLink>
|
||||
<NavLink to="/filter" className="nav-link">
|
||||
Filter
|
||||
</Link>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1.5rem" }}>
|
||||
<div className="theme-switcher">
|
||||
{themes.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`theme-btn theme-${t.id} ${
|
||||
theme === t.id ? "active" : ""
|
||||
}`}
|
||||
onClick={() => setTheme(t.id)}
|
||||
title={t.label}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleLogout} className="btn-secondary">
|
||||
Logout
|
||||
</button>
|
||||
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
|
||||
<option value="default">Default Theme</option>
|
||||
<option value="blackhole">Blackhole Theme</option>
|
||||
<option value="star">Star Theme</option>
|
||||
<option value="ball">Universe Ball Theme</option>
|
||||
<option value="reflect">Ball Cage Theme</option>
|
||||
<option value="clouds">Clouds Theme</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<ShaderBackground theme={theme} />
|
||||
<Routes>
|
||||
<Route path="/" element={<PersonList people={people} />} />
|
||||
<Route path="/games" element={<GameList />} />
|
||||
<Route path="/games" element={<GameList onShowToast={addToast} />} />
|
||||
<Route path="/filter" element={<GameFilter />} />
|
||||
<Route path="/person/:name" element={<PersonDetails />} />
|
||||
<Route path="/game/:title" element={<GameDetails />} />
|
||||
|
||||
14
frontend/src/GameFilter.css
Normal file
14
frontend/src/GameFilter.css
Normal file
@ -0,0 +1,14 @@
|
||||
.gamefilter-entry {
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--secondary-alt-bg);
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
width: 30%;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gamefilter-entry:hover {
|
||||
background-color: var(--primary-bg);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import {
|
||||
import { apiFetch } from "./api";
|
||||
import { Link } from "react-router-dom";
|
||||
import { GameImage } from "./GameImage";
|
||||
import "./GameFilter.css"
|
||||
|
||||
export function GameFilter() {
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
@ -117,28 +118,27 @@ export function GameFilter() {
|
||||
|
||||
<div style={{ marginBottom: "3rem" }}>
|
||||
<h3>Select People</h3>
|
||||
<div className="grid-container">
|
||||
<div
|
||||
className="grid-container"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "1rem",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{people.map((person) => (
|
||||
<div
|
||||
key={person.name}
|
||||
className="list-item"
|
||||
className="list-item gamefilter-entry"
|
||||
style={{
|
||||
borderColor: selectedPeople.has(person.name)
|
||||
? "var(--accent-color)"
|
||||
: "var(--border-color)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => togglePerson(person.name)}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPeople.has(person.name)}
|
||||
onChange={() => togglePerson(person.name)}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
<div style={{ gap: "0.5rem" }}>
|
||||
<strong>{person.name}</strong>
|
||||
</div>
|
||||
<div
|
||||
@ -164,7 +164,7 @@ export function GameFilter() {
|
||||
<Link
|
||||
to={`/game/${encodeURIComponent(game)}`}
|
||||
key={game}
|
||||
className="list-item"
|
||||
className="list-item game-entry"
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
@ -182,7 +182,8 @@ export function GameFilter() {
|
||||
marginTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
✓ {gameToPositive.get(game)!.size} selected would play
|
||||
<span>✓</span> {gameToPositive.get(game)!.size} selected
|
||||
would play
|
||||
</div>
|
||||
{selectedPeople.size - gameToPositive.get(game)!.size >
|
||||
0 && (
|
||||
@ -193,8 +194,13 @@ export function GameFilter() {
|
||||
marginTop: "0.3rem",
|
||||
}}
|
||||
>
|
||||
? {selectedPeople.size - gameToPositive.get(game)!.size}{" "}
|
||||
{(selectedPeople.size - gameToPositive.get(game)!.size) > 1 ? "are" : "is"} neutral
|
||||
<span>?</span>{" "}
|
||||
{selectedPeople.size - gameToPositive.get(game)!.size}{" "}
|
||||
{selectedPeople.size - gameToPositive.get(game)!.size >
|
||||
1
|
||||
? "are"
|
||||
: "is"}{" "}
|
||||
neutral
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -203,9 +209,22 @@ export function GameFilter() {
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={{ color: "var(--text-muted)", fontStyle: "italic" }}>
|
||||
No games found where all selected people would play
|
||||
<div
|
||||
style={{
|
||||
padding: "3rem",
|
||||
textAlign: "center",
|
||||
background: "var(--secondary-alt-bg)",
|
||||
borderRadius: "16px",
|
||||
border: "1px dashed var(--border-color)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>🔍</div>
|
||||
<p>No games found where all selected people would play.</p>
|
||||
<p style={{ fontSize: "0.9rem" }}>
|
||||
Try selecting fewer people or adding more opinions!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -11,8 +11,13 @@ import {
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { apiFetch, get_auth_status } from "./api";
|
||||
import { GameImage } from "./GameImage";
|
||||
import type { ToastType } from "./Toast";
|
||||
|
||||
export function GameList() {
|
||||
interface Props {
|
||||
onShowToast?: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function GameList({ onShowToast }: Props) {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [title, setTitle] = useState("");
|
||||
const [source, setSource] = useState<Source>(Source.STEAM);
|
||||
@ -20,7 +25,6 @@ export function GameList() {
|
||||
const [maxPlayers, setMaxPlayers] = useState(1);
|
||||
const [price, setPrice] = useState(0);
|
||||
const [remoteId, setRemoteId] = useState(0);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [opinions, setOpinions] = useState<Opinion[]>([]);
|
||||
|
||||
@ -96,22 +100,19 @@ export function GameList() {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage("success");
|
||||
onShowToast?.("Game added successfully!", "success");
|
||||
setTitle("");
|
||||
setMinPlayers(1);
|
||||
setMaxPlayers(1);
|
||||
setPrice(0);
|
||||
setRemoteId(0);
|
||||
fetchGames();
|
||||
setTimeout(() => setMessage(""), 3000);
|
||||
} else {
|
||||
setMessage("error");
|
||||
setTimeout(() => setMessage(""), 3000);
|
||||
onShowToast?.("Failed to add game. Please try again.", "error");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setMessage("error");
|
||||
setTimeout(() => setMessage(""), 3000);
|
||||
onShowToast?.("An error occurred while adding the game.", "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@ -128,7 +129,8 @@ export function GameList() {
|
||||
};
|
||||
|
||||
const formHeaderStyles: React.CSSProperties = {
|
||||
background: "linear-gradient(135deg, var(--accent-color) 0%, var(--secondary-accent) 100%)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--accent-color) 0%, var(--secondary-accent) 100%)",
|
||||
padding: "1.5rem 2rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@ -195,7 +197,8 @@ export function GameList() {
|
||||
const submitButtonStyles: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "1rem",
|
||||
background: "linear-gradient(135deg, var(--accent-color) 0%, var(--secondary-accent) 100%)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--accent-color) 0%, var(--secondary-accent) 100%)",
|
||||
border: "none",
|
||||
borderRadius: "12px",
|
||||
color: "white",
|
||||
@ -211,26 +214,6 @@ export function GameList() {
|
||||
transform: isSubmitting ? "none" : undefined,
|
||||
};
|
||||
|
||||
const messageStyles: React.CSSProperties = {
|
||||
padding: "1rem",
|
||||
borderRadius: "12px",
|
||||
marginBottom: "1.5rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
animation: "slideIn 0.3s ease",
|
||||
backgroundColor:
|
||||
message === "success"
|
||||
? "rgba(76, 175, 80, 0.15)"
|
||||
: "rgba(244, 67, 54, 0.15)",
|
||||
border: `1px solid ${
|
||||
message === "success"
|
||||
? "rgba(76, 175, 80, 0.3)"
|
||||
: "rgba(244, 67, 54, 0.3)"
|
||||
}`,
|
||||
color: message === "success" ? "#4caf50" : "#f44336",
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<style>
|
||||
@ -303,19 +286,6 @@ export function GameList() {
|
||||
</div>
|
||||
|
||||
<div style={formBodyStyles}>
|
||||
{message && (
|
||||
<div style={messageStyles}>
|
||||
<span style={{ fontSize: "1.2rem" }}>
|
||||
{message === "success" ? "✓" : "✕"}
|
||||
</span>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{message === "success"
|
||||
? "Game added successfully!"
|
||||
: "Failed to add game. Please try again."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Basic Info Section */}
|
||||
<div style={sectionStyles}>
|
||||
@ -501,9 +471,11 @@ export function GameList() {
|
||||
);
|
||||
|
||||
setOpinions(response.opinion);
|
||||
onShowToast?.(`Updated opinion for ${title}`, "info");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
onShowToast?.("Failed to update opinion", "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -527,9 +499,11 @@ export function GameList() {
|
||||
);
|
||||
|
||||
setOpinions(response.opinion);
|
||||
onShowToast?.(`Updated opinion for ${title}`, "info");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
onShowToast?.("Failed to update opinion", "error");
|
||||
});
|
||||
}
|
||||
|
||||
@ -556,7 +530,7 @@ export function GameList() {
|
||||
? opinion.wouldPlay
|
||||
? "#4caf50" // would play (green)
|
||||
: "#f44336" // would not play (red)
|
||||
: "#191f2e", // no opinion (bg-2)
|
||||
: "#ffff00", // no opinion (yellow)
|
||||
}}
|
||||
>
|
||||
<strong
|
||||
@ -565,7 +539,7 @@ export function GameList() {
|
||||
? opinion.wouldPlay
|
||||
? "0 0 10px #4caf50" // would play (green)
|
||||
: "0 0 10px #f44336" // would not play (red)
|
||||
: "none", // no opinion (bg-2)
|
||||
: "0 0 10px #ffff00", // no opinion (yellow)
|
||||
}}
|
||||
>
|
||||
{game.title}
|
||||
@ -582,30 +556,47 @@ export function GameList() {
|
||||
>
|
||||
<button
|
||||
onClick={() => handleOpinion(game.title, 1)}
|
||||
className="theme-btn game-btn"
|
||||
style={{
|
||||
width: "50%",
|
||||
borderColor: "#4caf50",
|
||||
width: "33%",
|
||||
borderColor: opinion?.wouldPlay
|
||||
? "#4caf50"
|
||||
: "transparent",
|
||||
background: "rgba(76, 175, 80, 0.1)",
|
||||
fontSize: "1.2rem",
|
||||
}}
|
||||
title="Would Play"
|
||||
>
|
||||
Would Play
|
||||
👍
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpinion(game.title, 2)}
|
||||
className="theme-btn game-btn"
|
||||
style={{
|
||||
width: "50%",
|
||||
borderColor: "#ffff00",
|
||||
width: "33%",
|
||||
borderColor: !opinion ? "#ffff00" : "transparent",
|
||||
background: "rgba(255, 255, 0, 0.1)",
|
||||
fontSize: "1.2rem",
|
||||
}}
|
||||
title="Neutral"
|
||||
>
|
||||
Neutral
|
||||
😐
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpinion(game.title, 0)}
|
||||
className="theme-btn game-btn"
|
||||
style={{
|
||||
width: "50%",
|
||||
borderColor: "#f44336",
|
||||
width: "33%",
|
||||
borderColor:
|
||||
opinion && !opinion.wouldPlay
|
||||
? "#f44336"
|
||||
: "transparent",
|
||||
background: "rgba(244, 67, 54, 0.1)",
|
||||
fontSize: "1.2rem",
|
||||
}}
|
||||
title="Would Not Play"
|
||||
>
|
||||
Would Not Play
|
||||
👎
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
19
frontend/src/PersonList.css
Normal file
19
frontend/src/PersonList.css
Normal file
@ -0,0 +1,19 @@
|
||||
.list-item {
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--secondary-alt-bg);
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: var(--primary-bg);
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { Person } from "../items";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { get_auth_status } from "./api";
|
||||
import "./PersonList.css"
|
||||
|
||||
interface Props {
|
||||
people: Person[];
|
||||
|
||||
45
frontend/src/Toast.tsx
Normal file
45
frontend/src/Toast.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
export type ToastType = "success" | "error" | "info";
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
type: ToastType;
|
||||
onClose: () => void;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({
|
||||
message,
|
||||
type,
|
||||
onClose,
|
||||
duration = 3000,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose, duration]);
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "✓";
|
||||
case "error":
|
||||
return "✕";
|
||||
default:
|
||||
return "ℹ";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`toast toast-${type}`}>
|
||||
<span className="toast-icon">{getIcon()}</span>
|
||||
<span className="toast-message">{message}</span>
|
||||
<button className="toast-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -82,11 +82,17 @@ input, select {
|
||||
border-radius: 6px;
|
||||
font-size: 1em;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: 2px solid var(--accent-color);
|
||||
border-color: transparent;
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(9, 109, 192, 0.2);
|
||||
}
|
||||
|
||||
* {
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
|
||||
1970
state.json
1970
state.json
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user