Compare commits
5 Commits
61ab121cdd
...
860de867db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
860de867db | ||
| f24e8fc1e9 | |||
| 0eea1b1ff4 | |||
| db417e50d9 | |||
| 4eed9adaad |
1
admins.json
Normal file
1
admins.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
["Code002Lover","HoherGeist"]
|
||||||
@ -13,7 +13,7 @@ bytes = "1"
|
|||||||
rocket_prost_responder_derive = { path = "rocket_prost_responder_derive" }
|
rocket_prost_responder_derive = { path = "rocket_prost_responder_derive" }
|
||||||
uuid = { version = "1.19.0", features = ["v4"] }
|
uuid = { version = "1.19.0", features = ["v4"] }
|
||||||
bcrypt = "0.17.1"
|
bcrypt = "0.17.1"
|
||||||
bincode = "2.0.1"
|
bincode = "3.0.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
reqwest = { version = "0.12.24", features = ["json"] }
|
reqwest = { version = "0.12.24", features = ["json"] }
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use crate::items;
|
|||||||
use crate::proto_utils::Proto;
|
use crate::proto_utils::Proto;
|
||||||
use rocket::State;
|
use rocket::State;
|
||||||
use rocket::futures::lock::Mutex;
|
use rocket::futures::lock::Mutex;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct AuthState {
|
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)]
|
#[derive(Debug)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct Token {
|
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>")]
|
#[post("/login", data = "<request>")]
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
state: &State<AuthState>,
|
state: &State<AuthState>,
|
||||||
@ -118,22 +178,28 @@ pub async fn logout(
|
|||||||
#[post("/get_auth_status", data = "<request>")]
|
#[post("/get_auth_status", data = "<request>")]
|
||||||
pub async fn get_auth_status(
|
pub async fn get_auth_status(
|
||||||
state: &State<AuthState>,
|
state: &State<AuthState>,
|
||||||
|
admin_state: &State<AdminState>,
|
||||||
request: Proto<items::AuthStatusRequest>,
|
request: Proto<items::AuthStatusRequest>,
|
||||||
) -> items::AuthStatusResponse {
|
) -> items::AuthStatusResponse {
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
let tokens = state.tokens.lock().await;
|
let tokens = state.tokens.lock().await;
|
||||||
|
|
||||||
if let Some(username) = tokens.get(&req.token) {
|
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 {
|
items::AuthStatusResponse {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
message: "Authenticated".to_string(),
|
message: "Authenticated".to_string(),
|
||||||
|
is_admin,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items::AuthStatusResponse {
|
items::AuthStatusResponse {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
username: "".to_string(),
|
username: "".to_string(),
|
||||||
message: "Not authenticated".to_string(),
|
message: "Not authenticated".to_string(),
|
||||||
|
is_admin: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,4 +9,5 @@ pub mod auth;
|
|||||||
pub mod proto_utils;
|
pub mod proto_utils;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
pub use auth::AdminState;
|
||||||
pub use store::User;
|
pub use store::User;
|
||||||
|
|||||||
@ -83,6 +83,10 @@ async fn add_game(
|
|||||||
|
|
||||||
game.title = game.title.trim().to_string();
|
game.title = game.title.trim().to_string();
|
||||||
|
|
||||||
|
if game.remote_id == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(existing) = games.iter().find(|g| {
|
if let Some(existing) = games.iter().find(|g| {
|
||||||
g.title == game.title || (g.remote_id == game.remote_id && g.source == game.source)
|
g.title == game.title || (g.remote_id == game.remote_id && g.source == game.source)
|
||||||
}) {
|
}) {
|
||||||
@ -99,6 +103,88 @@ async fn add_game(
|
|||||||
Some(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>")]
|
#[post("/opinion", data = "<req>")]
|
||||||
async fn add_opinion(
|
async fn add_opinion(
|
||||||
token: auth::Token,
|
token: auth::Token,
|
||||||
@ -330,6 +416,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
rocket::build()
|
rocket::build()
|
||||||
.manage(Mutex::new(user_list))
|
.manage(Mutex::new(user_list))
|
||||||
.manage(auth::AuthState::new())
|
.manage(auth::AuthState::new())
|
||||||
|
.manage(auth::AdminState::new())
|
||||||
.manage(Mutex::new(game_list))
|
.manage(Mutex::new(game_list))
|
||||||
.mount(
|
.mount(
|
||||||
"/api",
|
"/api",
|
||||||
@ -341,6 +428,9 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
add_opinion,
|
add_opinion,
|
||||||
remove_opinion,
|
remove_opinion,
|
||||||
add_game,
|
add_game,
|
||||||
|
update_game,
|
||||||
|
delete_game,
|
||||||
|
refresh_state,
|
||||||
get_game_thumbnail,
|
get_game_thumbnail,
|
||||||
get_games_batch
|
get_games_batch
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use crate::items::{Game, Person};
|
use crate::items::{Game, Person};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ pub struct PersistentState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub const STATE_FILE: &str = "state.json";
|
pub const STATE_FILE: &str = "state.json";
|
||||||
|
pub const ADMINS_FILE: &str = "admins.json";
|
||||||
|
|
||||||
pub fn save_state(games: &[Game], users: &[User]) {
|
pub fn save_state(games: &[Game], users: &[User]) {
|
||||||
let mut games = games.to_vec();
|
let mut games = games.to_vec();
|
||||||
@ -57,3 +59,17 @@ pub fn load_state() -> Option<(Vec<Game>, Vec<User>)> {
|
|||||||
}
|
}
|
||||||
None
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@ -90,6 +90,11 @@ export interface LogoutResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RefreshResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthStatusRequest {
|
export interface AuthStatusRequest {
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
@ -98,6 +103,7 @@ export interface AuthStatusResponse {
|
|||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
username: string;
|
username: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameRequest {
|
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 {
|
function createBaseAuthStatusRequest(): AuthStatusRequest {
|
||||||
return { token: "" };
|
return { token: "" };
|
||||||
}
|
}
|
||||||
@ -895,7 +977,7 @@ export const AuthStatusRequest: MessageFns<AuthStatusRequest> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function createBaseAuthStatusResponse(): AuthStatusResponse {
|
function createBaseAuthStatusResponse(): AuthStatusResponse {
|
||||||
return { authenticated: false, username: "", message: "" };
|
return { authenticated: false, username: "", message: "", isAdmin: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
|
export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
|
||||||
@ -909,6 +991,9 @@ export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
|
|||||||
if (message.message !== "") {
|
if (message.message !== "") {
|
||||||
writer.uint32(26).string(message.message);
|
writer.uint32(26).string(message.message);
|
||||||
}
|
}
|
||||||
|
if (message.isAdmin !== false) {
|
||||||
|
writer.uint32(32).bool(message.isAdmin);
|
||||||
|
}
|
||||||
return writer;
|
return writer;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -943,6 +1028,14 @@ export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
|
|||||||
message.message = reader.string();
|
message.message = reader.string();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
case 4: {
|
||||||
|
if (tag !== 32) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.isAdmin = reader.bool();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ((tag & 7) === 4 || tag === 0) {
|
if ((tag & 7) === 4 || tag === 0) {
|
||||||
break;
|
break;
|
||||||
@ -957,6 +1050,7 @@ export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
|
|||||||
authenticated: isSet(object.authenticated) ? globalThis.Boolean(object.authenticated) : false,
|
authenticated: isSet(object.authenticated) ? globalThis.Boolean(object.authenticated) : false,
|
||||||
username: isSet(object.username) ? globalThis.String(object.username) : "",
|
username: isSet(object.username) ? globalThis.String(object.username) : "",
|
||||||
message: isSet(object.message) ? globalThis.String(object.message) : "",
|
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 !== "") {
|
if (message.message !== "") {
|
||||||
obj.message = message.message;
|
obj.message = message.message;
|
||||||
}
|
}
|
||||||
|
if (message.isAdmin !== false) {
|
||||||
|
obj.isAdmin = message.isAdmin;
|
||||||
|
}
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -982,6 +1079,7 @@ export const AuthStatusResponse: MessageFns<AuthStatusResponse> = {
|
|||||||
message.authenticated = object.authenticated ?? false;
|
message.authenticated = object.authenticated ?? false;
|
||||||
message.username = object.username ?? "";
|
message.username = object.username ?? "";
|
||||||
message.message = object.message ?? "";
|
message.message = object.message ?? "";
|
||||||
|
message.isAdmin = object.isAdmin ?? false;
|
||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { PersonDetails } from "./PersonDetails";
|
|||||||
import { GameList } from "./GameList";
|
import { GameList } from "./GameList";
|
||||||
import { GameFilter } from "./GameFilter";
|
import { GameFilter } from "./GameFilter";
|
||||||
import { GameDetails } from "./GameDetails";
|
import { GameDetails } from "./GameDetails";
|
||||||
|
import { EditGame } from "./EditGame";
|
||||||
import { ShaderBackground } from "./ShaderBackground";
|
import { ShaderBackground } from "./ShaderBackground";
|
||||||
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
@ -88,7 +89,9 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
fetchPeople();
|
fetchPeople();
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
@ -102,6 +105,7 @@ function App() {
|
|||||||
setToken("");
|
setToken("");
|
||||||
setPeople([]);
|
setPeople([]);
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("isAdmin");
|
||||||
addToast("Logged out successfully", "info");
|
addToast("Logged out successfully", "info");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -168,11 +172,18 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<ShaderBackground theme={theme} />
|
<ShaderBackground theme={theme} />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PersonList people={people} />} />
|
<Route path="/" element={<PersonList people={people} onShowToast={addToast} />} />
|
||||||
<Route path="/games" element={<GameList onShowToast={addToast} />} />
|
<Route path="/games" element={<GameList onShowToast={addToast} />} />
|
||||||
<Route path="/filter" element={<GameFilter />} />
|
<Route path="/filter" element={<GameFilter />} />
|
||||||
<Route path="/person/:name" element={<PersonDetails />} />
|
<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>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
403
frontend/src/EditGame.tsx
Normal file
403
frontend/src/EditGame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,20 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { Game, Source } from "../items";
|
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 { title } = useParams<{ title: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [game, setGame] = useState<Game | null>(null);
|
const [game, setGame] = useState<Game | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const isAdmin = get_is_admin();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
@ -26,6 +33,32 @@ export function GameDetails() {
|
|||||||
});
|
});
|
||||||
}, [title]);
|
}, [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 (loading) return <div>Loading...</div>;
|
||||||
if (!game) return <div>Game not found</div>;
|
if (!game) return <div>Game not found</div>;
|
||||||
|
|
||||||
@ -40,7 +73,39 @@ export function GameDetails() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ maxWidth: "600px", margin: "0 auto" }}>
|
<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 style={{ display: "grid", gap: "1rem", marginTop: "1rem" }}>
|
||||||
<div>
|
<div>
|
||||||
<strong>Source:</strong>{" "}
|
<strong>Source:</strong>{" "}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export function GameList({ onShowToast }: Props) {
|
|||||||
const [price, setPrice] = useState(0);
|
const [price, setPrice] = useState(0);
|
||||||
const [remoteId, setRemoteId] = useState(0);
|
const [remoteId, setRemoteId] = useState(0);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [remoteIdError, setRemoteIdError] = useState("");
|
||||||
const [opinions, setOpinions] = useState<Opinion[]>([]);
|
const [opinions, setOpinions] = useState<Opinion[]>([]);
|
||||||
|
|
||||||
const fetchGames = () => {
|
const fetchGames = () => {
|
||||||
@ -79,6 +80,13 @@ export function GameList({ onShowToast }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (remoteId === 0) {
|
||||||
|
setRemoteIdError("Remote ID must be greater than 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRemoteIdError("");
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const game = {
|
const game = {
|
||||||
title,
|
title,
|
||||||
@ -391,11 +399,28 @@ export function GameList({ onShowToast }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={remoteId}
|
value={remoteId}
|
||||||
onChange={(e) => setRemoteId(Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
setRemoteId(Number(e.target.value));
|
||||||
|
setRemoteIdError("");
|
||||||
|
}}
|
||||||
min="0"
|
min="0"
|
||||||
style={inputStyles}
|
style={{
|
||||||
|
...inputStyles,
|
||||||
|
borderColor: remoteIdError ? "#f44336" : undefined,
|
||||||
|
}}
|
||||||
className="add-game-input"
|
className="add-game-input"
|
||||||
/>
|
/>
|
||||||
|
{remoteIdError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#f44336",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{remoteIdError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,20 +1,41 @@
|
|||||||
import { Person } from "../items";
|
import { Person } from "../items";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useState } from "react";
|
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"
|
import "./PersonList.css"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
people: Person[];
|
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 [current_user, set_current_user] = useState<string>("");
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
get_auth_status().then((res) => {
|
get_auth_status().then((res) => {
|
||||||
if (res) {
|
if (res) {
|
||||||
set_current_user(res.username);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@ -26,6 +47,43 @@ export const PersonList = ({ people }: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2>People List</h2>
|
<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>
|
||||||
<div className="grid-container">
|
<div className="grid-container">
|
||||||
{people.map((person, index) => {
|
{people.map((person, index) => {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export const apiFetch = async (
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status == 401) {
|
if (response.status == 401) {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("isAdmin");
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
throw new Error(`Request failed with status ${response.status}`);
|
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();
|
const buffer = await response.arrayBuffer();
|
||||||
try {
|
try {
|
||||||
const response = AuthStatusResponse.decode(new Uint8Array(buffer));
|
const response = AuthStatusResponse.decode(new Uint8Array(buffer));
|
||||||
|
localStorage.setItem("isAdmin", String(response.isAdmin));
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to decode auth status:", e);
|
console.error("Failed to decode auth status:", e);
|
||||||
return null;
|
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",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
--secondary-bg: rgb(var(--secondary-bg-rgb));
|
--secondary-bg: rgb(var(--secondary-bg-rgb));
|
||||||
--secondary-alt-bg: rgb(var(--tertiary-bg-rgb));
|
--secondary-alt-bg: rgb(var(--tertiary-bg-rgb));
|
||||||
--tertiary-bg: rgb(var(--border-color-rgb));
|
--tertiary-bg: rgb(var(--border-color-rgb));
|
||||||
--border-color: rgb(var(--text-color-rgb));
|
--border-color: rgb(var(--border-color-rgb));
|
||||||
|
|
||||||
font-family: "Inter", system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: "Inter", system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|||||||
@ -50,12 +50,18 @@ message LogoutResponse {
|
|||||||
string message = 2;
|
string message = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message RefreshResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message AuthStatusRequest { string token = 1; }
|
message AuthStatusRequest { string token = 1; }
|
||||||
|
|
||||||
message AuthStatusResponse {
|
message AuthStatusResponse {
|
||||||
bool authenticated = 1;
|
bool authenticated = 1;
|
||||||
string username = 2;
|
string username = 2;
|
||||||
string message = 3;
|
string message = 3;
|
||||||
|
bool isAdmin = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentication service
|
// Authentication service
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user