Compare commits

...

3 Commits

Author SHA1 Message Date
Renovate Bot
a366ce8163 chore(deps): update dependency vite to v7.3.0 2025-12-18 19:07:30 +00:00
d916014872 add batch game info request 2025-12-18 20:04:50 +01:00
f6d40b8f2e redesign 2025-12-18 19:23:05 +01:00
15 changed files with 1606 additions and 1244 deletions

View File

@ -44,6 +44,19 @@ async fn get_game(
games.iter().find(|g| g.title == title).cloned() games.iter().find(|g| g.title == title).cloned()
} }
#[post("/games/batch", data = "<req>")]
async fn get_games_batch(
_token: auth::Token,
game_list: &rocket::State<Mutex<Vec<Game>>>,
req: proto_utils::Proto<items::GetGameInfoRequest>,
) -> items::GameList {
let games = game_list.lock().await;
let req = req.into_inner();
let mut games = games.clone();
games.retain(|g| req.games.contains(&g.title));
items::GameList { games }
}
#[get("/games")] #[get("/games")]
async fn get_games( async fn get_games(
_token: auth::Token, _token: auth::Token,
@ -319,7 +332,8 @@ async fn main() -> Result<(), std::io::Error> {
add_opinion, add_opinion,
remove_opinion, remove_opinion,
add_game, add_game,
get_game_thumbnail get_game_thumbnail,
get_games_batch
], ],
) )
.mount( .mount(

View File

@ -116,6 +116,14 @@ export interface RemoveOpinionRequest {
gameTitle: string; gameTitle: string;
} }
export interface GetGameInfoRequest {
games: string[];
}
export interface GameInfoResponse {
games: Game[];
}
function createBasePerson(): Person { function createBasePerson(): Person {
return { name: "", opinion: [] }; return { name: "", opinion: [] };
} }
@ -1213,6 +1221,122 @@ export const RemoveOpinionRequest: MessageFns<RemoveOpinionRequest> = {
}, },
}; };
function createBaseGetGameInfoRequest(): GetGameInfoRequest {
return { games: [] };
}
export const GetGameInfoRequest: MessageFns<GetGameInfoRequest> = {
encode(message: GetGameInfoRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.games) {
writer.uint32(10).string(v!);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetGameInfoRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetGameInfoRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.games.push(reader.string());
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GetGameInfoRequest {
return { games: globalThis.Array.isArray(object?.games) ? object.games.map((e: any) => globalThis.String(e)) : [] };
},
toJSON(message: GetGameInfoRequest): unknown {
const obj: any = {};
if (message.games?.length) {
obj.games = message.games;
}
return obj;
},
create<I extends Exact<DeepPartial<GetGameInfoRequest>, I>>(base?: I): GetGameInfoRequest {
return GetGameInfoRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetGameInfoRequest>, I>>(object: I): GetGameInfoRequest {
const message = createBaseGetGameInfoRequest();
message.games = object.games?.map((e) => e) || [];
return message;
},
};
function createBaseGameInfoResponse(): GameInfoResponse {
return { games: [] };
}
export const GameInfoResponse: MessageFns<GameInfoResponse> = {
encode(message: GameInfoResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
for (const v of message.games) {
Game.encode(v!, writer.uint32(10).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GameInfoResponse {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGameInfoResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.games.push(Game.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): GameInfoResponse {
return { games: globalThis.Array.isArray(object?.games) ? object.games.map((e: any) => Game.fromJSON(e)) : [] };
},
toJSON(message: GameInfoResponse): unknown {
const obj: any = {};
if (message.games?.length) {
obj.games = message.games.map((e) => Game.toJSON(e));
}
return obj;
},
create<I extends Exact<DeepPartial<GameInfoResponse>, I>>(base?: I): GameInfoResponse {
return GameInfoResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GameInfoResponse>, I>>(object: I): GameInfoResponse {
const message = createBaseGameInfoResponse();
message.games = object.games?.map((e) => Game.fromPartial(e)) || [];
return message;
},
};
/** Authentication service */ /** Authentication service */
export interface AuthService { export interface AuthService {
Login(request: LoginRequest): Promise<LoginResponse>; Login(request: LoginRequest): Promise<LoginResponse>;
@ -1255,6 +1379,7 @@ export interface MainService {
GetGames(request: GetGamesRequest): Promise<GameList>; GetGames(request: GetGamesRequest): Promise<GameList>;
AddGame(request: Game): Promise<Game>; AddGame(request: Game): Promise<Game>;
AddOpinion(request: AddOpinionRequest): Promise<Person>; AddOpinion(request: AddOpinionRequest): Promise<Person>;
GetGameInfo(request: GetGameInfoRequest): Promise<GameInfoResponse>;
} }
export const MainServiceServiceName = "items.MainService"; export const MainServiceServiceName = "items.MainService";
@ -1268,6 +1393,7 @@ export class MainServiceClientImpl implements MainService {
this.GetGames = this.GetGames.bind(this); this.GetGames = this.GetGames.bind(this);
this.AddGame = this.AddGame.bind(this); this.AddGame = this.AddGame.bind(this);
this.AddOpinion = this.AddOpinion.bind(this); this.AddOpinion = this.AddOpinion.bind(this);
this.GetGameInfo = this.GetGameInfo.bind(this);
} }
GetGame(request: GameRequest): Promise<Game> { GetGame(request: GameRequest): Promise<Game> {
const data = GameRequest.encode(request).finish(); const data = GameRequest.encode(request).finish();
@ -1292,6 +1418,12 @@ export class MainServiceClientImpl implements MainService {
const promise = this.rpc.request(this.service, "AddOpinion", data); const promise = this.rpc.request(this.service, "AddOpinion", data);
return promise.then((data) => Person.decode(new BinaryReader(data))); return promise.then((data) => Person.decode(new BinaryReader(data)));
} }
GetGameInfo(request: GetGameInfoRequest): Promise<GameInfoResponse> {
const data = GetGameInfoRequest.encode(request).finish();
const promise = this.rpc.request(this.service, "GetGameInfo", data);
return promise.then((data) => GameInfoResponse.decode(new BinaryReader(data)));
}
} }
interface Rpc { interface Rpc {

View File

@ -29,11 +29,11 @@
"ts-proto": "^2.8.3", "ts-proto": "^2.8.3",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.1", "typescript-eslint": "^8.48.1",
"vite": "npm:rolldown-vite@7.2.5" "vite": "npm:rolldown-vite@7.3.0"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.2.5" "vite": "npm:rolldown-vite@7.3.0"
} }
} }
} }

167
frontend/pnpm-lock.yaml generated
View File

@ -5,7 +5,7 @@ settings:
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides: overrides:
vite: npm:rolldown-vite@7.2.5 vite: npm:rolldown-vite@7.3.0
importers: importers:
@ -38,7 +38,7 @@ importers:
version: 19.2.3(@types/react@19.2.7) version: 19.2.3(@types/react@19.2.7)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^5.1.2 specifier: ^5.1.2
version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.1)) version: 5.1.2(rolldown-vite@7.3.0(@types/node@24.10.1))
eslint: eslint:
specifier: ^9.39.1 specifier: ^9.39.1
version: 9.39.1 version: 9.39.1
@ -61,8 +61,8 @@ importers:
specifier: ^8.48.1 specifier: ^8.48.1
version: 8.48.1(eslint@9.39.1)(typescript@5.9.3) version: 8.48.1(eslint@9.39.1)(typescript@5.9.3)
vite: vite:
specifier: npm:rolldown-vite@7.2.5 specifier: npm:rolldown-vite@7.3.0
version: rolldown-vite@7.2.5(@types/node@24.10.1) version: rolldown-vite@7.3.0(@types/node@24.10.1)
packages: packages:
@ -234,99 +234,90 @@ packages:
'@napi-rs/wasm-runtime@1.1.0': '@napi-rs/wasm-runtime@1.1.0':
resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==}
'@oxc-project/runtime@0.97.0': '@oxc-project/runtime@0.101.0':
resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==} resolution: {integrity: sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
'@oxc-project/types@0.97.0': '@oxc-project/types@0.101.0':
resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==} resolution: {integrity: sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==}
'@rolldown/binding-android-arm64@1.0.0-beta.50': '@rolldown/binding-android-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==} resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@rolldown/binding-darwin-arm64@1.0.0-beta.50': '@rolldown/binding-darwin-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==} resolution: {integrity: sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@rolldown/binding-darwin-x64@1.0.0-beta.50': '@rolldown/binding-darwin-x64@1.0.0-beta.53':
resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==} resolution: {integrity: sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.0-beta.50': '@rolldown/binding-freebsd-x64@1.0.0-beta.53':
resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==} resolution: {integrity: sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53':
resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==} resolution: {integrity: sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53':
resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==} resolution: {integrity: sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50': '@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50': '@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.0-beta.50': '@rolldown/binding-wasm32-wasi@1.0.0-beta.53':
resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==} resolution: {integrity: sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
cpu: [wasm32] cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53':
resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==} resolution: {integrity: sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53':
resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==} resolution: {integrity: sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.0-beta.50':
resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@rolldown/pluginutils@1.0.0-beta.50':
resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==}
'@rolldown/pluginutils@1.0.0-beta.53': '@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
@ -425,7 +416,7 @@ packages:
resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: npm:rolldown-vite@7.3.0
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@ -897,13 +888,13 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
rolldown-vite@7.2.5: rolldown-vite@7.3.0:
resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} resolution: {integrity: sha512-5hI5NCJwKBGtzWtdKB3c2fOEpI77Iaa0z4mSzZPU1cJ/OqrGbFafm90edVCd7T9Snz+Sh09TMAv4EQqyVLzuEg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0 '@types/node': ^20.19.0 || >=22.12.0
esbuild: ^0.25.0 esbuild: ^0.27.0
jiti: '>=1.21.0' jiti: '>=1.21.0'
less: ^4.0.0 less: ^4.0.0
sass: ^1.70.0 sass: ^1.70.0
@ -937,8 +928,8 @@ packages:
yaml: yaml:
optional: true optional: true
rolldown@1.0.0-beta.50: rolldown@1.0.0-beta.53:
resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==} resolution: {integrity: sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
@ -1268,56 +1259,51 @@ snapshots:
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true optional: true
'@oxc-project/runtime@0.97.0': {} '@oxc-project/runtime@0.101.0': {}
'@oxc-project/types@0.97.0': {} '@oxc-project/types@0.101.0': {}
'@rolldown/binding-android-arm64@1.0.0-beta.50': '@rolldown/binding-android-arm64@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-darwin-arm64@1.0.0-beta.50': '@rolldown/binding-darwin-arm64@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-darwin-x64@1.0.0-beta.50': '@rolldown/binding-darwin-x64@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-freebsd-x64@1.0.0-beta.50': '@rolldown/binding-freebsd-x64@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-linux-x64-musl@1.0.0-beta.50': '@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-openharmony-arm64@1.0.0-beta.50': '@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-beta.50': '@rolldown/binding-wasm32-wasi@1.0.0-beta.53':
dependencies: dependencies:
'@napi-rs/wasm-runtime': 1.1.0 '@napi-rs/wasm-runtime': 1.1.0
optional: true optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53':
optional: true optional: true
'@rolldown/binding-win32-x64-msvc@1.0.0-beta.50':
optional: true
'@rolldown/pluginutils@1.0.0-beta.50': {}
'@rolldown/pluginutils@1.0.0-beta.53': {} '@rolldown/pluginutils@1.0.0-beta.53': {}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
@ -1454,7 +1440,7 @@ snapshots:
'@typescript-eslint/types': 8.48.1 '@typescript-eslint/types': 8.48.1
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.1))': '@vitejs/plugin-react@5.1.2(rolldown-vite@7.3.0(@types/node@24.10.1))':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
@ -1462,7 +1448,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.53 '@rolldown/pluginutils': 1.0.0-beta.53
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.18.0 react-refresh: 0.18.0
vite: rolldown-vite@7.2.5(@types/node@24.10.1) vite: rolldown-vite@7.3.0(@types/node@24.10.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -1870,38 +1856,37 @@ snapshots:
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
rolldown-vite@7.2.5(@types/node@24.10.1): rolldown-vite@7.3.0(@types/node@24.10.1):
dependencies: dependencies:
'@oxc-project/runtime': 0.97.0 '@oxc-project/runtime': 0.101.0
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
lightningcss: 1.30.2 lightningcss: 1.30.2
picomatch: 4.0.3 picomatch: 4.0.3
postcss: 8.5.6 postcss: 8.5.6
rolldown: 1.0.0-beta.50 rolldown: 1.0.0-beta.53
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 24.10.1 '@types/node': 24.10.1
fsevents: 2.3.3 fsevents: 2.3.3
rolldown@1.0.0-beta.50: rolldown@1.0.0-beta.53:
dependencies: dependencies:
'@oxc-project/types': 0.97.0 '@oxc-project/types': 0.101.0
'@rolldown/pluginutils': 1.0.0-beta.50 '@rolldown/pluginutils': 1.0.0-beta.53
optionalDependencies: optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.0-beta.50 '@rolldown/binding-android-arm64': 1.0.0-beta.53
'@rolldown/binding-darwin-arm64': 1.0.0-beta.50 '@rolldown/binding-darwin-arm64': 1.0.0-beta.53
'@rolldown/binding-darwin-x64': 1.0.0-beta.50 '@rolldown/binding-darwin-x64': 1.0.0-beta.53
'@rolldown/binding-freebsd-x64': 1.0.0-beta.50 '@rolldown/binding-freebsd-x64': 1.0.0-beta.53
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50 '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.53
'@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50 '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.53
'@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50 '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.53
'@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50 '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.53
'@rolldown/binding-linux-x64-musl': 1.0.0-beta.50 '@rolldown/binding-linux-x64-musl': 1.0.0-beta.53
'@rolldown/binding-openharmony-arm64': 1.0.0-beta.50 '@rolldown/binding-openharmony-arm64': 1.0.0-beta.53
'@rolldown/binding-wasm32-wasi': 1.0.0-beta.50 '@rolldown/binding-wasm32-wasi': 1.0.0-beta.53
'@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50 '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.53
'@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.53
'@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50
scheduler@0.27.0: {} scheduler@0.27.0: {}

View File

@ -34,47 +34,108 @@
transition: color 0.2s; transition: color 0.2s;
} }
.nav-link:hover, .nav-link.active { .nav-link.active {
color: var(--accent-color); 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 1rem;
margin-bottom: 1rem;
} }
.form-group label { .toast {
font-size: 0.9rem; 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); color: var(--text-muted);
cursor: pointer;
font-size: 1.5rem;
padding: 0;
line-height: 1;
} }
.btn-secondary { @keyframes slideInRight {
background-color: var(--secondary-alt-bg); 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); border: 1px solid var(--border-color);
} }
.btn-secondary:hover { .theme-btn:not(.game-btn) {
background-color: var(--border-color); width: 32px;
} height: 32px;
border-radius: 50%;
.list-item { border: 2px solid transparent;
background-color: var(--secondary-alt-bg); cursor: pointer;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
transition: transform 0.2s; transition: transform 0.2s;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
} }
.list-item:hover { .theme-btn:hover { transform: scale(1.1); }
transform: translateY(-2px); .theme-btn.active { border-color: var(--text-color); }
border-color: var(--accent-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 { .game-entry:hover {
display: grid; background-color: var(--primary-bg);
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
} }

View File

@ -7,9 +7,17 @@ import { GameList } from "./GameList";
import { GameFilter } from "./GameFilter"; import { GameFilter } from "./GameFilter";
import { GameDetails } from "./GameDetails"; import { GameDetails } from "./GameDetails";
import { ShaderBackground } from "./ShaderBackground"; 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 "./App.css";
import { apiFetch } from "./api"; import { apiFetch } from "./api";
import { Toast } from "./Toast";
import type { ToastType } from "./Toast";
interface ToastMessage {
id: number;
message: string;
type: ToastType;
}
function App() { function App() {
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
@ -17,6 +25,8 @@ function App() {
localStorage.getItem("token") || "" localStorage.getItem("token") || ""
); );
const [theme, setTheme] = useState<string>("default"); const [theme, setTheme] = useState<string>("default");
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (theme !== "default") { if (theme !== "default") {
@ -26,8 +36,18 @@ function App() {
} }
}, [theme]); }, [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 = () => { const fetchPeople = () => {
if (!token) return; if (!token) return;
setIsLoading(true);
apiFetch("/api") apiFetch("/api")
.then((res) => res.arrayBuffer()) .then((res) => res.arrayBuffer())
@ -35,7 +55,11 @@ function App() {
const list = PersonListProto.decode(new Uint8Array(buffer)); const list = PersonListProto.decode(new Uint8Array(buffer));
setPeople(list.person); 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(() => { useEffect(() => {
@ -46,49 +70,80 @@ function App() {
const handleLogin = (newToken: string) => { const handleLogin = (newToken: string) => {
setToken(newToken); setToken(newToken);
localStorage.setItem("token", newToken); localStorage.setItem("token", newToken);
addToast("Welcome back!", "success");
}; };
const handleLogout = () => { const handleLogout = () => {
setToken(""); setToken("");
setPeople([]); setPeople([]);
localStorage.removeItem("token"); localStorage.removeItem("token");
addToast("Logged out successfully", "info");
}; };
if (!token) { if (!token) {
return <Login onLogin={handleLogin} />; 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 ( return (
<BrowserRouter> <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="card">
<div className="navbar"> <div className="navbar">
<div className="nav-links"> <div className="nav-links">
<Link to="/" className="nav-link"> <NavLink to="/" className="nav-link">
People List People
</Link> </NavLink>
<Link to="/games" className="nav-link"> <NavLink to="/games" className="nav-link">
Games Games
</Link> </NavLink>
<Link to="/filter" className="nav-link"> <NavLink to="/filter" className="nav-link">
Filter 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>
</div> </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} /> <ShaderBackground theme={theme} />
<Routes> <Routes>
<Route path="/" element={<PersonList people={people} />} /> <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="/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 />} />

View 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);
}

View File

@ -3,10 +3,13 @@ import {
Person, Person,
PersonList as PersonListProto, PersonList as PersonListProto,
Game as GameProto, Game as GameProto,
GetGameInfoRequest,
GameInfoResponse,
} from "../items"; } from "../items";
import { apiFetch } from "./api"; import { apiFetch } from "./api";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { GameImage } from "./GameImage"; import { GameImage } from "./GameImage";
import "./GameFilter.css"
export function GameFilter() { export function GameFilter() {
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
@ -71,31 +74,38 @@ export function GameFilter() {
.filter(([, players]) => players.size === 0) .filter(([, players]) => players.size === 0)
.map(([game]) => game); .map(([game]) => game);
const games = game_titles.map(async (title) => { let games = game_titles.filter((title) => metaData[title]).map((title) => metaData[title]);
if (metaData[title]) { const gamesToFetch = GetGameInfoRequest.encode(
console.log("returned cached metadata"); GetGameInfoRequest.create({
return metaData[title]; games: game_titles.filter((title) => !metaData[title]),
} })
return await apiFetch(`/api/game/${encodeURIComponent(title)}`) ).finish();
.then((res) => res.arrayBuffer())
.then((buffer) => {
const game = GameProto.decode(new Uint8Array(buffer)) as GameProto;
metaData[title] = game;
return game;
})
.catch((err) => console.error("Failed to fetch game:", err));
});
Promise.all(games).then((games) => { apiFetch("/api/games/batch", {
const filteredGames = games.filter((g) => { method: "POST",
const game = g as GameProto; headers: {
return ( "Content-Type": "application/octet-stream",
game.maxPlayers >= selectedPeople.size && },
game.minPlayers <= selectedPeople.size body: gamesToFetch,
); })
.then((res) => res.arrayBuffer())
.then((buffer) => {
const list = GameInfoResponse.decode(new Uint8Array(buffer));
games = games.concat(list.games);
games.forEach((game) => {
metaData[game.title] = game;
});
const filteredGames = games.filter((g) => {
const game = g as GameProto;
return (
game.maxPlayers >= selectedPeople.size &&
game.minPlayers <= selectedPeople.size
);
});
setFilteredGames(filteredGames.map((g) => (g as GameProto).title));
}); });
setFilteredGames(filteredGames.map((g) => (g as GameProto).title));
});
}, [selectedPeople, people, metaData]); }, [selectedPeople, people, metaData]);
const togglePerson = (name: string) => { const togglePerson = (name: string) => {
@ -117,28 +127,27 @@ export function GameFilter() {
<div style={{ marginBottom: "3rem" }}> <div style={{ marginBottom: "3rem" }}>
<h3>Select People</h3> <h3>Select People</h3>
<div className="grid-container"> <div
className="grid-container"
style={{
display: "flex",
flexWrap: "wrap",
gap: "1rem",
justifyContent: "center",
}}
>
{people.map((person) => ( {people.map((person) => (
<div <div
key={person.name} key={person.name}
className="list-item" className="list-item gamefilter-entry"
style={{ style={{
borderColor: selectedPeople.has(person.name) borderColor: selectedPeople.has(person.name)
? "var(--accent-color)" ? "var(--accent-color)"
: "var(--border-color)", : "var(--border-color)",
cursor: "pointer",
}} }}
onClick={() => togglePerson(person.name)} onClick={() => togglePerson(person.name)}
> >
<div <div style={{ gap: "0.5rem" }}>
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<input
type="checkbox"
checked={selectedPeople.has(person.name)}
onChange={() => togglePerson(person.name)}
style={{ cursor: "pointer" }}
/>
<strong>{person.name}</strong> <strong>{person.name}</strong>
</div> </div>
<div <div
@ -164,7 +173,7 @@ export function GameFilter() {
<Link <Link
to={`/game/${encodeURIComponent(game)}`} to={`/game/${encodeURIComponent(game)}`}
key={game} key={game}
className="list-item" className="list-item game-entry"
style={{ style={{
textDecoration: "none", textDecoration: "none",
color: "inherit", color: "inherit",
@ -182,7 +191,8 @@ export function GameFilter() {
marginTop: "0.5rem", marginTop: "0.5rem",
}} }}
> >
{gameToPositive.get(game)!.size} selected would play <span></span> {gameToPositive.get(game)!.size} selected
would play
</div> </div>
{selectedPeople.size - gameToPositive.get(game)!.size > {selectedPeople.size - gameToPositive.get(game)!.size >
0 && ( 0 && (
@ -193,8 +203,13 @@ export function GameFilter() {
marginTop: "0.3rem", marginTop: "0.3rem",
}} }}
> >
? {selectedPeople.size - gameToPositive.get(game)!.size}{" "} <span>?</span>{" "}
{(selectedPeople.size - gameToPositive.get(game)!.size) > 1 ? "are" : "is"} neutral {selectedPeople.size - gameToPositive.get(game)!.size}{" "}
{selectedPeople.size - gameToPositive.get(game)!.size >
1
? "are"
: "is"}{" "}
neutral
</div> </div>
)} )}
</div> </div>
@ -203,9 +218,22 @@ export function GameFilter() {
))} ))}
</ul> </ul>
) : ( ) : (
<p style={{ color: "var(--text-muted)", fontStyle: "italic" }}> <div
No games found where all selected people would play style={{
</p> 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> </div>
)} )}

View File

@ -11,8 +11,13 @@ import {
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { apiFetch, get_auth_status } from "./api"; import { apiFetch, get_auth_status } from "./api";
import { GameImage } from "./GameImage"; 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 [games, setGames] = useState<Game[]>([]);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [source, setSource] = useState<Source>(Source.STEAM); const [source, setSource] = useState<Source>(Source.STEAM);
@ -20,7 +25,6 @@ export function GameList() {
const [maxPlayers, setMaxPlayers] = useState(1); const [maxPlayers, setMaxPlayers] = useState(1);
const [price, setPrice] = useState(0); const [price, setPrice] = useState(0);
const [remoteId, setRemoteId] = useState(0); const [remoteId, setRemoteId] = useState(0);
const [message, setMessage] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [opinions, setOpinions] = useState<Opinion[]>([]); const [opinions, setOpinions] = useState<Opinion[]>([]);
@ -96,22 +100,19 @@ export function GameList() {
}); });
if (res.ok) { if (res.ok) {
setMessage("success"); onShowToast?.("Game added successfully!", "success");
setTitle(""); setTitle("");
setMinPlayers(1); setMinPlayers(1);
setMaxPlayers(1); setMaxPlayers(1);
setPrice(0); setPrice(0);
setRemoteId(0); setRemoteId(0);
fetchGames(); fetchGames();
setTimeout(() => setMessage(""), 3000);
} else { } else {
setMessage("error"); onShowToast?.("Failed to add game. Please try again.", "error");
setTimeout(() => setMessage(""), 3000);
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setMessage("error"); onShowToast?.("An error occurred while adding the game.", "error");
setTimeout(() => setMessage(""), 3000);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -128,7 +129,8 @@ export function GameList() {
}; };
const formHeaderStyles: React.CSSProperties = { 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", padding: "1.5rem 2rem",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -195,7 +197,8 @@ export function GameList() {
const submitButtonStyles: React.CSSProperties = { const submitButtonStyles: React.CSSProperties = {
width: "100%", width: "100%",
padding: "1rem", 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", border: "none",
borderRadius: "12px", borderRadius: "12px",
color: "white", color: "white",
@ -211,26 +214,6 @@ export function GameList() {
transform: isSubmitting ? "none" : undefined, 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 ( return (
<div> <div>
<style> <style>
@ -303,19 +286,6 @@ export function GameList() {
</div> </div>
<div style={formBodyStyles}> <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}> <form onSubmit={handleSubmit}>
{/* Basic Info Section */} {/* Basic Info Section */}
<div style={sectionStyles}> <div style={sectionStyles}>
@ -489,7 +459,7 @@ export function GameList() {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
}, },
body: RemoveOpinionRequest.encode( body: RemoveOpinionRequest.encode(
AddOpinionRequest.create({ RemoveOpinionRequest.create({
gameTitle: title, gameTitle: title,
}) })
).finish(), ).finish(),
@ -501,9 +471,11 @@ export function GameList() {
); );
setOpinions(response.opinion); setOpinions(response.opinion);
onShowToast?.(`Updated opinion for ${title}`, "info");
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
onShowToast?.("Failed to update opinion", "error");
}); });
return; return;
} }
@ -527,9 +499,11 @@ export function GameList() {
); );
setOpinions(response.opinion); setOpinions(response.opinion);
onShowToast?.(`Updated opinion for ${title}`, "info");
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
onShowToast?.("Failed to update opinion", "error");
}); });
} }
@ -556,7 +530,7 @@ export function GameList() {
? opinion.wouldPlay ? opinion.wouldPlay
? "#4caf50" // would play (green) ? "#4caf50" // would play (green)
: "#f44336" // would not play (red) : "#f44336" // would not play (red)
: "#191f2e", // no opinion (bg-2) : "#ffff00", // no opinion (yellow)
}} }}
> >
<strong <strong
@ -565,7 +539,7 @@ export function GameList() {
? opinion.wouldPlay ? opinion.wouldPlay
? "0 0 10px #4caf50" // would play (green) ? "0 0 10px #4caf50" // would play (green)
: "0 0 10px #f44336" // would not play (red) : "0 0 10px #f44336" // would not play (red)
: "none", // no opinion (bg-2) : "0 0 10px #ffff00", // no opinion (yellow)
}} }}
> >
{game.title} {game.title}
@ -582,30 +556,47 @@ export function GameList() {
> >
<button <button
onClick={() => handleOpinion(game.title, 1)} onClick={() => handleOpinion(game.title, 1)}
className="theme-btn game-btn"
style={{ style={{
width: "50%", width: "33%",
borderColor: "#4caf50", borderColor: opinion?.wouldPlay
? "#4caf50"
: "transparent",
background: "rgba(76, 175, 80, 0.1)",
fontSize: "1.2rem",
}} }}
title="Would Play"
> >
Would Play 👍
</button> </button>
<button <button
onClick={() => handleOpinion(game.title, 2)} onClick={() => handleOpinion(game.title, 2)}
className="theme-btn game-btn"
style={{ style={{
width: "50%", width: "33%",
borderColor: "#ffff00", borderColor: !opinion ? "#ffff00" : "transparent",
background: "rgba(255, 255, 0, 0.1)",
fontSize: "1.2rem",
}} }}
title="Neutral"
> >
Neutral 😐
</button> </button>
<button <button
onClick={() => handleOpinion(game.title, 0)} onClick={() => handleOpinion(game.title, 0)}
className="theme-btn game-btn"
style={{ style={{
width: "50%", width: "33%",
borderColor: "#f44336", borderColor:
opinion && !opinion.wouldPlay
? "#f44336"
: "transparent",
background: "rgba(244, 67, 54, 0.1)",
fontSize: "1.2rem",
}} }}
title="Would Not Play"
> >
Would Not Play 👎
</button> </button>
</div> </div>
</div> </div>

View 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;
}

