Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
32997b9
feat: UI polish - Inline SVGs, Mobile Touch Targets, and Shimmer Load…
poketonova Jun 7, 2026
f88d9f3
Merge pull request #3 from poketonova/feature/bulk-delete
poketonova Jun 7, 2026
b2d41c7
Merge pull request #2 from poketonova/fix/chart-reliability
poketonova Jun 7, 2026
2efa507
feat: implement scanner store for camera state and scan results
poketonova Jun 7, 2026
2a1793b
feat: implement trade store with IndexedDB persistence and price delt…
poketonova Jun 7, 2026
ba28bdf
feat: integrate scanner ui with ocr service and pinia stores in Trade…
poketonova Jun 7, 2026
e4335be
feat: add static mockup dashboard component
poketonova Jun 7, 2026
931c5e3
feat: add route for mockup dashboard
poketonova Jun 7, 2026
a218526
feat: deploy dark-themed dashboard mockup with glassmorphism UI
poketonova Jun 7, 2026
32ea994
feat: integrate OCR/Scanner interface and expand dashboard mockup views
poketonova Jun 7, 2026
c7d865f
feat: upgrade landing page with glassmorphism empty state and demo hy…
poketonova Jun 7, 2026
ea91ea6
fix: switch to hash history for GH Pages compatibility and set vite b…
poketonova Jun 7, 2026
d6cc2b2
feat: wire up scan button in PortfolioView.vue for direct card scanning
poketonova Jun 7, 2026
8c083fb
Implement Tesseract.js OCR for card identification and manual fallbac…
poketonova Jun 7, 2026
fb3d20d
fix: change base to relative path to fix blank page on Vercel preview
poketonova Jun 7, 2026
346918e
fix: add tesseract.js optimization and cjs interop for vite build to …
poketonova Jun 7, 2026
b4d5146
fix: update vite config and wrap tesseract in dynamic import to preve…
poketonova Jun 7, 2026
5d36b0e
fix: ensure relative base path and tesseract build interop in vite.co…
poketonova Jun 7, 2026
4da6fb7
fix: add .js extensions to imports in scanPipeline for ESM compliance
poketonova Jun 7, 2026
145f343
fix: convert TradeAnalyzerView to JS and fix missing component and im…
poketonova Jun 7, 2026
466d05d
fix: ensure Tesseract.js createWorker is correctly resolved from dyna…
poketonova Jun 7, 2026
3fca8fb
fix: downgrade vite to ^6.0.4 and apexcharts to ^3.45.0 for build sta…
poketonova Jun 7, 2026
3064439
feat: add CameraViewfinder.vue stub using native MediaDevices API to …
poketonova Jun 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
"preview": "vite preview"
},
"dependencies": {
"apexcharts": "^5.10.5",
"apexcharts": "^3.45.0",
"dexie": "^4.4.2",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"tesseract.js": "^5.1.0",
"vue": "^3.5.32",
"vue-router": "^4.6.4",
"vue3-apexcharts": "^1.11.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.4"
"vite": "^6.0.4"
}
}
69 changes: 52 additions & 17 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,35 @@
<div class="app-layout">
<aside class="sidebar" :class="{ open: sidebarOpen }">
<a href="/" class="sidebar-logo" @click.prevent="hardRefresh">
<span class="logo-icon">⬡</span>
<span class="logo-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hexagon" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</span>
<span class="logo-text">Rarebox</span>
</a>
<button class="btn btn-ghost btn-icon sidebar-close" @click="sidebarOpen = false" style="position:absolute;top:20px;right:12px;z-index:10">✕</button>
<button class="btn btn-ghost btn-icon sidebar-close" @click="sidebarOpen = false" aria-label="Close sidebar" style="position:absolute;top:16px;right:12px;z-index:10">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>

