diff --git a/unity-client/Assets/Scripts/UI/CollectionController.cs b/unity-client/Assets/Scripts/UI/CollectionController.cs
new file mode 100644
index 0000000..f020a01
--- /dev/null
+++ b/unity-client/Assets/Scripts/UI/CollectionController.cs
@@ -0,0 +1,353 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Text;
+using UnityEngine;
+using UnityEngine.UI;
+using UnityEngine.SceneManagement;
+using TMPro;
+using Newtonsoft.Json;
+using CommanderAILab.Models;
+using CommanderAILab.Services;
+
+namespace CommanderAILab.UI
+{
+ ///
+ /// Collection scene controller — card grid with search, filter, sort,
+ /// card detail panel, async image loading, pagination, and mobile support.
+ ///
+ public class CollectionController : MonoBehaviour
+ {
+ // ── Header ──────────────────────────────────────────────────────────
+ [Header("Navigation")]
+ [SerializeField] private Button backButton;
+
+ [Header("Search & Sort")]
+ [SerializeField] private TMP_InputField searchInput;
+ [SerializeField] private TMP_Dropdown sortDropdown;
+ [SerializeField] private Button searchButton;
+ [SerializeField] private Button filterToggleButton; // mobile: open/close drawer
+
+ [Header("Filter Sidebar")]
+ [SerializeField] private GameObject filterPanel; // root – toggled on mobile
+ [SerializeField] private TMP_Dropdown colorFilterDropdown;
+ [SerializeField] private TMP_Dropdown typeFilterDropdown;
+ [SerializeField] private TMP_Dropdown setFilterDropdown;
+ [SerializeField] private TMP_Dropdown rarityFilterDropdown;
+ [SerializeField] private TMP_Dropdown ownedFilterDropdown; // All / Owned / Missing
+ [SerializeField] private Button applyFilterButton;
+ [SerializeField] private Button clearFilterButton;
+
+ [Header("Card Grid")]
+ [SerializeField] private Transform cardGridParent; // ScrollRect content
+ [SerializeField] private GameObject cardPrefab; // CardPrefab instance
+ [SerializeField] private ScrollRect gridScrollRect;
+
+ [Header("Pagination")]
+ [SerializeField] private Button loadMoreButton;
+ [SerializeField] private TMP_Text pageInfoText;
+ [SerializeField] private GameObject loadingSpinner;
+
+ [Header("Card Detail Panel")]
+ [SerializeField] private GameObject detailPanel;
+ [SerializeField] private Image detailCardFront;
+ [SerializeField] private Image detailCardBack; // placeholder / back face
+ [SerializeField] private TMP_Text detailCardName;
+ [SerializeField] private TMP_Text detailManaCost;
+ [SerializeField] private TMP_Text detailType;
+ [SerializeField] private TMP_Text detailRarity;
+ [SerializeField] private TMP_Text detailSet;
+ [SerializeField] private TMP_Text detailOracleText;
+ [SerializeField] private TMP_Text detailQuantity;
+ [SerializeField] private Button detailAddToDeckButton;
+ [SerializeField] private Button detailCloseButton;
+
+ [Header("Error")]
+ [SerializeField] private GameObject errorPanel;
+ [SerializeField] private TMP_Text errorText;
+ [SerializeField] private Button retryButton;
+
+ // ── State ────────────────────────────────────────────────────────────
+ private List _cards = new();
+ private CardModel _selectedCard;
+ private int _currentPage = 1;
+ private int _totalCards = 0;
+ private const int PageSize = 40;
+ private bool _isLoading = false;
+ private bool _filterOpen = false;
+
+ // Current filter / sort state
+ private string _searchQuery = "";
+ private string _colorFilter = "";
+ private string _typeFilter = "";
+ private string _setFilter = "";
+ private string _rarityFilter = "";
+ private string _ownedFilter = ""; // "all" | "owned" | "missing"
+ private string _sortField = "name";
+
+ // Pooled card tile GameObjects
+ private readonly List _cardTiles = new();
+
+ // ── Lifecycle ────────────────────────────────────────────────────────
+ private void Start()
+ {
+ // Back button → MainMenu
+ backButton.onClick.AddListener(() => SceneManager.LoadScene("MainMenu"));
+
+ // Search
+ searchButton.onClick.AddListener(OnSearchClicked);
+ searchInput.onSubmit.AddListener(_ => OnSearchClicked());
+
+ // Sort
+ sortDropdown.onValueChanged.AddListener(_ => RefreshFromPage1());
+
+ // Filter actions
+ applyFilterButton.onClick.AddListener(RefreshFromPage1);
+ clearFilterButton.onClick.AddListener(OnClearFilters);
+ filterToggleButton.onClick.AddListener(ToggleFilterPanel);
+
+ // Pagination
+ loadMoreButton.onClick.AddListener(OnLoadMore);
+ loadMoreButton.gameObject.SetActive(false);
+
+ // Detail panel
+ detailCloseButton.onClick.AddListener(CloseDetailPanel);
+ detailAddToDeckButton.onClick.AddListener(OnAddToDeck);
+ detailPanel.SetActive(false);
+
+ // Error retry
+ retryButton.onClick.AddListener(RefreshFromPage1);
+ errorPanel.SetActive(false);
+
+ // On mobile start with filter panel hidden
+ _filterOpen = Screen.width > 900;
+ filterPanel.SetActive(_filterOpen);
+
+ // Initial load
+ RefreshFromPage1();
+ }
+
+ // ── Search / Filter / Sort ───────────────────────────────────────────
+ private void OnSearchClicked()
+ {
+ _searchQuery = searchInput.text.Trim();
+ RefreshFromPage1();
+ }
+
+ private void OnClearFilters()
+ {
+ colorFilterDropdown.value = 0;
+ typeFilterDropdown.value = 0;
+ setFilterDropdown.value = 0;
+ rarityFilterDropdown.value = 0;
+ ownedFilterDropdown.value = 0;
+ searchInput.text = "";
+ _searchQuery = "";
+ RefreshFromPage1();
+ }
+
+ private void ToggleFilterPanel()
+ {
+ _filterOpen = !_filterOpen;
+ filterPanel.SetActive(_filterOpen);
+ }
+
+ // ── Data Loading ─────────────────────────────────────────────────────
+ private void RefreshFromPage1()
+ {
+ _currentPage = 1;
+ _cards.Clear();
+ ClearCardTiles();
+ LoadPage(_currentPage);
+ }
+
+ private void OnLoadMore()
+ {
+ _currentPage++;
+ LoadPage(_currentPage);
+ }
+
+ private void LoadPage(int page)
+ {
+ if (_isLoading) return;
+ _isLoading = true;
+ loadingSpinner.SetActive(true);
+ loadMoreButton.interactable = false;
+ errorPanel.SetActive(false);
+
+ // Collect filter values from dropdowns
+ _colorFilter = DropdownValue(colorFilterDropdown);
+ _typeFilter = DropdownValue(typeFilterDropdown);
+ _setFilter = DropdownValue(setFilterDropdown);
+ _rarityFilter = DropdownValue(rarityFilterDropdown);
+ _ownedFilter = DropdownValue(ownedFilterDropdown);
+ _sortField = SortFieldFromDropdown(sortDropdown.value);
+
+ string path = BuildQueryPath(page);
+ StartCoroutine(ApiClient.Instance.GetRaw(path,
+ json => OnPageLoaded(json, page),
+ errMsg => OnLoadError(errMsg)));
+ }
+
+ private string BuildQueryPath(int page)
+ {
+ var sb = new StringBuilder("/api/collection?");
+ sb.Append($"page={page}&per_page={PageSize}");
+ if (!string.IsNullOrEmpty(_searchQuery))
+ sb.Append($"&search={UnityWebRequest.EscapeURL(_searchQuery)}");
+ if (!string.IsNullOrEmpty(_colorFilter))
+ sb.Append($"&colors={UnityWebRequest.EscapeURL(_colorFilter)}");
+ if (!string.IsNullOrEmpty(_typeFilter))
+ sb.Append($"&type={UnityWebRequest.EscapeURL(_typeFilter)}");
+ if (!string.IsNullOrEmpty(_setFilter))
+ sb.Append($"&set={UnityWebRequest.EscapeURL(_setFilter)}");
+ if (!string.IsNullOrEmpty(_rarityFilter))
+ sb.Append($"&rarity={UnityWebRequest.EscapeURL(_rarityFilter)}");
+ if (!string.IsNullOrEmpty(_ownedFilter) && _ownedFilter != "all")
+ sb.Append($"&owned={_ownedFilter}");
+ if (!string.IsNullOrEmpty(_sortField))
+ sb.Append($"&sort={_sortField}");
+ return sb.ToString();
+ }
+
+ private void OnPageLoaded(string json, int page)
+ {
+ _isLoading = false;
+ loadingSpinner.SetActive(false);
+
+ CollectionResponse response;
+ try { response = JsonConvert.DeserializeObject(json); }
+ catch (Exception e)
+ {
+ OnLoadError($"Parse error: {e.Message}");
+ return;
+ }
+
+ _totalCards = response.total;
+ _cards.AddRange(response.cards);
+
+ UpdatePageInfo();
+ SpawnCardTiles(response.cards);
+
+ bool hasMore = _cards.Count < _totalCards;
+ loadMoreButton.gameObject.SetActive(hasMore);
+ loadMoreButton.interactable = true;
+ }
+
+ private void OnLoadError(string msg)
+ {
+ _isLoading = false;
+ loadingSpinner.SetActive(false);
+ errorText.text = $"Failed to load collection:\n{msg}";
+ errorPanel.SetActive(true);
+ Debug.LogWarning($"[CollectionController] {msg}");
+ }
+
+ // ── Card Tiles ───────────────────────────────────────────────────────
+ private void SpawnCardTiles(List cards)
+ {
+ foreach (var card in cards)
+ {
+ var tile = Instantiate(cardPrefab, cardGridParent);
+ _cardTiles.Add(tile);
+
+ // Set name label (assumes CardPrefab has a TMP child named "CardName")
+ var nameLabel = tile.GetComponentInChildren();
+ if (nameLabel != null) nameLabel.text = card.name;
+
+ // Async image load
+ var img = tile.GetComponentInChildren();
+ if (img != null && !string.IsNullOrEmpty(card.imageUrl))
+ {
+ ImageCache.Instance.GetSprite(card.imageUrl, sprite =>
+ {
+ if (img != null) img.sprite = sprite;
+ });
+ }
+
+ // Click → open detail panel
+ var btn = tile.GetComponentInChildren