Compare commits

...

3 Commits

Author SHA1 Message Date
0eea1b1ff4 add refresh state from file 2026-01-11 19:13:56 +01:00
db417e50d9 add edit/delete game capabilities 2026-01-11 18:41:56 +01:00
4eed9adaad add more validation 2026-01-11 14:21:12 +01:00
13 changed files with 865 additions and 13 deletions

1
admins.json Normal file
View File

@ -0,0 +1 @@
["Code002Lover","HoherGeist"]

View File

@ -2,7 +2,7 @@ use crate::items;
use crate::proto_utils::Proto;
use rocket::State;
use rocket::futures::lock::Mutex;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
pub struct AuthState {
@ -24,6 +24,24 @@ impl Default for AuthState {
}
}
pub struct AdminState {
pub admins: Mutex<HashSet<String>>,
}
impl AdminState {
pub fn new() -> Self {
Self {
admins: Mutex::new(crate::store::load_admins()),
}
}
}
impl Default for AdminState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct Token {
@ -62,6 +80,48 @@ impl<'r> rocket::request::FromRequest<'r> for Token {
}
}
#[derive(Debug)]
pub struct AdminToken {
pub token: String,
pub username: String,
}
#[rocket::async_trait]
impl<'r> rocket::request::FromRequest<'r> for AdminToken {
type Error = ();
async fn from_request(
request: &'r rocket::Request<'_>,
) -> rocket::request::Outcome<Self, Self::Error> {
let token = request.headers().get_one("Authorization");
match token {
Some(token) => {
if let Some(token) = token.strip_prefix("Bearer ") {
let auth_state = request.guard::<&State<AuthState>>().await.unwrap();
let tokens = auth_state.tokens.lock().await;
if let Some(username) = tokens.get(token) {
let admin_state =
request.guard::<&State<crate::AdminState>>().await.unwrap();
let admins = admin_state.admins.lock().await;
if crate::store::is_admin(username, &admins) {
return rocket::request::Outcome::Success(AdminToken {
token: token.to_string(),
username: username.clone(),
});
}
}
}
rocket::request::Outcome::Error((rocket::http::Status::Forbidden, ()))
}
None => rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, ())),
}
}
}
#[post("/login", data = "<request>")]
pub async fn login(
state: &State<AuthState>,
@ -118,22 +178,28 @@ pub async fn logout(
#[post("/get_auth_status", data = "<request>")]
pub async fn get_auth_status(
state: &State<AuthState>,
admin_state: &State<AdminState>,
request: Proto<items::AuthStatusRequest>,
) -> items::AuthStatusResponse {
let req = request.into_inner();
let tokens = state.tokens.lock().await;
if let Some(username) = tokens.get(&req.token) {
let admins = admin_state.admins.lock().await;
let is_admin = crate::store::is_admin(username, &admins);
items::AuthStatusResponse {
authenticated: true,
username: username.clone(),
message: "Authenticated".to_string(),
is_admin,
}
} else {
items::AuthStatusResponse {
authenticated: false,
username: "".to_string(),
message: "Not authenticated".to_string(),
is_admin: false,
}
}
}

View File

@ -9,4 +9,5 @@ pub mod auth;
pub mod proto_utils;
pub mod store;
pub use auth::AdminState;
pub use store::User;

View File

@ -83,6 +83,10 @@ async fn add_game(
game.title = game.title.trim().to_string();
if game.remote_id == 0 {
return None;
}
if let Some(existing) = games.iter().find(|g| {
g.title == game.title || (g.remote_id == game.remote_id && g.source == game.source)
}) {
@ -99,6 +103,88 @@ async fn add_game(
Some(game)
}
#[patch("/game", data = "<game>")]
async fn update_game(
_token: auth::AdminToken,
game_list: &rocket::State<Mutex<Vec<Game>>>,
user_list: &rocket::State<Mutex<Vec<User>>>,
game: proto_utils::Proto<items::Game>,
) -> Option<items::Game> {
let mut games = game_list.lock().await;
let mut game = game.into_inner();
game.title = game.title.trim().to_string();
if game.remote_id == 0 {
return None;
}
let mut r_existing = None;
if let Some(existing) = games.iter_mut().find(|g| g.title == game.title) {
existing.source = game.source;
existing.min_players = game.min_players;
existing.max_players = game.max_players;
existing.price = game.price;
existing.remote_id = game.remote_id;
r_existing = Some(existing.clone());
}
let users = user_list.lock().await;
save_state(&games, &users);
r_existing
}
#[delete("/game/<title>")]
async fn delete_game(
_token: auth::AdminToken,
game_list: &rocket::State<Mutex<Vec<Game>>>,
user_list: &rocket::State<Mutex<Vec<User>>>,
title: &str,
) -> Option<items::Game> {
let mut games = game_list.lock().await;
if let Some(pos) = games
.iter()
.position(|g| g.title.to_lowercase() == title.to_lowercase())
{
let game = games.remove(pos);
let users = user_list.lock().await;
save_state(&games, &users);
return Some(game);
}
None
}
#[post("/refresh")]
async fn refresh_state(
_token: auth::AdminToken,
game_list: &rocket::State<Mutex<Vec<Game>>>,
user_list: &rocket::State<Mutex<Vec<User>>>,
) -> items::RefreshResponse {
if let Some((new_games, new_users)) = store::load_state() {
let mut games = game_list.lock().await;
let mut users = user_list.lock().await;
*games = new_games;
*users = new_users;
items::RefreshResponse {
success: true,
message: "State refreshed from file".to_string(),
}
} else {
items::RefreshResponse {
success: false,
message: "Failed to load state from file".to_string(),
}
}
}
#[post("/opinion", data = "<req>")]
async fn add_opinion(
token: auth::Token,
@ -330,6 +416,7 @@ async fn main() -> Result<(), std::io::Error> {
rocket::build()
.manage(Mutex::new(user_list))
.manage(auth::AuthState::new())
.manage(auth::AdminState::new())
.manage(Mutex::new(game_list))
.mount(
"/api",
@ -341,6 +428,9 @@ async fn main() -> Result<(), std::io::Error> {
add_opinion,
remove_opinion,
add_game,
update_game,
delete_game,
refresh_state,
get_game_thumbnail,
get_games_batch
],

View File

@ -1,5 +1,6 @@
use crate::items::{Game, Person};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::File;
use std::io::BufReader;
@ -30,6 +31,7 @@ pub struct PersistentState {
}
pub const STATE_FILE: &str = "state.json";
pub const ADMINS_FILE: &str = "admins.json";
pub fn save_state(games: &[Game], users: &[User]) {
let mut games = games.to_vec();
@ -57,3 +59,17 @@ pub fn load_state() -> Option<(Vec<Game>, Vec<User>)> {
}
None
}
pub fn load_admins() -> HashSet<String> {
if let Ok(file) = File::open(ADMINS_FILE) {
let reader = BufReader::new(file);
if let Ok(admins) = serde_json::from_reader::<_, Vec<String>>(reader) {
return admins.into_iter().map(|s| s.to_lowercase()).collect();
}
}
HashSet::new()
}
pub fn is_admin(username: &str, admins: &HashSet<String>) -> bool {
admins.contains(&username.to_lowercase())
}

View File

@ -90,6 +90,11 @@ export interface LogoutResponse {
message: string;
}
export interface RefreshResponse {
success: boolean;
message: string;
}
export interface AuthStatusRequest {
token: string;
}
@ -98,6 +103,7 @@ export interface AuthStatusResponse {
authenticated: boolean;
username: string;
message: string;
isAdmin: boolean;
}
export interface GameRequest {
@ -836,6 +842,82 @@ export const LogoutResponse: MessageFns<LogoutResponse> = {
},
};
function createBaseRefreshResponse(): RefreshResponse {
return { success: false, message: "" };
}
export const RefreshResponse: MessageFns<RefreshResponse> = {
encode(message: RefreshResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.success !== false) {
writer.uint32(8).bool(message.success);
}
if (message.message !== "") {
writer.uint32(18).string(message.message);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): RefreshResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseRefreshResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.success = reader.bool();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.message = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): RefreshResponse {
return {
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
message: isSet(object.message) ? globalThis.String(object.message) : "",
};
},
toJSON(message: RefreshResponse): unknown {
const obj: any = {};
if (message.success !== false) {
obj.success = message.success;
}
if (message.message !== "") {
obj.message = message.message;
}
return obj;
},
create<I extends Exact<DeepPartial<RefreshResponse>, I>>(base?: I): RefreshResponse {
return RefreshResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RefreshResponse>, I>>(object: I): RefreshResponse {
const message = createBaseRefreshResponse();
message.success = object.success ?? false;
message.message = object.message ?? "";
return message;
},
};
function createBaseAuthStatusRequest(): AuthStatusRequest {
return { token: "" };
}
@ -895,7 +977,7 @@ export const AuthStatusRequest: MessageFns<AuthStatusRequest> = {
};
function createBaseAuthStatusResponse(): AuthStatusResponse {
return { authenticated: false, username: "", message: "" };
return { authenticated: false, username: "", message: "", isAdmin: false };
}
export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
@ -909,6 +991,9 @@ export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
if (message.message !== "") {
writer.uint32(26).string(message.message);
}
if (message.isAdmin !== false) {
writer.uint32(32).bool(message.isAdmin);
}
return writer;
},
@ -943,6 +1028,14 @@ export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
message.message = reader.string();
continue;
}
case 4: {
if (tag !== 32) {
break;
}
message.isAdmin = reader.bool();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@ -957,6 +1050,7 @@ export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
authenticated: isSet(object.authenticated) ? globalThis.Boolean(object.authenticated) : false,
username: isSet(object.username) ? globalThis.String(object.username) : "",
message: isSet(object.message) ? globalThis.String(object.message) : "",
isAdmin: isSet(object.isAdmin) ? globalThis.Boolean(object.isAdmin) : false,
};
},
@ -971,6 +1065,9 @@ export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
if (message.message !== "") {
obj.message = message.message;
}
if (message.isAdmin !== false) {
obj.isAdmin = message.isAdmin;
}
return obj;
},
@ -982,6 +1079,7 @@ export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
message.authenticated = object.authenticated ?? false;
message.username = object.username ?? "";
message.message = object.message ?? "";
message.isAdmin = object.isAdmin ?? false;
return message;
},
};

View File

@ -6,6 +6,7 @@ import { PersonDetails } from "./PersonDetails";
import { GameList } from "./GameList";
import { GameFilter } from "./GameFilter";
import { GameDetails } from "./GameDetails";
import { EditGame } from "./EditGame";
import { ShaderBackground } from "./ShaderBackground";
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
import "./App.css";
@ -88,7 +89,9 @@ function App() {
};
useEffect(() => {
if (token) {
fetchPeople();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
@ -102,6 +105,7 @@ function App() {
setToken("");
setPeople([]);
localStorage.removeItem("token");
localStorage.removeItem("isAdmin");
addToast("Logged out successfully", "info");
};
@ -168,11 +172,18 @@ function App() {
</div>
<ShaderBackground theme={theme} />
<Routes>
<Route path="/" element={<PersonList people={people} />} />
<Route path="/" element={<PersonList people={people} onShowToast={addToast} />} />
<Route path="/games" element={<GameList onShowToast={addToast} />} />
<Route path="/filter" element={<GameFilter />} />
<Route path="/person/:name" element={<PersonDetails />} />
<Route path="/game/:title" element={<GameDetails />} />
<Route
path="/game/:title"
element={<GameDetails onShowToast={addToast} />}
/>
<Route
path="/game/:title/edit"
element={<EditGame onShowToast={addToast} />}
/>
</Routes>
</div>
</BrowserRouter>

403
frontend/src/EditGame.tsx Normal file
View File

@ -0,0 +1,403 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Game, Source } from "../items";
import { apiFetch } from "./api";
import type { ToastType } from "./Toast";
interface Props {
onShowToast?: (message: string, type?: ToastType) => void;
}
export function EditGame({ onShowToast }: Props) {
const { title } = useParams<{ title: string }>();
const navigate = useNavigate();
const [game, setGame] = useState<Game | null>(null);
const [newTitle, setNewTitle] = useState("");
const [source, setSource] = useState<Source>(Source.STEAM);
const [minPlayers, setMinPlayers] = useState(1);
const [maxPlayers, setMaxPlayers] = useState(1);
const [price, setPrice] = useState(0);
const [remoteId, setRemoteId] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [remoteIdError, setRemoteIdError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!title) return;
apiFetch(`/api/game/${encodeURIComponent(title)}`)
.then(async (res) => {
if (!res.ok) throw new Error("Game not found");
return Game.decode(new Uint8Array(await res.arrayBuffer()));
})
.then((data) => {
setGame(data);
setNewTitle(data.title);
setSource(data.source);
setMinPlayers(data.minPlayers);
setMaxPlayers(data.maxPlayers);
setPrice(data.price);
setRemoteId(data.remoteId);
setLoading(false);
})
.catch((err) => {
console.error(err);
onShowToast?.("Failed to load game", "error");
setLoading(false);
});
}, [title, onShowToast]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (remoteId === 0) {
setRemoteIdError("Remote ID must be greater than 0");
return;
}
if (!newTitle.trim()) {
onShowToast?.("Game title is required", "error");
return;
}
setRemoteIdError("");
setIsSubmitting(true);
const updatedGame = {
title: newTitle.trim(),
source,
minPlayers,
maxPlayers,
price,
remoteId,
};
try {
const encoded = Game.encode(updatedGame).finish();
const res = await apiFetch("/api/game", {
method: "PATCH",
headers: {
"Content-Type": "application/octet-stream",
},
body: encoded,
});
if (res.ok) {
onShowToast?.("Game updated successfully!", "success");
navigate(`/game/${encodeURIComponent(newTitle.trim())}`);
} else {
onShowToast?.("Failed to update game. Please try again.", "error");
}
} catch (err) {
console.error(err);
onShowToast?.("An error occurred while updating the game.", "error");
} finally {
setIsSubmitting(false);
}
};
if (loading) return <div>Loading...</div>;
if (!game) return <div>Game not found</div>;
const formCardStyles: React.CSSProperties = {
background:
"linear-gradient(135deg, var(--secondary-bg) 0%, var(--secondary-alt-bg) 100%)",
borderRadius: "20px",
padding: "0",
maxWidth: "520px",
margin: "0 auto",
boxShadow: "0 20px 40px rgba(0, 0, 0, 0.3), 0 0 0 1px var(--border-color)",
overflow: "hidden",
};
const formHeaderStyles: React.CSSProperties = {
background:
"linear-gradient(135deg, var(--accent-color) 0%, var(--secondary-accent) 100%)",
padding: "1.5rem 2rem",
display: "flex",
alignItems: "center",
gap: "1rem",
};
const formBodyStyles: React.CSSProperties = {
padding: "2rem",
};
const sectionStyles: React.CSSProperties = {
marginBottom: "1.5rem",
};
const sectionTitleStyles: React.CSSProperties = {
fontSize: "0.75rem",
textTransform: "uppercase",
letterSpacing: "0.1em",
color: "var(--text-muted)",
marginBottom: "1rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
};
const inputGroupStyles: React.CSSProperties = {
position: "relative",
marginBottom: "1rem",
};
const labelStyles: React.CSSProperties = {
fontSize: "0.85rem",
color: "var(--text-muted)",
marginBottom: "0.5rem",
display: "block",
fontWeight: 500,
};
const inputStyles: React.CSSProperties = {
width: "100%",
padding: "0.875rem 1rem",
backgroundColor: "var(--tertiary-bg)",
border: "2px solid var(--border-color)",
borderRadius: "12px",
color: "var(--text-color)",
fontSize: "1rem",
transition: "all 0.2s ease",
boxSizing: "border-box",
};
const gridStyles: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1rem",
};
const dividerStyles: React.CSSProperties = {
height: "1px",
background:
"linear-gradient(90deg, transparent, var(--border-color), transparent)",
margin: "1.5rem 0",
};
const buttonStyles: React.CSSProperties = {
padding: "1rem",
background:
"linear-gradient(135deg, var(--accent-color) 0%, var(--secondary-accent) 100%)",
border: "none",
borderRadius: "12px",
color: "white",
fontSize: "1rem",
fontWeight: 600,
cursor: isSubmitting ? "not-allowed" : "pointer",
transition: "all 0.3s ease",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
opacity: isSubmitting ? 0.7 : 1,
transform: isSubmitting ? "none" : undefined,
};
const cancelStyles: React.CSSProperties = {
...buttonStyles,
background: "var(--tertiary-bg)",
color: "var(--text-color)",
border: "2px solid var(--border-color)",
};
return (
<div>
<div style={formCardStyles}>
<div style={formHeaderStyles}>
<div
style={{
width: "48px",
height: "48px",
borderRadius: "14px",
backgroundColor: "rgba(255, 255, 255, 0.2)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.5rem",
}}
>
</div>
<div>
<h2
style={{
margin: 0,
fontSize: "1.5rem",
fontWeight: 700,
color: "white",
}}
>
Edit Game
</h2>
<p
style={{
margin: 0,
fontSize: "0.9rem",
color: "rgba(255, 255, 255, 0.8)",
}}
>
Update game information
</p>
</div>
</div>
<div style={formBodyStyles}>
<form onSubmit={handleSubmit}>
<div style={sectionStyles}>
<div style={sectionTitleStyles}>
<span>📝</span>
Basic Information
</div>
<div style={inputGroupStyles}>
<label style={labelStyles}>Game Title</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
required
placeholder="Enter game title..."
style={inputStyles}
className="add-game-input"
/>
</div>
<div style={inputGroupStyles}>
<label style={labelStyles}>Platform Source</label>
<select
value={source}
onChange={(e) => setSource(Number(e.target.value))}
style={{
...inputStyles,
cursor: "pointer",
appearance: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23a0a0a0' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 1rem center",
paddingRight: "2.5rem",
}}
className="add-game-input"
>
<option value={Source.STEAM}>🎮 Steam</option>
<option value={Source.ROBLOX}>🟢 Roblox</option>
</select>
</div>
</div>
<div style={dividerStyles}></div>
<div style={sectionStyles}>
<div style={sectionTitleStyles}>
<span>👥</span>
Player Count
</div>
<div style={gridStyles}>
<div style={inputGroupStyles}>
<label style={labelStyles}>Minimum Players</label>
<input
type="number"
value={minPlayers}
onChange={(e) => setMinPlayers(Number(e.target.value))}
min="1"
style={inputStyles}
className="add-game-input"
/>
</div>
<div style={inputGroupStyles}>
<label style={labelStyles}>Maximum Players</label>
<input
type="number"
value={maxPlayers}
onChange={(e) => setMaxPlayers(Number(e.target.value))}
min="1"
style={inputStyles}
className="add-game-input"
/>
</div>
</div>
</div>
<div style={dividerStyles}></div>
<div style={sectionStyles}>
<div style={sectionTitleStyles}>
<span>💰</span>
Additional Details
</div>
<div style={gridStyles}>
<div style={inputGroupStyles}>
<label style={labelStyles}>Price ()</label>
<input
type="number"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
min="0"
step="0.01"
style={inputStyles}
className="add-game-input"
/>
</div>
<div style={inputGroupStyles}>
<label style={labelStyles}>Remote ID</label>
<input
type="number"
value={remoteId}
onChange={(e) => {
setRemoteId(Number(e.target.value));
setRemoteIdError("");
}}
min="0"
style={{
...inputStyles,
borderColor: remoteIdError ? "#f44336" : undefined,
}}
className="add-game-input"
/>
{remoteIdError && (
<div
style={{
color: "#f44336",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{remoteIdError}
</div>
)}
</div>
</div>
</div>
<div style={gridStyles}>
<button
type="button"
onClick={() => navigate(`/game/${encodeURIComponent(game.title)}`)}
disabled={isSubmitting}
style={cancelStyles}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
style={buttonStyles}
>
{isSubmitting ? (
<>Updating...</>
) : (
<>
<span style={{ fontSize: "1.1rem" }}>💾</span>
Save Changes
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@ -1,13 +1,20 @@
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { Game, Source } from "../items";
import { apiFetch } from "./api";
import { apiFetch, get_is_admin } from "./api";
export function GameDetails() {
interface Props {
onShowToast?: (message: string, type?: "success" | "error" | "info") => void;
}
export function GameDetails({ onShowToast }: Props) {
const { title } = useParams<{ title: string }>();
const navigate = useNavigate();
const [game, setGame] = useState<Game | null>(null);
const [loading, setLoading] = useState(true);
const isAdmin = get_is_admin();
useEffect(() => {
if (!title) return;
@ -26,6 +33,32 @@ export function GameDetails() {
});
}, [title]);
const handleDelete = async () => {
if (
!confirm(
`Are you sure you want to delete "${game?.title}"? This action cannot be undone.`
)
) {
return;
}
try {
const res = await apiFetch(`/api/game/${encodeURIComponent(title || "")}`, {
method: "DELETE",
});
if (res.ok) {
onShowToast?.(`"${game?.title}" deleted successfully`, "success");
navigate("/games");
} else {
onShowToast?.("Failed to delete game", "error");
}
} catch (err) {
console.error(err);
onShowToast?.("An error occurred while deleting the game", "error");
}
};
if (loading) return <div>Loading...</div>;
if (!game) return <div>Game not found</div>;
@ -40,7 +73,39 @@ export function GameDetails() {
return (
<div className="card" style={{ maxWidth: "600px", margin: "0 auto" }}>
<h2>{game.title}</h2>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2 style={{ margin: 0 }}>{game.title}</h2>
{isAdmin && (
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={() => navigate(`/game/${encodeURIComponent(game.title)}/edit`)}
className="btn-secondary"
style={{ padding: "0.5rem 1rem", fontSize: "0.9rem" }}
>
Edit
</button>
<button
onClick={handleDelete}
className="btn-primary"
style={{
padding: "0.5rem 1rem",
fontSize: "0.9rem",
background: "#f44336",
border: "none",
}}
>
🗑 Delete
</button>
</div>
)}
</div>
<div style={{ display: "grid", gap: "1rem", marginTop: "1rem" }}>
<div>
<strong>Source:</strong>{" "}

View File

@ -26,6 +26,7 @@ export function GameList({ onShowToast }: Props) {
const [price, setPrice] = useState(0);
const [remoteId, setRemoteId] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [remoteIdError, setRemoteIdError] = useState("");
const [opinions, setOpinions] = useState<Opinion[]>([]);
const fetchGames = () => {
@ -79,6 +80,13 @@ export function GameList({ onShowToast }: Props) {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (remoteId === 0) {
setRemoteIdError("Remote ID must be greater than 0");
return;
}
setRemoteIdError("");
setIsSubmitting(true);
const game = {
title,
@ -391,11 +399,28 @@ export function GameList({ onShowToast }: Props) {
<input
type="number"
value={remoteId}
onChange={(e) => setRemoteId(Number(e.target.value))}
onChange={(e) => {
setRemoteId(Number(e.target.value));
setRemoteIdError("");
}}
min="0"
style={inputStyles}
style={{
...inputStyles,
borderColor: remoteIdError ? "#f44336" : undefined,
}}
className="add-game-input"
/>
{remoteIdError && (
<div
style={{
color: "#f44336",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{remoteIdError}
</div>
)}
</div>
</div>
</div>

View File

@ -1,20 +1,41 @@
import { Person } from "../items";
import { Link } from "react-router-dom";
import { useState } from "react";
import { get_auth_status } from "./api";
import { get_auth_status, refresh_state, get_is_admin } from "./api";
import type { ToastType } from "./Toast";
import "./PersonList.css"
interface Props {
people: Person[];
onShowToast?: (message: string, type?: ToastType) => void;
}
export const PersonList = ({ people }: Props) => {
export const PersonList = ({ people, onShowToast }: Props) => {
const [current_user, set_current_user] = useState<string>("");
const [isRefreshing, setIsRefreshing] = useState(false);
get_auth_status().then((res) => {
if (res) {
set_current_user(res.username);
}
});
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refresh_state();
onShowToast?.("State refreshed from file successfully", "success");
window.location.reload();
} catch (err) {
console.error(err);
onShowToast?.("Failed to refresh state from file", "error");
} finally {
setIsRefreshing(false);
}
};
const isAdmin = get_is_admin();
return (
<div>
<div
@ -26,6 +47,43 @@ export const PersonList = ({ people }: Props) => {
}}
>
<h2>People List</h2>
{isAdmin && (
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="btn-secondary"
style={{
padding: "0.5rem 1rem",
fontSize: "0.9rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
opacity: isRefreshing ? 0.7 : 1,
cursor: isRefreshing ? "not-allowed" : "pointer",
}}
>
{isRefreshing ? (
<>
<span
style={{
width: "14px",
height: "14px",
border: "2px solid rgba(255,255,255,0.3)",
borderTopColor: "currentColor",
borderRadius: "50%",
animation: "spin 0.8s linear infinite",
}}
></span>
Refreshing...
</>
) : (
<>
<span>🔄</span>
Refresh from File
</>
)}
</button>
)}
</div>
<div className="grid-container">
{people.map((person, index) => {

View File

@ -21,6 +21,7 @@ export const apiFetch = async (
if (!response.ok) {
if (response.status == 401) {
localStorage.removeItem("token");
localStorage.removeItem("isAdmin");
window.location.href = "/";
}
throw new Error(`Request failed with status ${response.status}`);
@ -43,9 +44,20 @@ export const get_auth_status = async (): Promise<AuthStatusResponse | null> => {
const buffer = await response.arrayBuffer();
try {
const response = AuthStatusResponse.decode(new Uint8Array(buffer));
localStorage.setItem("isAdmin", String(response.isAdmin));
return response;
} catch (e) {
console.error("Failed to decode auth status:", e);
return null;
}
};
export const get_is_admin = (): boolean => {
return localStorage.getItem("isAdmin") === "true";
};
export const refresh_state = async (): Promise<void> => {
await apiFetch("/api/refresh", {
method: "POST",
});
};

View File

@ -50,12 +50,18 @@ message LogoutResponse {
string message = 2;
}
message RefreshResponse {
bool success = 1;
string message = 2;
}
message AuthStatusRequest { string token = 1; }
message AuthStatusResponse {
bool authenticated = 1;
string username = 2;
string message = 3;
bool isAdmin = 4;
}
// Authentication service