From 0dae393d0d06da867e4dc6c61d94ffbb217fa9ba Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 2 Mar 2026 13:39:18 -0700 Subject: [PATCH] can play game --- .vscode/settings.json | 3 + AGENTS.md | 12 + README.md | 12 + backend/lib/backend/application.ex | 11 - backend/lib/backend/cluster.ex | 37 +- backend/lib/backend/game_state.ex | 56 ++- backend/lib/backend/lobby_tracker.ex | 25 ++ .../channels/connected_user_channel.ex | 118 +++++ .../lib/backend_web/channels/game_channel.ex | 60 --- .../lib/backend_web/channels/user_socket.ex | 39 +- backend/priv/gettext/en/LC_MESSAGES/errors.po | 11 - backend/priv/gettext/errors.pot | 10 - client/package.json | 5 +- client/pnpm-lock.yaml | 404 ++++++++++++++++-- client/src/App.css | 40 -- client/src/App.tsx | 33 +- client/src/contexts/GameChannelContext.tsx | 27 +- client/src/contexts/WebSocketContext.tsx | 17 +- client/src/game/BoardDisplay.tsx | 87 ++-- client/src/game/BoardPlayer.tsx | 28 ++ client/src/game/ConnectionStatus.tsx | 14 +- client/src/game/NameInput.tsx | 62 +-- client/src/game/SessionOverride.tsx | 24 ++ client/src/game/UserInput.tsx | 23 +- client/src/index.css | 128 +++--- client/src/main.tsx | 34 +- client/vite.config.ts | 10 +- 27 files changed, 856 insertions(+), 474 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 backend/lib/backend/lobby_tracker.ex create mode 100644 backend/lib/backend_web/channels/connected_user_channel.ex delete mode 100644 backend/lib/backend_web/channels/game_channel.ex delete mode 100644 backend/priv/gettext/en/LC_MESSAGES/errors.po delete mode 100644 backend/priv/gettext/errors.pot delete mode 100644 client/src/App.css create mode 100644 client/src/game/BoardPlayer.tsx create mode 100644 client/src/game/SessionOverride.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8e5a45c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "css.lint.unknownAtRules": "ignore" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8929af6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ + +/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) + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..42430b5 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ + + + +distribute persistent share tables with mnesia + + + +resources: + + +Channels: +- 1 channel per browser instance \ No newline at end of file diff --git a/backend/lib/backend/application.ex b/backend/lib/backend/application.ex index 1b070fe..0a250e9 100644 --- a/backend/lib/backend/application.ex +++ b/backend/lib/backend/application.ex @@ -1,28 +1,17 @@ defmodule Backend.Application do - # See https://hexdocs.pm/elixir/Application.html - # for more information on OTP Applications @moduledoc false - use Application @impl true def start(_type, _args) do children = [ BackendWeb.Telemetry, - {DNSCluster, query: Application.get_env(:backend, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Backend.PubSub}, - # Start the Cluster manager for distributed Erlang Backend.Cluster, - # Use GlobalSingleton supervisor for instant failover {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 ] - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options opts = [strategy: :one_for_one, name: Backend.Supervisor] Supervisor.start_link(children, opts) end diff --git a/backend/lib/backend/cluster.ex b/backend/lib/backend/cluster.ex index 4a77c8f..eacbdcb 100644 --- a/backend/lib/backend/cluster.ex +++ b/backend/lib/backend/cluster.ex @@ -33,43 +33,30 @@ defmodule Backend.Cluster do end defp connect_to_cluster do - # Get all cluster nodes and exclude ourselves - all_nodes = + self_node = node() + + cluster_nodes = System.get_env("CLUSTER_NODES", "") |> String.split(",", trim: true) |> 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 = - Enum.filter(cluster_nodes, fn node_name -> - case Node.connect(node_name) do - 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) + connection_attempts + |> Enum.filter(fn %{status: status} -> status == false end) connected_nodes = Node.list() - # Expected count is other nodes (not including ourselves) - expected_count = length(cluster_nodes) - actual_count = length(connected_nodes) - - if failures != [] or actual_count < expected_count do + if failures != [] or length(connected_nodes) < length(cluster_nodes) do 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 defp schedule_connect do # Retry connection every 10 seconds diff --git a/backend/lib/backend/game_state.ex b/backend/lib/backend/game_state.ex index 0a13738..9f49fda 100644 --- a/backend/lib/backend/game_state.ex +++ b/backend/lib/backend/game_state.ex @@ -10,6 +10,7 @@ defmodule Backend.GameState do @name {:global, __MODULE__} @pubsub Backend.PubSub @topic "game_state" + @fps 30 # Client API @@ -39,6 +40,10 @@ defmodule Backend.GameState do GenServer.cast(@name, {:move_player, player_id, directions}) 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 GenServer.call(@name, :get_state) end @@ -48,6 +53,9 @@ defmodule Backend.GameState do @impl true def init(_) do Logger.info("GameState starting on node: #{node()}") + sleep_delay = round(1000 / @fps) + :timer.send_interval(sleep_delay, :tick) + {:ok, %{}} end @@ -57,43 +65,53 @@ defmodule Backend.GameState do x = :rand.uniform(800) 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) {:reply, new_state, new_state} end @impl true - def handle_cast({:move_player, player_id, directions}, state) do - Logger.info("Processing player move") - + def handle_cast({:update_player_keys, player_id, keys_pressed}, state) do player = case Map.get(state, player_id) do nil -> 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 + Map.put(existing_player, :keys_pressed, keys_pressed) 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 - new_position = - 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 + @impl true + def handle_info(:tick, state) do + # On each tick, move players based on their keys_pressed + new_state = + Enum.reduce(state, state, fn {player_id, player}, acc -> + directions = player.keys_pressed || [] + step = 10 + + new_player = + 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) - new_state = Map.put(state, player_id, new_position) broadcast_state(new_state) {:noreply, new_state} end diff --git a/backend/lib/backend/lobby_tracker.ex b/backend/lib/backend/lobby_tracker.ex new file mode 100644 index 0000000..ff05f1d --- /dev/null +++ b/backend/lib/backend/lobby_tracker.ex @@ -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 diff --git a/backend/lib/backend_web/channels/connected_user_channel.ex b/backend/lib/backend_web/channels/connected_user_channel.ex new file mode 100644 index 0000000..6a0a023 --- /dev/null +++ b/backend/lib/backend_web/channels/connected_user_channel.ex @@ -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 diff --git a/backend/lib/backend_web/channels/game_channel.ex b/backend/lib/backend_web/channels/game_channel.ex deleted file mode 100644 index b07e6a0..0000000 --- a/backend/lib/backend_web/channels/game_channel.ex +++ /dev/null @@ -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 diff --git a/backend/lib/backend_web/channels/user_socket.ex b/backend/lib/backend_web/channels/user_socket.ex index 34c7955..830d4ba 100644 --- a/backend/lib/backend_web/channels/user_socket.ex +++ b/backend/lib/backend_web/channels/user_socket.ex @@ -1,36 +1,27 @@ defmodule BackendWeb.UserSocket do 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 - 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 - def connect(_params, socket, _connect_info) do - {:ok, socket} + def connect(%{"user_name" => user_name}, socket, _connect_info) do + {: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 - # 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 + 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 end diff --git a/backend/priv/gettext/en/LC_MESSAGES/errors.po b/backend/priv/gettext/en/LC_MESSAGES/errors.po deleted file mode 100644 index cdec3a1..0000000 --- a/backend/priv/gettext/en/LC_MESSAGES/errors.po +++ /dev/null @@ -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" diff --git a/backend/priv/gettext/errors.pot b/backend/priv/gettext/errors.pot deleted file mode 100644 index d6f47fa..0000000 --- a/backend/priv/gettext/errors.pot +++ /dev/null @@ -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. - diff --git a/client/package.json b/client/package.json index 78cac76..9271fc1 100644 --- a/client/package.json +++ b/client/package.json @@ -10,9 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "@tailwindcss/vite": "^4.2.1", "phoenix": "^1.8.4", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "tailwindcss": "^4.2.1", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 1f77db0..64cb50a 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: 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: specifier: ^1.8.4 version: 1.8.4 @@ -17,6 +20,12 @@ importers: react-dom: specifier: ^19.2.0 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: '@eslint/js': specifier: ^9.39.1 @@ -35,19 +44,19 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': 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: specifier: ^1.0.0 version: 1.0.0 eslint: specifier: ^9.39.1 - version: 9.39.3 + version: 9.39.3(jiti@2.6.1) eslint-plugin-react-hooks: 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: 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: specifier: ^16.5.0 version: 16.5.0 @@ -56,10 +65,10 @@ importers: version: 5.9.3 typescript-eslint: 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: 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: @@ -500,6 +509,96 @@ packages: cpu: [x64] 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': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -687,9 +786,17 @@ packages: deep-is@0.1.4: 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: 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: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -814,6 +921,9 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} 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: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -851,6 +961,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -884,6 +998,76 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} 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: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -894,6 +1078,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@10.2.2: resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} engines: {node: 18 || 20 || >=22} @@ -1015,6 +1202,13 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 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: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1310,9 +1504,9 @@ snapshots: '@esbuild/win32-x64@0.27.3': 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: - eslint: 9.39.3 + eslint: 9.39.3(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1463,6 +1657,74 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': 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': dependencies: '@babel/parser': 7.29.0 @@ -1502,15 +1764,15 @@ snapshots: dependencies: 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: '@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/type-utils': 8.56.1(eslint@9.39.3)(typescript@5.9.3) - '@typescript-eslint/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(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 9.39.3 + eslint: 9.39.3(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -1518,14 +1780,14 @@ snapshots: transitivePeerDependencies: - 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: '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/types': 8.56.1 '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 - eslint: 9.39.3 + eslint: 9.39.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -1548,13 +1810,13 @@ snapshots: dependencies: 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: '@typescript-eslint/types': 8.56.1 '@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 - eslint: 9.39.3 + eslint: 9.39.3(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -1577,13 +1839,13 @@ snapshots: transitivePeerDependencies: - 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: - '@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/types': 8.56.1 '@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 transitivePeerDependencies: - supports-color @@ -1593,7 +1855,7 @@ snapshots: '@typescript-eslint/types': 8.56.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: '@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 '@types/babel__core': 7.20.5 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: - supports-color @@ -1684,8 +1946,15 @@ snapshots: deep-is@0.1.4: {} + detect-libc@2.1.2: {} + 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: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -1719,20 +1988,20 @@ snapshots: 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: '@babel/core': 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 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - 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: - eslint: 9.39.3 + eslint: 9.39.3(jiti@2.6.1) eslint-scope@8.4.0: dependencies: @@ -1745,9 +2014,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.3: + eslint@9.39.3(jiti@2.6.1): 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/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -1781,6 +2050,8 @@ snapshots: minimatch: 3.1.3 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -1841,6 +2112,8 @@ snapshots: globals@16.5.0: {} + graceful-fs@4.2.11: {} + has-flag@4.0.0: {} hermes-estree@0.25.1: {} @@ -1868,6 +2141,8 @@ snapshots: isexe@2.0.0: {} + jiti@2.6.1: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -1893,6 +2168,55 @@ snapshots: prelude-ls: 1.2.1 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: dependencies: p-locate: 5.0.0 @@ -1903,6 +2227,10 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + minimatch@10.2.2: dependencies: brace-expansion: 5.0.3 @@ -2022,6 +2350,10 @@ snapshots: dependencies: has-flag: 4.0.0 + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -2035,13 +2367,13 @@ snapshots: dependencies: 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: - '@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/parser': 8.56.1(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(jiti@2.6.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) - eslint: 9.39.3 + '@typescript-eslint/utils': 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 transitivePeerDependencies: - supports-color @@ -2060,7 +2392,7 @@ snapshots: dependencies: 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: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -2071,6 +2403,8 @@ snapshots: optionalDependencies: '@types/node': 24.10.13 fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 which@2.0.2: dependencies: diff --git a/client/src/App.css b/client/src/App.css deleted file mode 100644 index 6cd2806..0000000 --- a/client/src/App.css +++ /dev/null @@ -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; -} diff --git a/client/src/App.tsx b/client/src/App.tsx index d8afc45..e56759a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,41 +1,26 @@ -import { useState } from "react"; -import "./App.css"; +import { type FC } from "react"; import { UserInput } from "./game/UserInput"; import { BoardDisplay } from "./game/BoardDisplay"; import { ConnectionStatus } from "./game/ConnectionStatus"; -import { NameInput } from "./game/NameInput"; +import { SessionOverride } from "./game/SessionOverride"; +import { useGameChannelContext } from "./contexts/useGameChannelContext"; -const getPlayerNameFromUrl = () => { - const params = new URLSearchParams(window.location.search); - return params.get("name") || null; -}; +const App: FC<{ playerName: string }> = ({ playerName }) => { + const { isOverridden } = useGameChannelContext(); -function App() { - const [playerName, setPlayerName] = useState( - getPlayerNameFromUrl, - ); - - if (!playerName) { - return ; + if (isOverridden) { + return ; } return ( <> -
+
); -} +}; export default App; diff --git a/client/src/contexts/GameChannelContext.tsx b/client/src/contexts/GameChannelContext.tsx index edc5233..8d820ed 100644 --- a/client/src/contexts/GameChannelContext.tsx +++ b/client/src/contexts/GameChannelContext.tsx @@ -6,6 +6,7 @@ interface GameChannelContextValue { channel: Channel | null; channelStatus: string; isJoined: boolean; + isOverridden: boolean; joinGame: (name: string) => void; } @@ -13,29 +14,32 @@ const GameChannelContext = createContext( undefined, ); -interface GameChannelProviderProps { - channelName: string; - params?: Record; - children: ReactNode; -} - export function GameChannelProvider({ - channelName, - params = {}, + userName, children, -}: GameChannelProviderProps) { +}: { + userName: string; + children: ReactNode; +}) { const { socket, isConnected } = useWebSocketContext(); const [channelStatus, setChannelStatus] = useState("waiting"); const [isJoined, setIsJoined] = useState(false); + const [isOverridden, setIsOverridden] = useState(false); const [channel, setChannel] = useState(null); + const channelName = "user:" + userName; useEffect(() => { if (!socket || !isConnected) { return; } 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 .join() @@ -62,7 +66,7 @@ export function GameChannelProvider({ setIsJoined(false); setChannelStatus("waiting"); }; - }, [socket, isConnected, channelName, params]); + }, [socket, isConnected, channelName]); const joinGame = (name: string) => { if (!channel) return; @@ -87,6 +91,7 @@ export function GameChannelProvider({ channel, channelStatus, isJoined, + isOverridden, joinGame, }} > diff --git a/client/src/contexts/WebSocketContext.tsx b/client/src/contexts/WebSocketContext.tsx index 3c6529f..95ab0a7 100644 --- a/client/src/contexts/WebSocketContext.tsx +++ b/client/src/contexts/WebSocketContext.tsx @@ -6,26 +6,29 @@ interface WebSocketContextValue { connectionStatus: string; isConnected: boolean; } - const WebSocketContext = createContext( undefined, ); -interface WebSocketProviderProps { +export function WebSocketProvider({ + url, + userName, + children, +}: { url: string; + userName: string; children: ReactNode; -} - -export function WebSocketProvider({ url, children }: WebSocketProviderProps) { +}) { const [connectionStatus, setConnectionStatus] = useState("connecting"); const [isConnected, setIsConnected] = useState(false); const [socket, setSocket] = useState(null); useEffect(() => { - console.log(`Connecting to ${url}`); + console.log(`Connecting to ${url} as ${userName}`); const newSocket = new Socket(url, { + params: { user_name: userName }, timeout: 100, reconnectAfterMs: (tries) => [ @@ -60,7 +63,7 @@ export function WebSocketProvider({ url, children }: WebSocketProviderProps) { newSocket.disconnect(); setSocket(null); }; - }, [url]); + }, [url, userName]); return ( { +type GameState = z.infer; + +export const BoardDisplay = ({ playerName }: { playerName: string }) => { const { channel, isJoined, joinGame } = useGameChannelContext(); const [players, setPlayers] = useState({}); useEffect(() => { if (channel && !isJoined) { - // Send join_game message with player name joinGame(playerName); } }, [channel, isJoined, playerName, joinGame]); @@ -26,62 +25,34 @@ export const BoardDisplay = ({ playerName }: { useEffect(() => { if (!channel) return; - // Listen for game state updates - const ref = channel.on("game_state", (payload: { players: GameState }) => { - setPlayers(payload.players); - }); + const ref = channel.on( + "game_state", + (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 () => { channel.off("game_state", ref); }; }, [channel]); return ( -
+
{Object.entries(players).map(([name, player]) => ( -
-
- {name} -
-
+ name={name} + x={player.x} + y={player.y} + isCurrentPlayer={name === playerName} + /> ))}
); diff --git a/client/src/game/BoardPlayer.tsx b/client/src/game/BoardPlayer.tsx new file mode 100644 index 0000000..6ca92c8 --- /dev/null +++ b/client/src/game/BoardPlayer.tsx @@ -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 ( +
+
+ {name} +
+
+ ); +}; diff --git a/client/src/game/ConnectionStatus.tsx b/client/src/game/ConnectionStatus.tsx index 6fb9997..b51cd7b 100644 --- a/client/src/game/ConnectionStatus.tsx +++ b/client/src/game/ConnectionStatus.tsx @@ -6,19 +6,7 @@ export const ConnectionStatus = () => { const { channelStatus } = useGameChannelContext(); return ( -
+
WebSocket: {connectionStatus}
Channel: {channelStatus}
diff --git a/client/src/game/NameInput.tsx b/client/src/game/NameInput.tsx index 4fddbe2..43dd33f 100644 --- a/client/src/game/NameInput.tsx +++ b/client/src/game/NameInput.tsx @@ -29,35 +29,9 @@ export const NameInput = ({ onNameSubmit }: NameInputProps) => { }; return ( -
-
-

+
+
+

Enter Your Name

@@ -67,34 +41,16 @@ export const NameInput = ({ onNameSubmit }: NameInputProps) => { onChange={(e) => setName(e.target.value)} placeholder="Your name..." autoFocus - style={{ - width: "100%", - padding: "12px", - fontSize: "16px", - background: "#0f3460", - border: "2px solid #53a8b6", - borderRadius: "5px", - color: "white", - fontFamily: "monospace", - marginBottom: "20px", - boxSizing: "border-box", - }} + 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" /> diff --git a/client/src/game/SessionOverride.tsx b/client/src/game/SessionOverride.tsx new file mode 100644 index 0000000..6c42694 --- /dev/null +++ b/client/src/game/SessionOverride.tsx @@ -0,0 +1,24 @@ +import type { FC } from "react"; + +export const SessionOverride: FC = () => { + return ( +
+
+
⚠️
+

+ Session Overridden +

+

+ This session has been replaced by a new browser connection. Your + inputs are no longer active. +

+ +
+
+ ); +}; diff --git a/client/src/game/UserInput.tsx b/client/src/game/UserInput.tsx index d68ef6e..0cce10c 100644 --- a/client/src/game/UserInput.tsx +++ b/client/src/game/UserInput.tsx @@ -12,34 +12,37 @@ export const UserInput = ({ playerName }: { playerName: string }) => { const key = e.key.toLowerCase(); if (["w", "a", "s", "d"].includes(key)) { 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 key = e.key.toLowerCase(); 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 = () => { + if (channel.state === "joined") { + keysPressed.current.forEach((key) => { + channel.push("key_up", { key, name: playerName }); + }); + } 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("keyup", handleKeyUp); window.addEventListener("blur", handleBlur); return () => { - clearInterval(interval); window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", handleBlur); diff --git a/client/src/index.css b/client/src/index.css index fb92a9f..06f073f 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,66 +1,92 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; +@import "tailwindcss"; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; +/* :root { + --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%); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} + --color-accent-50: hsl(13 35% 94%); + --color-accent-100: hsl(13 38% 88%); + --color-accent-200: hsl(13 40% 77%); + --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 { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; + --color-accent-50: hsl(7 35% 94%); + --color-accent-100: hsl(7 38% 88%); + --color-accent-200: hsl(7 40% 77%); + --color-accent-300: hsl(7 42% 66%); + --color-accent-400: hsl(7 45% 55%); + --color-accent-500: hsl(7 48% 50%); + --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 { - margin: 0; - min-width: 320px; - min-height: 100vh; + @apply m-0 min-w-[320px] min-h-screen font-sans antialiased; } h1 { - font-size: 3.2em; - line-height: 1.1; + @apply text-5xl font-bold leading-tight; +} + +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 { - border-radius: 8px; - 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; + @apply rounded-lg border border-transparent px-5 py-2.5 text-base font-medium cursor-pointer transition-colors; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } +button:disabled { + @apply opacity-50 cursor-not-allowed; } diff --git a/client/src/main.tsx b/client/src/main.tsx index cecf601..64be8f7 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,18 +1,38 @@ -import { StrictMode } from "react"; +import { StrictMode, useState, type FC } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App.tsx"; -import { GameChannelProvider } from "./contexts/GameChannelContext"; 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"; -createRoot(document.getElementById("root")!).render( - - - - +const getPlayerNameFromUrl = () => { + const params = new URLSearchParams(window.location.search); + return params.get("name") || null; +}; + +export const ProvidersWithName: FC = () => { + const [playerName, setPlayerName] = useState( + getPlayerNameFromUrl, + ); + + if (!playerName) { + return ; + } + + return ( + + + + ); +}; + +createRoot(document.getElementById("root")!).render( + + , ); diff --git a/client/vite.config.ts b/client/vite.config.ts index 4efe189..0a5c497 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,13 +1,15 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ export default defineConfig({ plugins: [ + tailwindcss(), react({ babel: { - plugins: [['babel-plugin-react-compiler']], + plugins: [["babel-plugin-react-compiler"]], }, }), ], -}) +});