Align UI copy: My Collection vs Lists #54

Merged
varutasu merged 2 commits from feat/rename-collections-vocabulary into main 2026-05-29 10:53:41 -04:00
27 changed files with 258 additions and 148 deletions

View file

@ -59,3 +59,11 @@ Don't duplicate navigation in a page — extend `NavigationContent` inside Layou
## Branding ## Branding
The canonical product brand is **Deck Hearth** (two words, internal cap), ratified 2026-05-24 in the `pick-a-name` convoy. The repo directory + GitHub project name remain `tcg-vault` until the queued `rename-repo-and-vercel-project` convoy ships. New UI copy MUST use `Deck Hearth` verbatim — do not introduce a third name, do not abbreviate to "DH" outside the logo glyph (currently used in `components/Layout.js` lines 621, 714 as the sidebar logo monogram). The canonical product brand is **Deck Hearth** (two words, internal cap), ratified 2026-05-24 in the `pick-a-name` convoy. The repo directory + GitHub project name remain `tcg-vault` until the queued `rename-repo-and-vercel-project` convoy ships. New UI copy MUST use `Deck Hearth` verbatim — do not introduce a third name, do not abbreviate to "DH" outside the logo glyph (currently used in `components/Layout.js` lines 621, 714 as the sidebar logo monogram).
## Product vocabulary (ownership vs lists)
- **My Collection** — global ownership (`user_cards`, `/my-cards`). Never say "Owned Cards" or "Mark Owned" in UI.
- **Lists** — curated binders (`collections` table, `/collections`). Say "List" in buttons/modals; URL stays `/collections` until a future slug convoy.
- **Synced binder** — display name for the system collection (`is_system_collection`); DB literal `'All My Cards'` is internal only.
- Import copy from `lib/collection-vocabulary.js` (`VOCAB`, `collectionDisplayName`, `formatProcessedDestination`).
- CI `forbidden-stale-strings` enforces the three retired phrases in `pages/` + `components/`.

View file