View File

@ -2,6 +2,7 @@ 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 } from "./api";
import "./PersonList.css"
interface Props { interface Props {
people: Person[]; people: Person[];

45
frontend/src/Toast.tsx Normal file
View 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}>
&times;
</button>
</div>
);
};

View File

@ -82,11 +82,17 @@ input, select {
border-radius: 6px; border-radius: 6px;
font-size: 1em; font-size: 1em;
font-family: inherit; font-family: inherit;
transition: all 0.2s ease;
} }
input:focus, select:focus { input:focus, select:focus {
outline: 2px solid var(--accent-color); outline: none;
border-color: transparent; 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;
} }

View File

@ -3,31 +3,32 @@ syntax = "proto3";
package items; package items;
message Person { message Person {
string name = 1; string name = 1;
repeated Opinion opinion = 2; repeated Opinion opinion = 2;
} }
message Opinion { message Opinion {
string title = 1; string title = 1;
bool would_play = 2; bool would_play = 2;
} }
message Game { message Game {
reserved 3; reserved 3;
string title = 1; string title = 1;
Source source = 2; Source source = 2;
uint32 min_players = 4; uint32 min_players = 4;
uint32 max_players = 5; uint32 max_players = 5;
uint32 price = 6; uint32 price = 6;
uint64 remote_id = 7; uint64 remote_id = 7;
} }
enum Source { enum Source {
STEAM = 0; STEAM = 0;
ROBLOX = 1; ROBLOX = 1;
} }
message PersonList { repeated Person person = 1; } message PersonList { repeated Person person = 1; }
message GameList { repeated Game games = 1; } message GameList { repeated Game games = 1; }
// Authentication messages // Authentication messages
@ -37,24 +38,24 @@ message LoginRequest {
} }
message LoginResponse { message LoginResponse {
string token = 1; string token = 1;
bool success = 2; bool success = 2;
string message = 3; string message = 3;
} }
message LogoutRequest { string token = 1; } message LogoutRequest { string token = 1; }
message LogoutResponse { message LogoutResponse {
bool success = 1; bool success = 1;
string message = 2; 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;
} }
// Authentication service // Authentication service
@ -65,18 +66,28 @@ service AuthService {
} }
message GameRequest { string title = 1; } message GameRequest { string title = 1; }
message GetGamesRequest {} message GetGamesRequest {}
message AddOpinionRequest { message AddOpinionRequest {
string game_title = 1; string game_title = 1;
bool would_play = 2; bool would_play = 2;
} }
message RemoveOpinionRequest { string game_title = 1; } message RemoveOpinionRequest { string game_title = 1; }
message GetGameInfoRequest {
repeated string games = 1;
}
message GameInfoResponse {
repeated Game games = 1;
}
service MainService { service MainService {
rpc GetGame(GameRequest) returns (Game); rpc GetGame(GameRequest) returns (Game);
rpc GetGames(GetGamesRequest) returns (GameList); rpc GetGames(GetGamesRequest) returns (GameList);
rpc AddGame(Game) returns (Game); rpc AddGame(Game) returns (Game);
rpc AddOpinion(AddOpinionRequest) returns (Person); rpc AddOpinion(AddOpinionRequest) returns (Person);
rpc GetGameInfo(GetGameInfoRequest) returns (GameInfoResponse);
} }

1976
state.json

File diff suppressed because it is too large Load Diff