can play game
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"css.lint.unknownAtRules": "ignore"
|
||||||
|
}
|
||||||
12
AGENTS.md
Normal file
12
AGENTS.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<project-structure>
|
||||||
|
/client has a react typescript pnpm app with tailwindcss
|
||||||
|
- always inline prop definitions in function call
|
||||||
|
- use react FC<> components
|
||||||
|
- custom navy theme colors defined in index.css
|
||||||
|
- never use white or black, make text light navy and background dark navy
|
||||||
|
/backend
|
||||||
|
- elixir phoenix websocket server
|
||||||
|
- runs highly available in a cluster (dev environment in docker-compose.yml)
|
||||||
|
</project-structure>
|
||||||
|
|
||||||
|
|
||||||
12
README.md
Normal file
12
README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
distribute persistent share tables with mnesia
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
resources:
|
||||||
|
|
||||||
|
|
||||||
|
Channels: <https://hexdocs.pm/phoenix/channels.html>
|
||||||
|
- 1 channel per browser instance
|
||||||
@@ -1,28 +1,17 @@
|
|||||||
defmodule Backend.Application do
|
defmodule Backend.Application do
|
||||||
# See https://hexdocs.pm/elixir/Application.html
|
|
||||||
# for more information on OTP Applications
|
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Application
|
use Application
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
children = [
|
||||||
BackendWeb.Telemetry,
|
BackendWeb.Telemetry,
|
||||||
{DNSCluster, query: Application.get_env(:backend, :dns_cluster_query) || :ignore},
|
|
||||||
{Phoenix.PubSub, name: Backend.PubSub},
|
{Phoenix.PubSub, name: Backend.PubSub},
|
||||||
# Start the Cluster manager for distributed Erlang
|
|
||||||
Backend.Cluster,
|
Backend.Cluster,
|
||||||
# Use GlobalSingleton supervisor for instant failover
|
|
||||||
{Backend.GlobalSingleton, Backend.GameState},
|
{Backend.GlobalSingleton, Backend.GameState},
|
||||||
# Start a worker by calling: Backend.Worker.start_link(arg)
|
|
||||||
# {Backend.Worker, arg},
|
|
||||||
# Start to serve requests, typically the last entry
|
|
||||||
BackendWeb.Endpoint
|
BackendWeb.Endpoint
|
||||||
]
|
]
|
||||||
|
|
||||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
|
||||||
# for other strategies and supported options
|
|
||||||
opts = [strategy: :one_for_one, name: Backend.Supervisor]
|
opts = [strategy: :one_for_one, name: Backend.Supervisor]
|
||||||
Supervisor.start_link(children, opts)
|
Supervisor.start_link(children, opts)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -33,43 +33,30 @@ defmodule Backend.Cluster do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp connect_to_cluster do
|
defp connect_to_cluster do
|
||||||
# Get all cluster nodes and exclude ourselves
|
self_node = node()
|
||||||
all_nodes =
|
|
||||||
|
cluster_nodes =
|
||||||
System.get_env("CLUSTER_NODES", "")
|
System.get_env("CLUSTER_NODES", "")
|
||||||
|> String.split(",", trim: true)
|
|> String.split(",", trim: true)
|
||||||
|> Enum.map(&String.to_atom/1)
|
|> Enum.map(&String.to_atom/1)
|
||||||
|
|> Enum.filter(&(&1 != self_node))
|
||||||
|
|
||||||
cluster_nodes = Enum.reject(all_nodes, &(&1 == node()))
|
connection_attempts =
|
||||||
|
cluster_nodes
|
||||||
|
|> Enum.map(&%{node: &1, status: Node.connect(&1)})
|
||||||
|
|
||||||
failures =
|
failures =
|
||||||
Enum.filter(cluster_nodes, fn node_name ->
|
connection_attempts
|
||||||
case Node.connect(node_name) do
|
|> Enum.filter(fn %{status: status} -> status == false end)
|
||||||
true ->
|
|
||||||
# Logger.debug("Connected to node: #{node_name}")
|
|
||||||
false
|
|
||||||
|
|
||||||
false ->
|
|
||||||
Logger.warning("Failed to connect to node: #{node_name}")
|
|
||||||
true
|
|
||||||
|
|
||||||
:ignored ->
|
|
||||||
# Already connected, not a failure
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
connected_nodes = Node.list()
|
connected_nodes = Node.list()
|
||||||
|
|
||||||
# Expected count is other nodes (not including ourselves)
|
if failures != [] or length(connected_nodes) < length(cluster_nodes) do
|
||||||
expected_count = length(cluster_nodes)
|
|
||||||
actual_count = length(connected_nodes)
|
|
||||||
|
|
||||||
if failures != [] or actual_count < expected_count do
|
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
"Cluster status: #{actual_count}/#{expected_count} nodes connected: #{inspect(connected_nodes)}"
|
"Cluster status: #{length(connected_nodes)}/#{length(cluster_nodes)} nodes connected: #{inspect(connected_nodes)}"
|
||||||
)
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
defp schedule_connect do
|
defp schedule_connect do
|
||||||
# Retry connection every 10 seconds
|
# Retry connection every 10 seconds
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ defmodule Backend.GameState do
|
|||||||
@name {:global, __MODULE__}
|
@name {:global, __MODULE__}
|
||||||
@pubsub Backend.PubSub
|
@pubsub Backend.PubSub
|
||||||
@topic "game_state"
|
@topic "game_state"
|
||||||
|
@fps 30
|
||||||
|
|
||||||
# Client API
|
# Client API
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ defmodule Backend.GameState do
|
|||||||
GenServer.cast(@name, {:move_player, player_id, directions})
|
GenServer.cast(@name, {:move_player, player_id, directions})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_player_keys(player_id, keys_pressed) do
|
||||||
|
GenServer.cast(@name, {:update_player_keys, player_id, keys_pressed})
|
||||||
|
end
|
||||||
|
|
||||||
def get_state do
|
def get_state do
|
||||||
GenServer.call(@name, :get_state)
|
GenServer.call(@name, :get_state)
|
||||||
end
|
end
|
||||||
@@ -48,6 +53,9 @@ defmodule Backend.GameState do
|
|||||||
@impl true
|
@impl true
|
||||||
def init(_) do
|
def init(_) do
|
||||||
Logger.info("GameState starting on node: #{node()}")
|
Logger.info("GameState starting on node: #{node()}")
|
||||||
|
sleep_delay = round(1000 / @fps)
|
||||||
|
:timer.send_interval(sleep_delay, :tick)
|
||||||
|
|
||||||
{:ok, %{}}
|
{:ok, %{}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -57,43 +65,53 @@ defmodule Backend.GameState do
|
|||||||
x = :rand.uniform(800)
|
x = :rand.uniform(800)
|
||||||
y = :rand.uniform(600)
|
y = :rand.uniform(600)
|
||||||
|
|
||||||
new_state = Map.put(state, player_id, %{x: x, y: y})
|
new_state = Map.put(state, player_id, %{x: x, y: y, keys_pressed: []})
|
||||||
broadcast_state(new_state)
|
broadcast_state(new_state)
|
||||||
{:reply, new_state, new_state}
|
{:reply, new_state, new_state}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_cast({:move_player, player_id, directions}, state) do
|
def handle_cast({:update_player_keys, player_id, keys_pressed}, state) do
|
||||||
Logger.info("Processing player move")
|
|
||||||
|
|
||||||
player =
|
player =
|
||||||
case Map.get(state, player_id) do
|
case Map.get(state, player_id) do
|
||||||
nil ->
|
nil ->
|
||||||
Logger.warning(
|
Logger.warning(
|
||||||
"Move attempted for non-existent player: #{player_id}, creating at random position"
|
"Key update for non-existent player: #{player_id}, creating at random position"
|
||||||
)
|
)
|
||||||
|
|
||||||
%{x: :rand.uniform(800), y: :rand.uniform(600)}
|
%{x: :rand.uniform(800), y: :rand.uniform(600), keys_pressed: keys_pressed}
|
||||||
|
|
||||||
existing_player ->
|
existing_player ->
|
||||||
existing_player
|
Map.put(existing_player, :keys_pressed, keys_pressed)
|
||||||
end
|
end
|
||||||
|
|
||||||
step = 10
|
new_state = Map.put(state, player_id, player)
|
||||||
|
broadcast_state(new_state)
|
||||||
|
{:noreply, new_state}
|
||||||
|
end
|
||||||
|
|
||||||
# Apply all directions to calculate final position
|
@impl true
|
||||||
new_position =
|
def handle_info(:tick, state) do
|
||||||
Enum.reduce(directions, player, fn direction, acc ->
|
# On each tick, move players based on their keys_pressed
|
||||||
case direction do
|
new_state =
|
||||||
"w" -> %{acc | y: max(0, acc.y - step)}
|
Enum.reduce(state, state, fn {player_id, player}, acc ->
|
||||||
"a" -> %{acc | x: max(0, acc.x - step)}
|
directions = player.keys_pressed || []
|
||||||
"s" -> %{acc | y: min(600, acc.y + step)}
|
step = 10
|
||||||
"d" -> %{acc | x: min(800, acc.x + step)}
|
|
||||||
_ -> acc
|
new_player =
|
||||||
end
|
Enum.reduce(directions, player, fn direction, acc ->
|
||||||
|
case direction do
|
||||||
|
"w" -> %{acc | y: max(0, acc.y - step)}
|
||||||
|
"a" -> %{acc | x: max(0, acc.x - step)}
|
||||||
|
"s" -> %{acc | y: min(600, acc.y + step)}
|
||||||
|
"d" -> %{acc | x: min(800, acc.x + step)}
|
||||||
|
_ -> acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
Map.put(acc, player_id, new_player)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
new_state = Map.put(state, player_id, new_position)
|
|
||||||
broadcast_state(new_state)
|
broadcast_state(new_state)
|
||||||
{:noreply, new_state}
|
{:noreply, new_state}
|
||||||
end
|
end
|
||||||
|
|||||||
25
backend/lib/backend/lobby_tracker.ex
Normal file
25
backend/lib/backend/lobby_tracker.ex
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
defmodule Backend.LobbyTracker do
|
||||||
|
use GenServer
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@name {:global, __MODULE__}
|
||||||
|
|
||||||
|
def start_link(_opts) do
|
||||||
|
GenServer.start_link(__MODULE__, %{}, name: @name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_lobbies do
|
||||||
|
GenServer.call(@name, :get_lobbies)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_) do
|
||||||
|
Logger.info("LobbyTracker started on node: #{node()}")
|
||||||
|
{:ok, %{}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:get_lobbies, _from, state) do
|
||||||
|
{:reply, state, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
118
backend/lib/backend_web/channels/connected_user_channel.ex
Normal file
118
backend/lib/backend_web/channels/connected_user_channel.ex
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
defmodule BackendWeb.ConnectedUserChannel do
|
||||||
|
@moduledoc """
|
||||||
|
Manages requests that come in from browser
|
||||||
|
"""
|
||||||
|
use BackendWeb, :channel
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def join("user:" <> user_name, _params, %{assigns: %{user_name: socket_user}} = socket)
|
||||||
|
when user_name == socket_user do
|
||||||
|
Logger.info("WebSocket connected to #{node()}, user: #{user_name}")
|
||||||
|
Phoenix.PubSub.subscribe(Backend.PubSub, "game_state")
|
||||||
|
Phoenix.PubSub.subscribe(Backend.PubSub, "user_sessions:#{user_name}")
|
||||||
|
|
||||||
|
# Notify other sessions for this user
|
||||||
|
Phoenix.PubSub.broadcast_from(
|
||||||
|
Backend.PubSub,
|
||||||
|
self(),
|
||||||
|
"user_sessions:#{user_name}",
|
||||||
|
:new_browser_connection
|
||||||
|
)
|
||||||
|
|
||||||
|
current_state = Backend.GameState.get_state()
|
||||||
|
{:ok, %{game_state: current_state}, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def join("user:" <> user_name, _params, %{assigns: %{user_name: socket_user}}) do
|
||||||
|
Logger.warning("User #{socket_user} attempted to join channel for #{user_name}")
|
||||||
|
{:error, %{reason: "unauthorized"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def join(_topic, _params, _socket) do
|
||||||
|
{:error, %{reason: "authentication required"}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:game_state_updated, state}, socket) do
|
||||||
|
push(socket, "game_state", %{game_state: state})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:new_browser_connection, socket) do
|
||||||
|
Logger.warning("New browser connection detected for user: #{socket.assigns.user_name}")
|
||||||
|
push(socket, "new_browser_connection", %{})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_in("join_game", %{"name" => name}, socket) do
|
||||||
|
Logger.info("Player '#{name}' joining game on #{node()}")
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:player_name, name)
|
||||||
|
|> assign(:keys_pressed, MapSet.new())
|
||||||
|
|
||||||
|
Backend.GameState.add_player(name)
|
||||||
|
|
||||||
|
{:reply, :ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_in("key_down", %{"key" => key}, socket) do
|
||||||
|
case socket.assigns[:player_name] do
|
||||||
|
nil ->
|
||||||
|
Logger.warning("Key down attempted without joining game")
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
player_name ->
|
||||||
|
keys_pressed = MapSet.put(socket.assigns[:keys_pressed] || MapSet.new(), key)
|
||||||
|
socket = assign(socket, :keys_pressed, keys_pressed)
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
"Player '#{player_name}' key down: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
Backend.GameState.update_player_keys(player_name, MapSet.to_list(keys_pressed))
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_in("key_up", %{"key" => key}, socket) do
|
||||||
|
case socket.assigns[:player_name] do
|
||||||
|
nil ->
|
||||||
|
Logger.warning("Key up attempted without joining game")
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
player_name ->
|
||||||
|
keys_pressed = MapSet.delete(socket.assigns[:keys_pressed] || MapSet.new(), key)
|
||||||
|
socket = assign(socket, :keys_pressed, keys_pressed)
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
"Player '#{player_name}' key up: #{key}, keys: #{inspect(MapSet.to_list(keys_pressed))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
Backend.GameState.update_player_keys(player_name, MapSet.to_list(keys_pressed))
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def terminate(_reason, socket) do
|
||||||
|
case socket.assigns[:player_name] do
|
||||||
|
nil ->
|
||||||
|
Logger.info("WebSocket disconnected from #{node()}")
|
||||||
|
|
||||||
|
player_name ->
|
||||||
|
Logger.info("Player '#{player_name}' disconnected from #{node()}")
|
||||||
|
Backend.GameState.remove_player(player_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
defmodule BackendWeb.GameChannel do
|
|
||||||
@moduledoc """
|
|
||||||
Channel for handling game events and player movements.
|
|
||||||
"""
|
|
||||||
use BackendWeb, :channel
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def join("game:lobby", _payload, socket) do
|
|
||||||
Logger.info("WebSocket connected to #{node()}, waiting for name")
|
|
||||||
Phoenix.PubSub.subscribe(Backend.PubSub, "game_state")
|
|
||||||
|
|
||||||
current_state = Backend.GameState.get_state()
|
|
||||||
{:ok, %{players: current_state}, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_info({:game_state_updated, state}, socket) do
|
|
||||||
push(socket, "game_state", %{players: state})
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_in("join_game", %{"name" => name}, socket) do
|
|
||||||
Logger.info("Player '#{name}' joining game on #{node()}")
|
|
||||||
|
|
||||||
socket = assign(socket, :player_name, name)
|
|
||||||
Backend.GameState.add_player(name)
|
|
||||||
|
|
||||||
{:reply, :ok, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_in("move", %{"directions" => directions}, socket) do
|
|
||||||
case socket.assigns[:player_name] do
|
|
||||||
nil ->
|
|
||||||
Logger.warning("Move attempted without joining game")
|
|
||||||
{:noreply, socket}
|
|
||||||
|
|
||||||
player_name ->
|
|
||||||
Logger.debug("Player '#{player_name}' moved #{inspect(directions)} on #{node()}")
|
|
||||||
Backend.GameState.move_player(player_name, directions)
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def terminate(_reason, socket) do
|
|
||||||
case socket.assigns[:player_name] do
|
|
||||||
nil ->
|
|
||||||
Logger.info("WebSocket disconnected from #{node()}")
|
|
||||||
|
|
||||||
player_name ->
|
|
||||||
Logger.info("Player '#{player_name}' disconnected from #{node()}")
|
|
||||||
Backend.GameState.remove_player(player_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,36 +1,27 @@
|
|||||||
defmodule BackendWeb.UserSocket do
|
defmodule BackendWeb.UserSocket do
|
||||||
use Phoenix.Socket
|
use Phoenix.Socket
|
||||||
|
require Logger
|
||||||
|
|
||||||
# A Socket handler
|
|
||||||
#
|
|
||||||
# It's possible to control the websocket connection and
|
|
||||||
# assign values that can be accessed by your channel topics.
|
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
channel("game:*", BackendWeb.GameChannel)
|
channel("user:*", BackendWeb.ConnectedUserChannel)
|
||||||
|
|
||||||
# Socket params are passed from the client and can
|
|
||||||
# be used to verify and authenticate a user. After
|
|
||||||
# verification, you can put default assigns into
|
|
||||||
# the socket that will be set for all channels, ie
|
|
||||||
#
|
|
||||||
# {:ok, assign(socket, :user_id, verified_user_id)}
|
|
||||||
#
|
|
||||||
# To deny connection, return `:error` or `{:error, term}`.
|
|
||||||
@impl true
|
@impl true
|
||||||
def connect(_params, socket, _connect_info) do
|
def connect(%{"user_name" => user_name}, socket, _connect_info) do
|
||||||
{:ok, socket}
|
{:ok, assign(socket, :user_name, user_name)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def connect(_params, _socket, _connect_info) do
|
||||||
|
Logger.warning("WebSocket connection rejected: user_name required")
|
||||||
|
{:error, %{reason: "user_name required"}}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Socket id's are topics that allow you to identify all sockets for a given user:
|
|
||||||
#
|
|
||||||
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
|
|
||||||
#
|
|
||||||
# Would allow you to broadcast a "disconnect" event and terminate
|
|
||||||
# all active sockets and channels for a given user:
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# Returning `nil` makes this socket anonymous.
|
|
||||||
@impl true
|
@impl true
|
||||||
|
def id(%{assigns: %{user_name: user_name}}) do
|
||||||
|
# assign websocket to user name
|
||||||
|
# allows other parts of app to manipulate all sockets for a given user
|
||||||
|
"user_socket:#{user_name}"
|
||||||
|
end
|
||||||
|
|
||||||
def id(_socket), do: nil
|
def id(_socket), do: nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
## `msgid`s in this file come from POT (.pot) files.
|
|
||||||
##
|
|
||||||
## Do not add, change, or remove `msgid`s manually here as
|
|
||||||
## they're tied to the ones in the corresponding POT file
|
|
||||||
## (with the same domain).
|
|
||||||
##
|
|
||||||
## Use `mix gettext.extract --merge` or `mix gettext.merge`
|
|
||||||
## to merge POT files into PO files.
|
|
||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Language: en\n"
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
## This is a PO Template file.
|
|
||||||
##
|
|
||||||
## `msgid`s here are often extracted from source code.
|
|
||||||
## Add new translations manually only if they're dynamic
|
|
||||||
## translations that can't be statically extracted.
|
|
||||||
##
|
|
||||||
## Run `mix gettext.extract` to bring this file up to
|
|
||||||
## date. Leave `msgstr`s empty as changing them here has no
|
|
||||||
## effect: edit them in PO (`.po`) files instead.
|
|
||||||
|
|
||||||
@@ -10,9 +10,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"phoenix": "^1.8.4",
|
"phoenix": "^1.8.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
404
client/pnpm-lock.yaml
generated
404
client/pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@tailwindcss/vite':
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1))
|
||||||
phoenix:
|
phoenix:
|
||||||
specifier: ^1.8.4
|
specifier: ^1.8.4
|
||||||
version: 1.8.4
|
version: 1.8.4
|
||||||
@@ -17,6 +20,12 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
tailwindcss:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
|
zod:
|
||||||
|
specifier: ^4.3.6
|
||||||
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.1
|
||||||
@@ -35,19 +44,19 @@ importers:
|
|||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.1.1
|
specifier: ^5.1.1
|
||||||
version: 5.1.4(vite@7.3.1(@types/node@24.10.13))
|
version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1))
|
||||||
babel-plugin-react-compiler:
|
babel-plugin-react-compiler:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.1
|
||||||
version: 9.39.3
|
version: 9.39.3(jiti@2.6.1)
|
||||||
eslint-plugin-react-hooks:
|
eslint-plugin-react-hooks:
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.0.1(eslint@9.39.3)
|
version: 7.0.1(eslint@9.39.3(jiti@2.6.1))
|
||||||
eslint-plugin-react-refresh:
|
eslint-plugin-react-refresh:
|
||||||
specifier: ^0.4.24
|
specifier: ^0.4.24
|
||||||
version: 0.4.26(eslint@9.39.3)
|
version: 0.4.26(eslint@9.39.3(jiti@2.6.1))
|
||||||
globals:
|
globals:
|
||||||
specifier: ^16.5.0
|
specifier: ^16.5.0
|
||||||
version: 16.5.0
|
version: 16.5.0
|
||||||
@@ -56,10 +65,10 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
typescript-eslint:
|
typescript-eslint:
|
||||||
specifier: ^8.48.0
|
specifier: ^8.48.0
|
||||||
version: 8.56.1(eslint@9.39.3)(typescript@5.9.3)
|
version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^7.3.1
|
specifier: ^7.3.1
|
||||||
version: 7.3.1(@types/node@24.10.13)
|
version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -500,6 +509,96 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@tailwindcss/node@4.2.1':
|
||||||
|
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-android-arm64@4.2.1':
|
||||||
|
resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-arm64@4.2.1':
|
||||||
|
resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-x64@4.2.1':
|
||||||
|
resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-freebsd-x64@4.2.1':
|
||||||
|
resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
|
||||||
|
resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
|
||||||
|
resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
||||||
|
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
||||||
|
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
||||||
|
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
||||||
|
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
cpu: [wasm32]
|
||||||
|
bundledDependencies:
|
||||||
|
- '@napi-rs/wasm-runtime'
|
||||||
|
- '@emnapi/core'
|
||||||
|
- '@emnapi/runtime'
|
||||||
|
- '@tybys/wasm-util'
|
||||||
|
- '@emnapi/wasi-threads'
|
||||||
|
- tslib
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
|
||||||
|
resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-win32-x64-msvc@4.2.1':
|
||||||
|
resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide@4.2.1':
|
||||||
|
resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
|
||||||
|
'@tailwindcss/vite@4.2.1':
|
||||||
|
resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^5.2.0 || ^6 || ^7
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
@@ -687,9 +786,17 @@ packages:
|
|||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
electron-to-chromium@1.5.302:
|
electron-to-chromium@1.5.302:
|
||||||
resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==}
|
resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==}
|
||||||
|
|
||||||
|
enhanced-resolve@5.20.0:
|
||||||
|
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
esbuild@0.27.3:
|
esbuild@0.27.3:
|
||||||
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -814,6 +921,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
|
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
graceful-fs@4.2.11:
|
||||||
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
has-flag@4.0.0:
|
has-flag@4.0.0:
|
||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -851,6 +961,10 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
jiti@2.6.1:
|
||||||
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -884,6 +998,76 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.31.1:
|
||||||
|
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.31.1:
|
||||||
|
resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.31.1:
|
||||||
|
resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.31.1:
|
||||||
|
resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.31.1:
|
||||||
|
resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.31.1:
|
||||||
|
resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.31.1:
|
||||||
|
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.31.1:
|
||||||
|
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.31.1:
|
||||||
|
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.31.1:
|
||||||
|
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.31.1:
|
||||||
|
resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss@1.31.1:
|
||||||
|
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -894,6 +1078,9 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
minimatch@10.2.2:
|
minimatch@10.2.2:
|
||||||
resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==}
|
resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -1015,6 +1202,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
tailwindcss@4.2.1:
|
||||||
|
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
|
||||||
|
|
||||||
|
tapable@2.3.0:
|
||||||
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1310,9 +1504,9 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.27.3':
|
'@esbuild/win32-x64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.3)':
|
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.3
|
eslint: 9.39.3(jiti@2.6.1)
|
||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
|
|
||||||
'@eslint-community/regexpp@4.12.2': {}
|
'@eslint-community/regexpp@4.12.2': {}
|
||||||
@@ -1463,6 +1657,74 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/node@4.2.1':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/remapping': 2.3.5
|
||||||
|
enhanced-resolve: 5.20.0
|
||||||
|
jiti: 2.6.1
|
||||||
|
lightningcss: 1.31.1
|
||||||
|
magic-string: 0.30.21
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
tailwindcss: 4.2.1
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-android-arm64@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-arm64@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-x64@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-freebsd-x64@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-win32-x64-msvc@4.2.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide@4.2.1':
|
||||||
|
optionalDependencies:
|
||||||
|
'@tailwindcss/oxide-android-arm64': 4.2.1
|
||||||
|
'@tailwindcss/oxide-darwin-arm64': 4.2.1
|
||||||
|
'@tailwindcss/oxide-darwin-x64': 4.2.1
|
||||||
|
'@tailwindcss/oxide-freebsd-x64': 4.2.1
|
||||||
|
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
|
||||||
|
'@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
|
||||||
|
'@tailwindcss/oxide-linux-arm64-musl': 4.2.1
|
||||||
|
'@tailwindcss/oxide-linux-x64-gnu': 4.2.1
|
||||||
|
'@tailwindcss/oxide-linux-x64-musl': 4.2.1
|
||||||
|
'@tailwindcss/oxide-wasm32-wasi': 4.2.1
|
||||||
|
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
|
||||||
|
'@tailwindcss/oxide-win32-x64-msvc': 4.2.1
|
||||||
|
|
||||||
|
'@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1))':
|
||||||
|
dependencies:
|
||||||
|
'@tailwindcss/node': 4.2.1
|
||||||
|
'@tailwindcss/oxide': 4.2.1
|
||||||
|
tailwindcss: 4.2.1
|
||||||
|
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.0
|
'@babel/parser': 7.29.0
|
||||||
@@ -1502,15 +1764,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
'@typescript-eslint/parser': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/scope-manager': 8.56.1
|
'@typescript-eslint/scope-manager': 8.56.1
|
||||||
'@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
|
'@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.56.1
|
'@typescript-eslint/visitor-keys': 8.56.1
|
||||||
eslint: 9.39.3
|
eslint: 9.39.3(jiti@2.6.1)
|
||||||
ignore: 7.0.5
|
ignore: 7.0.5
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||||
@@ -1518,14 +1780,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3)':
|
'@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/scope-manager': 8.56.1
|
'@typescript-eslint/scope-manager': 8.56.1
|
||||||
'@typescript-eslint/types': 8.56.1
|
'@typescript-eslint/types': 8.56.1
|
||||||
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.56.1
|
'@typescript-eslint/visitor-keys': 8.56.1
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.3
|
eslint: 9.39.3(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -1548,13 +1810,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@typescript-eslint/type-utils@8.56.1(eslint@9.39.3)(typescript@5.9.3)':
|
'@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/types': 8.56.1
|
'@typescript-eslint/types': 8.56.1
|
||||||
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.3
|
eslint: 9.39.3(jiti@2.6.1)
|
||||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -1577,13 +1839,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.9.3)':
|
'@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3)
|
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
|
||||||
'@typescript-eslint/scope-manager': 8.56.1
|
'@typescript-eslint/scope-manager': 8.56.1
|
||||||
'@typescript-eslint/types': 8.56.1
|
'@typescript-eslint/types': 8.56.1
|
||||||
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||||
eslint: 9.39.3
|
eslint: 9.39.3(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -1593,7 +1855,7 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.56.1
|
'@typescript-eslint/types': 8.56.1
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13))':
|
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||||
@@ -1601,7 +1863,7 @@ snapshots:
|
|||||||
'@rolldown/pluginutils': 1.0.0-rc.3
|
'@rolldown/pluginutils': 1.0.0-rc.3
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.18.0
|
react-refresh: 0.18.0
|
||||||
vite: 7.3.1(@types/node@24.10.13)
|
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -1684,8 +1946,15 @@ snapshots:
|
|||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.302: {}
|
electron-to-chromium@1.5.302: {}
|
||||||
|
|
||||||
|
enhanced-resolve@5.20.0:
|
||||||
|
dependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
tapable: 2.3.0
|
||||||
|
|
||||||
esbuild@0.27.3:
|
esbuild@0.27.3:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.27.3
|
'@esbuild/aix-ppc64': 0.27.3
|
||||||
@@ -1719,20 +1988,20 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
eslint-plugin-react-hooks@7.0.1(eslint@9.39.3):
|
eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/parser': 7.29.0
|
'@babel/parser': 7.29.0
|
||||||
eslint: 9.39.3
|
eslint: 9.39.3(jiti@2.6.1)
|
||||||
hermes-parser: 0.25.1
|
hermes-parser: 0.25.1
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
zod-validation-error: 4.0.2(zod@4.3.6)
|
zod-validation-error: 4.0.2(zod@4.3.6)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-react-refresh@0.4.26(eslint@9.39.3):
|
eslint-plugin-react-refresh@0.4.26(eslint@9.39.3(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.39.3
|
eslint: 9.39.3(jiti@2.6.1)
|
||||||
|
|
||||||
eslint-scope@8.4.0:
|
eslint-scope@8.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1745,9 +2014,9 @@ snapshots:
|
|||||||
|
|
||||||
eslint-visitor-keys@5.0.1: {}
|
eslint-visitor-keys@5.0.1: {}
|
||||||
|
|
||||||
eslint@9.39.3:
|
eslint@9.39.3(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3)
|
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
'@eslint/config-array': 0.21.1
|
'@eslint/config-array': 0.21.1
|
||||||
'@eslint/config-helpers': 0.4.2
|
'@eslint/config-helpers': 0.4.2
|
||||||
@@ -1781,6 +2050,8 @@ snapshots:
|
|||||||
minimatch: 3.1.3
|
minimatch: 3.1.3
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
optionator: 0.9.4
|
optionator: 0.9.4
|
||||||
|
optionalDependencies:
|
||||||
|
jiti: 2.6.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -1841,6 +2112,8 @@ snapshots:
|
|||||||
|
|
||||||
globals@16.5.0: {}
|
globals@16.5.0: {}
|
||||||
|
|
||||||
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
hermes-estree@0.25.1: {}
|
hermes-estree@0.25.1: {}
|
||||||
@@ -1868,6 +2141,8 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
@@ -1893,6 +2168,55 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.31.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss@1.31.1:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
optionalDependencies:
|
||||||
|
lightningcss-android-arm64: 1.31.1
|
||||||
|
lightningcss-darwin-arm64: 1.31.1
|
||||||
|
lightningcss-darwin-x64: 1.31.1
|
||||||
|
lightningcss-freebsd-x64: 1.31.1
|
||||||
|
lightningcss-linux-arm-gnueabihf: 1.31.1
|
||||||
|
lightningcss-linux-arm64-gnu: 1.31.1
|
||||||
|
lightningcss-linux-arm64-musl: 1.31.1
|
||||||
|
lightningcss-linux-x64-gnu: 1.31.1
|
||||||
|
lightningcss-linux-x64-musl: 1.31.1
|
||||||
|
lightningcss-win32-arm64-msvc: 1.31.1
|
||||||
|
lightningcss-win32-x64-msvc: 1.31.1
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
@@ -1903,6 +2227,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
minimatch@10.2.2:
|
minimatch@10.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.3
|
brace-expansion: 5.0.3
|
||||||
@@ -2022,6 +2350,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
tailwindcss@4.2.1: {}
|
||||||
|
|
||||||
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
@@ -2035,13 +2367,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|
||||||
typescript-eslint@8.56.1(eslint@9.39.3)(typescript@5.9.3):
|
typescript-eslint@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3)(typescript@5.9.3)
|
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/parser': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
|
'@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||||
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)
|
||||||
eslint: 9.39.3
|
eslint: 9.39.3(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -2060,7 +2392,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
vite@7.3.1(@types/node@24.10.13):
|
vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.3
|
esbuild: 0.27.3
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
@@ -2071,6 +2403,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.10.13
|
'@types/node': 24.10.13
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
jiti: 2.6.1
|
||||||
|
lightningcss: 1.31.1
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#root {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
@@ -1,41 +1,26 @@
|
|||||||
import { useState } from "react";
|
import { type FC } from "react";
|
||||||
import "./App.css";
|
|
||||||
import { UserInput } from "./game/UserInput";
|
import { UserInput } from "./game/UserInput";
|
||||||
import { BoardDisplay } from "./game/BoardDisplay";
|
import { BoardDisplay } from "./game/BoardDisplay";
|
||||||
import { ConnectionStatus } from "./game/ConnectionStatus";
|
import { ConnectionStatus } from "./game/ConnectionStatus";
|
||||||
import { NameInput } from "./game/NameInput";
|
import { SessionOverride } from "./game/SessionOverride";
|
||||||
|
import { useGameChannelContext } from "./contexts/useGameChannelContext";
|
||||||
|
|
||||||
const getPlayerNameFromUrl = () => {
|
const App: FC<{ playerName: string }> = ({ playerName }) => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const { isOverridden } = useGameChannelContext();
|
||||||
return params.get("name") || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function App() {
|
if (isOverridden) {
|
||||||
const [playerName, setPlayerName] = useState<string | null>(
|
return <SessionOverride />;
|
||||||
getPlayerNameFromUrl,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!playerName) {
|
|
||||||
return <NameInput onNameSubmit={setPlayerName} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UserInput playerName={playerName} />
|
<UserInput playerName={playerName} />
|
||||||
<div
|
<div className="w-screen h-screen bg-navy-900">
|
||||||
style={{
|
|
||||||
width: "100vw",
|
|
||||||
height: "100vh",
|
|
||||||
background: "#1a1a2e",
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
<BoardDisplay playerName={playerName} />
|
<BoardDisplay playerName={playerName} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface GameChannelContextValue {
|
|||||||
channel: Channel | null;
|
channel: Channel | null;
|
||||||
channelStatus: string;
|
channelStatus: string;
|
||||||
isJoined: boolean;
|
isJoined: boolean;
|
||||||
|
isOverridden: boolean;
|
||||||
joinGame: (name: string) => void;
|
joinGame: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,29 +14,32 @@ const GameChannelContext = createContext<GameChannelContextValue | undefined>(
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
interface GameChannelProviderProps {
|
|
||||||
channelName: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameChannelProvider({
|
export function GameChannelProvider({
|
||||||
channelName,
|
userName,
|
||||||
params = {},
|
|
||||||
children,
|
children,
|
||||||
}: GameChannelProviderProps) {
|
}: {
|
||||||
|
userName: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
const { socket, isConnected } = useWebSocketContext();
|
const { socket, isConnected } = useWebSocketContext();
|
||||||
const [channelStatus, setChannelStatus] = useState<string>("waiting");
|
const [channelStatus, setChannelStatus] = useState<string>("waiting");
|
||||||
const [isJoined, setIsJoined] = useState(false);
|
const [isJoined, setIsJoined] = useState(false);
|
||||||
|
const [isOverridden, setIsOverridden] = useState(false);
|
||||||
const [channel, setChannel] = useState<Channel | null>(null);
|
const [channel, setChannel] = useState<Channel | null>(null);
|
||||||
|
|
||||||
|
const channelName = "user:" + userName;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !isConnected) {
|
if (!socket || !isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Joining channel: ${channelName}`);
|
console.log(`Joining channel: ${channelName}`);
|
||||||
const newChannel = socket.channel(channelName, params);
|
const newChannel = socket.channel(channelName, { });
|
||||||
|
|
||||||
|
newChannel.on("new_browser_connection", () => {
|
||||||
|
console.warn("⚠️ Session overridden by new browser connection");
|
||||||
|
setIsOverridden(true);
|
||||||
|
});
|
||||||
|
|
||||||
newChannel
|
newChannel
|
||||||
.join()
|
.join()
|
||||||
@@ -62,7 +66,7 @@ export function GameChannelProvider({
|
|||||||
setIsJoined(false);
|
setIsJoined(false);
|
||||||
setChannelStatus("waiting");
|
setChannelStatus("waiting");
|
||||||
};
|
};
|
||||||
}, [socket, isConnected, channelName, params]);
|
}, [socket, isConnected, channelName]);
|
||||||
|
|
||||||
const joinGame = (name: string) => {
|
const joinGame = (name: string) => {
|
||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
@@ -87,6 +91,7 @@ export function GameChannelProvider({
|
|||||||
channel,
|
channel,
|
||||||
channelStatus,
|
channelStatus,
|
||||||
isJoined,
|
isJoined,
|
||||||
|
isOverridden,
|
||||||
joinGame,
|
joinGame,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,26 +6,29 @@ interface WebSocketContextValue {
|
|||||||
connectionStatus: string;
|
connectionStatus: string;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WebSocketContext = createContext<WebSocketContextValue | undefined>(
|
const WebSocketContext = createContext<WebSocketContextValue | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
interface WebSocketProviderProps {
|
export function WebSocketProvider({
|
||||||
|
url,
|
||||||
|
userName,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
|
userName: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}) {
|
||||||
|
|
||||||
export function WebSocketProvider({ url, children }: WebSocketProviderProps) {
|
|
||||||
const [connectionStatus, setConnectionStatus] =
|
const [connectionStatus, setConnectionStatus] =
|
||||||
useState<string>("connecting");
|
useState<string>("connecting");
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [socket, setSocket] = useState<Socket | null>(null);
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`Connecting to ${url}`);
|
console.log(`Connecting to ${url} as ${userName}`);
|
||||||
|
|
||||||
const newSocket = new Socket(url, {
|
const newSocket = new Socket(url, {
|
||||||
|
params: { user_name: userName },
|
||||||
timeout: 100,
|
timeout: 100,
|
||||||
reconnectAfterMs: (tries) =>
|
reconnectAfterMs: (tries) =>
|
||||||
[
|
[
|
||||||
@@ -60,7 +63,7 @@ export function WebSocketProvider({ url, children }: WebSocketProviderProps) {
|
|||||||
newSocket.disconnect();
|
newSocket.disconnect();
|
||||||
setSocket(null);
|
setSocket(null);
|
||||||
};
|
};
|
||||||
}, [url]);
|
}, [url, userName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebSocketContext.Provider
|
<WebSocketContext.Provider
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useGameChannelContext } from "../contexts/useGameChannelContext";
|
import { useGameChannelContext } from "../contexts/useGameChannelContext";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { BoardPlayer } from "./BoardPlayer";
|
||||||
|
|
||||||
interface Player {
|
const PlayerSchema = z.object({
|
||||||
x: number;
|
x: z.number(),
|
||||||
y: number;
|
y: z.number(),
|
||||||
}
|
});
|
||||||
|
|
||||||
interface GameState {
|
const GameStateSchema = z.record(z.string(), PlayerSchema);
|
||||||
[playerName: string]: Player;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BoardDisplay = ({ playerName }: {
|
type GameState = z.infer<typeof GameStateSchema>;
|
||||||
playerName: string;
|
|
||||||
}) => {
|
export const BoardDisplay = ({ playerName }: { playerName: string }) => {
|
||||||
const { channel, isJoined, joinGame } = useGameChannelContext();
|
const { channel, isJoined, joinGame } = useGameChannelContext();
|
||||||
const [players, setPlayers] = useState<GameState>({});
|
const [players, setPlayers] = useState<GameState>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (channel && !isJoined) {
|
if (channel && !isJoined) {
|
||||||
// Send join_game message with player name
|
|
||||||
joinGame(playerName);
|
joinGame(playerName);
|
||||||
}
|
}
|
||||||
}, [channel, isJoined, playerName, joinGame]);
|
}, [channel, isJoined, playerName, joinGame]);
|
||||||
@@ -26,62 +25,34 @@ export const BoardDisplay = ({ playerName }: {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
|
|
||||||
// Listen for game state updates
|
const ref = channel.on(
|
||||||
const ref = channel.on("game_state", (payload: { players: GameState }) => {
|
"game_state",
|
||||||
setPlayers(payload.players);
|
(payload: { game_state: unknown }) => {
|
||||||
});
|
const result = GameStateSchema.safeParse(payload.game_state);
|
||||||
|
if (result.success) {
|
||||||
|
setPlayers(result.data);
|
||||||
|
} else {
|
||||||
|
console.error("Invalid game state received:", result.error);
|
||||||
|
setPlayers({});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Cleanup listener on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
channel.off("game_state", ref);
|
channel.off("game_state", ref);
|
||||||
};
|
};
|
||||||
}, [channel]);
|
}, [channel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative w-200 h-150 bg-navy-800 my-12.5 mx-auto border-2 border-navy-700 overflow-hidden">
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
width: "800px",
|
|
||||||
height: "600px",
|
|
||||||
background: "#16213e",
|
|
||||||
margin: "50px auto",
|
|
||||||
border: "2px solid #0f3460",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Object.entries(players).map(([name, player]) => (
|
{Object.entries(players).map(([name, player]) => (
|
||||||
<div
|
<BoardPlayer
|
||||||
key={name}
|
key={name}
|
||||||
style={{
|
name={name}
|
||||||
position: "absolute",
|
x={player.x}
|
||||||
left: player.x,
|
y={player.y}
|
||||||
top: player.y,
|
isCurrentPlayer={name === playerName}
|
||||||
width: "20px",
|
/>
|
||||||
height: "20px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: name === playerName ? "#e94560" : "#53a8b6",
|
|
||||||
border:
|
|
||||||
name === playerName ? "3px solid #ff6b6b" : "2px solid #48d6e0",
|
|
||||||
transition: "all 0.1s linear",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
boxShadow:
|
|
||||||
name === playerName ? "0 0 10px #e94560" : "0 0 5px #53a8b6",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "-25px",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "10px",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
28
client/src/game/BoardPlayer.tsx
Normal file
28
client/src/game/BoardPlayer.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { type FC } from "react";
|
||||||
|
|
||||||
|
export const BoardPlayer: FC<{
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
isCurrentPlayer: boolean;
|
||||||
|
}> = ({ name, x, y, isCurrentPlayer }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
transition-all duration-50
|
||||||
|
absolute w-5 h-5 rounded-full linear -translate-x-1/2 -translate-y-1/2 ${
|
||||||
|
isCurrentPlayer
|
||||||
|
? "bg-accent-600 border-[3px] border-accent-400 shadow-[0_0_10px_var(--color-accent-600)]"
|
||||||
|
: "bg-navy-400 border-2 border-navy-300 shadow-[0_0_5px_var(--color-navy-400)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-6.25 left-1/2 -translate-x-1/2 text-navy-50 text-[10px] whitespace-nowrap">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,19 +6,7 @@ export const ConnectionStatus = () => {
|
|||||||
const { channelStatus } = useGameChannelContext();
|
const { channelStatus } = useGameChannelContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="text-navy-50 font-mono bg-navy-900/80 p-2.5 rounded-[5px] text-xs">
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 10,
|
|
||||||
right: 10,
|
|
||||||
color: "white",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
background: "rgba(0,0,0,0.5)",
|
|
||||||
padding: "10px",
|
|
||||||
borderRadius: "5px",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>WebSocket: {connectionStatus}</div>
|
<div>WebSocket: {connectionStatus}</div>
|
||||||
<div>Channel: {channelStatus}</div>
|
<div>Channel: {channelStatus}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,35 +29,9 @@ export const NameInput = ({ onNameSubmit }: NameInputProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-screen h-screen bg-navy-900 flex items-center justify-center">
|
||||||
style={{
|
<div className="bg-navy-800 p-10 rounded-[10px] border-2 border-navy-700 max-w-100 w-full">
|
||||||
width: "100vw",
|
<h1 className="text-accent-500 font-mono text-2xl mb-5 text-center">
|
||||||
height: "100vh",
|
|
||||||
background: "#1a1a2e",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: "#16213e",
|
|
||||||
padding: "40px",
|
|
||||||
borderRadius: "10px",
|
|
||||||
border: "2px solid #0f3460",
|
|
||||||
maxWidth: "400px",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
color: "#e94560",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "24px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Enter Your Name
|
Enter Your Name
|
||||||
</h1>
|
</h1>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -67,34 +41,16 @@ export const NameInput = ({ onNameSubmit }: NameInputProps) => {
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Your name..."
|
placeholder="Your name..."
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{
|
className="w-full p-3 text-base bg-navy-700 border-2 border-navy-500 rounded-[5px] text-navy-50 font-mono mb-5 box-border placeholder:text-navy-400"
|
||||||
width: "100%",
|
|
||||||
padding: "12px",
|
|
||||||
fontSize: "16px",
|
|
||||||
background: "#0f3460",
|
|
||||||
border: "2px solid #53a8b6",
|
|
||||||
borderRadius: "5px",
|
|
||||||
color: "white",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
marginBottom: "20px",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!name.trim()}
|
disabled={!name.trim()}
|
||||||
style={{
|
className={`w-full p-3 text-base border-none rounded-[5px] text-navy-50 font-mono font-bold ${
|
||||||
width: "100%",
|
name.trim()
|
||||||
padding: "12px",
|
? "bg-accent-600 hover:bg-accent-500 cursor-pointer"
|
||||||
fontSize: "16px",
|
: "bg-navy-600 cursor-not-allowed"
|
||||||
background: name.trim() ? "#e94560" : "#666",
|
}`}
|
||||||
border: "none",
|
|
||||||
borderRadius: "5px",
|
|
||||||
color: "white",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
cursor: name.trim() ? "pointer" : "not-allowed",
|
|
||||||
fontWeight: "bold",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Join Game
|
Join Game
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
24
client/src/game/SessionOverride.tsx
Normal file
24
client/src/game/SessionOverride.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
export const SessionOverride: FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-navy-900/95 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-navy-800 border-2 border-accent-600 rounded-lg p-8 max-w-md text-center">
|
||||||
|
<div className="text-6xl mb-4">⚠️</div>
|
||||||
|
<h2 className="text-2xl font-bold text-accent-500 mb-4">
|
||||||
|
Session Overridden
|
||||||
|
</h2>
|
||||||
|
<p className="text-navy-200 mb-6">
|
||||||
|
This session has been replaced by a new browser connection. Your
|
||||||
|
inputs are no longer active.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="bg-accent-600 hover:bg-accent-500 text-navy-900 font-bold py-2 px-6 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,34 +12,37 @@ export const UserInput = ({ playerName }: { playerName: string }) => {
|
|||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
if (["w", "a", "s", "d"].includes(key)) {
|
if (["w", "a", "s", "d"].includes(key)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
keysPressed.current.add(key);
|
if (!keysPressed.current.has(key) && channel.state === "joined") {
|
||||||
|
keysPressed.current.add(key);
|
||||||
|
channel.push("key_down", { key, name: playerName });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
if (["w", "a", "s", "d"].includes(key)) {
|
if (["w", "a", "s", "d"].includes(key)) {
|
||||||
keysPressed.current.delete(key);
|
if (keysPressed.current.has(key) && channel.state === "joined") {
|
||||||
|
keysPressed.current.delete(key);
|
||||||
|
channel.push("key_up", { key, name: playerName });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
|
if (channel.state === "joined") {
|
||||||
|
keysPressed.current.forEach((key) => {
|
||||||
|
channel.push("key_up", { key, name: playerName });
|
||||||
|
});
|
||||||
|
}
|
||||||
keysPressed.current.clear();
|
keysPressed.current.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (keysPressed.current.size > 0 && channel.state === "joined") {
|
|
||||||
const directions = Array.from(keysPressed.current);
|
|
||||||
channel.push("move", { directions, name: playerName });
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
window.addEventListener("keyup", handleKeyUp);
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
window.addEventListener("blur", handleBlur);
|
window.addEventListener("blur", handleBlur);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
window.removeEventListener("blur", handleBlur);
|
window.removeEventListener("blur", handleBlur);
|
||||||
|
|||||||
@@ -1,66 +1,92 @@
|
|||||||
:root {
|
@import "tailwindcss";
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
/* :root {
|
||||||
color: rgba(255, 255, 255, 0.87);
|
--color-navy-50: hsl(222 30% 94%);
|
||||||
background-color: #242424;
|
--color-navy-100: hsl(222 32% 88%);
|
||||||
|
--color-navy-200: hsl(222 35% 77%);
|
||||||
|
--color-navy-300: hsl(222 38% 66%);
|
||||||
|
--color-navy-400: hsl(222 40% 55%);
|
||||||
|
--color-navy-500: hsl(222 45% 43%);
|
||||||
|
--color-navy-600: hsl(222 50% 34%);
|
||||||
|
--color-navy-700: hsl(222 55% 26%);
|
||||||
|
--color-navy-800: hsl(222 58% 17%);
|
||||||
|
--color-navy-900: hsl(222 60% 9%);
|
||||||
|
--color-navy-950: hsl(222 55% 5%);
|
||||||
|
|
||||||
font-synthesis: none;
|
--color-accent-50: hsl(13 35% 94%);
|
||||||
text-rendering: optimizeLegibility;
|
--color-accent-100: hsl(13 38% 88%);
|
||||||
-webkit-font-smoothing: antialiased;
|
--color-accent-200: hsl(13 40% 77%);
|
||||||
-moz-osx-font-smoothing: grayscale;
|
--color-accent-300: hsl(13 42% 66%);
|
||||||
}
|
--color-accent-400: hsl(13 45% 55%);
|
||||||
|
--color-accent-500: hsl(13 48% 50%);
|
||||||
|
--color-accent-600: hsl(13 50% 42%);
|
||||||
|
--color-accent-700: hsl(13 52% 34%);
|
||||||
|
--color-accent-800: hsl(13 55% 23%);
|
||||||
|
--color-accent-900: hsl(13 58% 12%);
|
||||||
|
--color-accent-950: hsl(13, 60%, 6%);
|
||||||
|
} */
|
||||||
|
@theme {
|
||||||
|
--color-navy-50: hsl(222 30% 94%);
|
||||||
|
--color-navy-100: hsl(222 32% 88%);
|
||||||
|
--color-navy-200: hsl(222 35% 77%);
|
||||||
|
--color-navy-300: hsl(222 38% 66%);
|
||||||
|
--color-navy-400: hsl(222 40% 55%);
|
||||||
|
--color-navy-500: hsl(222 45% 43%);
|
||||||
|
--color-navy-600: hsl(222 50% 34%);
|
||||||
|
--color-navy-700: hsl(222 55% 26%);
|
||||||
|
--color-navy-800: hsl(222 58% 17%);
|
||||||
|
--color-navy-900: hsl(222 60% 9%);
|
||||||
|
--color-navy-950: hsl(222 55% 5%);
|
||||||
|
|
||||||
a {
|
--color-accent-50: hsl(7 35% 94%);
|
||||||
font-weight: 500;
|
--color-accent-100: hsl(7 38% 88%);
|
||||||
color: #646cff;
|
--color-accent-200: hsl(7 40% 77%);
|
||||||
text-decoration: inherit;
|
--color-accent-300: hsl(7 42% 66%);
|
||||||
}
|
--color-accent-400: hsl(7 45% 55%);
|
||||||
a:hover {
|
--color-accent-500: hsl(7 48% 50%);
|
||||||
color: #535bf2;
|
--color-accent-600: hsl(7 50% 42%);
|
||||||
|
--color-accent-700: hsl(7 52% 34%);
|
||||||
|
--color-accent-800: hsl(7 55% 23%);
|
||||||
|
--color-accent-900: hsl(7 58% 12%);
|
||||||
|
--color-accent-950: hsl(7 60% 6%);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
@apply m-0 min-w-[320px] min-h-screen font-sans antialiased;
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 3.2em;
|
@apply text-5xl font-bold leading-tight;
|
||||||
line-height: 1.1;
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@apply text-4xl font-bold leading-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
@apply text-3xl font-semibold leading-snug;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
@apply text-2xl font-semibold leading-snug;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
@apply text-xl font-semibold leading-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
@apply text-lg font-semibold leading-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply font-medium no-underline transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border-radius: 8px;
|
@apply rounded-lg border border-transparent px-5 py-2.5 text-base font-medium cursor-pointer transition-colors;
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
button:disabled {
|
||||||
:root {
|
@apply opacity-50 cursor-not-allowed;
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,38 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode, useState, type FC } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import { GameChannelProvider } from "./contexts/GameChannelContext";
|
|
||||||
import { WebSocketProvider } from "./contexts/WebSocketContext.tsx";
|
import { WebSocketProvider } from "./contexts/WebSocketContext.tsx";
|
||||||
|
import { GameChannelProvider } from "./contexts/GameChannelContext.tsx";
|
||||||
|
import { NameInput } from "./game/NameInput.tsx";
|
||||||
|
|
||||||
const WS_SERVER = "ws://localhost:4000/socket";
|
const WS_SERVER = "ws://localhost:4000/socket";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
const getPlayerNameFromUrl = () => {
|
||||||
<StrictMode>
|
const params = new URLSearchParams(window.location.search);
|
||||||
<WebSocketProvider url={WS_SERVER}>
|
return params.get("name") || null;
|
||||||
<GameChannelProvider channelName="game:lobby">
|
};
|
||||||
<App />
|
|
||||||
|
export const ProvidersWithName: FC = () => {
|
||||||
|
const [playerName, setPlayerName] = useState<string | null>(
|
||||||
|
getPlayerNameFromUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!playerName) {
|
||||||
|
return <NameInput onNameSubmit={setPlayerName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebSocketProvider url={WS_SERVER} userName={playerName}>
|
||||||
|
<GameChannelProvider userName={playerName}>
|
||||||
|
<App playerName={playerName} />
|
||||||
</GameChannelProvider>
|
</GameChannelProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ProvidersWithName />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
react({
|
react({
|
||||||
babel: {
|
babel: {
|
||||||
plugins: [['babel-plugin-react-compiler']],
|
plugins: [["babel-plugin-react-compiler"]],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user