@ -208,6 +208,40 @@ jobs:
fi fi
echo "OK: no client-side LLM key leakage patterns detected." echo "OK: no client-side LLM key leakage patterns detected."
forbidden-stale-strings:
name: No stale ownership/collection copy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fail if forbidden UI strings appear in pages/ or components/
run: |
# rename-collections-vocabulary convoy — ownership vs curated-list taxonomy.
# API/DB literals (e.g. system collection name) live under pages/api/ only.
PATTERNS=(
'Mark Owned'
'Owned Cards'
'All My Cards'
)
FOUND=()
for pattern in "${PATTERNS[@]}"; do
while IFS= read -r line; do
FOUND+=("$line")
done < <(grep -rFn "$pattern" pages/ components/ \
--include='*.js' \
--exclude-dir=api 2>/dev/null || true)
done
if [ ${#FOUND[@]} -gt 0 ]; then
echo "::error::Forbidden stale UI string(s) in pages/ or components/. Use lib/collection-vocabulary.js labels — see AGENTS.md § Product vocabulary."
printf '%s\n' "${FOUND[@]}" | sort -u | while IFS= read -r line; do
file=$(echo "$line" | cut -d: -f1)
lineno=$(echo "$line" | cut -d: -f2)
text=$(echo "$line" | cut -d: -f3-)
echo "::error file=${file},line=${lineno}::${text}"
done
exit 1
fi
echo "OK: no forbidden stale strings in pages/ or components/."
test: test:
name: Unit tests (vitest) name: Unit tests (vitest)
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -12,6 +12,20 @@ Guidance for agents and humans working in this repo. Prefer existing patterns ov
> as a historical regression-lock per Risk 4 of the pick-a-name > as a historical regression-lock per Risk 4 of the pick-a-name
> convoy. > convoy.
## Product vocabulary
User-facing copy distinguishes **ownership** (everything you own) from **curated lists** (binders/subsets). Import labels from `lib/collection-vocabulary.js` (`VOCAB`, `collectionDisplayName`) rather than hardcoding strings.
| Concept | UI label | Route / schema | Notes |
| --- | --- | --- | --- |
| Global ownership | **My Collection** | `/my-cards`, `user_cards` | Scanner default destination; replaces "Owned" / "Mark Owned" |
| Curated list / binder | **List** / **Lists** | `/collections`, `/collection/[id]`, `collections` table | URL slug unchanged in v1; nav says "Lists" |
| Auto-sync system list | **Synced binder** (display) | `collections` row with `is_system_collection = true` | DB name stays `'All My Cards'` — never render; use `collectionDisplayName()` |
| Add ownership | **Add to My Collection** | `POST /api/user-cards`, scanner bulk | Replaces "Mark Owned" / "Mark as Owned" |
| Add to curated list | **Add to List** | `POST /api/collections/:id/cards` | Replaces "Add to Collection" in scanner/card flows |
CI job `forbidden-stale-strings` blocks `"Mark Owned"`, `"Owned Cards"`, and `"All My Cards"` in `pages/` + `components/` (API literals exempt).
## 1. Project overview ## 1. Project overview
A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database. A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database.

View file

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function BulkSelectionToolbar({ export default function BulkSelectionToolbar({
selectedCards, selectedCards,
@ -96,7 +97,7 @@ export default function BulkSelectionToolbar({
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
<span className="hidden sm:inline">Own</span> <span className="hidden sm:inline">{VOCAB.MY_COLLECTION}</span>
</button> </button>
</div> </div>
@ -137,7 +138,7 @@ export default function BulkSelectionToolbar({
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
<span>Remove from Owned</span> <span>{VOCAB.REMOVE_FROM_MY_COLLECTION}</span>
</button> </button>
<div className="border-t border-gray-200 my-1"></div> <div className="border-t border-gray-200 my-1"></div>

View file

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { VOCAB } from '../lib/collection-vocabulary.js';
// Expanding Add Button Component // Expanding Add Button Component
function ExpandingAddButton({ card, onAddToCollection, onAddToDeck, onMarkAsOwned }) { function ExpandingAddButton({ card, onAddToCollection, onAddToDeck, onMarkAsOwned }) {
@ -31,7 +32,7 @@ function ExpandingAddButton({ card, onAddToCollection, onAddToDeck, onMarkAsOwne
onClick={(e) => handleOptionClick(e, () => onAddToCollection([card]))} onClick={(e) => handleOptionClick(e, () => onAddToCollection([card]))}
className="p-1.5 rounded-md shadow-lg transition-all duration-150 hover:scale-105 flex items-center space-x-1 text-xs whitespace-nowrap" className="p-1.5 rounded-md shadow-lg transition-all duration-150 hover:scale-105 flex items-center space-x-1 text-xs whitespace-nowrap"
style={{ backgroundColor: 'var(--accent-flame)', color: 'white' }} style={{ backgroundColor: 'var(--accent-flame)', color: 'white' }}
title="Add to Collection" title={VOCAB.ADD_TO_LIST}
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
@ -55,7 +56,7 @@ function ExpandingAddButton({ card, onAddToCollection, onAddToDeck, onMarkAsOwne
onClick={(e) => handleOptionClick(e, onMarkAsOwned)} onClick={(e) => handleOptionClick(e, onMarkAsOwned)}
className="p-1.5 rounded-md shadow-lg transition-all duration-150 hover:scale-105 flex items-center space-x-1 text-xs whitespace-nowrap" className="p-1.5 rounded-md shadow-lg transition-all duration-150 hover:scale-105 flex items-center space-x-1 text-xs whitespace-nowrap"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }} style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}
title="Mark as Owned" title={VOCAB.ADD_TO_MY_COLLECTION}
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
@ -74,7 +75,7 @@ function ExpandingAddButton({ card, onAddToCollection, onAddToDeck, onMarkAsOwne
color: 'white', color: 'white',
boxShadow: '0 0 0 2px var(--accent-ember), 0 4px 6px -1px rgba(216, 67, 21, 0.3)' boxShadow: '0 0 0 2px var(--accent-ember), 0 4px 6px -1px rgba(216, 67, 21, 0.3)'
}} }}
title={isExpanded ? "Add to Collection" : "Show Options"} title={isExpanded ? VOCAB.ADD_TO_LIST : 'Show Options'}
> >
<svg <svg
className={`w-4 h-4 transition-transform duration-150 ${isExpanded ? 'rotate-45' : ''}`} className={`w-4 h-4 transition-transform duration-150 ${isExpanded ? 'rotate-45' : ''}`}
@ -259,7 +260,7 @@ export default function CardItem({
onAddToCollection(card); onAddToCollection(card);
}} }}
className="p-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-colors" className="p-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-colors"
title="Add to Collection" title={VOCAB.ADD_TO_LIST}
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
@ -381,7 +382,7 @@ export default function CardItem({
card={card} card={card}
onAddToCollection={onAddToCollection} onAddToCollection={onAddToCollection}
onAddToDeck={onAddToDeck} onAddToDeck={onAddToDeck}
onMarkAsOwned={() => alert('Mark as owned (coming soon)')} onMarkAsOwned={() => alert(`${VOCAB.ADD_TO_MY_COLLECTION} (coming soon)`)}
/> />
</div> </div>
</div> </div>

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function CollectionSelectionModal({ export default function CollectionSelectionModal({
isOpen, isOpen,
@ -132,7 +133,7 @@ export default function CollectionSelectionModal({
<div className="p-6 border-b border-gray-200 dark:border-gray-700"> <div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white"> <h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Add to Collections {VOCAB.ADD_TO_LISTS}
</h2> </h2>
<button <button
onClick={onClose} onClick={onClose}
@ -175,7 +176,7 @@ export default function CollectionSelectionModal({
{cards.length} card{cards.length !== 1 ? 's' : ''} selected {cards.length} card{cards.length !== 1 ? 's' : ''} selected
</p> </p>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
Choose collections to add {cards.length === 1 ? 'this card' : 'these cards'} to Choose lists to add {cards.length === 1 ? 'this card' : 'these cards'} to
</p> </p>
</div> </div>
</div> </div>
@ -184,7 +185,7 @@ export default function CollectionSelectionModal({
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
placeholder="Search collections..." placeholder="Search lists..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 pl-10 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full px-4 py-2 pl-10 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
@ -200,7 +201,7 @@ export default function CollectionSelectionModal({
{loading ? ( {loading ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
<p className="text-gray-600 dark:text-gray-400">Loading collections...</p> <p className="text-gray-600 dark:text-gray-400">Loading lists...</p>
</div> </div>
) : filteredCollections.length === 0 ? ( ) : filteredCollections.length === 0 ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
@ -208,10 +209,10 @@ export default function CollectionSelectionModal({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg> </svg>
<p className="text-gray-600 dark:text-gray-400 mb-2"> <p className="text-gray-600 dark:text-gray-400 mb-2">
{searchQuery ? 'No collections match your search' : 'No collections found'} {searchQuery ? 'No lists match your search' : 'No lists found'}
</p> </p>
<p className="text-sm text-gray-500 dark:text-gray-500"> <p className="text-sm text-gray-500 dark:text-gray-500">
{searchQuery ? 'Try a different search term' : 'Create your first collection to get started'} {searchQuery ? 'Try a different search term' : 'Create your first list to get started'}
</p> </p>
</div> </div>
) : ( ) : (
@ -305,7 +306,7 @@ export default function CollectionSelectionModal({
<div className="text-sm text-gray-600 dark:text-gray-400"> <div className="text-sm text-gray-600 dark:text-gray-400">
{selectedCollections.length > 0 && ( {selectedCollections.length > 0 && (
<span> <span>
{selectedCollections.length} collection{selectedCollections.length !== 1 ? 's' : ''} selected {selectedCollections.length} list{selectedCollections.length !== 1 ? 's' : ''} selected
</span> </span>
)} )}
</div> </div>
@ -332,7 +333,7 @@ export default function CollectionSelectionModal({
<span>Adding...</span> <span>Adding...</span>
</div> </div>
) : ( ) : (
`Add to ${selectedCollections.length} Collection${selectedCollections.length !== 1 ? 's' : ''}` `Add to ${selectedCollections.length} List${selectedCollections.length !== 1 ? 's' : ''}`
)} )}
</button> </button>
</div> </div>

View file

@ -176,7 +176,7 @@ function NavigationContent({ user, router, onItemClick }) {
active: router.pathname === '/dashboard' || router.pathname === '/collections' || router.pathname === '/my-cards' || router.pathname === '/decks' || router.pathname === '/analytics', active: router.pathname === '/dashboard' || router.pathname === '/collections' || router.pathname === '/my-cards' || router.pathname === '/decks' || router.pathname === '/analytics',
expanded: true, expanded: true,
items: [ items: [
{ name: 'Collections', href: '/collections', active: router.pathname === '/collections' }, { name: 'Lists', href: '/collections', active: router.pathname === '/collections' },
{ name: 'Cards', href: '/my-cards', active: router.pathname === '/my-cards' }, { name: 'Cards', href: '/my-cards', active: router.pathname === '/my-cards' },
{ name: 'Decks', href: '/decks', active: router.pathname === '/decks' }, { name: 'Decks', href: '/decks', active: router.pathname === '/decks' },
{ name: 'Analytics', href: '/analytics', active: router.pathname === '/analytics', isPlaceholder: true } { name: 'Analytics', href: '/analytics', active: router.pathname === '/analytics', isPlaceholder: true }
@ -201,7 +201,7 @@ function NavigationContent({ user, router, onItemClick }) {
active: router.pathname.startsWith('/community'), active: router.pathname.startsWith('/community'),
expanded: isCommunityExpanded, expanded: isCommunityExpanded,
items: [ items: [
{ name: 'Collections', href: '/community/collections', active: router.pathname === '/community/collections' }, { name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' },
{ name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' }, { name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' },
{ name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' } { name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' }
] ]
@ -791,7 +791,7 @@ export default function Layout({ children, user = null, showSearch = false }) {
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
placeholder="Search cards, decks, or collections..." placeholder="Search cards, decks, or lists..."
className="search-bar pr-12" className="search-bar pr-12"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}

View file

@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
export default function LoginCTA({ message = "Sign up to create your own collections and more!" }) { export default function LoginCTA({ message = 'Sign up to create your own lists and more!' }) {
return ( return (
<div className="fixed bottom-6 right-6 z-50"> <div className="fixed bottom-6 right-6 z-50">
<div <div

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { formatProcessedDestination, VOCAB } from '../lib/collection-vocabulary.js';
export const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG']; export const CONDITION_OPTIONS = ['NM', 'LP', 'MP', 'HP', 'DMG'];
@ -223,7 +224,7 @@ export default function ScannedCardItem({
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }} style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
> >
<span aria-hidden="true">💎 </span> <span aria-hidden="true">💎 </span>
{isAdding ? 'Adding…' : 'Mark Owned'} {isAdding ? 'Adding…' : VOCAB.ADD_TO_MY_COLLECTION}
</button> </button>
<button <button
type="button" type="button"
@ -251,9 +252,9 @@ export default function ScannedCardItem({
border: '1px solid var(--border)', border: '1px solid var(--border)',
}} }}
defaultValue="" defaultValue=""
aria-label={`Add ${card.name} to a collection`} aria-label={`Add ${card.name} to a list`}
> >
<option value="">📚 Add to Collection</option> <option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</option>
{collections.map((collection) => ( {collections.map((collection) => (
<option key={collection.id} value={collection.id}> <option key={collection.id} value={collection.id}>
{collection.name} {collection.name}
@ -291,7 +292,7 @@ export default function ScannedCardItem({
) : ( ) : (
<div className="text-sm flex items-center gap-2" style={{ color: 'var(--accent-ember)' }}> <div className="text-sm flex items-center gap-2" style={{ color: 'var(--accent-ember)' }}>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<span>Added to {card.processedAction}</span> <span>Added to {formatProcessedDestination(card.processedAction)}</span>
</div> </div>
)} )}
</div> </div>

View file

@ -1,3 +1,5 @@
import { VOCAB } from '../lib/collection-vocabulary.js';
const GAME_OPTIONS = [ const GAME_OPTIONS = [
{ value: 'All', label: 'All games' }, { value: 'All', label: 'All games' },
{ value: 'MTG', label: 'Magic' }, { value: 'MTG', label: 'Magic' },
@ -30,7 +32,7 @@ export default function ScannerDestinationPicker({
const handleTypeChange = (type) => { const handleTypeChange = (type) => {
if (type === 'owned') { if (type === 'owned') {
onDestinationChange({ type: 'owned', id: null, label: 'My owned cards' }); onDestinationChange({ type: 'owned', id: null, label: VOCAB.MY_COLLECTION });
return; return;
} }
if (type === 'collection' && filteredCollections.length > 0) { if (type === 'collection' && filteredCollections.length > 0) {
@ -122,8 +124,8 @@ export default function ScannerDestinationPicker({
<fieldset className="flex flex-wrap gap-2" disabled={disabled}> <fieldset className="flex flex-wrap gap-2" disabled={disabled}>
<legend className="sr-only">Destination type</legend> <legend className="sr-only">Destination type</legend>
{[ {[
{ type: 'owned', label: '💎 Owned' }, { type: 'owned', label: `💎 ${VOCAB.MY_COLLECTION}` },
{ type: 'collection', label: '📚 Collection' }, { type: 'collection', label: `📚 ${VOCAB.LIST}` },
{ type: 'deck', label: '🃏 Deck' }, { type: 'deck', label: '🃏 Deck' },
].map((option) => { ].map((option) => {
const isActive = destination?.type === option.type; const isActive = destination?.type === option.type;
@ -154,7 +156,7 @@ export default function ScannerDestinationPicker({
{destination?.type === 'collection' && filteredCollections.length > 0 && ( {destination?.type === 'collection' && filteredCollections.length > 0 && (
<label className="flex flex-col gap-1 mt-4 text-sm"> <label className="flex flex-col gap-1 mt-4 text-sm">
<span style={{ color: 'var(--text-secondary)' }}>Collection</span> <span style={{ color: 'var(--text-secondary)' }}>{VOCAB.LIST}</span>
<select <select
value={String(destination.id)} value={String(destination.id)}
onChange={handleTargetChange} onChange={handleTargetChange}
@ -200,7 +202,7 @@ export default function ScannerDestinationPicker({
{destination?.type === 'collection' && filteredCollections.length === 0 && ( {destination?.type === 'collection' && filteredCollections.length === 0 && (
<p className="mt-3 text-sm" style={{ color: 'var(--text-secondary)' }}> <p className="mt-3 text-sm" style={{ color: 'var(--text-secondary)' }}>
No collections match this game filter. No lists match this game filter.
</p> </p>
)} )}

View file

@ -113,7 +113,7 @@ export default function ShareModal({
const handleSocialShare = (platform) => { const handleSocialShare = (platform) => {
const url = window.location.href; const url = window.location.href;
const title = `Check out this collection on Deck Hearth`; const title = 'Check out this list on Deck Hearth';
const shareUrls = { const shareUrls = {
twitter: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`, twitter: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`,
@ -176,7 +176,7 @@ export default function ShareModal({
</button> </button>
</div> </div>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
This collection will be available in the community. This list will be available in the community.
</p> </p>
</div> </div>
</div> </div>
@ -244,7 +244,7 @@ export default function ShareModal({
{/* Current Permissions */} {/* Current Permissions */}
<div className="mb-6"> <div className="mb-6">
<p className="text-sm text-gray-700 mb-3"> <p className="text-sm text-gray-700 mb-3">
Only those invited can view or collaborate on this collection. Only those invited can view or collaborate on this list.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">

View file

@ -23,8 +23,8 @@
| --- | --- | --- | | --- | --- | --- |
| **Identity** | `users`, `user_settings`, `user_avatars` | Accounts, profile, preferences | | **Identity** | `users`, `user_settings`, `user_avatars` | Accounts, profile, preferences |
| **Catalog** | `cards` | Master card list across MTG / Pokémon / Lorcana | | **Catalog** | `cards` | Master card list across MTG / Pokémon / Lorcana |
| **Ownership** | `user_cards`, `user_favorites` | What a user owns / has favorited | | **Ownership** | `user_cards`, `user_favorites` | What a user owns / has favorited (UI: **My Collection**) |
| **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing | | **Collections** | `collections`, `collection_cards`, `collection_permissions`, `collection_activity` | Curated card lists with sharing (UI: **Lists**) |
| **Decks** | `decks`, `deck_cards` | Playable deck definitions | | **Decks** | `decks`, `deck_cards` | Playable deck definitions |
| **Scanning** | `card_submissions`, `scan_attempts` | Unknown-card review queue + scan telemetry | | **Scanning** | `card_submissions`, `scan_attempts` | Unknown-card review queue + scan telemetry |
| **Invitations** | `invitations` (referenced; verify) | Pending share requests | | **Invitations** | `invitations` (referenced; verify) | Pending share requests |
@ -124,7 +124,7 @@
| `visibility` | `VARCHAR(20)` default `'private'` | **Coexists with `is_public`; verify single source of truth** | | `visibility` | `VARCHAR(20)` default `'private'` | **Coexists with `is_public`; verify single source of truth** |
| `tcg` | `VARCHAR(50)` default `'MTG'` | | | `tcg` | `VARCHAR(50)` default `'MTG'` | |
| `tags` | `TEXT` | Comma-separated; consider migrating to JSONB array | | `tags` | `TEXT` | Comma-separated; consider migrating to JSONB array |
| `is_system_collection` | `BOOLEAN` default `false` | E.g. "All My Cards" auto-collection | | `is_system_collection` | `BOOLEAN` default `false` | Auto-sync list; DB name `'All My Cards'` (internal — UI: **Synced binder** via `collectionDisplayName()`) |
| `created_at`, `updated_at` | `TIMESTAMP` | | | `created_at`, `updated_at` | `TIMESTAMP` | |
### collection_cards ### collection_cards

View file

@ -0,0 +1,40 @@
/**
* Product vocabulary ownership vs curated lists.
* See AGENTS.md § Product vocabulary and docs/SCHEMA_MAP.md glossary.
*/
/** Internal DB name for the auto-sync system collection — never render in UI. */
export const SYSTEM_COLLECTION_DB_NAME = 'All My Cards';
export const VOCAB = {
MY_COLLECTION: 'My Collection',
LISTS: 'Lists',
LIST: 'List',
ADD_TO_MY_COLLECTION: 'Add to My Collection',
REMOVE_FROM_MY_COLLECTION: 'Remove from My Collection',
ADD_TO_LIST: 'Add to List',
ADD_TO_LISTS: 'Add to Lists',
SYNCED_BINDER: 'Synced binder',
SYSTEM_COLLECTION_SYNC_HINT: 'Automatically syncs with My Collection',
};
/** User-facing label for a collection row (maps system collection DB name). */
export function collectionDisplayName(collection) {
if (!collection) return '';
if (
collection.isSystemCollection ||
collection.is_system_collection ||
collection.name === SYSTEM_COLLECTION_DB_NAME
) {
return VOCAB.SYNCED_BINDER;
}
return collection.name;
}
/** Scanner / bulk-action success line: "Added to …" */
export function formatProcessedDestination(action) {
if (action === 'owned') return VOCAB.MY_COLLECTION;
if (action === 'collection') return VOCAB.LIST;
if (action === 'deck') return 'deck';
return action;
}

View file

@ -5,6 +5,7 @@ import { useAuth } from '../../lib/use-auth';
import CollectionSelectionModal from '../../components/CollectionSelectionModal'; import CollectionSelectionModal from '../../components/CollectionSelectionModal';
import { ManaCost, ColorIdentity, AdvancedManaCost } from '../../components/ManaSymbols'; import { ManaCost, ColorIdentity, AdvancedManaCost } from '../../components/ManaSymbols';
import ManaSymbolSettings from '../../components/ManaSymbolSettings'; import ManaSymbolSettings from '../../components/ManaSymbolSettings';
import { VOCAB, collectionDisplayName } from '../../lib/collection-vocabulary.js';
export default function CardDetail() { export default function CardDetail() {
const router = useRouter(); const router = useRouter();
@ -468,7 +469,7 @@ export default function CardDetail() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{ownedQuantity > 0 && ( {ownedQuantity > 0 && (
<span className="px-3 py-1 rounded-full text-sm font-medium bg-green-500 bg-opacity-80"> <span className="px-3 py-1 rounded-full text-sm font-medium bg-green-500 bg-opacity-80">
Owned ({ownedQuantity}) In {VOCAB.MY_COLLECTION} ({ownedQuantity})
</span> </span>
)} )}
<button <button
@ -489,14 +490,14 @@ export default function CardDetail() {
className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2" className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2"
> >
<span>📦</span> <span>📦</span>
{ownedQuantity > 0 ? 'Update Quantity' : 'Mark as Owned'} {ownedQuantity > 0 ? 'Update Quantity' : VOCAB.ADD_TO_MY_COLLECTION}
</button> </button>
<button <button
onClick={() => setShowCollectionModal(true)} onClick={() => setShowCollectionModal(true)}
className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2" className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2"
> >
<span>📁</span> <span>📁</span>
Add to Collection {VOCAB.ADD_TO_LIST}
</button> </button>
<button <button
onClick={() => setShowDeckModal(true)} onClick={() => setShowDeckModal(true)}
@ -541,7 +542,7 @@ export default function CardDetail() {
{cardCollections.map(collection => ( {cardCollections.map(collection => (
<div key={collection.id} className="flex items-center gap-2 text-sm"> <div key={collection.id} className="flex items-center gap-2 text-sm">
<span>📁</span> <span>📁</span>
<span>{collection.name}</span> <span>{collectionDisplayName(collection)}</span>
</div> </div>
))} ))}
{cardDecks.map(deck => ( {cardDecks.map(deck => (
@ -759,7 +760,7 @@ export default function CardDetail() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-primary)' }}> <div className="bg-white rounded-2xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> <h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
{ownedQuantity > 0 ? 'Update Quantity' : 'Mark as Owned'} {ownedQuantity > 0 ? 'Update Quantity' : VOCAB.ADD_TO_MY_COLLECTION}
</h3> </h3>
<div className="mb-6"> <div className="mb-6">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
@ -798,7 +799,7 @@ export default function CardDetail() {
onClick={() => handleOwnershipUpdate(quantity)} onClick={() => handleOwnershipUpdate(quantity)}
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200" className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
> >
{ownedQuantity > 0 ? 'Update' : 'Mark as Owned'} {ownedQuantity > 0 ? 'Update' : VOCAB.ADD_TO_MY_COLLECTION}
</button> </button>
<button <button
onClick={() => setShowQuantityModal(false)} onClick={() => setShowQuantityModal(false)}

View file

@ -9,6 +9,7 @@ import ProtectedRoute from '../components/ProtectedRoute';
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols'; import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings'; import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/use-auth'; import { useAuth } from '../lib/use-auth';
import { VOCAB } from '../lib/collection-vocabulary.js';
// Public fallback component for non-authenticated users // Public fallback component for non-authenticated users
function PublicCardsView() { function PublicCardsView() {
@ -461,7 +462,7 @@ function AuthenticatedCards() {
try { try {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
if (!token) { if (!token) {
alert('Please log in to mark cards as owned'); alert(`Please log in to ${VOCAB.ADD_TO_MY_COLLECTION.toLowerCase()}`);
return; return;
} }
@ -486,16 +487,16 @@ function AuthenticatedCards() {
} }
if (successCount === cards.length) { if (successCount === cards.length) {
alert(`Successfully marked ${cards.length} cards as owned!`); alert(`Successfully added ${cards.length} cards to ${VOCAB.MY_COLLECTION}!`);
} else { } else {
alert(`Marked ${successCount} out of ${cards.length} cards as owned. Some operations may have failed.`); alert(`Added ${successCount} out of ${cards.length} cards to ${VOCAB.MY_COLLECTION}. Some operations may have failed.`);
} }
// Clear selection after successful operation // Clear selection after successful operation
setSelectedCards([]); setSelectedCards([]);
} catch (error) { } catch (error) {
console.error('Error in bulk mark as owned:', error); console.error('Error in bulk mark as owned:', error);
alert('Failed to mark cards as owned. Please try again.'); alert(`Failed to add cards to ${VOCAB.MY_COLLECTION}. Please try again.`);
} }
}; };
@ -503,7 +504,7 @@ function AuthenticatedCards() {
try { try {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
if (!token) { if (!token) {
alert('Please log in to remove cards from owned'); alert(`Please log in to ${VOCAB.REMOVE_FROM_MY_COLLECTION.toLowerCase()}`);
return; return;
} }
@ -528,16 +529,16 @@ function AuthenticatedCards() {
} }
if (successCount === cards.length) { if (successCount === cards.length) {
alert(`Successfully removed ${cards.length} cards from owned!`); alert(`Successfully removed ${cards.length} cards from ${VOCAB.MY_COLLECTION}!`);
} else { } else {
alert(`Removed ${successCount} out of ${cards.length} cards from owned. Some operations may have failed.`); alert(`Removed ${successCount} out of ${cards.length} cards from ${VOCAB.MY_COLLECTION}. Some operations may have failed.`);
} }
// Clear selection after successful operation // Clear selection after successful operation
setSelectedCards([]); setSelectedCards([]);
} catch (error) { } catch (error) {
console.error('Error in bulk remove from owned:', error); console.error('Error in bulk remove from owned:', error);
alert('Failed to remove cards from owned. Please try again.'); alert(`Failed to remove cards from ${VOCAB.MY_COLLECTION}. Please try again.`);
} }
}; };
@ -582,7 +583,7 @@ function AuthenticatedCards() {
const totalAttempts = results.length; const totalAttempts = results.length;
if (successCount === totalAttempts) { if (successCount === totalAttempts) {
alert(`Successfully added ${cards.length} card${cards.length !== 1 ? 's' : ''} to ${selectedCollectionIds.length} collection${selectedCollectionIds.length !== 1 ? 's' : ''}!`); alert(`Successfully added ${cards.length} card${cards.length !== 1 ? 's' : ''} to ${selectedCollectionIds.length} list${selectedCollectionIds.length !== 1 ? 's' : ''}!`);
} else { } else {
alert(`Added ${successCount} out of ${totalAttempts} cards. Some additions may have failed.`); alert(`Added ${successCount} out of ${totalAttempts} cards. Some additions may have failed.`);
} }
@ -1459,7 +1460,7 @@ function Card3D({ card, viewMode, getRarityLabel, getRarityColor }) {
)} )}
{card.collections && card.collections.length > 0 && ( {card.collections && card.collections.length > 0 && (
<div> <div>
<span className="font-semibold">Collections:</span> {card.collections.join(', ')} <span className="font-semibold">{VOCAB.LISTS}:</span> {card.collections.join(', ')}
</div> </div>
)} )}
</div> </div>

View file

@ -10,6 +10,7 @@ import LoginCTA from '../../components/LoginCTA';
import { ManaCost, ColorFilterSymbol } from '../../components/ManaSymbols'; import { ManaCost, ColorFilterSymbol } from '../../components/ManaSymbols';
import ManaSymbolSettings from '../../components/ManaSymbolSettings'; import ManaSymbolSettings from '../../components/ManaSymbolSettings';
import { useAuth } from '../../lib/use-auth'; import { useAuth } from '../../lib/use-auth';
import { VOCAB, collectionDisplayName } from '../../lib/collection-vocabulary.js';
export default function CollectionView() { export default function CollectionView() {
const router = useRouter(); const router = useRouter();
@ -177,7 +178,7 @@ export default function CollectionView() {
setShowEditModal(false); setShowEditModal(false);
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to update collection'); alert(error.error || 'Failed to update list');
} }
} catch (error) { } catch (error) {
console.error('Error updating collection:', error); console.error('Error updating collection:', error);
@ -198,7 +199,7 @@ export default function CollectionView() {
router.push('/collections'); router.push('/collections');
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to delete collection'); alert(error.error || 'Failed to delete list');
} }
} catch (error) { } catch (error) {
console.error('Error deleting collection:', error); console.error('Error deleting collection:', error);
@ -518,11 +519,11 @@ export default function CollectionView() {
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <div className="text-center">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> <h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Collection not found List not found
</h2> </h2>
<Link href="/collections"> <Link href="/collections">
<button className="px-4 py-2 rounded-lg text-white" style={{ backgroundColor: 'var(--accent-ember)' }}> <button className="px-4 py-2 rounded-lg text-white" style={{ backgroundColor: 'var(--accent-ember)' }}>
Back to Collections Back to Lists
</button> </button>
</Link> </Link>
</div> </div>
@ -594,7 +595,7 @@ export default function CollectionView() {
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center space-x-3 mb-2"> <div className="flex items-center space-x-3 mb-2">
<h1 className="text-3xl font-bold" style={{ color: 'var(--text-primary)' }}> <h1 className="text-3xl font-bold" style={{ color: 'var(--text-primary)' }}>
{collection.name} {collectionDisplayName(collection)}
</h1> </h1>
{/* System collection indicator */} {/* System collection indicator */}
{collection.isSystemCollection && ( {collection.isSystemCollection && (
@ -607,7 +608,7 @@ export default function CollectionView() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 text-xs bg-gray-900 text-white rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap"> <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 text-xs bg-gray-900 text-white rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap">
Automatically syncs with your owned cards {VOCAB.SYSTEM_COLLECTION_SYNC_HINT}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div> <div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div> </div>
</div> </div>
@ -851,7 +852,7 @@ export default function CollectionView() {
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-6xl mb-4">🃏</div> <div className="text-6xl mb-4">🃏</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}> <h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
Start Building Your Collection Start Building Your List
</h3> </h3>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Add cards to get started with your collection Add cards to get started with your collection
@ -894,19 +895,19 @@ export default function CollectionView() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="card max-w-md w-full mx-4"> <div className="card max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> <h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Edit Collection Edit List
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Collection Name List Name
</label> </label>
<input <input
type="text" type="text"
className="input-field w-full" className="input-field w-full"
value={editForm.name} value={editForm.name}
onChange={(e) => setEditForm({...editForm, name: e.target.value})} onChange={(e) => setEditForm({...editForm, name: e.target.value})}
placeholder="Enter collection name" placeholder="Enter list name"
/> />
</div> </div>
<div> <div>
@ -918,7 +919,7 @@ export default function CollectionView() {
rows="3" rows="3"
value={editForm.description} value={editForm.description}
onChange={(e) => setEditForm({...editForm, description: e.target.value})} onChange={(e) => setEditForm({...editForm, description: e.target.value})}
placeholder="Describe your collection" placeholder="Describe your list"
/> />
</div> </div>
<div> <div>
@ -936,7 +937,7 @@ export default function CollectionView() {
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border)' }}> <div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border)' }}>
<div> <div>
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> <label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Public Collection Public List
</label> </label>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Make this collection discoverable by other users Make this collection discoverable by other users
@ -989,10 +990,10 @@ export default function CollectionView() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="card max-w-md w-full mx-4"> <div className="card max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> <h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Delete Collection Delete List
</h2> </h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Are you sure you want to delete "{collection.name}"? This action cannot be undone and will permanently remove all cards and data associated with this collection. Are you sure you want to delete "{collectionDisplayName(collection)}"? This action cannot be undone and will permanently remove all cards and data associated with this list.
</p> </p>
<div className="flex space-x-3"> <div className="flex space-x-3">
<button <button
@ -1009,7 +1010,7 @@ export default function CollectionView() {
onClick={handleDeleteCollection} onClick={handleDeleteCollection}
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md bg-red-600 text-white hover:bg-red-700" className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md bg-red-600 text-white hover:bg-red-700"
> >
Delete Collection Delete List
</button> </button>
</div> </div>
</div> </div>
@ -1037,7 +1038,7 @@ export default function CollectionView() {
{/* Show login CTA for non-authenticated users */} {/* Show login CTA for non-authenticated users */}
{!user && collection?.isPublic && ( {!user && collection?.isPublic && (
<LoginCTA message="Sign up to create your own collections and collaborate with others!" /> <LoginCTA message="Sign up to create your own lists and collaborate with others!" />
)} )}
</Layout> </Layout>
); );

View file

@ -4,6 +4,7 @@ import Layout from '../components/Layout';
import PermissionIndicator from '../components/PermissionIndicator'; import PermissionIndicator from '../components/PermissionIndicator';
import { useAuth } from '../lib/use-auth'; import { useAuth } from '../lib/use-auth';
import Link from 'next/link'; import Link from 'next/link';
import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js';
export default function Collections() { export default function Collections() {
const router = useRouter(); const router = useRouter();
@ -154,7 +155,7 @@ export default function Collections() {
fetchCollections(); fetchCollections();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to create collection'); alert(error.error || 'Failed to create list');
} }
} catch (error) { } catch (error) {
console.error('Error creating collection:', error); console.error('Error creating collection:', error);
@ -185,7 +186,7 @@ export default function Collections() {
setEditTagInput(''); // Clear the tag input setEditTagInput(''); // Clear the tag input
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to update collection'); alert(error.error || 'Failed to update list');
} }
} catch (error) { } catch (error) {
console.error('Error updating collection:', error); console.error('Error updating collection:', error);
@ -194,7 +195,7 @@ export default function Collections() {
}; };
const handleDeleteCollection = async (collectionId) => { const handleDeleteCollection = async (collectionId) => {
if (confirm('Are you sure you want to delete this collection? This action cannot be undone.')) { if (confirm('Are you sure you want to delete this list? This action cannot be undone.')) {
try { try {
const response = await fetch(`/api/collections/${collectionId}`, { const response = await fetch(`/api/collections/${collectionId}`, {
method: 'DELETE' method: 'DELETE'
@ -204,7 +205,7 @@ export default function Collections() {
fetchCollections(); // Refresh the list fetchCollections(); // Refresh the list
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to delete collection'); alert(error.error || 'Failed to delete list');
} }
} catch (error) { } catch (error) {
console.error('Error deleting collection:', error); console.error('Error deleting collection:', error);
@ -388,10 +389,10 @@ export default function Collections() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold" style={{ color: 'var(--text-primary)' }}> <h1 className="text-3xl font-bold" style={{ color: 'var(--text-primary)' }}>
My Collections {VOCAB.LISTS}
</h1> </h1>
<p className="mt-2" style={{ color: 'var(--text-secondary)' }}> <p className="mt-2" style={{ color: 'var(--text-secondary)' }}>
Collections you own, collaborate on, or have been shared with you Lists you own, collaborate on, or have been shared with you
</p> </p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@ -414,7 +415,7 @@ export default function Collections() {
color: 'white' color: 'white'
}} }}
> >
+ Create Collection + Create List
</button> </button>
</div> </div>
</div> </div>
@ -426,7 +427,7 @@ export default function Collections() {
<div className="flex-1"> <div className="flex-1">
<input <input
type="text" type="text"
placeholder="Search collections..." placeholder="Search lists..."
className="input-field w-full" className="input-field w-full"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@ -454,12 +455,12 @@ export default function Collections() {
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-6xl mb-4">📦</div> <div className="text-6xl mb-4">📦</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}> <h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{searchQuery ? 'No collections found' : 'No collections yet'} {searchQuery ? 'No lists found' : 'No lists yet'}
</h3> </h3>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
{searchQuery {searchQuery
? 'Try adjusting your search terms' ? 'Try adjusting your search terms'
: 'Create your first collection to get started' : 'Create your first list to get started'
} }
</p> </p>
{!searchQuery && ( {!searchQuery && (
@ -471,7 +472,7 @@ export default function Collections() {
color: 'white' color: 'white'
}} }}
> >
Create Collection Create List
</button> </button>
)} )}
</div> </div>
@ -493,7 +494,7 @@ export default function Collections() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<h3 className="text-lg font-semibold leading-tight" style={{ color: 'var(--text-primary)' }}> <h3 className="text-lg font-semibold leading-tight" style={{ color: 'var(--text-primary)' }}>
{collection.name} {collectionDisplayName(collection)}
</h3> </h3>
{/* System collection indicator */} {/* System collection indicator */}
{collection.isSystemCollection && ( {collection.isSystemCollection && (
@ -506,7 +507,7 @@ export default function Collections() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 text-xs bg-gray-900 text-white rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap"> <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 text-xs bg-gray-900 text-white rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10 whitespace-nowrap">
Automatically syncs with your owned cards {VOCAB.SYSTEM_COLLECTION_SYNC_HINT}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div> <div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div> </div>
</div> </div>
@ -638,19 +639,19 @@ export default function Collections() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="card max-w-md w-full mx-4"> <div className="card max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> <h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Create New Collection Create New List
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Collection Name List Name
</label> </label>
<input <input
type="text" type="text"
className="input-field w-full" className="input-field w-full"
value={newCollection.name} value={newCollection.name}
onChange={(e) => setNewCollection({...newCollection, name: e.target.value})} onChange={(e) => setNewCollection({...newCollection, name: e.target.value})}
placeholder="Enter collection name" placeholder="Enter list name"
/> />
</div> </div>
<div> <div>
@ -662,7 +663,7 @@ export default function Collections() {
rows="3" rows="3"
value={newCollection.description} value={newCollection.description}
onChange={(e) => setNewCollection({...newCollection, description: e.target.value})} onChange={(e) => setNewCollection({...newCollection, description: e.target.value})}
placeholder="Describe your collection" placeholder="Describe your list"
/> />
</div> </div>
<div> <div>
@ -734,7 +735,7 @@ export default function Collections() {
</div> </div>
)} )}
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Add tags to help organize and categorize your collection Add tags to help organize and categorize your list
</p> </p>
</div> </div>
</div> </div>
@ -742,10 +743,10 @@ export default function Collections() {
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border)' }}> <div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border)' }}>
<div> <div>
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> <label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Public Collection Public List
</label> </label>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Make this collection discoverable by other users Make this list discoverable by other users
</p> </p>
</div> </div>
<button <button
@ -786,7 +787,7 @@ export default function Collections() {
color: 'white' color: 'white'
}} }}
> >
Create Collection Create List
</button> </button>
</div> </div>
</div> </div>
@ -799,10 +800,10 @@ export default function Collections() {
<div className="card max-w-md w-full mx-4 text-center"> <div className="card max-w-md w-full mx-4 text-center">
<div className="text-6xl mb-4">🎉</div> <div className="text-6xl mb-4">🎉</div>
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> <h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Collection Created! List Created!
</h2> </h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Your collection "{createdCollection.name}" has been created successfully. Your list "{createdCollection.name}" has been created successfully.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<button <button
@ -816,7 +817,7 @@ export default function Collections() {
color: 'white' color: 'white'
}} }}
> >
View Collection View List
</button> </button>
<button <button
onClick={() => setShowSuccessModal(false)} onClick={() => setShowSuccessModal(false)}
@ -826,7 +827,7 @@ export default function Collections() {
color: 'var(--text-secondary)' color: 'var(--text-secondary)'
}} }}
> >
Stay on Collections Page Stay on Lists Page
</button> </button>
</div> </div>
</div> </div>
@ -838,12 +839,12 @@ export default function Collections() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="card max-w-md w-full mx-4"> <div className="card max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> <h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Edit Collection Edit List
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Collection Name List Name
</label> </label>
<input <input
type="text" type="text"
@ -934,10 +935,10 @@ export default function Collections() {
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border)' }}> <div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border)' }}>
<div> <div>
<label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> <label className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Public Collection Public List
</label> </label>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Make this collection discoverable by other users Make this list discoverable by other users
</p> </p>
</div> </div>
<button <button
@ -978,7 +979,7 @@ export default function Collections() {
color: 'white' color: 'white'
}} }}
> >
Update Collection Update List
</button> </button>
</div> </div>
</div> </div>

View file

@ -4,6 +4,7 @@ import Link from 'next/link';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import PermissionIndicator from '../../components/PermissionIndicator'; import PermissionIndicator from '../../components/PermissionIndicator';
import { useAuth } from '../../lib/use-auth'; import { useAuth } from '../../lib/use-auth';
import { VOCAB } from '../../lib/collection-vocabulary.js';
export default function CommunityCollections() { export default function CommunityCollections() {
const router = useRouter(); const router = useRouter();
@ -198,10 +199,10 @@ export default function CommunityCollections() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold" style={{ color: 'var(--text-primary)' }}> <h1 className="text-3xl font-bold" style={{ color: 'var(--text-primary)' }}>
Community Collections Community Lists
</h1> </h1>
<p className="mt-2" style={{ color: 'var(--text-secondary)' }}> <p className="mt-2" style={{ color: 'var(--text-secondary)' }}>
Discover public collections shared by the community Discover public lists shared by the community
</p> </p>
</div> </div>
</div> </div>
@ -211,7 +212,7 @@ export default function CommunityCollections() {
<div className="flex-1 max-w-md"> <div className="flex-1 max-w-md">
<input <input
type="text" type="text"
placeholder="Search collections..." placeholder="Search lists..."
className="input-field w-full" className="input-field w-full"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
@ -239,12 +240,12 @@ export default function CommunityCollections() {
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-6xl mb-4">🌍</div> <div className="text-6xl mb-4">🌍</div>
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}> <h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{searchQuery ? 'No collections found' : 'No public collections yet'} {searchQuery ? 'No lists found' : 'No public lists yet'}
</h3> </h3>
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
{searchQuery {searchQuery
? 'Try adjusting your search terms' ? 'Try adjusting your search terms'
: 'Be the first to share a public collection with the community!' : 'Be the first to share a public list with the community!'
} }
</p> </p>
{!searchQuery && ( {!searchQuery && (
@ -255,7 +256,7 @@ export default function CommunityCollections() {
color: 'white' color: 'white'
}} }}
> >
Go to My Collections Go to My Lists
</button> </button>
</Link> </Link>
)} )}

View file

@ -4,6 +4,7 @@ import Layout from '../components/Layout';
import PermissionIndicator from '../components/PermissionIndicator'; import PermissionIndicator from '../components/PermissionIndicator';
import { useAuth } from '../lib/use-auth'; import { useAuth } from '../lib/use-auth';
import Link from 'next/link'; import Link from 'next/link';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function Dashboard() { export default function Dashboard() {
const router = useRouter(); const router = useRouter();
@ -79,7 +80,7 @@ export default function Dashboard() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}> <h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
My Collections {VOCAB.LISTS}
</h1> </h1>
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}> <p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
Manage your collection of trading cards and decks Manage your collection of trading cards and decks
@ -92,7 +93,7 @@ export default function Dashboard() {
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg> </svg>
<span>Create Collection</span> <span>Create List</span>
</div> </div>
</button> </button>
</Link> </Link>
@ -119,7 +120,7 @@ export default function Dashboard() {
</div> </div>
<div> <div>
<p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{collections.length}</p> <p className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{collections.length}</p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Collections</p> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Lists</p>
</div> </div>
</div> </div>
</div> </div>
@ -159,7 +160,7 @@ export default function Dashboard() {
{/* Collections Grid */} {/* Collections Grid */}
<div className="mb-6"> <div className="mb-6">
<h2 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>Recent Collections</h2> <h2 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>Recent Lists</h2>
{collections.length === 0 ? ( {collections.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--bg-secondary)' }}> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--bg-secondary)' }}>
@ -167,13 +168,13 @@ export default function Dashboard() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg> </svg>
</div> </div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No Collections Yet</h3> <h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No Lists Yet</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}> <p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
Create your first collection to start organizing your cards Create your first collection to start organizing your cards
</p> </p>
<Link href="/collections"> <Link href="/collections">
<button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}> <button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>
Create Your First Collection Create Your First List
</button> </button>
</Link> </Link>
</div> </div>
@ -219,7 +220,7 @@ export default function Dashboard() {
<div className="text-center"> <div className="text-center">
<Link href="/collections"> <Link href="/collections">
<button className="px-6 py-3 font-medium rounded-xl border-2 transition-all duration-200 hover:opacity-80" style={{ color: 'var(--text-primary)', borderColor: 'var(--accent-ember)' }}> <button className="px-6 py-3 font-medium rounded-xl border-2 transition-all duration-200 hover:opacity-80" style={{ color: 'var(--text-primary)', borderColor: 'var(--accent-ember)' }}>
View All Collections View All Lists
</button> </button>
</Link> </Link>
</div> </div>

View file

@ -118,7 +118,7 @@ export default function Home() {
borderColor: 'var(--accent-ember)' borderColor: 'var(--accent-ember)'
}} }}
> >
Explore Collections Explore Lists
</Link> </Link>
</div> </div>
</div> </div>
@ -137,7 +137,7 @@ export default function Home() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg> </svg>
</div> </div>
<h3 className="text-xl font-bold mb-4 gradient-text-flame">Organize Collections</h3> <h3 className="text-xl font-bold mb-4 gradient-text-flame">Organize Lists</h3>
<p style={{ color: 'var(--text-secondary)' }}> <p style={{ color: 'var(--text-secondary)' }}>
Create custom collections, track card values, and organize by sets, rarity, or any system that works for you. Create custom collections, track card values, and organize by sets, rarity, or any system that works for you.
</p> </p>
@ -173,7 +173,7 @@ export default function Home() {
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4 gradient-text-flame"> <h2 className="text-4xl font-bold mb-4 gradient-text-flame">
Featured Collections Featured Lists
</h2> </h2>
<p className="text-xl" style={{ color: 'var(--text-secondary)' }}> <p className="text-xl" style={{ color: 'var(--text-secondary)' }}>
Discover amazing collections from our community Discover amazing collections from our community
@ -223,7 +223,7 @@ export default function Home() {
</div> </div>
<h3 className="text-xl font-bold mb-2 gradient-text-flame">{collection.name}</h3> <h3 className="text-xl font-bold mb-2 gradient-text-flame">{collection.name}</h3>
<p className="text-sm mb-4 line-clamp-2" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm mb-4 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{collection.description || 'A carefully curated collection'} {collection.description || 'A carefully curated list'}
</p> </p>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}> <span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
@ -247,7 +247,7 @@ export default function Home() {
border: '2px solid var(--accent-ember)' border: '2px solid var(--accent-ember)'
}} }}
> >
View All Collections View All Lists
<svg className="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg> </svg>

View file

@ -69,14 +69,14 @@ export default function AcceptInvite() {
Invitation Accepted! Invitation Accepted!
</h2> </h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
You now have access to the collection "{result.collection.name}". You now have access to the list "{result.collection.name}".
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<button <button
onClick={() => router.push(`/collection/${result.collection.id}`)} onClick={() => router.push(`/collection/${result.collection.id}`)}
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200" className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
> >
View Collection View List
</button> </button>
<button <button
onClick={() => router.push('/collections')} onClick={() => router.push('/collections')}
@ -86,7 +86,7 @@ export default function AcceptInvite() {
color: 'var(--text-primary)' color: 'var(--text-primary)'
}} }}
> >
View All Collections View All Lists
</button> </button>
</div> </div>
</div> </div>
@ -104,7 +104,7 @@ export default function AcceptInvite() {
onClick={() => router.push('/collections')} onClick={() => router.push('/collections')}
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200" className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
> >
Browse Collections Browse Lists
</button> </button>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}

View file

@ -69,15 +69,15 @@ export default function DeclineInvite() {
Invitation Declined Invitation Declined
</h2> </h2>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}> <p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
You have declined the invitation to "{result.collection.name}". You have declined the invitation to "{result.collection.name}".
You can always ask the collection owner for another invitation if you change your mind. You can always ask the list owner for another invitation if you change your mind.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<button <button
onClick={() => router.push('/collections')} onClick={() => router.push('/collections')}
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200" className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
> >
Browse Public Collections Browse Public Lists
</button> </button>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}
@ -105,7 +105,7 @@ export default function DeclineInvite() {
onClick={() => router.push('/collections')} onClick={() => router.push('/collections')}
className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200" className="w-full px-6 py-3 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
> >
Browse Collections Browse Lists
</button> </button>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}

View file

@ -8,6 +8,7 @@ import CollectionSelectionModal from '../components/CollectionSelectionModal';
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols'; import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings'; import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/use-auth'; import { useAuth } from '../lib/use-auth';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function MyCards() { export default function MyCards() {
const router = useRouter(); const router = useRouter();
@ -261,10 +262,10 @@ export default function MyCards() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}> <h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
My Cards {VOCAB.MY_COLLECTION}
</h1> </h1>
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}> <p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
Browse and manage your owned trading cards Browse and manage cards in your collection
</p> </p>
</div> </div>
<div className="flex space-x-2 sm:space-x-4"> <div className="flex space-x-2 sm:space-x-4">
@ -356,7 +357,7 @@ export default function MyCards() {
{/* Results Summary */} {/* Results Summary */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<p style={{ color: 'var(--text-secondary)' }}> <p style={{ color: 'var(--text-secondary)' }}>
{pagination.total} owned cards found {pagination.total} cards in {VOCAB.MY_COLLECTION}
</p> </p>
{selectedCards.length > 0 && ( {selectedCards.length > 0 && (
<p style={{ color: 'var(--text-primary)' }}> <p style={{ color: 'var(--text-primary)' }}>
@ -432,9 +433,9 @@ export default function MyCards() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg> </svg>
</div> </div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No Owned Cards Found</h3> <h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No cards in {VOCAB.MY_COLLECTION}</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}> <p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
{searchQuery ? 'Try adjusting your search filters' : 'Start building your collection by browsing available cards'} {searchQuery ? 'Try adjusting your search filters' : `Start adding cards to ${VOCAB.MY_COLLECTION} by browsing available cards`}
</p> </p>
<Link href="/cards"> <Link href="/cards">
<button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}> <button className="px-6 py-3 font-medium rounded-xl transition-all duration-200 hover:opacity-90" style={{ backgroundColor: 'var(--accent-ember)', color: 'white' }}>

View file

@ -393,7 +393,7 @@ export default function Profile() {
{/* Stats Card */} {/* Stats Card */}
<div className="card p-6 mt-6"> <div className="card p-6 mt-6">
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}> <h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Collection Stats List Stats
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@ -403,7 +403,7 @@ export default function Profile() {
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span style={{ color: 'var(--text-secondary)' }}>Collections</span> <span style={{ color: 'var(--text-secondary)' }}>Lists</span>
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}> <span className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{stats.totalCollections} {stats.totalCollections}
</span> </span>

View file

@ -9,10 +9,11 @@ import { ManaCost, ColorIdentity } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings'; import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/use-auth'; import { useAuth } from '../lib/use-auth';
import { useFocusTrap } from '../lib/use-focus-trap.js'; import { useFocusTrap } from '../lib/use-focus-trap.js';
import { VOCAB } from '../lib/collection-vocabulary.js';
const SESSION_STORAGE_KEY = 'deckhearth:scanner-session'; const SESSION_STORAGE_KEY = 'deckhearth:scanner-session';
const DEFAULT_DESTINATION = { type: 'owned', id: null, label: 'My owned cards' }; const DEFAULT_DESTINATION = { type: 'owned', id: null, label: VOCAB.MY_COLLECTION };
const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false }; const DEFAULT_SCAN_DEFAULTS = { condition: 'NM', isFoil: false };
function loadSavedScannerSession() { function loadSavedScannerSession() {
@ -120,7 +121,7 @@ export default function Scanner() {
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
// Filter out system collections (like "All My Cards") // Filter out auto-sync system lists (not user-curated)
const userCollections = data.filter(collection => !collection.is_system_collection); const userCollections = data.filter(collection => !collection.is_system_collection);
setCollections(userCollections); setCollections(userCollections);
} }
@ -367,7 +368,7 @@ export default function Scanner() {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to add to owned cards'); throw new Error(`Failed to add to ${VOCAB.MY_COLLECTION}`);
} }
}; };
@ -688,7 +689,7 @@ export default function Scanner() {
style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }} style={{ backgroundColor: 'var(--accent-gold)', color: 'white' }}
> >
<span aria-hidden="true">💎 </span> <span aria-hidden="true">💎 </span>
Mark Owned {VOCAB.ADD_TO_MY_COLLECTION}
</button> </button>
{collections.length > 0 && ( {collections.length > 0 && (
@ -703,9 +704,9 @@ export default function Scanner() {
disabled={isProcessing} disabled={isProcessing}
className="px-4 py-2 rounded-lg font-medium" className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }} style={{ backgroundColor: 'var(--accent-ember)', color: 'white', border: 'none' }}
aria-label="Add selected cards to collection" aria-label="Add selected cards to list"
> >
<option value="">📚 Add to Collection</option> <option value="">{`📚 ${VOCAB.ADD_TO_LIST}`}</option>
{collections.map(collection => ( {collections.map(collection => (
<option key={collection.id} value={collection.id}> <option key={collection.id} value={collection.id}>
{collection.name} {collection.name}
@ -769,15 +770,15 @@ export default function Scanner() {
aria-labelledby="create-collection-title" aria-labelledby="create-collection-title"
> >
<h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}> <h3 id="create-collection-title" className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Create New Collection Create New List
</h3> </h3>
<label htmlFor="create-collection-name" className="sr-only"> <label htmlFor="create-collection-name" className="sr-only">
Collection name List name
</label> </label>
<input <input
id="create-collection-name" id="create-collection-name"
type="text" type="text"
placeholder="Collection name..." placeholder="List name..."
value={newCollectionName} value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)} onChange={(e) => setNewCollectionName(e.target.value)}
className="w-full px-4 py-2 rounded-lg border mb-4" className="w-full px-4 py-2 rounded-lg border mb-4"

View file

@ -156,7 +156,7 @@ export default function Settings() {
const handleDeleteAccount = async () => { const handleDeleteAccount = async () => {
const confirmed = window.confirm( const confirmed = window.confirm(
'Are you sure you want to delete your account? This action cannot be undone and will permanently delete all your cards, collections, and decks.' 'Are you sure you want to delete your account? This action cannot be undone and will permanently delete all your cards, lists, and decks.'
); );
if (!confirmed) return; if (!confirmed) return;
@ -316,7 +316,7 @@ export default function Settings() {
{/* Collection Visibility */} {/* Collection Visibility */}
<div> <div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Default Collection Visibility Default List Visibility
</label> </label>
<select <select
value={settings.collection_visibility} value={settings.collection_visibility}
@ -328,7 +328,7 @@ export default function Settings() {
<option value="unlisted">Unlisted</option> <option value="unlisted">Unlisted</option>
</select> </select>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
Controls who can see your new collections by default Controls who can see your new lists by default
</p> </p>
</div> </div>
@ -546,7 +546,7 @@ export default function Settings() {
Email Notifications Email Notifications
</p> </p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Receive notifications about your collections and cards Receive notifications about your lists and cards
</p> </p>
</div> </div>
<button <button

View file

@ -22,15 +22,15 @@ describe('ScannerDestinationPicker', () => {
<ScannerDestinationPicker <ScannerDestinationPicker
gameFilter="All" gameFilter="All"
onGameFilterChange={vi.fn()} onGameFilterChange={vi.fn()}
destination={{ type: 'owned', id: null, label: 'My owned cards' }} destination={{ type: 'owned', id: null, label: 'My Collection' }}
onDestinationChange={vi.fn()} onDestinationChange={vi.fn()}
collections={COLLECTIONS} collections={COLLECTIONS}
decks={DECKS} decks={DECKS}
/> />
); );
expect(screen.getByRole('button', { name: /owned/i }).getAttribute('aria-pressed')).toBe('true'); expect(screen.getByRole('button', { name: /my collection/i }).getAttribute('aria-pressed')).toBe('true');
expect(screen.getByRole('button', { name: /collection/i }).getAttribute('aria-pressed')).toBe('false'); expect(screen.getByRole('button', { name: /list/i }).getAttribute('aria-pressed')).toBe('false');
}); });
it('calls onDestinationChange when switching to a collection', () => { it('calls onDestinationChange when switching to a collection', () => {
@ -40,14 +40,14 @@ describe('ScannerDestinationPicker', () => {
<ScannerDestinationPicker <ScannerDestinationPicker
gameFilter="All" gameFilter="All"
onGameFilterChange={vi.fn()} onGameFilterChange={vi.fn()}
destination={{ type: 'owned', id: null, label: 'My owned cards' }} destination={{ type: 'owned', id: null, label: 'My Collection' }}
onDestinationChange={onDestinationChange} onDestinationChange={onDestinationChange}
collections={COLLECTIONS} collections={COLLECTIONS}
decks={DECKS} decks={DECKS}
/> />
); );
fireEvent.click(screen.getByRole('button', { name: /collection/i })); fireEvent.click(screen.getByRole('button', { name: /^📚 list$/i }));
expect(onDestinationChange).toHaveBeenCalledWith({ expect(onDestinationChange).toHaveBeenCalledWith({
type: 'collection', type: 'collection',
@ -79,7 +79,7 @@ describe('ScannerDestinationPicker', () => {
<ScannerDestinationPicker <ScannerDestinationPicker
gameFilter="All" gameFilter="All"
onGameFilterChange={onGameFilterChange} onGameFilterChange={onGameFilterChange}
destination={{ type: 'owned', id: null, label: 'My owned cards' }} destination={{ type: 'owned', id: null, label: 'My Collection' }}
onDestinationChange={vi.fn()} onDestinationChange={vi.fn()}
collections={COLLECTIONS} collections={COLLECTIONS}
decks={DECKS} decks={DECKS}