<nav class="sidebar-nav">
<router-link to="/" class="nav-item" @click="sidebarOpen = false">
<span class="nav-icon">⊞</span> Dashboard
<span class="nav-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layout-grid" aria-hidden="true"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg>
</span> Dashboard
</router-link>
<router-link to="/search" class="nav-item" @click="sidebarOpen = false">
<span class="nav-icon">⌕</span> Search Cards
<span class="nav-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</span> Search Cards
</router-link>
<router-link to="/sets" class="nav-item" @click="sidebarOpen = false">
<span class="nav-icon">◫</span> Browse Sets
<span class="nav-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layers" aria-hidden="true"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.1 6.27a2 2 0 0 0 0 3.46l9.07 4.09a2 2 0 0 0 1.66 0l9.07-4.09a2 2 0 0 0 0-3.46z"/><path d="m2.1 14.74 9.07 4.09a2 2 0 0 0 1.66 0l9.07-4.09"/><path d="m2.1 10.54 9.07 4.09a2 2 0 0 0 1.66 0l9.07-4.09"/></svg>
</span> Browse Sets
</router-link>
<router-link to="/decks" class="nav-item" @click="sidebarOpen = false">
<span class="nav-icon">🃏</span> Decks
<span class="nav-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gallery-vertical-end" aria-hidden="true"><path d="M7 2h10"/><path d="M5 6h14"/><rect width="18" height="12" x="3" y="10" rx="2"/></svg>
</span> Decks
</router-link>
</nav>

Expand All @@ -42,26 +54,30 @@
<span class="portfolio-nav-count">{{ p.items.length }}</span>
</router-link>

<button class="nav-item add-portfolio-btn" @click="showNewPortfolioModal = true">
<span class="nav-icon">+</span> New Portfolio
<button class="nav-item add-portfolio-btn" @click="showNewPortfolioModal = true" aria-label="Add new portfolio">
<span class="nav-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
</span> New Portfolio
</button>
</nav>

<div class="sidebar-bottom">
<router-link to="/settings" class="nav-item" @click="sidebarOpen = false">
<span class="nav-icon">⚙</span> Settings
<span class="nav-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2l-.27 1.32a.41.41 0 0 1-.5.33l-1.32-.27a2 2 0 0 0-2.39 1.18l-.22.44a2 2 0 0 0 1.18 2.39l1.32.27a.41.41 0 0 1 .33.5l-.27 1.32a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2l.27-1.32a.41.41 0 0 1 .5-.33l1.32.27a2 2 0 0 0 2.39-1.18l.22-.44a2 2 0 0 0-1.18-2.39l-1.32-.27a.41.41 0 0 1-.33-.5l.27-1.32a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
</span> Settings
</router-link>
</div>
</aside>

