404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
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(Math.ceil(Number(e.target.value.replace(',', '.'))))}
|
|
min="0"
|
|
step="1"
|
|
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>
|
|
);
|
|
}
|