662 lines
17 KiB
Svelte
662 lines
17 KiB
Svelte
<script>
|
|
import { API_URL, WS_URL } from '$lib/api.js';
|
|
import { apiFetch } from '$lib/api.js';
|
|
import { goto } from '$app/navigation';
|
|
import { onMount } from 'svelte';
|
|
import Card from '$lib/Card.svelte';
|
|
|
|
let allCards = $state([]);
|
|
let loading = $state(true);
|
|
const token = () => localStorage.getItem('token');
|
|
|
|
// Sort
|
|
let sortBy = $state('name');
|
|
|
|
// Filters
|
|
const RARITIES = ['common', 'uncommon', 'rare', 'super_rare', 'epic', 'legendary'];
|
|
const TYPES = ['person', 'location', 'artwork', 'life_form', 'event', 'group', 'science_thing', 'vehicle', 'organization', 'other'];
|
|
|
|
let selectedRarities = $state(new Set(RARITIES));
|
|
let selectedTypes = $state(new Set(TYPES));
|
|
|
|
let filtersOpen = $state(false);
|
|
|
|
const RARITY_ORDER = Object.fromEntries(RARITIES.map((r, i) => [r, i]));
|
|
|
|
function label(str) {
|
|
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ');
|
|
}
|
|
|
|
|
|
let sortAsc = $state(true);
|
|
let costMin = $state(1);
|
|
let costMax = $state(10);
|
|
let searchQuery = $state('');
|
|
|
|
let filtered = $derived.by(() => {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
let result = allCards.filter(c =>
|
|
selectedRarities.has(c.card_rarity) &&
|
|
selectedTypes.has(c.card_type) &&
|
|
c.cost >= costMin &&
|
|
c.cost <= costMax &&
|
|
(!q || c.name.toLowerCase().includes(q))
|
|
);
|
|
|
|
result = result.slice().sort((a, b) => {
|
|
let cmp = 0;
|
|
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
|
else if (sortBy === 'cost') cmp = b.cost - a.cost || a.name.localeCompare(b.name);
|
|
else if (sortBy === 'attack') cmp = b.attack - a.attack || a.name.localeCompare(b.name);
|
|
else if (sortBy === 'defense') cmp = b.defense - a.defense || a.name.localeCompare(b.name);
|
|
else if (sortBy === 'rarity') cmp = RARITY_ORDER[b.card_rarity] - RARITY_ORDER[a.card_rarity] || a.name.localeCompare(b.name);
|
|
return sortAsc ? cmp : -cmp;
|
|
});
|
|
|
|
return result;
|
|
});
|
|
|
|
function toggleSort(val) {
|
|
if (sortBy === val) sortAsc = !sortAsc;
|
|
else { sortBy = val; sortAsc = true; }
|
|
}
|
|
|
|
function toggleRarity(r) {
|
|
const s = new Set(selectedRarities);
|
|
s.has(r) ? s.delete(r) : s.add(r);
|
|
selectedRarities = s;
|
|
}
|
|
|
|
function toggleType(t) {
|
|
const s = new Set(selectedTypes);
|
|
s.has(t) ? s.delete(t) : s.add(t);
|
|
selectedTypes = s;
|
|
}
|
|
|
|
function allRaritiesSelected() { return selectedRarities.size === RARITIES.length; }
|
|
function allTypesSelected() { return selectedTypes.size === TYPES.length; }
|
|
|
|
function toggleAllRarities() {
|
|
selectedRarities = allRaritiesSelected() ? new Set() : new Set(RARITIES);
|
|
}
|
|
|
|
function toggleAllTypes() {
|
|
selectedTypes = allTypesSelected() ? new Set() : new Set(TYPES);
|
|
}
|
|
|
|
onMount(async () => {
|
|
if (!token()) { goto('/auth'); return; }
|
|
const res = await apiFetch(`${API_URL}/cards`);
|
|
if (res.status === 401) { goto('/auth'); return; }
|
|
allCards = await res.json();
|
|
loading = false;
|
|
});
|
|
|
|
let selectedCard = $state(null);
|
|
let refreshStatus = $state(null);
|
|
let countdownDisplay = $state('');
|
|
let countdownInterval = null;
|
|
let reportLoading = $state(false);
|
|
let refreshLoading = $state(false);
|
|
let actionMessage = $state('');
|
|
|
|
async function fetchRefreshStatus() {
|
|
const res = await apiFetch(`${API_URL}/profile/refresh-status`);
|
|
refreshStatus = await res.json();
|
|
if (!refreshStatus.can_refresh && refreshStatus.next_refresh_at) {
|
|
startRefreshCountdown(new Date(refreshStatus.next_refresh_at));
|
|
}
|
|
}
|
|
|
|
function startRefreshCountdown(nextRefreshAt) {
|
|
clearInterval(countdownInterval);
|
|
countdownInterval = setInterval(() => {
|
|
const diff = nextRefreshAt - Date.now();
|
|
if (diff <= 0) {
|
|
clearInterval(countdownInterval);
|
|
refreshStatus = { can_refresh: true, next_refresh_at: null };
|
|
countdownDisplay = '';
|
|
return;
|
|
}
|
|
const h = Math.floor(diff / 3600000);
|
|
const m = Math.floor((diff % 3600000) / 60000);
|
|
const s = Math.floor((diff % 60000) / 1000);
|
|
countdownDisplay = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
|
}, 1000);
|
|
}
|
|
|
|
function openCard(card) {
|
|
selectedCard = card;
|
|
actionMessage = '';
|
|
fetchRefreshStatus();
|
|
}
|
|
|
|
function closeCard() {
|
|
selectedCard = null;
|
|
clearInterval(countdownInterval);
|
|
countdownDisplay = '';
|
|
actionMessage = '';
|
|
}
|
|
|
|
async function reportCard() {
|
|
reportLoading = true;
|
|
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/report`, {
|
|
method: 'POST'
|
|
});
|
|
reportLoading = false;
|
|
if (res.ok) {
|
|
selectedCard = { ...selectedCard, reported: true };
|
|
allCards = allCards.map(c => c.id === selectedCard.id ? { ...c, reported: true } : c);
|
|
actionMessage = 'Card reported. Thank you!';
|
|
} else {
|
|
actionMessage = 'Failed to report card.';
|
|
}
|
|
}
|
|
|
|
async function refreshCard() {
|
|
refreshLoading = true;
|
|
actionMessage = '';
|
|
const res = await apiFetch(`${API_URL}/cards/${selectedCard.id}/refresh`, {
|
|
method: 'POST'
|
|
});
|
|
refreshLoading = false;
|
|
if (res.ok) {
|
|
const updated = await res.json();
|
|
// Update card in allCards list
|
|
allCards = allCards.map(c => c.id === updated.id ? updated : c);
|
|
selectedCard = updated;
|
|
refreshStatus = { can_refresh: false, next_refresh_at: null };
|
|
await fetchRefreshStatus();
|
|
actionMessage = 'Card refreshed!';
|
|
} else {
|
|
const err = await res.json();
|
|
actionMessage = err.detail ?? 'Failed to refresh card.';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<main>
|
|
<div class="toolbar">
|
|
<div class="sort-row">
|
|
<span class="toolbar-label">Sort by</span>
|
|
{#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]}
|
|
<button
|
|
class="sort-btn"
|
|
class:active={sortBy === val}
|
|
onclick={() => toggleSort(val)}
|
|
>
|
|
{lbl}
|
|
{#if sortBy === val}
|
|
<span class="sort-arrow">{sortAsc ? '↑' : '↓'}</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
|
|
<input
|
|
class="search-input"
|
|
type="search"
|
|
placeholder="Search by name…"
|
|
bind:value={searchQuery}
|
|
/>
|
|
|
|
<button class="filter-toggle" onclick={() => filtersOpen = !filtersOpen}>
|
|
{filtersOpen ? 'Hide filters' : 'Filter'}
|
|
{#if selectedRarities.size < RARITIES.length || selectedTypes.size < TYPES.length || costMin > 1 || costMax < 10}
|
|
<span class="filter-dot"></span>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
{#if filtersOpen}
|
|
<div class="filters">
|
|
<div class="filter-group">
|
|
<div class="filter-group-header">
|
|
<span class="filter-group-label">Rarity</span>
|
|
<button class="select-all" onclick={toggleAllRarities}>
|
|
{allRaritiesSelected() ? 'Deselect all' : 'Select all'}
|
|
</button>
|
|
</div>
|
|
<div class="checkboxes">
|
|
{#each RARITIES as r}
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" checked={selectedRarities.has(r)} onchange={() => toggleRarity(r)} />
|
|
{label(r)}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<div class="filter-group-header">
|
|
<span class="filter-group-label">Type</span>
|
|
<button class="select-all" onclick={toggleAllTypes}>
|
|
{allTypesSelected() ? 'Deselect all' : 'Select all'}
|
|
</button>
|
|
</div>
|
|
<div class="checkboxes">
|
|
{#each TYPES as t}
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" checked={selectedTypes.has(t)} onchange={() => toggleType(t)} />
|
|
{label(t)}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<div class="filter-group-header">
|
|
<span class="filter-group-label">Cost</span>
|
|
<button class="select-all" onclick={() => { costMin = 1; costMax = 10; }}>Reset</button>
|
|
</div>
|
|
<div class="cost-range">
|
|
<span class="range-label">Min: {costMin}</span>
|
|
<input type="range" min="1" max="10" bind:value={costMin}
|
|
oninput={() => { if (costMin > costMax) costMax = costMin; }} />
|
|
<span class="range-label">Max: {costMax}</span>
|
|
<input type="range" min="1" max="10" bind:value={costMax}
|
|
oninput={() => { if (costMax < costMin) costMin = costMax; }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if loading}
|
|
<p class="status">Loading your cards...</p>
|
|
{:else if filtered.length === 0}
|
|
<p class="status">No cards match your filters.</p>
|
|
{:else}
|
|
<p class="card-count">{filtered.length} card{filtered.length === 1 ? '' : 's'}</p>
|
|
<div class="grid">
|
|
{#each filtered as card (card.id)}
|
|
<button class="card-btn" onclick={() => openCard(card)}>
|
|
<Card {card} />
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if selectedCard}
|
|
<div class="backdrop" onclick={closeCard}>
|
|
<div class="card-popup" onclick={(e) => e.stopPropagation()}>
|
|
<Card card={selectedCard} />
|
|
<div class="popup-actions">
|
|
<div class="action-col">
|
|
<button
|
|
class="report-btn"
|
|
onclick={reportCard}
|
|
disabled={reportLoading || selectedCard.reported}
|
|
>
|
|
{selectedCard.reported ? 'Already Reported' : reportLoading ? 'Reporting...' : 'Report Error'}
|
|
</button>
|
|
</div>
|
|
<div class="action-col">
|
|
<button
|
|
class="refresh-btn"
|
|
onclick={refreshCard}
|
|
disabled={refreshLoading || (refreshStatus && !refreshStatus.can_refresh)}
|
|
>
|
|
{refreshLoading ? 'Refreshing...' : 'Refresh Card'}
|
|
</button>
|
|
<span class="refresh-countdown">
|
|
{refreshStatus && !refreshStatus.can_refresh && countdownDisplay ? `${countdownDisplay} until next refresh` : ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p class="action-message">{actionMessage}</p>
|
|
<button class="close-btn" onclick={closeCard}>✕</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
|
|
|
main {
|
|
height: 100vh;
|
|
overflow-y: auto;
|
|
background: #0d0a04;
|
|
padding: 0 2rem 2rem 2rem;
|
|
}
|
|
|
|
.toolbar {
|
|
position: sticky;
|
|
top: 0px;
|
|
z-index: 50;
|
|
background: #0d0a04;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
|
margin-bottom: 2rem;
|
|
padding-top: 32px;
|
|
}
|
|
|
|
.sort-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.search-input {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: 15px;
|
|
background: rgba(255,255,255,0.04);
|
|
border: 1px solid rgba(107, 76, 30, 0.4);
|
|
border-radius: 4px;
|
|
color: #f0d080;
|
|
padding: 5px 10px;
|
|
outline: none;
|
|
width: 220px;
|
|
margin-left: auto;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.search-input:focus { border-color: #c8861a; }
|
|
.search-input::placeholder { color: rgba(240, 180, 80, 0.3); }
|
|
|
|
.toolbar-label {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: 11px;
|
|
color: rgba(240, 180, 80, 0.5);
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
margin-right: 0.25rem;
|
|
}
|
|
|
|
.sort-btn {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
background: none;
|
|
border: 1px solid rgba(107, 76, 30, 0.4);
|
|
border-radius: 4px;
|
|
color: rgba(240, 180, 80, 0.6);
|
|
padding: 4px 10px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.sort-btn:hover {
|
|
border-color: #c8861a;
|
|
color: #f0d080;
|
|
}
|
|
|
|
.sort-btn.active {
|
|
background: #3d2507;
|
|
border-color: #c8861a;
|
|
color: #f0d080;
|
|
}
|
|
|
|
.filter-toggle {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
background: none;
|
|
border: 1px solid rgba(107, 76, 30, 0.4);
|
|
border-radius: 4px;
|
|
color: rgba(240, 180, 80, 0.6);
|
|
padding: 4px 10px;
|
|
cursor: pointer;
|
|
margin-left: 0.5rem;
|
|
position: relative;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.filter-toggle:hover {
|
|
border-color: #c8861a;
|
|
color: #f0d080;
|
|
}
|
|
|
|
.filter-dot {
|
|
position: absolute;
|
|
top: -3px;
|
|
right: -3px;
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: #c8861a;
|
|
}
|
|
|
|
.filters {
|
|
display: flex;
|
|
gap: 3rem;
|
|
padding-top: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.filter-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.filter-group-header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.filter-group-label {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: rgba(240, 180, 80, 0.5);
|
|
}
|
|
|
|
.select-all {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: 12px;
|
|
font-style: italic;
|
|
background: none;
|
|
border: none;
|
|
color: rgba(240, 180, 80, 0.5);
|
|
cursor: pointer;
|
|
padding: 0;
|
|
text-decoration: underline;
|
|
transition: color 0.15s;
|
|
}
|
|
|
|
.select-all:hover {
|
|
color: #f0d080;
|
|
}
|
|
|
|
.checkboxes {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem 1rem;
|
|
}
|
|
|
|
.checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: 14px;
|
|
color: rgba(240, 180, 80, 0.8);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.checkbox-label input {
|
|
accent-color: #c8861a;
|
|
width: 14px;
|
|
height: 14px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.card-count {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: 16px;
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.4);
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.grid {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 20px;
|
|
justify-content: center;
|
|
padding-bottom: 50px
|
|
}
|
|
|
|
.status {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: 16px;
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.5);
|
|
text-align: center;
|
|
margin-top: 4rem;
|
|
}
|
|
|
|
.sort-arrow {
|
|
font-size: 10px;
|
|
margin-left: 3px;
|
|
}
|
|
|
|
.cost-range {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.range-label {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: 11px;
|
|
color: rgba(240, 180, 80, 0.7);
|
|
min-width: 60px;
|
|
}
|
|
|
|
input[type=range] {
|
|
accent-color: #c8861a;
|
|
width: 160px;
|
|
}
|
|
|
|
.card-btn {
|
|
all: unset;
|
|
cursor: pointer;
|
|
display: block;
|
|
border-radius: 12px;
|
|
transition: transform 0.15s;
|
|
}
|
|
|
|
.card-btn:hover {
|
|
transform: translateY(-4px);
|
|
}
|
|
|
|
.backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.8);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
backdrop-filter: blur(6px);
|
|
}
|
|
|
|
.card-popup {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.popup-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
justify-content: center;
|
|
}
|
|
|
|
.action-col {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
min-height: 60px;
|
|
}
|
|
|
|
.report-btn, .refresh-btn {
|
|
font-family: 'Cinzel', serif;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
border-radius: 4px;
|
|
padding: 8px 18px;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.report-btn {
|
|
background: rgba(180, 60, 60, 0.5);
|
|
border: 1px solid rgba(240,250,240,0.8);
|
|
color: white;
|
|
}
|
|
|
|
.report-btn:hover:not(:disabled) {
|
|
/* border-color: #c84040; */
|
|
color: #E0E0E0;
|
|
background: rgba(180, 40, 40, 0.9);
|
|
}
|
|
|
|
.refresh-btn {
|
|
background: #3d2507;
|
|
border: 1px solid #c8861a;
|
|
color: #f0d080;
|
|
}
|
|
|
|
.refresh-btn:hover:not(:disabled) {
|
|
background: #5a3510;
|
|
}
|
|
|
|
.report-btn:disabled, .refresh-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.refresh-countdown {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: 12px;
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.5);
|
|
}
|
|
|
|
.action-message {
|
|
font-family: 'Crimson Text', serif;
|
|
font-size: 14px;
|
|
font-style: italic;
|
|
color: rgba(240, 180, 80, 0.7);
|
|
margin: 0;
|
|
min-height: 1.4em;
|
|
text-align: center;
|
|
}
|
|
|
|
.close-btn {
|
|
position: absolute;
|
|
top: -12px;
|
|
right: -12px;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
background: #1a1008;
|
|
border: 1px solid #6b4c1e;
|
|
color: rgba(240, 180, 80, 0.7);
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.close-btn:hover {
|
|
border-color: #c8861a;
|
|
color: #f0d080;
|
|
}
|
|
</style> |