<div class="main-wrapper">
<header class="topbar">
<button class="btn btn-ghost btn-icon hamburger" @click="sidebarOpen = !sidebarOpen">
<button class="btn btn-ghost btn-icon hamburger" @click="sidebarOpen = !sidebarOpen" aria-label="Open sidebar">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu" aria-hidden="true"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
</button>
<div class="topbar-breadcrumb">
{{ currentPageTitle }}
<button v-if="currentTour" class="tour-info-btn" @click="replayTour" title="Watch tour video">i</button>
<button v-if="currentTour" class="tour-info-btn" @click="replayTour" title="Watch tour video" aria-label="Watch tour video">i</button>
</div>
<router-link to="/search" class="btn btn-primary btn-sm">
+ Add Card
Expand Down Expand Up @@ -90,7 +106,9 @@
<div class="modal slide-up-enter-active">
<div class="modal-header">
<h3>New Portfolio</h3>
<button class="btn btn-ghost btn-icon" @click="showNewPortfolioModal = false">✕</button>
<button class="btn btn-ghost btn-icon" @click="showNewPortfolioModal = false" aria-label="Close modal">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
Expand All @@ -107,6 +125,7 @@
:class="{ active: newPortfolioColor === c }"
:style="{ background: c }"
@click="newPortfolioColor = c"
:aria-label="'Select color ' + c"
></button>
</div>
</div>
Expand Down Expand Up @@ -232,17 +251,33 @@ onMounted(async () => {
}
.sidebar-logo:hover { opacity: 0.8; }
.logo-icon {
font-size: 22px;
color: var(--accent);
display: inline-block;
display: inline-flex;
animation: logo-float 3s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
.logo-icon {
animation: none;
}
.sidebar {
transition: none;
}
}

@keyframes logo-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
.logo-text { font-size: 18px; font-weight: 700; color: var(--text-primary); flex: 1; }
.sidebar-close { display: none; }
.sidebar-close {
display: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 12px;
}

.sidebar-nav {
display: flex;
Expand Down Expand Up @@ -273,7 +308,7 @@ onMounted(async () => {
background: var(--accent-dim);
color: var(--accent);
}
.nav-icon { font-size: 15px; width: 18px; text-align: center; flex-shrink: 0; }
.nav-icon { width: 18px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }

.sidebar-section-label {
font-size: 10px;
Expand Down
33 changes: 26 additions & 7 deletions src/components/PriceChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@

<script setup>
import { ref, watch, computed, onMounted } from 'vue'
import { fetchPriceHistory, buildChartSeries, filterByYears, getVariantLabel } from '../services/priceHistory'
import { fetchPriceHistory, buildChartSeries, getVariantLabel } from '../services/priceHistory'

const props = defineProps({
cardId: { type: String, required: true },
Expand Down Expand Up @@ -147,7 +147,9 @@ const priceStats = computed(() => {
const data = chartSeries.value?.[0]?.data
if (!data || data.length === 0) return null

const prices = data.map(p => p.y).filter(Boolean)
const prices = data.map(p => p.y).filter(v => v !== null && v !== undefined)
if (prices.length === 0) return null

const current = prices[prices.length - 1]
const first = prices[0]
const high = Math.max(...prices)
Expand Down Expand Up @@ -183,6 +185,7 @@ function buildSeries() {
}
const allSeries = Array.isArray(result) ? result : result.series
selectedVariant.value = result.key || selectedVariant.value
availableVariants.value = result.availableVariants || availableVariants.value
applyRange(allSeries)
}

Expand All @@ -195,33 +198,49 @@ function setRange(range) {
}

function applyRange(allSeries) {
if (!allSeries || allSeries.length === 0) {
noData.value = true
return
}

const DAY = 24 * 60 * 60 * 1000
const rangeMap = { '7d': 7 * DAY, '1m': 30 * DAY, '6m': 182 * DAY, '1y': 365 * DAY, '3y': 1095 * DAY }
const ms = rangeMap[activeRange.value] || 1095 * DAY
const cutoff = Date.now() - ms

let filtered = allSeries.filter(p => p.x >= cutoff)

// If fewer than 2 points in range, anchor from the last known price before cutoff
// so the chart always draws a line instead of going blank
// FIX: Robust filtering anchor logic
// If we have fewer than 2 points in the requested range, but historical points exist,
// we must include the last known point before the cutoff to draw a valid line.
if (filtered.length < 2) {
const before = allSeries.filter(p => p.x < cutoff)
if (before.length > 0) {
const anchor = before[before.length - 1]
// Use the cutoff timestamp for the anchor point to bridge the gap perfectly
filtered = [{ x: cutoff, y: anchor.y }, ...filtered]
}
}

// Still nothing — fall back to all available data
const display = filtered.length >= 2 ? filtered : allSeries
// If still less than 2 points (e.g. only 1 point exists in total), fallback to all data
// but ensure at least 2 points for ApexCharts area rendering (duplicate if needed)
let display = filtered.length >= 2 ? filtered : allSeries
if (display.length === 1) {
const p = display[0]
display = [{ x: p.x - DAY, y: p.y }, p]
}

if (display.length === 0) {
noData.value = true
return
}

chartSeries.value = [{ name: 'Price', data: display }]

const color = display.length >= 2
? (display[display.length - 1].y >= display[0].y ? '#3fb950' : '#f85149')
: '#f5a623'

// Set x-axis tick format based on range — prevents "hours" showing on 7D/1M
const dtFmtMap = {
'7d': 'dd MMM', '1m': 'dd MMM', '6m': 'MMM yyyy', '1y': 'MMM yyyy', '3y': 'MMM yyyy',
}
Expand Down
123 changes: 123 additions & 0 deletions src/components/scanner/CameraViewfinder.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<template>
<div class="camera-viewfinder h-full w-full bg-black flex flex-col items-center justify-center relative overflow-hidden">
<!-- Video Stream -->
<video
ref="videoRef"
autoplay
playsinline
class="h-full w-full object-cover"
:class="{ 'opacity-50': isProcessing }"
></video>

<!-- Scanner Overlay / Guidelines -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-64 h-88 border-2 border-rb-accent/50 rounded-2xl relative shadow-[0_0_0_100vw_rgba(0,0,0,0.4)]">
<div class="absolute -top-10 left-1/2 -translate-x-1/2 text-rb-accent text-xs font-bold whitespace-nowrap uppercase tracking-widest">
Align Card in Frame
</div>
<!-- Corner Accents -->
<div class="absolute -top-1 -left-1 w-6 h-6 border-t-4 border-l-4 border-rb-accent rounded-tl-xl"></div>
<div class="absolute -top-1 -right-1 w-6 h-6 border-t-4 border-r-4 border-rb-accent rounded-tr-xl"></div>
<div class="absolute -bottom-1 -left-1 w-6 h-6 border-b-4 border-l-4 border-rb-accent rounded-bl-xl"></div>
<div class="absolute -bottom-1 -right-1 w-6 h-6 border-b-4 border-r-4 border-rb-accent rounded-br-xl"></div>
</div>
</div>

<!-- Controls -->
<div class="absolute bottom-10 inset-x-0 flex items-center justify-center gap-8 px-6">
<button
@click="$emit('close')"
class="w-12 h-12 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center text-white hover:bg-white/20 active:scale-95 transition-all"
aria-label="Close scanner"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>

<button
@click="capture"
:disabled="isProcessing"
class="w-20 h-20 rounded-full border-4 border-white flex items-center justify-center active:scale-90 transition-all disabled:opacity-50"
aria-label="Capture card"
>
<div class="w-16 h-16 rounded-full bg-rb-accent shadow-lg shadow-rb-accent/40"></div>
</button>

<div class="w-12 h-12"></div> <!-- Spacer to center the capture button -->
</div>

<canvas ref="canvasRef" class="hidden"></canvas>
</div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const emit = defineEmits(['capture', 'close'])
const props = defineProps({
isProcessing: {
type: BOOLEAN,
default: false
}
})

const videoRef = ref(null)
const canvasRef = ref(null)
let stream = null

async function initCamera() {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
})
if (videoRef.value) {
videoRef.value.srcObject = stream
}
} catch (err) {
console.error('Camera access failed:', err)
alert('Could not access camera. Please ensure permissions are granted.')
emit('close')
}
}

function capture() {
if (!videoRef.value || !canvasRef.value) return

const video = videoRef.value
const canvas = canvasRef.value
const context = canvas.getContext('2d')

// Set canvas size to video aspect ratio
canvas.width = video.videoWidth
canvas.height = video.videoHeight

// Draw current frame to canvas
context.drawImage(video, 0, 0, canvas.width, canvas.height)

// Get base64 data
const imageData = canvas.toDataURL('image/jpeg', 0.8)
emit('capture', imageData)
}

onMounted(() => {
initCamera()
})

onBeforeUnmount(() => {
if (stream) {
stream.getTracks().forEach(track => track.stop())
}
})
</script>

<style scoped>
.h-88 {
height: 22rem;
}
</style>
5 changes: 3 additions & 2 deletions src/router/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'

const routes = [
{ path: '/', name: 'Dashboard', component: () => import('../views/DashboardView.vue'), meta: { title: 'Dashboard', description: 'Track your Pokémon TCG collection — live prices, portfolio charts, and gain/loss across all your portfolios.' } },
{ path: '/mockup', name: 'Mockup', component: () => import('../views/MockDashboard.vue'), meta: { title: 'New Dashboard Mockup', description: 'Prototype of the new dark-themed dashboard interface.' } },
{ path: '/search', name: 'Search', component: () => import('../views/SearchView.vue'), meta: { title: 'Search Cards', description: 'Search any Pokémon card across all sets. Live TCGPlayer prices and price history charts.' } },
{ path: '/sets', name: 'Sets', component: () => import('../views/SetsView.vue'), meta: { title: 'Browse Sets', description: 'Browse every Pokémon TCG set. View cards, release dates, and card counts.' } },
{ path: '/portfolio/:id', name: 'Portfolio', component: () => import('../views/PortfolioView.vue'), meta: { title: 'Portfolio', description: 'View portfolio items, value charts, gain/loss, and manage your collection.' } },
Expand All @@ -13,7 +14,7 @@ const routes = [
]

const router = createRouter({
history: createWebHistory(),
history: createWebHashHistory(),
routes,
scrollBehavior: () => ({ top: 0 })
})
Expand Down
Loading