Compare commits

..

27 Commits

Author SHA1 Message Date
Dan Milne
84485af5a2 Standard RB fixes
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2026-01-31 14:12:14 +11:00
Dan Milne
e9f1eb8d2e Update gems and ruby version 2026-01-31 14:09:55 +11:00
Dan Milne
c5796b57fb Better build script 2025-11-03 16:43:12 +11:00
Dan Milne
670a5ba473 Fix
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-03 15:42:33 +11:00
Dan Milne
fb34071478 Add c omponent for the icons
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-03 09:58:23 +11:00
Dan Milne
858edcb8b5 USe the API TOKEN if we have it
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-03 00:14:55 +11:00
Dan Milne
fc10b9d5b3 Updates
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-02 22:33:28 +11:00
Dan Milne
bf10d0232a Add image processing gem
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-01 13:08:50 +11:00
Dan Milne
a09d8996da Default to production values
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-01 12:57:16 +11:00
Dan Milne
3e6220f66f bugfix
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-01 12:40:03 +11:00
Dan Milne
425fe2d6da Add optional sentry config, move active_storage into storage/uploads/
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-01 12:34:40 +11:00
Dan Milne
c3ffde807c More readme
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-27 14:20:12 +11:00
Dan Milne
7239291e73 Update README with info on how to get products into Shelflife. Update some active_storage tables
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-27 14:18:29 +11:00
Dan Milne
b2a05ab69a Add Docker compose instructions
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-27 14:12:56 +11:00
Dan Milne
2138d6ec33 Consolide some TBDB client code. RAILS_MASTER_KEY -> SECRET_KEY_BASE
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-23 15:54:15 +11:00
Dan Milne
1bf69a3d10 Rename env example file, improve docker instructions
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-23 15:31:47 +11:00
Dan Milne
fac2c46f61 Switch to bun. Remove credentials
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-23 15:11:20 +11:00
Dan Milne
21c4312fa3 Fix tests, add files missing from git 2025-10-23 12:20:48 +11:00
Dan Milne
966d37af74 Fix some tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-23 12:14:21 +11:00
Dan Milne
a04f1960a4 Update gems 2025-10-23 12:14:07 +11:00
Dan Milne
2a2a2322c6 Refactor TBDB connections, drop API Token and go with Dynamic OAuth. Many other improvements
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-23 11:57:52 +11:00
Dan Milne
828537de8a fix 2025-10-23 11:45:05 +11:00
Dan Milne
870e83e48d Add a license 2025-10-23 11:30:31 +11:00
Dan Milne
d8889a29c0 Fix up api quota expiry display.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-12 22:01:41 +11:00
Dan Milne
50b7db5ebe Stop using the API Token, using only oAuth now. 2025-10-12 19:14:16 +11:00
Dan Milne
311ecafb74 More complete oauth. Handle deleted OauthApp by re-registering
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-08 08:19:39 +11:00
Dan Milne
6d74e7aff1 Update gems 2025-10-08 08:16:04 +11:00
141 changed files with 5713 additions and 2249 deletions

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Rails Production Environment Variables
# Copy this file to .env and fill in the values
# Required: Secret Key Base for production
# Generate this key using any of these Linux-compatible methods:
# Method 1: Using OpenSSL (recommended - most reliable)
# openssl rand -hex 64
# Method 2: From Rails application (if Rails is available)
# bin/rails secret
SECRET_KEY_BASE=your_generated_64_byte_hex_string_here
# Optional: Additional environment variables
# Uncomment and modify as needed
# Application host for URL generation in production
# Required for OAuth callbacks and mailer links
APPLICATION_HOST=your-production-domain.com
# Sentry error tracking and performance monitoring
# Get your DSN from your Sentry project settings
SENTRY_DSN=https://your-dsn-here@sentry.io/project-id
# Logging configuration
# RAILS_LOG_TO_STDOUT=true

8
.envrc Normal file
View File

@@ -0,0 +1,8 @@
export ANTHROPIC_AUTH_TOKEN=121f5560d45f4f25bb16eb1f4dfe7c87.qTmWFrvtxyEm1VKP
export ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
export ANTHROPIC_DEFAULT_OPUS_MODEL=GLM-4.6
export ANTHROPIC_DEFAULT_SONNET_MODEL=GLM-4.6
export ANTHROPIC_DEFAULT_HAIKU_MODEL=GLM-4.5-Air
export TBDB_API_TOKEN=tbdb-kOKT-LMQEsC6xz9mJzLm5
export TBDB_BASE_URL=https://api.thebookdb.info
export TBDB_API_URL=https://api.thebookdb.info

9
.gitignore vendored
View File

@@ -7,8 +7,9 @@
# Ignore bundler config.
/.bundle
# Ignore all environment files.
/.env*
# Ignore environment files, but allow template files.
/.env
!.env.example
# Ignore all logfiles and tempfiles.
/log/*
@@ -39,5 +40,9 @@
/node_modules
*.DS_Store*
# Old JavaScript package manager lock files (we use Bun now)
package-lock.json
yarn.lock
# Business planning documents
ROADMAP.md

View File

@@ -1 +1 @@
3.4.5
4.0.1

View File

@@ -125,16 +125,23 @@ User-specific tracking of barcode scans:
### Development Notes
- Uses Rails 8's new defaults with Solid adapters for caching, queuing, and cables
- **Solid Cache**: Configured with `config.solid_cache.connects_to = { database: { writing: :cache } }`
- **Solid Queue**: Configured with `config.solid_queue.connects_to = { database: { writing: :queue } }`
- Separate SQLite databases for cache, queue, and cable
- **Phlex 2 components** replace traditional ERB views - use `plain` for mixed text/HTML content
- Stimulus controllers handle all JavaScript interactions
- esbuild handles JavaScript bundling, Tailwind 4 handles CSS compilation
- SQLite for development and testing (multiple databases for different Rails features)
- **Data Separation**: Product = bibliographic data, LibraryItem = physical copy tracking
- **Auto-enrichment**: TBDB API data automatically populates format fields during product creation
- **ULID Primary Keys**: All models use ULID strings as primary keys (not integers)
- **Foreign Keys**: All foreign key references are strings pointing to ULID primary keys
- **Primary Keys**: Standard Rails integer auto-increment primary keys (no ULIDs)
- **Book Identification**: Books identified by EAN-13 barcodes (13-digit strings)
- **Image Handling**: Products have `cover_image` (Active Storage) and `cover_image_url` fields
- **TBDB Integration**:
- Single shared `TbdbConnection` singleton for OAuth (not per-user)
- `Tbdb::Client` - API client for making authenticated requests (lazy validation)
- `Tbdb::OauthService` - OAuth lifecycle management (register, exchange, refresh, revoke)
- Client initialization is cheap (just DB query), validation happens on first request
- Pagination handled by Pagy gem
- the MCP gitea is available for the repository dkam/shelf-life for git actions

View File

@@ -8,7 +8,7 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.5
ARG RUBY_VERSION=3.4.7
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
@@ -30,15 +30,14 @@ FROM base AS build
# Install packages needed to build gems and node modules
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev node-gyp pkg-config python-is-python3 && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev node-gyp pkg-config python-is-python3 unzip && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install JavaScript dependencies
ARG NODE_VERSION=24.4.1
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
rm -rf /tmp/node-build-master
# Install JavaScript dependencies (Bun instead of Node.js)
ARG BUN_VERSION=1.2.17
ENV PATH=/root/.bun/bin:$PATH
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \
rm -rf /root/.bun/cache
# Install application gems
COPY Gemfile Gemfile.lock ./
@@ -47,8 +46,8 @@ RUN bundle install && \
bundle exec bootsnap precompile --gemfile
# Install node modules
COPY package.json package-lock.json ./
RUN npm ci
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Copy application code
COPY . .

14
Gemfile
View File

@@ -1,7 +1,9 @@
source "https://rubygems.org"
ruby "4.0.1"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.0.2"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Web interface for managing background jobs [https://github.com/basecamp/mission_control-jobs]
@@ -25,13 +27,15 @@ gem "jbuilder"
gem "bcrypt", "~> 3.1.7"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "tzinfo-data", platforms: %i[windows jruby]
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"
gem "image_processing"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false
@@ -46,7 +50,7 @@ gem "thruster", require: false
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
gem "debug", platforms: %i[mri windows], require: "debug/prelude"
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false
@@ -79,3 +83,7 @@ gem "sqids", "~> 0.2.2"
gem "down", "~> 5.4"
gem "csv", "~> 3.3"
# Error tracking and performance monitoring
gem "sentry-ruby"
gem "sentry-rails"

View File

@@ -1,29 +1,31 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
action_text-trix (2.1.16)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
mail (>= 2.8.0)
actionmailer (8.0.2.1)
actionpack (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,42 +33,43 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2.1)
actionpack (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actiontext (8.1.2)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
actionview (8.1.2)
activesupport (= 8.1.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
activejob (8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.3.6)
activemodel (8.0.2.1)
activesupport (= 8.0.2.1)
activerecord (8.0.2.1)
activemodel (= 8.0.2.1)
activesupport (= 8.0.2.1)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
timeout (>= 0.4.0)
activestorage (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activesupport (= 8.0.2.1)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
marcel (~> 1.0)
activesupport (8.0.2.1)
activesupport (8.1.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
@@ -78,8 +81,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
benchmark (0.4.1)
bigdecimal (3.2.3)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
@@ -95,13 +97,13 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
cssbundling-rails (1.4.3)
railties (>= 6.0.0)
csv (3.3.5)
date (3.4.1)
date (3.5.1)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -113,23 +115,34 @@ GEM
addressable (~> 2.8)
drb (2.2.3)
ed25519 (1.4.0)
erb (5.0.2)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.3.0)
et-orbi (1.4.0)
tzinfo
fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
i18n (1.14.7)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.2.2)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.1)
irb (1.15.2)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
@@ -138,8 +151,8 @@ GEM
activesupport (>= 7.0.0)
jsbundling-rails (1.3.1)
railties (>= 6.0.0)
json (2.13.2)
kamal (2.7.0)
json (2.18.0)
kamal (2.8.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -153,18 +166,23 @@ GEM
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
marcel (1.1.0)
matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.25.5)
mini_portile2 (2.8.9)
minitest (6.0.1)
prism (~> 1.5)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
actionpack (>= 7.1)
@@ -176,7 +194,7 @@ GEM
stimulus-rails
turbo-rails
msgpack (1.8.0)
net-imap (0.5.10)
net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
@@ -190,20 +208,20 @@ GEM
net-smtp (0.5.1)
net-protocol
net-ssh (7.3.0)
nio4r (2.7.4)
nokogiri (1.18.10-aarch64-linux-gnu)
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl)
nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-gnu)
nokogiri (1.19.0-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-arm-linux-musl)
nokogiri (1.19.0-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.3)
pagy (9.4.0)
@@ -217,44 +235,44 @@ GEM
phlex (~> 2.3.0)
railties (>= 7.1, < 9)
zeitwerk (~> 2.7)
pp (0.6.2)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.5.1)
propshaft (1.2.1)
prism (1.8.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.2.6)
psych (5.3.1)
date
stringio
public_suffix (6.0.2)
puma (7.0.3)
puma (7.1.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.1)
rack (3.2.4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (8.0.2.1)
actioncable (= 8.0.2.1)
actionmailbox (= 8.0.2.1)
actionmailer (= 8.0.2.1)
actionpack (= 8.0.2.1)
actiontext (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activemodel (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
bundler (>= 1.15.0)
railties (= 8.0.2.1)
railties (= 8.1.2)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -262,24 +280,26 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.2)
rake (13.3.1)
rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.2)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rubocop (1.80.2)
rubocop (1.81.6)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -287,17 +307,17 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.46.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.46.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.26.0)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails (2.33.3)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.33.4)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -308,38 +328,49 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
rubyzip (3.1.0)
ruby-vips (2.2.5)
ffi (~> 1.12)
logger
rubyzip (3.2.0)
securerandom (0.4.1)
selenium-webdriver (4.35.0)
selenium-webdriver (4.37.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.0.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.0.0)
sentry-ruby (6.0.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.7)
solid_cache (1.0.8)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.2.1)
solid_queue (1.2.2)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqids (0.2.2)
sqlite3 (2.7.3-aarch64-linux-gnu)
sqlite3 (2.7.3-aarch64-linux-musl)
sqlite3 (2.7.3-arm-linux-gnu)
sqlite3 (2.7.3-arm-linux-musl)
sqlite3 (2.7.3-arm64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu)
sqlite3 (2.7.3-x86_64-linux-musl)
sqlite3 (2.7.4)
mini_portile2 (~> 2.8.0)
sqlite3 (2.7.4-aarch64-linux-gnu)
sqlite3 (2.7.4-aarch64-linux-musl)
sqlite3 (2.7.4-arm-linux-gnu)
sqlite3 (2.7.4-arm-linux-musl)
sqlite3 (2.7.4-arm64-darwin)
sqlite3 (2.7.4-x86_64-linux-gnu)
sqlite3 (2.7.4-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
@@ -349,7 +380,7 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
stringio (3.2.0)
tailwindcss-rails (4.3.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
@@ -359,21 +390,22 @@ GEM
tailwindcss-ruby (4.1.13-arm64-darwin)
tailwindcss-ruby (4.1.13-x86_64-linux-gnu)
tailwindcss-ruby (4.1.13-x86_64-linux-musl)
thor (1.4.0)
thruster (0.1.15)
thruster (0.1.15-aarch64-linux)
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-linux)
timeout (0.4.3)
turbo-rails (2.0.16)
thor (1.5.0)
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-linux)
timeout (0.6.0)
tsort (0.2.0)
turbo-rails (2.0.17)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.3)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
web-console (4.2.1)
actionview (>= 6.0.0)
@@ -387,7 +419,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.3)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
@@ -396,6 +428,7 @@ PLATFORMS
arm-linux-gnu
arm-linux-musl
arm64-darwin-24
arm64-darwin-25
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
@@ -410,6 +443,7 @@ DEPENDENCIES
debug
dotenv-rails (~> 3.1)
down (~> 5.4)
image_processing
jbuilder
jsbundling-rails
kamal
@@ -419,9 +453,11 @@ DEPENDENCIES
phlex-rails (~> 2.3)
propshaft
puma (>= 5.0)
rails (~> 8.0.2)
rails (~> 8.1.2)
rubocop-rails-omakase
selenium-webdriver
sentry-rails
sentry-ruby
solid_cable
solid_cache
solid_queue
@@ -434,5 +470,8 @@ DEPENDENCIES
tzinfo-data
web-console
RUBY VERSION
ruby 4.0.1p0
BUNDLED WITH
2.7.1

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 dkam
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

192
README.md
View File

@@ -1,24 +1,190 @@
# README
# ShelfLife
This README would normally document whatever steps are necessary to get the
application up and running.
A personal library management application for tracking books, DVDs, board games, and other media through barcode scanning.
Things you may want to cover:
## Docker Deployment
* Ruby version
ShelfLife is designed to run as a single Docker container with SQLite and Rails' Solid adapters for caching, queuing, and real-time features.
* System dependencies
Barcode scanning is the ideal method of getting data into Shelflife, and using a Camera on a web app requires HTTPS. Alternatively, you can edit your library and manually import products, by entering their barcode / ISBNs.
* Configuration
## Caddy configuation
* Database creation
You can use Caddy to forward traffic to Shellife
* Database initialization
```
shelflife.example.com {
reverse_proxy 192.168.1.2:3000
}
```
* How to run the test suite
### Building the Docker Image
* Services (job queues, cache servers, search engines, etc.)
#### Simple Build
```bash
docker build -t shelflife .
```
* Deployment instructions
#### Multi-Architecture Build (Recommended)
Use the included build script for AMD64 + ARM64 images:
* ...
```bash
# Build for local testing
bin/build
# Build and tag with version
bin/build -v v1.0.0
# Build and push to GitHub Container Registry
bin/build -v v1.0.0 -p
# Build and push to Docker Hub instead
bin/build -v v1.0.0 -p -r dockerhub
```
**Tags Created:**
- `latest` - Always points to the most recent build
- `<git-hash>` - Immutable reference to exact commit (e.g., `a1b2c3d`)
- `<version>` - Semantic version if specified (e.g., `v1.0.0`)
### Running with Docker
Run the container with a volume for persistent data:
```bash
docker run -d \
-p 3000:80 \
-v shelflife_data:/rails/storage \
-e RAILS_MASTER_KEY=<your_master_key> \
--name shelflife \
shelflife
```
The application will be available at http://localhost:3000
### Pre-built Images
#### GitHub Container Registry (Recommended)
```bash
# Pull the latest image
docker pull ghcr.io/dkam/shelflife:latest
# Run the pre-built image
docker run -d \
-p 3000:80 \
-v shelflife_data:/rails/storage \
-e RAILS_MASTER_KEY=<your_master_key> \
--name shelflife \
ghcr.io/dkam/shelflife:latest
```
#### Docker Compose
Create a `.env` file with `SECRET_KEY_BASE=...` value in it.
To generate the SECRET_KEY_BASE value, use `openssl rand -hex 64`
Then create the storage and log directories.
```
services:
web:
image: ghcr.io/thebookdb/shelf-life:latest # Using GitHub Container Registry
ports:
- "3000:80"
environment:
- RAILS_ENV=production
- SECRET_KEY_BASE=${SECRET_KEY_BASE}
- RAILS_LOG_TO_STDOUT=true
volumes:
- ./storage:/rails/storage
- ./log:/rails/log
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/up"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
```
### Environment Variables
- `RAILS_MASTER_KEY`: Required for decrypting credentials
- `RAILS_ENV`: Set to `production` (default in Docker)
#### Generating the Master Key
The master key is required to decrypt Rails credentials. You have a few options:
**Option 1: Use the existing key from this repository**
```bash
# Copy the key from config/master.key (if you have access to this repo)
cat config/master.key
```
**Option 2: Generate a new master key for your installation**
```bash
# This will create new config/master.key and config/credentials.yml.enc files
rails credentials:edit
# Or generate just the key
openssl rand -hex 32
```
**Option 3: Use a simple key for testing (not recommended for production)**
```bash
# Generate a simple random key
ruby -e "puts SecureRandom.hex(16)"
```
When running Docker, use the key like this:
```bash
docker run -d \
-p 3000:80 \
-v shelflife_data:/rails/storage \
-e RAILS_MASTER_KEY=6a6f0a07b11c2b1b954ea2b126ad4b36 \
--name shelflife \
shelflife
```
### Data Persistence
Mount a volume to `/rails/storage` to persist:
- SQLite databases
- User uploads (cover images)
- Cache and queue data
## Development Setup
For local development without Docker:
### Requirements
* Ruby 3.4.5
* Node.js 24.4.1
* SQLite3
### Setup
```bash
# Install dependencies
bundle install
npm install
# Setup database
bin/rails db:create db:migrate db:seed
# Start development server
overmind start -f Procfile.dev
# or
foreman start -f Procfile.dev
```
### Development Commands
- `bin/rails server` - Start Rails server
- `bin/rails console` - Rails console
- `bin/rails test` - Run tests
- `npm run build` - Build JavaScript
- `npm run build:css` - Build CSS
- `bundle exec rubocop` - Ruby linting
- `bundle exec brakeman` - Security analysis

178
SITE.md Normal file
View File

@@ -0,0 +1,178 @@
# Shelflife App
A comprehensive description of each page and feature in Shelflife.
## Core Concept
Shelflife helps you manage physical collections of books, games, DVDs, and other barcode-scannable items. The app distinguishes between:
- **Products**: Bibliographic data about a work/edition (identified by EAN-13 barcode)
- **Library Items**: Physical copies of products that belong to specific libraries
- **Libraries**: Collections that organize library items (shared between all users)
- **Scans**: User-specific history of barcode scans
Each product can have multiple library items across different libraries (if you own multiple copies).
## Authentication & User Management
### Login (`/signin`)
Email/password login with session management. Required for most features.
### Signup (`/signup`)
New user registration with email and password.
### User Profile (`/profile`)
Shows user information, TBDB (The Book Database) connection status, and preferences.
### Edit Profile (`/profile/edit`)
Update user information and manage account settings.
### Change Password (`/profile/change_password`)
Secure password change functionality.
## Main Navigation
### Fixed Header Navigation
- Brand "ShelfLife" on the left
- Contextual navigation links (Scan, Libraries, My Scans, Profile)
- Prominent "Scan" button for quick access
- Responsive design with mobile optimization
## Dashboard (`/)
**Landing page after login**
- Statistics: Total products, library items, scans
- Quick access to recent scans and libraries
- Navigation cards for main features
- Shows recently scanned products across all libraries
## Barcode Scanning
### Adaptive Scanner (`/scanner`)
Full-featured camera-based barcode scanner:
- Portrait and landscape optimized layouts
- Library selection dropdown (auto-assign scanned items)
- Recent scans display (last 10 scans)
- Real-time camera feed with EAN-13 validation
- Automatic product creation/enrichment from TBDB
### Horizontal Scanner (`/scanner/horizontal`)
Alternative scanning interface optimized for landscape mode:
- Streamlined UI for rapid scanning
- Single-scan mode (jumps directly to product)
- Scan-to-library mode with dropdown selection
## My Scans (`/scans`)
Personal scan history for the current user:
- Chronological list of all scanned barcodes
- Product links and scan timestamps
- Delete individual scans
- Pagination for large scan histories
## Product Management
### Product Details (`/:gtin` or `/products/:id`)
Comprehensive product information display:
- Cover image and basic metadata (title, author, publisher)
- TBDB-enriched data (format, language, region, players, age range)
- All library items across all libraries
- Refresh product data from TBDB
- Delete product (removes all associated scans and library items)
**Note**: Products are created automatically through barcode scanning or manual GTIN entry. No direct manual product creation form exists.
## Libraries
### Libraries Index (`/libraries`)
List of all libraries in the system:
- Library cards with names and descriptions
- Sample items from each library (randomly selected)
- Create new library button
- Search and filter functionality
### Library Details (`/libraries/:id`)
Detailed view of a single library:
- Paginated list of library items grouped by product
- Library statistics and metadata
- Edit library button
- Export library data (CSV)
- Import items button
### Edit Library (`/libraries/:id/edit`)
Update library information:
- Name, description, location
- Public/private visibility settings
- Bulk barcode import (manual GTIN entry)
### Library Import (`/libraries/:id/import`)
Bulk addition of items via CSV file upload:
- Supports GTIN-13 barcodes
- Creates products and library items automatically
- Error handling and validation reporting
### Library Export (`/libraries/:id/export`)
Download library data as CSV:
- All library items with full metadata
- Condition, acquisition, and status information
- Compatible with import system
## Library Items
### Library Item Details (`/library_items/:id`)
Comprehensive information about a physical copy:
- Product information (cover, title, author, etc.)
- Physical condition details and photos
- Acquisition information (purchase date, price, source)
- Current location and ownership status
- Lending status (if applicable)
- Value tracking and private notes
### Edit Library Item (`/library_items/:id/edit`)
Extensive item management options:
- **Condition Tracking**: Condition grade, notes, damage description
- **Acquisition Details**: Purchase date, price, source, copy identifier
- **Status Management**: Available, checked out, missing, damaged, etc.
- **Location**: Storage location within library
- **Ownership**: Owned, borrowed, on loan, consignment
- **Value Tracking**: Replacement cost, original retail price, market value
- **Lending**: Lent to person, due date (currently hidden in UI)
- **Metadata**: Tags, favorite status, private notes
## Lending Features (Currently Hidden)
The application includes comprehensive lending functionality that is built but not exposed in the current UI:
- **Checkout/Checkin System**: Built into LibraryItem model
- **Lending Status Tracking**: lent_to and due_date fields
- **Overdue Detection**: Automatic detection with scopes
- **Status Management**: Item status enum for circulation tracking
*Note: These features exist in the data model and can be enabled when needed.*
## Advanced Features
### TBDB Integration
- OAuth connection to The Book Database
- Automatic product data enrichment
- Manual refresh of product data
- Connection status monitoring in user profile
### Background Processing
- Solid Queue for background jobs
- Automatic data fetching and enrichment
- Real-time UI updates via Turbo streams
### Import/Export System
- CSV-based library data management
- Bulk barcode import via manual entry
- Error handling and validation
- Data portability between libraries
## Mobile Responsiveness
All pages are optimized for mobile devices:
- Responsive layouts with Tailwind CSS
- Touch-friendly interfaces
- Adaptive scanner layouts
- Collapsible navigation on mobile
- Landscape/h orientation support

View File

@@ -10,5 +10,4 @@
*
*= require_tree .
*= require_self
*= require choices
*/

View File

@@ -7,10 +7,11 @@ module ApplicationCable
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
end

View File

@@ -25,8 +25,7 @@ class Components::Auth::SigninView < Components::Base
f.email_field(:email_address,
required: true,
class: "mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm",
placeholder: "Email address"
)
placeholder: "Email address")
end
div do
@@ -36,20 +35,17 @@ class Components::Auth::SigninView < Components::Base
f.password_field(:password,
required: true,
class: "mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm",
placeholder: "Password"
)
placeholder: "Password")
end
end
div do
f.submit("Sign in",
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
)
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors")
end
div(class: "text-center") do
span(class: "text-sm text-gray-600") do
"Don't have an account? "
a(href: signup_path, class: "font-medium text-primary-600 hover:text-primary-500") do
"Sign up"
end

View File

@@ -29,8 +29,7 @@ class Components::Auth::SignupView < Components::Base
f.email_field(:email_address,
required: true,
class: "mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm",
placeholder: "Email address"
)
placeholder: "Email address")
render_field_errors(:email_address)
end
@@ -41,8 +40,7 @@ class Components::Auth::SignupView < Components::Base
f.password_field(:password,
required: true,
class: "mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm",
placeholder: "Password"
)
placeholder: "Password")
render_field_errors(:password)
end
@@ -53,21 +51,18 @@ class Components::Auth::SignupView < Components::Base
f.password_field(:password_confirmation,
required: true,
class: "mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm",
placeholder: "Confirm password"
)
placeholder: "Confirm password")
render_field_errors(:password_confirmation)
end
end
div do
f.submit("Sign up",
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
)
class: "group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors")
end
div(class: "text-center") do
span(class: "text-sm text-gray-600") do
"Already have an account? "
a(href: signin_path, class: "font-medium text-primary-600 hover:text-primary-500") do
"Sign in"
end

View File

@@ -5,10 +5,10 @@ class Components::Base < Phlex::HTML
include Phlex::Rails::Helpers::Routes
include Phlex::Rails::Helpers::FormWith
include Rails.application.routes.url_helpers
# Add Rails URL helpers with default options
def default_url_options
{ host: 'localhost', port: 3000 }
{host: "localhost", port: 3000}
end
register_element :turbo_frame

View File

@@ -1,5 +1,6 @@
class Components::Libraries::EditView < Components::Base
include Phlex::Rails::Helpers::FormAuthenticityToken
def initialize(library:)
@library = library
end
@@ -16,6 +17,18 @@ class Components::Libraries::EditView < Components::Base
end
div(class: "bg-white rounded-lg shadow-md p-6") do
# Display errors if any
if @library.errors.any?
div(class: "mb-6 bg-red-50 border border-red-200 rounded-lg p-4") do
h3(class: "text-red-800 font-semibold mb-2") { "Error#{"s" if @library.errors.count > 1}" }
ul(class: "list-disc list-inside text-red-700 text-sm") do
@library.errors.full_messages.each do |message|
li { message }
end
end
end
end
form(action: library_path(@library), method: "post") do
input(type: "hidden", name: "_method", value: "patch")
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)

View File

@@ -44,7 +44,7 @@ class Components::Libraries::ImportView < Components::Base
href: library_path(@library),
class: "px-4 py-2 text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors"
) { "Cancel" }
button(
type: "submit",
class: "px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
@@ -55,7 +55,7 @@ class Components::Libraries::ImportView < Components::Base
div(class: "mt-8 bg-white rounded-lg shadow-md p-6") do
h3(class: "text-lg font-semibold text-gray-900 mb-4") { "Sample File Formats" }
div(class: "space-y-4") do
div do
h4(class: "text-sm font-medium text-gray-700 mb-2") { "CSV Format:" }
@@ -67,7 +67,7 @@ class Components::Libraries::ImportView < Components::Base
CSV
end
end
div do
h4(class: "text-sm font-medium text-gray-700 mb-2") { "Text Format:" }
pre(class: "bg-gray-100 p-3 rounded text-sm overflow-x-auto") do
@@ -84,4 +84,4 @@ class Components::Libraries::ImportView < Components::Base
end
end
end
end
end

View File

@@ -49,7 +49,7 @@ class Components::Libraries::IndexView < Components::Base
div(class: "flex items-center text-sm text-gray-500") do
span(class: "bg-gray-100 px-3 py-1 rounded-full") do
"#{item_count} #{'item'.pluralize(item_count)}"
"#{item_count} #{"item".pluralize(item_count)}"
end
end
end

View File

@@ -60,7 +60,7 @@ class Components::Libraries::LibraryItemView < Components::Base
f.button "Remove",
type: "submit",
class: "text-red-600 hover:text-red-800",
data: { confirm: "Remove from library?" }
data: {confirm: "Remove from library?"}
end
end
end

View File

@@ -0,0 +1,143 @@
class Components::Libraries::ProductGroupView < Components::Base
def initialize(product:, library_items:)
@product = product
@library_items = library_items
end
def view_template
div(class: "bg-white rounded-lg shadow-md overflow-hidden mb-4") do
# Product header with cover and basic info
div(class: "p-4 flex items-start gap-4 border-b border-gray-200") do
# Cover art
div(class: "flex-shrink-0") do
if @product.cover_image.attached?
img(
src: Rails.application.routes.url_helpers.rails_blob_path(@product.cover_image, only_path: true),
alt: @product.safe_title,
class: "w-20 h-28 object-cover rounded shadow-sm"
)
elsif @product.cover_image_url.present?
img(
src: @product.cover_image_url,
alt: @product.safe_title,
class: "w-20 h-28 object-cover rounded shadow-sm"
)
else
div(class: "w-20 h-28 bg-gray-200 rounded flex items-center justify-center") do
span(class: "text-3xl") { product_icon(@product.product_type) }
end
end
end
# Product details
div(class: "flex-1") do
a(href: "/#{@product.gtin}", class: "text-xl font-semibold text-gray-900 hover:text-blue-600 transition-colors") do
@product.safe_title
end
if @product.author.present?
p(class: "text-gray-600 mt-1") { "by #{@product.author}" }
end
div(class: "flex flex-wrap items-center mt-2 text-sm text-gray-500 gap-2") do
span(class: "bg-gray-100 px-2 py-1 rounded") { (@product.product_type || "other").humanize }
span { "GTIN: #{@product.gtin}" }
end
end
# Copy count badge
div(class: "flex-shrink-0") do
div(class: "bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-semibold") do
plain "#{@library_items.count} "
plain (@library_items.count == 1) ? "copy" : "copies"
end
end
end
# List of copies
div(class: "bg-gray-50") do
@library_items.each_with_index do |item, index|
div(class: "px-4 py-3 #{"border-t border-gray-200" if index > 0}") do
div(class: "flex items-center justify-between") do
# Copy info
div(class: "flex-1") do
div(class: "flex items-center gap-3 text-sm") do
span(class: "font-medium text-gray-700") do
plain "Copy "
plain (index + 1).to_s
end
if item.item_status&.name.present?
status_color = status_badge_color(item.item_status.name)
span(class: "px-2 py-1 rounded text-xs font-medium #{status_color}") do
item.item_status.name
end
end
if item.condition.present?
span(class: "text-gray-600") { "Condition: #{item.condition}" }
end
if item.location.present?
span(class: "text-gray-600") { "📍 #{item.location}" }
end
end
if item.notes.present?
p(class: "text-sm text-gray-600 mt-1") { item.notes }
end
end
# Actions
div(class: "flex items-center gap-2") do
a(
href: library_item_path(item),
class: "text-blue-600 hover:text-blue-800 font-medium text-sm"
) { "Manage" }
end
end
end
end
end
end
end
private
def product_icon(product_type)
case product_type
when "book" then "📚"
when "video" then "💿"
when "ebook" then "📱"
when "audiobook" then "🎧"
when "toy" then "🧸"
when "lego" then "🧱"
when "pop" then "🎭"
when "graphic_novel" then "📖"
when "box_set" then "📦"
when "music" then "🎵"
when "ereader" then "📖"
when "table_top_game" then "🎲"
else "📦"
end
end
def status_badge_color(status_name)
case status_name
when "Available"
"bg-green-100 text-green-800"
when "Checked Out"
"bg-yellow-100 text-yellow-800"
when "Missing"
"bg-red-100 text-red-800"
when "Damaged"
"bg-orange-100 text-orange-800"
when "In Repair"
"bg-purple-100 text-purple-800"
when "Retired"
"bg-gray-100 text-gray-800"
else
"bg-blue-100 text-blue-800"
end
end
end

View File

@@ -1,24 +1,26 @@
class Components::Libraries::ShowView < Components::Base
include ActionView::Helpers::FormTagHelper
include Phlex::Rails::Helpers::TurboStreamFrom
def initialize(library:, library_items:, pagy: nil)
def initialize(library:, products: [], grouped_items: {}, pagy: nil)
@library = library
@library_items = library_items
@products = products
@grouped_items = grouped_items
@pagy = pagy
end
def view_template
div(class: "min-h-screen bg-gray-50") do
render Components::Shared::NavigationView.new
# Subscribe to library updates for real-time product enrichment
turbo_cable_stream_source(
channel: "Turbo::StreamsChannel",
channel: "Turbo::StreamsChannel",
signed_stream_name: Turbo::StreamsChannel.signed_stream_name("library_#{@library.id}")
)
div(class: "pt-20 px-4", id: "blahblahblah") do
div(class: "max-w-4xl mx-auto") do
div(class: "max-w-7xl mx-auto") do
div(class: "mb-8") do
div(class: "flex items-center justify-between") do
div do
@@ -27,27 +29,36 @@ class Components::Libraries::ShowView < Components::Base
p(class: "text-gray-600 mt-2") { @library.description }
end
end
div(class: "flex gap-2") do
a(href: import_library_path(@library), class: "bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors") { "Import" }
a(href: export_library_path(@library, format: :csv), class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors") { "Export CSV" }
a(href: edit_library_path(@library), class: "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors") { "Edit Library" }
end
end
end
if @library_items.any?
if @products.any?
# Pagination at top
render_pagination if @pagy && @pagy.pages > 1
div(class: "grid gap-4") do
@library_items.each do |library_item|
render Components::Libraries::LibraryItemView.new(library_item: library_item)
# Render grouped products
div(class: "mt-4") do
@products.each do |product|
library_items = @grouped_items[product]
render Components::Libraries::ProductGroupView.new(
product: product,
library_items: library_items
)
end
end
# Pagination at bottom
render_pagination if @pagy && @pagy.pages > 1
# Pagination and action buttons at bottom
div(class: "mt-8") do
div(class: "flex justify-center") do
render_pagination if @pagy && @pagy.pages > 1
end
div(class: "flex justify-center gap-2 mt-4") do
a(href: import_library_path(@library), class: "bg-green-600 text-white px-3 py-1.5 text-sm rounded-lg hover:bg-green-700 transition-colors") { "Import" }
a(href: export_library_path(@library, format: :csv), class: "bg-purple-600 text-white px-3 py-1.5 text-sm rounded-lg hover:bg-purple-700 transition-colors") { "Export CSV" }
a(href: edit_library_path(@library), class: "bg-blue-600 text-white px-3 py-1.5 text-sm rounded-lg hover:bg-blue-700 transition-colors") { "Edit Library" }
end
end
else
div(class: "bg-white rounded-lg shadow-md p-8 text-center") do
div(class: "text-6xl mb-4") { "📚" }
@@ -66,50 +77,51 @@ class Components::Libraries::ShowView < Components::Base
private
def render_pagination
div(class: "mt-8 flex justify-center") do
nav(class: "flex space-x-2") do
# Previous button
if @pagy.prev
a(href: library_path(@library, page: @pagy.prev),
class: "px-3 py-2 bg-white border rounded-md hover:bg-gray-50") do
"Previous"
nav(class: "flex items-center gap-1 text-sm") do
# Previous link
if @pagy.prev
a(href: library_path(@library, page: @pagy.prev),
class: "text-blue-600 hover:text-blue-800 hover:underline px-2") do
"← Previous"
end
else
span(class: "text-gray-400 px-2") do
"← Previous"
end
end
span(class: "text-gray-400 mx-1") { "|" }
# Page numbers
start_page = [@pagy.page - 2, 1].max
end_page = [@pagy.page + 2, @pagy.pages].min
(start_page..end_page).each do |page_num|
if page_num == @pagy.page
span(class: "font-semibold text-gray-900 px-2") do
page_num.to_s
end
else
span(class: "px-3 py-2 text-gray-300 bg-gray-100 border rounded-md cursor-not-allowed") do
"Previous"
a(href: library_path(@library, page: page_num),
class: "text-blue-600 hover:text-blue-800 hover:underline px-2") do
page_num.to_s
end
end
end
# Page numbers
start_page = [@pagy.page - 2, 1].max
end_page = [@pagy.page + 2, @pagy.pages].min
span(class: "text-gray-400 mx-1") { "|" }
(start_page..end_page).each do |page_num|
if page_num == @pagy.page
span(class: "px-3 py-2 text-white bg-blue-600 border rounded-md mr-1") do
page_num.to_s
end
else
a(href: library_path(@library, page: page_num),
class: "px-3 py-2 bg-white border rounded-md hover:bg-gray-50 mr-1") do
page_num.to_s
end
end
# Next link
if @pagy.next
a(href: library_path(@library, page: @pagy.next),
class: "text-blue-600 hover:text-blue-800 hover:underline px-2") do
"Next →"
end
# Next button
if @pagy.next
a(href: library_path(@library, page: @pagy.next),
class: "px-3 py-2 bg-white border rounded-md hover:bg-gray-50") do
"Next"
end
else
span(class: "px-3 py-2 text-gray-300 bg-gray-100 border rounded-md cursor-not-allowed") do
"Next"
end
else
span(class: "text-gray-400 px-2") do
"Next →"
end
end
end
end
end

View File

@@ -0,0 +1,385 @@
class Components::LibraryItems::EditView < Components::Base
include Phlex::Rails::Helpers::FormAuthenticityToken
def initialize(library_item:)
@library_item = library_item
@product = library_item.product
@library = library_item.library
end
def view_template
div(class: "min-h-screen bg-gray-50") do
render Components::Shared::NavigationView.new
div(class: "pt-20 px-4") do
div(class: "max-w-3xl mx-auto") do
# Header with breadcrumb
div(class: "mb-6") do
nav(class: "text-sm text-gray-600 mb-4") do
a(href: libraries_path, class: "hover:text-blue-600") { "Libraries" }
plain " / "
a(href: library_path(@library), class: "hover:text-blue-600") { @library.name }
plain " / "
span(class: "text-gray-900") { "Edit Item" }
end
h1(class: "text-3xl font-bold text-gray-900") { "Edit Library Item" }
p(class: "text-gray-600 mt-2") { @product.safe_title }
end
div(class: "bg-white rounded-lg shadow-md p-6") do
# Display errors if any
if @library_item.errors.any?
div(class: "mb-6 bg-red-50 border border-red-200 rounded-lg p-4") do
h3(class: "text-red-800 font-semibold mb-2") { "Error#{"s" if @library_item.errors.count > 1}" }
ul(class: "list-disc list-inside text-red-700 text-sm") do
@library_item.errors.full_messages.each do |message|
li { message }
end
end
end
end
form(action: library_item_path(@library_item), method: "post") do
input(type: "hidden", name: "_method", value: "patch")
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)
# Basic Information Section
div(class: "mb-8") do
h2(class: "text-lg font-semibold text-gray-800 mb-4 pb-2 border-b") { "Basic Information" }
div(class: "grid grid-cols-1 md:grid-cols-2 gap-4") do
# Status
div do
label(for: "library_item_item_status_id", class: "block text-sm font-medium text-gray-700 mb-2") { "Status" }
select(
id: "library_item_item_status_id",
name: "library_item[item_status_id]",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500",
data: {
controller: "slim-select",
slim_select_placeholder_value: "Select status..."
}
) do
option(value: "", selected: @library_item.item_status_id.nil?) { "Select status..." }
ItemStatus.all.each do |status|
option(
value: status.id,
selected: @library_item.item_status_id == status.id
) { status.name }
end
end
end
# Ownership Status
div do
label(for: "library_item_ownership_status_id", class: "block text-sm font-medium text-gray-700 mb-2") { "Ownership" }
select(
id: "library_item_ownership_status_id",
name: "library_item[ownership_status_id]",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500",
data: {
controller: "slim-select",
slim_select_placeholder_value: "Select ownership..."
}
) do
option(value: "", selected: @library_item.ownership_status_id.nil?) { "Select ownership..." }
OwnershipStatus.all.each do |ownership|
option(
value: ownership.id,
selected: @library_item.ownership_status_id == ownership.id
) { ownership.name }
end
end
end
# Location
div do
label(for: "library_item_location", class: "block text-sm font-medium text-gray-700 mb-2") { "Location" }
select(
id: "library_item_location",
name: "library_item[location]",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500",
data: {
controller: "slim-select",
slim_select_allow_create_value: true,
slim_select_placeholder_value: "Select or type location..."
}
) do
option(value: "", selected: @library_item.location.blank?)
# Get distinct locations from existing library items
LibraryItem.where.not(location: [nil, ""]).distinct.pluck(:location).sort.each do |location|
option(
value: location,
selected: @library_item.location == location
) { location }
end
end
end
# Copy Identifier
div do
label(for: "library_item_copy_identifier", class: "block text-sm font-medium text-gray-700 mb-2") { "Copy ID" }
input(
type: "text",
id: "library_item_copy_identifier",
name: "library_item[copy_identifier]",
value: @library_item.copy_identifier,
placeholder: "e.g., Copy 1, First Edition",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
)
end
end
end
# Condition Section
div(class: "mb-8") do
h2(class: "text-lg font-semibold text-gray-800 mb-4 pb-2 border-b") { "Condition" }
div(class: "grid grid-cols-1 md:grid-cols-2 gap-4 mb-4") do
# Condition
div do
label(for: "library_item_condition_id", class: "block text-sm font-medium text-gray-700 mb-2") { "Condition" }
select(
id: "library_item_condition_id",
name: "library_item[condition_id]",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500",
data: {
controller: "slim-select",
slim_select_placeholder_value: "Select condition..."
}
) do
option(value: "", selected: @library_item.condition_id.nil?) { "Not specified" }
Condition.all.each do |condition|
option(
value: condition.id,
selected: @library_item.condition_id == condition.id
) { condition.name }
end
end
end
end
# Condition Notes
div do
label(for: "library_item_condition_notes", class: "block text-sm font-medium text-gray-700 mb-2") { "Condition Notes" }
textarea(
id: "library_item_condition_notes",
name: "library_item[condition_notes]",
rows: "3",
placeholder: "Describe any damage, wear, or condition details...",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
) { @library_item.condition_notes }
end
end
# Acquisition Section
div(class: "mb-8") do
h2(class: "text-lg font-semibold text-gray-800 mb-4 pb-2 border-b") { "Acquisition Details" }
div(class: "grid grid-cols-1 md:grid-cols-2 gap-4") do
# Acquisition Date
div do
label(for: "library_item_acquisition_date", class: "block text-sm font-medium text-gray-700 mb-2") { "Date Acquired" }
input(
type: "date",
id: "library_item_acquisition_date",
name: "library_item[acquisition_date]",
value: @library_item.acquisition_date&.strftime("%Y-%m-%d"),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
)
end
# Acquisition Source
div do
label(for: "library_item_acquisition_source_id", class: "block text-sm font-medium text-gray-700 mb-2") { "Source" }
select(
id: "library_item_acquisition_source_id",
name: "library_item[acquisition_source_id]",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500",
data: {
controller: "slim-select",
slim_select_placeholder_value: "Select source..."
}
) do
option(value: "", selected: @library_item.acquisition_source_id.nil?) { "Not specified" }
AcquisitionSource.all.each do |source|
option(
value: source.id,
selected: @library_item.acquisition_source_id == source.id
) { source.name }
end
end
end
# Purchase Price
div do
label(for: "library_item_acquisition_price", class: "block text-sm font-medium text-gray-700 mb-2") { "Purchase Price" }
input(
type: "number",
step: "0.01",
id: "library_item_acquisition_price",
name: "library_item[acquisition_price]",
value: @library_item.acquisition_price,
placeholder: "0.00",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
)
end
# Original Retail Price
div do
label(for: "library_item_original_retail_price", class: "block text-sm font-medium text-gray-700 mb-2") { "Original Retail Price" }
input(
type: "number",
step: "0.01",
id: "library_item_original_retail_price",
name: "library_item[original_retail_price]",
value: @library_item.original_retail_price,
placeholder: "0.00",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
)
end
# Replacement Cost
div do
label(for: "library_item_replacement_cost", class: "block text-sm font-medium text-gray-700 mb-2") { "Replacement Cost" }
input(
type: "number",
step: "0.01",
id: "library_item_replacement_cost",
name: "library_item[replacement_cost]",
value: @library_item.replacement_cost,
placeholder: "0.00",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
)
end
# Current Market Value
div do
label(for: "library_item_current_market_value", class: "block text-sm font-medium text-gray-700 mb-2") { "Current Market Value" }
input(
type: "number",
step: "0.01",
id: "library_item_current_market_value",
name: "library_item[current_market_value]",
value: @library_item.current_market_value,
placeholder: "0.00",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
)
end
end
end
# Circulation Section
div(class: "mb-8") do
h2(class: "text-lg font-semibold text-gray-800 mb-4 pb-2 border-b") { "Circulation" }
div(class: "grid grid-cols-1 md:grid-cols-2 gap-4") do
# Lent To
div do
label(for: "library_item_lent_to", class: "block text-sm font-medium text-gray-700 mb-2") { "Lent To" }
input(
type: "text",
id: "library_item_lent_to",
name: "library_item[lent_to]",
value: @library_item.lent_to,
placeholder: "Person's name",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
)
end
# Due Date
div do
label(for: "library_item_due_date", class: "block text-sm font-medium text-gray-700 mb-2") { "Due Date" }
input(
type: "date",
id: "library_item_due_date",
name: "library_item[due_date]",
value: @library_item.due_date&.strftime("%Y-%m-%d"),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
)
end
end
end
# Notes Section
div(class: "mb-8") do
h2(class: "text-lg font-semibold text-gray-800 mb-4 pb-2 border-b") { "Notes" }
# Public Notes
div(class: "mb-4") do
label(for: "library_item_notes", class: "block text-sm font-medium text-gray-700 mb-2") { "Notes" }
textarea(
id: "library_item_notes",
name: "library_item[notes]",
rows: "3",
placeholder: "General notes about this copy...",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
) { @library_item.notes }
end
# Private Notes
div do
label(for: "library_item_private_notes", class: "block text-sm font-medium text-gray-700 mb-2") { "Private Notes" }
textarea(
id: "library_item_private_notes",
name: "library_item[private_notes]",
rows: "3",
placeholder: "Private notes (not shared)...",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
) { @library_item.private_notes }
end
end
# Tags Section
div(class: "mb-8") do
h2(class: "text-lg font-semibold text-gray-800 mb-4 pb-2 border-b") { "Tags & Preferences" }
div(class: "grid grid-cols-1 gap-4") do
# Tags
div do
label(for: "library_item_tags", class: "block text-sm font-medium text-gray-700 mb-2") { "Tags" }
input(
type: "text",
id: "library_item_tags",
name: "library_item[tags]",
value: @library_item.tags,
placeholder: "Comma-separated tags (e.g., favorite, signed, first-edition)",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
)
end
# Is Favorite
div(class: "flex items-center") do
input(
type: "checkbox",
id: "library_item_is_favorite",
name: "library_item[is_favorite]",
value: "1",
checked: @library_item.is_favorite,
class: "h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
)
label(for: "library_item_is_favorite", class: "ml-2 text-sm font-medium text-gray-700") { "Mark as favorite ⭐" }
end
end
end
# Action buttons
div(class: "flex items-center justify-between pt-6 border-t") do
a(
href: library_item_path(@library_item),
class: "text-gray-600 hover:text-gray-800"
) { "Cancel" }
button(
type: "submit",
class: "bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
) { "Update Item" }
end
end
end
end
end
end
end
end

View File

@@ -0,0 +1,216 @@
class Components::LibraryItems::ShowView < Components::Base
def initialize(library_item:)
@library_item = library_item
@product = library_item.product
@library = library_item.library
end
def view_template
div(class: "min-h-screen bg-gray-50") do
render Components::Shared::NavigationView.new
div(class: "pt-20 px-4") do
div(class: "max-w-4xl mx-auto") do
# Header with breadcrumb
div(class: "mb-6") do
nav(class: "text-sm text-gray-600 mb-4") do
a(href: libraries_path, class: "hover:text-blue-600") { "Libraries" }
plain " / "
a(href: library_path(@library), class: "hover:text-blue-600") { @library.name }
plain " / "
span(class: "text-gray-900") { "Item Details" }
end
h1(class: "text-3xl font-bold text-gray-900") { "Library Item" }
end
div(class: "bg-white rounded-lg shadow-md overflow-hidden") do
# Product information section
div(class: "border-b border-gray-200 p-6") do
h2(class: "text-lg font-semibold text-gray-800 mb-4") { "Product Information" }
div(class: "flex gap-6") do
# Cover image
div(class: "flex-shrink-0") do
if @product.cover_image.attached?
img(
src: Rails.application.routes.url_helpers.rails_blob_path(@product.cover_image, only_path: true),
alt: @product.safe_title,
class: "w-32 h-44 object-cover rounded shadow-sm"
)
elsif @product.cover_image_url.present?
img(
src: @product.cover_image_url,
alt: @product.safe_title,
class: "w-32 h-44 object-cover rounded shadow-sm"
)
else
div(class: "w-32 h-44 bg-gray-200 rounded flex items-center justify-center") do
span(class: "text-4xl") { product_icon(@product.product_type) }
end
end
end
# Product details
div(class: "flex-1") do
a(href: "/#{@product.gtin}", class: "text-xl font-semibold text-blue-600 hover:text-blue-800") do
@product.safe_title
end
if @product.author.present?
p(class: "text-gray-700 mt-2") { "by #{@product.author}" }
end
div(class: "mt-4 space-y-1 text-sm text-gray-600") do
div {
plain "Type: "
span(class: "font-medium") { (@product.product_type || "other").humanize }
}
div {
plain "GTIN: "
span(class: "font-medium") { @product.gtin }
}
end
end
end
end
# Item-specific details section
div(class: "p-6") do
h2(class: "text-lg font-semibold text-gray-800 mb-4") { "Item Details" }
div(class: "grid grid-cols-1 md:grid-cols-2 gap-4") do
detail_item("Library", @library.name)
detail_item("Status", @library_item.item_status&.name || "Not set")
detail_item("Condition", @library_item.condition || "Not recorded")
detail_item("Location", @library_item.location || "Not specified")
detail_item("Ownership", @library_item.ownership_status&.name || "Not set")
detail_item("Copy ID", @library_item.copy_identifier) if @library_item.copy_identifier.present?
end
if @library_item.condition_notes.present?
div(class: "mt-4") do
h3(class: "text-sm font-semibold text-gray-700 mb-1") { "Condition Notes" }
p(class: "text-gray-600 text-sm") { @library_item.condition_notes }
end
end
if @library_item.notes.present?
div(class: "mt-4") do
h3(class: "text-sm font-semibold text-gray-700 mb-1") { "Notes" }
p(class: "text-gray-600 text-sm") { @library_item.notes }
end
end
end
# Acquisition details section
if has_acquisition_details?
div(class: "border-t border-gray-200 p-6") do
h2(class: "text-lg font-semibold text-gray-800 mb-4") { "Acquisition Details" }
div(class: "grid grid-cols-1 md:grid-cols-2 gap-4") do
detail_item("Date Acquired", @library_item.acquisition_date&.strftime("%B %d, %Y"))
detail_item("Source", @library_item.acquisition_source&.name)
detail_item("Purchase Price", format_currency(@library_item.acquisition_price))
detail_item("Original Retail", format_currency(@library_item.original_retail_price))
detail_item("Replacement Cost", format_currency(@library_item.replacement_cost))
detail_item("Current Value", format_currency(@library_item.current_market_value))
end
end
end
# Circulation details
if @library_item.lent_to.present?
div(class: "border-t border-gray-200 p-6 bg-yellow-50") do
h2(class: "text-lg font-semibold text-gray-800 mb-4") { "Circulation Status" }
div(class: "grid grid-cols-1 md:grid-cols-2 gap-4") do
detail_item("Lent To", @library_item.lent_to)
detail_item("Due Date", @library_item.due_date&.strftime("%B %d, %Y"))
if @library_item.overdue?
div(class: "col-span-2") do
div(class: "bg-red-100 border border-red-300 text-red-800 px-4 py-2 rounded") do
plain "⚠️ This item is overdue!"
end
end
end
end
end
end
# Tags
if @library_item.tags&.any?
div(class: "border-t border-gray-200 p-6") do
h2(class: "text-lg font-semibold text-gray-800 mb-3") { "Tags" }
div(class: "flex flex-wrap gap-2") do
@library_item.tags.each do |tag|
span(class: "bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm") { tag }
end
end
end
end
# Action buttons
div(class: "border-t border-gray-200 p-6 bg-gray-50") do
div(class: "flex gap-3") do
a(
href: edit_library_item_path(@library_item),
class: "bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition-colors"
) { "Edit Item Details" }
a(
href: library_path(@library),
class: "bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700 transition-colors"
) { "Back to Library" }
end
end
end
end
end
end
end
private
def detail_item(label, value)
return unless value.present?
div(class: "text-sm") do
dt(class: "text-gray-600 font-medium") { label }
dd(class: "text-gray-900 mt-1") { value }
end
end
def has_acquisition_details?
@library_item.acquisition_date.present? ||
@library_item.acquisition_source.present? ||
@library_item.acquisition_price.present? ||
@library_item.original_retail_price.present? ||
@library_item.replacement_cost.present? ||
@library_item.current_market_value.present?
end
def format_currency(amount)
return nil unless amount.present?
"$#{"%.2f" % amount}"
end
def product_icon(product_type)
case product_type
when "book" then "📚"
when "video" then "💿"
when "ebook" then "📱"
when "audiobook" then "🎧"
when "toy" then "🧸"
when "lego" then "🧱"
when "pop" then "🎭"
when "graphic_novel" then "📖"
when "box_set" then "📦"
when "music" then "🎵"
when "ereader" then "📖"
when "table_top_game" then "🎲"
else "📦"
end
end
end

View File

@@ -115,20 +115,59 @@ class Components::Products::DisplayDataView < Components::Base
if !@product.enriched?
div(class: "px-6 pb-4") do
rate_limit_status = Rails.cache.read("tbdb_rate_limit_status")
enrichment_status = @product.tbdb_data&.dig("status")
if rate_limit_status&.dig(:limited)
if enrichment_status == "authentication_failed"
# Show authentication error with reconnect link
div(class: "bg-red-50 border border-red-200 rounded-lg p-3") do
div(class: "flex items-start justify-between gap-3") do
div(class: "flex-1") do
div(class: "text-sm font-medium text-red-800") { "❌ TBDB Connection Required" }
div(class: "text-xs text-red-600 mt-1") do
plain @product.tbdb_data&.dig("message") || "Authentication failed. Please reconnect to TBDB."
end
end
a(
href: "/profile",
class: "inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-red-600 hover:bg-red-700"
) { "View Connection" }
end
end
elsif enrichment_status == "quota_exhausted"
# Show quota exhausted message with retry time
retry_at = @product.tbdb_data&.dig("retry_at")
div(class: "bg-purple-50 border border-purple-200 rounded-lg p-3 text-center") do
div(class: "text-sm text-purple-700") do
plain "📊 Daily API quota exhausted. "
if retry_at
plain "Will retry at #{Time.parse(retry_at).strftime("%I:%M %p")}."
else
plain "Will retry automatically when quota resets."
end
end
end
elsif enrichment_status == "rate_limited" || rate_limit_status&.dig(:limited)
# Show rate limit message
retry_at = @product.tbdb_data&.dig("retry_at") || rate_limit_status&.dig(:reset_time)
div(class: "bg-orange-50 border border-orange-200 rounded-lg p-3 text-center") do
div(class: "flex items-center justify-center gap-2") do
span(class: "text-sm text-orange-700") do
plain "📚 API rate limit reached. Data fetching will resume automatically at "
strong { rate_limit_status[:reset_time].strftime("%I:%M %p") }
plain "📚 API rate limit reached. Data fetching will resume "
if retry_at
if retry_at.is_a?(String)
plain "at #{Time.parse(retry_at).strftime("%I:%M %p")}"
else
plain "at #{retry_at.strftime("%I:%M %p")}"
end
else
plain "automatically"
end
plain "."
end
end
end
elsif @product.enrichment_failed?
# Show enrichment error message
# Show generic enrichment error message
div(class: "bg-red-50 border border-red-200 rounded-lg p-3 text-center") do
span(class: "text-sm text-red-700") do
plain "⚠️ Failed to fetch additional details. Jobs will retry automatically."

View File

@@ -11,8 +11,8 @@ class Components::Products::DisplayView < Components::Base
# Subscribe to product updates - keep outside the frame content
turbo_stream_from "product_#{@product.id}"
# Main container that won't be replaced
div(id: "product-container-#{@product.id}", data: { product_id: @product.id }) do
# Main container with proper centering
div(id: "product-container-#{@product.id}", data: {product_id: @product.id}, class: "max-w-5xl mx-auto px-4 sm:px-6 lg:px-8") do
div(class: "bg-white rounded-lg shadow-md overflow-hidden") do
# Product data section - uses DisplayDataView for consistency
turbo_frame(id: "product-data") do
@@ -36,7 +36,7 @@ class Components::Products::DisplayView < Components::Base
div(class: "space-y-2") do
current_library_items.each do |item|
div(class: "flex justify-between items-center text-sm") do
a(href: library_path(item.library), class: "text-green-700 hover:text-green-900 hover:underline font-medium", data: { turbo_frame: "_top" }) { item.library.name }
a(href: library_path(item.library), class: "text-green-700 hover:text-green-900 hover:underline font-medium", data: {turbo_frame: "_top"}) { item.library.name }
if item.condition.present?
span(class: "text-green-600 text-xs bg-green-100 px-2 py-1 rounded") { item.condition }
end
@@ -56,8 +56,18 @@ class Components::Products::DisplayView < Components::Base
# Library Selection Dropdown
render Components::Shared::LibraryDropdownView.new(product: @product)
# Delete Product Button
div(class: "mt-4 pt-4 border-t border-gray-300") do
# Refresh and Delete Product Buttons
div(class: "mt-4 pt-4 border-t border-gray-300 flex gap-2") do
# Refresh Data Button
form(method: "post", action: refresh_product_path(@product), class: "inline") do
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)
button(
type: "submit",
class: "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm"
) { "Refresh Data" }
end
# Delete Product Button
form(method: "post", action: product_path(@product), class: "inline") do
input(type: "hidden", name: "_method", value: "delete")
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)

View File

@@ -7,65 +7,70 @@ class Components::Products::IndexView < Components::Base
div(class: "min-h-screen bg-gray-50") do
# Header Section
div(class: "bg-white shadow-sm border-b border-gray-200") do
div(class: "max-w-md mx-auto p-4") do
div(class: "py-6") do
div(class: "flex items-center justify-between mb-2") do
h1(class: "text-xl font-bold text-gray-800") { "Shelf Life" }
h1(class: "text-2xl font-bold text-gray-800") { "Shelf Life" }
div(class: "flex gap-3") do
a(
href: "/scanner",
class: "bg-green-600 text-white px-3 py-2 rounded-lg hover:bg-green-700 transition-colors text-sm font-medium flex items-center gap-1"
class: "bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors text-sm font-medium flex items-center gap-2"
) do
span { "📱" }
span { "Scan" }
end
a(
href: "/scans",
class: "text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-1"
class: "text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-blue-50 transition-colors"
) do
span { "📋" }
span { "My Scans" }
end
end
end
p(class: "text-sm text-gray-600") { "Your digital library" }
p(class: "text-gray-600") { "Your digital library" }
end
end
# Content Area
div(class: "p-4") do
div(class: "max-w-md mx-auto") do
div(class: "py-6") do
div(class: "max-w-5xl mx-auto") do
if @recent_products.any?
# Recent additions section
div(class: "bg-white rounded-lg shadow-md p-6 mb-6") do
h2(class: "text-lg font-semibold text-gray-800 mb-4") { "Recent Additions" }
div(class: "space-y-3") do
h2(class: "text-xl font-semibold text-gray-800 mb-6") { "Recent Additions" }
div(class: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4") do
@recent_products.each do |product|
a(href: "/#{product.gtin}", class: "block") do
div(class: "flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors") do
a(href: "/#{product.gtin}", class: "group block") do
div(class: "flex items-center gap-4 p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-all hover:shadow-sm") do
div(class: "flex-shrink-0") do
if product.cover_image.attached?
img(
src: url_for(product.cover_image),
alt: product.title,
class: "w-12 h-12 object-cover rounded border"
class: "w-16 h-16 object-cover rounded-lg border shadow-sm"
)
elsif product.cover_image_url.present?
img(
src: product.cover_image_url,
alt: product.title,
class: "w-12 h-12 object-cover rounded border"
class: "w-16 h-16 object-cover rounded-lg border shadow-sm"
)
else
div(class: "w-12 h-12 bg-gray-200 rounded border flex items-center justify-center") do
span(class: "text-lg") { "📚" }
div(class: "w-16 h-16 bg-gray-200 rounded-lg border flex items-center justify-center") do
span(class: "text-2xl") { "📚" }
end
end
end
div(class: "flex-1 min-w-0") do
h3(class: "font-medium text-gray-900 truncate") { product.title }
h3(class: "font-semibold text-gray-900 truncate group-hover:text-blue-600 transition-colors") { product.title }
if product.author.present?
p(class: "text-sm text-gray-600 truncate") { product.author }
end
if product.product_type.present?
span(class: "inline-block text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full mt-1") {
product.product_type.humanize
}
end
end
end
end
@@ -74,26 +79,38 @@ class Components::Products::IndexView < Components::Base
end
else
# Welcome/empty state
div(class: "bg-white rounded-lg shadow-md p-6 text-center") do
div(class: "text-6xl mb-4") { "📚" }
h2(class: "text-xl font-semibold text-gray-800 mb-2") { "Welcome to Shelf Life" }
p(class: "text-gray-600 mb-6") { "Start building your digital library by scanning books, DVDs, and board games" }
div(class: "bg-white rounded-lg shadow-md p-12 text-center") do
div(class: "text-8xl mb-6") { "📚" }
h2(class: "text-3xl font-bold text-gray-800 mb-4") { "Welcome to Shelf Life" }
p(class: "text-lg text-gray-600 mb-8 max-w-2xl mx-auto") { "Start building your digital library by scanning books, DVDs, board games, and other items with barcodes" }
a(
href: "/scanner",
class: "inline-flex items-center gap-2 bg-green-600 text-white px-6 py-3 rounded-lg hover:bg-green-700 transition-colors font-medium"
class: "inline-flex items-center gap-3 bg-green-600 text-white px-8 py-4 rounded-lg hover:bg-green-700 transition-colors font-semibold text-lg shadow-lg hover:shadow-xl transform hover:scale-105"
) do
span(class: "text-lg") { "📱" }
span(class: "text-xl") { "📱" }
span { "Start Scanning" }
end
div(class: "bg-blue-50 rounded-lg p-4 text-left mt-6") do
h3(class: "font-semibold text-blue-800 mb-2") { "How it works:" }
ul(class: "text-sm text-blue-700 space-y-1") do
li { "• Tap 'Start Scanning' above" }
li { "• Point camera at book barcode" }
li { "• Product info appears instantly" }
li { "• Add to your library or wishlist" }
div(class: "bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 text-left mt-8 max-w-lg mx-auto") do
h3(class: "font-semibold text-blue-800 mb-4 text-lg") { "How it works:" }
ul(class: "text-blue-700 space-y-3") do
li(class: "flex items-start gap-2") do
span(class: "text-green-600") { "" }
span { "Tap 'Start Scanning' above to open the camera" }
end
li(class: "flex items-start gap-2") do
span(class: "text-green-600") { "" }
span { "Point camera at product barcode" }
end
li(class: "flex items-start gap-2") do
span(class: "text-green-600") { "" }
span { "Product info appears instantly" }
end
li(class: "flex items-start gap-2") do
span(class: "text-green-600") { "" }
span { "Add to your library or wishlist" }
end
end
end
end

View File

@@ -4,7 +4,37 @@ class Components::Products::ShowView < Phlex::HTML
end
def view_template
# Render the display component inside a turbo frame for the show page
render Components::Products::DisplayView.new(product: @product)
div(class: "min-h-screen bg-gray-50") do
# Header Section
div(class: "bg-white shadow-sm border-b border-gray-200") do
div(class: "py-6") do
div(class: "flex items-center justify-between mb-2") do
h1(class: "text-2xl font-bold text-gray-800") { "Product Details" }
div(class: "flex gap-3") do
a(
href: "/scanner",
class: "bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors text-sm font-medium flex items-center gap-2"
) do
span { "📱" }
span { "Scan" }
end
a(
href: "/",
class: "text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-blue-50 transition-colors"
) do
span { "🏠" }
span { "Home" }
end
end
end
p(class: "text-gray-600") { "View and manage product information" }
end
end
# Content Area
div(class: "py-6") do
render Components::Products::DisplayView.new(product: @product)
end
end
end
end

View File

@@ -81,7 +81,7 @@ class Components::Scanners::AdaptiveView < Components::Base
border-radius: 8px !important;
max-width: 300px !important;
}
@media (orientation: landscape) {
#adaptive-scanner .qr-scanner-ui {
right: 10px !important;
@@ -103,7 +103,7 @@ class Components::Scanners::AdaptiveView < Components::Base
div(class: "h-full flex flex-col md:hidden") do
render_portrait_layout
end
# Landscape Layout (desktop and rotated mobile)
div(class: "h-full md:flex hidden") do
render_landscape_layout
@@ -263,10 +263,9 @@ class Components::Scanners::AdaptiveView < Components::Base
data_adaptive_barcode_scanner_target: "libraryStatus"
) do
h3(class: "font-semibold text-gray-800 mb-3") { "Library Settings" }
if Current.library
p(class: "text-sm text-blue-800 mb-3") do
"Scanned items will be added to: "
span(class: "font-semibold") { Current.library.name }
end
else
@@ -310,7 +309,7 @@ class Components::Scanners::AdaptiveView < Components::Base
end
end
end
div(class: "mt-4 pt-4 border-t") do
a(
href: "/scans",
@@ -321,4 +320,4 @@ class Components::Scanners::AdaptiveView < Components::Base
end
end
end
end
end

View File

@@ -175,10 +175,9 @@ class Components::Scanners::HorizontalView < Components::Base
data_horizontal_barcode_scanner_target: "libraryStatus"
) do
h3(class: "font-semibold text-gray-800 mb-3") { "Library Settings" }
if Current.library
p(class: "text-sm text-blue-800 mb-3") do
"Scanned items will be added to: "
span(class: "font-semibold") { Current.library.name }
end
else
@@ -222,7 +221,7 @@ class Components::Scanners::HorizontalView < Components::Base
end
end
end
div(class: "mt-4 pt-4 border-t") do
a(
href: "/scans",
@@ -236,4 +235,4 @@ class Components::Scanners::HorizontalView < Components::Base
end
end
end
end
end

View File

@@ -303,7 +303,6 @@ class Components::Scanners::IndexView < Components::Base
if Current.library
p(class: "text-sm text-blue-800 mb-3") do
"Scanned items will be added to: "
span(class: "font-semibold") { Current.library.name }
end
else

View File

@@ -22,7 +22,7 @@ class Components::Scanners::ScanItemView < Components::Base
div(
id: @scan ? dom_id(@scan) : nil,
class: "p-3 hover:bg-gray-50 transition-colors",
data: { scan_id: @scan&.id }
data: {scan_id: @scan&.id}
) do
div(class: "flex items-center gap-3") do
# Compact product image
@@ -64,7 +64,7 @@ class Components::Scanners::ScanItemView < Components::Base
if @scan.product.library_items.any?
libraries = @scan.product.library_items.includes(:library).map(&:library).map(&:name)
span(class: "text-xs text-green-600 font-medium") do
"In #{libraries.join(', ')}"
"In #{libraries.join(", ")}"
end
else
span(class: "text-xs text-gray-400") { "Not in library" }
@@ -74,15 +74,15 @@ class Components::Scanners::ScanItemView < Components::Base
# Quick action button
div(class: "flex-shrink-0") do
unless @scan.product.library_items.any?
if @scan.product.library_items.any?
span(class: "text-green-600 text-xs") { "" }
else
button(
type: "button",
data_action: "click->barcode-scanner#quickAddToLibrary",
data_product_id: @scan.product.id,
class: "bg-green-100 hover:bg-green-200 text-green-700 text-xs px-2 py-1 rounded-md font-medium transition-colors"
) { "Add" }
else
span(class: "text-green-600 text-xs") { "" }
end
end
end
@@ -93,29 +93,29 @@ class Components::Scanners::ScanItemView < Components::Base
# Template for JavaScript to clone and populate
div(
class: "p-3 hover:bg-gray-50 transition-colors",
data: { template: "scan-item" }
data: {template: "scan-item"}
) do
div(class: "flex items-center gap-3") do
# Product image placeholder
div(class: "flex-shrink-0") do
div(class: "w-10 h-12 bg-gray-200 rounded flex items-center justify-center") do
span(class: "text-lg", data: { template_field: "icon" }) { "📚" }
span(class: "text-lg", data: {template_field: "icon"}) { "📚" }
end
end
# Product details
div(class: "flex-1 min-w-0") do
h3(class: "font-medium text-gray-900 text-sm leading-tight truncate") do
span(data: { template_field: "title" }) { "Loading..." }
span(data: {template_field: "title"}) { "Loading..." }
end
p(class: "text-xs text-gray-600 truncate") do
span(data: { template_field: "author" }) { "" }
span(data: {template_field: "author"}) { "" }
end
div(class: "flex items-center justify-between mt-1") do
span(class: "text-xs text-gray-400") { "Just now" }
span(class: "text-xs text-gray-400", data: { template_field: "status" }) { "Checking..." }
span(class: "text-xs text-gray-400", data: {template_field: "status"}) { "Checking..." }
end
end
@@ -124,7 +124,7 @@ class Components::Scanners::ScanItemView < Components::Base
button(
type: "button",
data_action: "click->barcode-scanner#quickAddToLibrary",
data: { template_field: "add_button" },
data: {template_field: "add_button"},
class: "bg-green-100 hover:bg-green-200 text-green-700 text-xs px-2 py-1 rounded-md font-medium transition-colors"
) { "Add" }
end

View File

@@ -8,21 +8,50 @@ module Components
end
def view_template
div(class: "max-w-4xl mx-auto p-4") do
# Add turbo stream subscription
turbo_stream_from "scans"
h1(class: "text-2xl font-bold text-gray-900 mb-6") { "Recently Scanned" }
if @recent_scans.any?
div(id: "recent_scans", class: "space-y-4") do
@recent_scans.each do |scan|
render Components::Scans::ScanItemView.new(scan: scan)
div(class: "min-h-screen bg-gray-50") do
# Header Section
div(class: "bg-white shadow-sm border-b border-gray-200") do
div(class: "py-6") do
div(class: "flex items-center justify-between mb-2") do
h1(class: "text-2xl font-bold text-gray-800") { "My Scans" }
div(class: "flex gap-3") do
a(
href: "/scanner",
class: "bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors text-sm font-medium flex items-center gap-2"
) do
span { "📱" }
span { "Scan" }
end
a(
href: "/",
class: "text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-blue-50 transition-colors"
) do
span { "🏠" }
span { "Home" }
end
end
end
p(class: "text-gray-600") { "Your scan history" }
end
else
div(id: "recent_scans") do
empty_state
end
# Content Area
div(class: "py-6") do
div(class: "max-w-5xl mx-auto px-4 sm:px-6 lg:px-8") do
# Add turbo stream subscription
turbo_stream_from "scans"
if @recent_scans.any?
div(id: "recent_scans", class: "space-y-4") do
@recent_scans.each do |scan|
render Components::Scans::ScanItemView.new(scan: scan)
end
end
else
div(id: "recent_scans") do
empty_state
end
end
end
end
end
@@ -30,17 +59,18 @@ module Components
private
def empty_state
div(class: "text-center py-12") do
div(class: "text-6xl mb-4") { "🔍" }
h2(class: "text-xl font-semibold text-gray-800 mb-2") { "No scans yet" }
p(class: "text-gray-600 mb-4") { "Start scanning barcodes to see your history here" }
div(class: "bg-white rounded-lg shadow-md p-12 text-center") do
div(class: "text-8xl mb-6") { "🔍" }
h2(class: "text-3xl font-bold text-gray-800 mb-4") { "No scans yet" }
p(class: "text-lg text-gray-600 mb-8 max-w-2xl mx-auto") { "Start scanning barcodes to build your scan history and track your library items" }
a(
href: "/scanner",
class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
class: "inline-flex items-center gap-3 bg-green-600 text-white px-8 py-4 rounded-lg hover:bg-green-700 transition-colors font-semibold text-lg shadow-lg hover:shadow-xl transform hover:scale-105"
) do
"Start Scanning"
span(class: "text-xl") { "📱" }
span { "Start Scanning" }
end
end
end

View File

@@ -71,14 +71,14 @@ module Components
p(class: "text-xs text-gray-400") do
@scan.scanned_at.strftime("%b %d, %Y")
end
# Delete scan button
div(class: "mt-2") do
form_with url: scan_path(@scan), method: :delete, class: "inline", local: true do |f|
f.button "Delete",
f.button "Delete",
type: "submit",
class: "text-red-600 hover:text-red-800 text-xs",
data: { confirm: "Delete this scan?" }
data: {confirm: "Delete this scan?"}
end
end
end
@@ -89,11 +89,10 @@ module Components
def safe_image_url(attachment)
# Try to get the image URL, fallback to placeholder for background jobs
begin
rails_blob_path(attachment, only_path: true) if attachment.attached?
rescue
"/placeholder-image.jpg" # fallback for background jobs
end
rails_blob_path(attachment, only_path: true) if attachment.attached?
rescue
"/placeholder-image.jpg" # fallback for background jobs
end
def time_ago_in_words(time)

View File

@@ -6,7 +6,7 @@ class Components::Shared::FlashMessagesView < Components::Base
def view_template
return unless flash.any?
div(class: "fixed top-20 left-1/2 transform -translate-x-1/2 z-40 max-w-md w-full px-4") do
div(class: "fixed top-20 left-1/2 transform -translate-x-1/2 z-40 max-w-2xl w-full px-4 sm:px-6 lg:px-8") do
flash.each do |type, message|
div(class: flash_classes(type)) do
message
@@ -19,7 +19,7 @@ class Components::Shared::FlashMessagesView < Components::Base
def flash_classes(type)
base_classes = "px-4 py-3 rounded-md text-sm font-medium"
case type.to_s
when "notice"
"#{base_classes} bg-green-50 text-green-700 border border-green-200"
@@ -31,4 +31,4 @@ class Components::Shared::FlashMessagesView < Components::Base
"#{base_classes} bg-blue-50 text-blue-700 border border-blue-200"
end
end
end
end

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
class Components::Shared::IconView < Phlex::SVG
def initialize(name:, **attributes)
@name = name
@attributes = attributes
end
def view_template
svg(**default_attributes.merge(@attributes)) do
case @name
when :check
path(fill_rule: "evenodd", d: "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", clip_rule: "evenodd")
else
raise ArgumentError, "Unknown icon: #{@name}"
end
end
end
private
def default_attributes
{
fill: "currentColor",
viewBox: "0 0 20 20",
class: "w-5 h-5"
}
end
end

View File

@@ -11,30 +11,30 @@ class Components::Shared::LibraryDropdownView < Components::Base
def view_template
# Turbo frame for updating entire component
turbo_frame(id: dom_id(@product, 'library_dropdown')) do
turbo_frame(id: dom_id(@product, "library_dropdown")) do
div(
class: "relative",
data: { controller: "library-dropdown", "library-dropdown-product-id-value": @product.id }
data: {controller: "library-dropdown", "library-dropdown-product-id-value": @product.id}
) do
# Dropdown button
button(
type: "button",
data: { action: "click->library-dropdown#toggle" },
data: {action: "click->library-dropdown#toggle"},
class: @button_class
) do
span { @button_text }
span(class: "ml-2") { "" }
end
# Dropdown content using fixed positioning to escape overflow-hidden
div(
data: { "library-dropdown-target": "content" },
data: {"library-dropdown-target": "content"},
class: "hidden fixed bg-white border border-gray-200 rounded-lg shadow-lg max-h-96 overflow-y-auto z-50"
) do
div(data: { "library-dropdown-target": "snippet" }, id: 'library_dropdown_content') do
div(data: {"library-dropdown-target": "snippet"}, id: "library_dropdown_content") do
div(class: "p-3") do
div(class: "text-sm font-semibold text-gray-700 mb-2") { "Select libraries:" }
@libraries.each do |library|
render_library_checkbox(library)
end
@@ -50,19 +50,19 @@ class Components::Shared::LibraryDropdownView < Components::Base
def render_library_checkbox(library)
library_item = @product.library_items.find { |li| li.library == library } || LibraryItem.new(library: library, product: @product)
is_in_library = library_item.persisted?
div(class: "flex items-center gap-2 py-1") do
form_with(
model: library_item,
url: "/library_items",
method: :post,
local: false,
data: { controller: "library-form" },
data: {controller: "library-form"},
class: "flex items-center gap-2 w-full"
) do |f|
f.hidden_field :product_id, value: @product.id
f.hidden_field :library_id, value: library.id
label(
for: "library_item_exist_#{library.id}",
class: "flex items-center gap-2 text-sm text-gray-700 cursor-pointer flex-1"
@@ -72,18 +72,18 @@ class Components::Shared::LibraryDropdownView < Components::Base
{
id: "library_item_exist_#{library.id}",
checked: is_in_library,
data: { action: "change->library-form#submit" },
data: {action: "change->library-form#submit"},
class: "rounded border-gray-300 text-green-600 focus:ring-green-500"
}
)
span { library.name }
if library.description.present?
span(class: "text-gray-400 text-xs ml-1") { " - #{library.description}" }
end
end
span(class: "text-xs transition-opacity duration-500") do
if is_in_library
span(class: "text-green-600") { "✓ In library" }
@@ -92,4 +92,4 @@ class Components::Shared::LibraryDropdownView < Components::Base
end
end
end
end
end

View File

@@ -49,7 +49,7 @@ class Components::Shared::NavigationView < Components::Base
end
a(
href: signout_path,
data: { turbo_method: :delete },
data: {turbo_method: :delete},
class: "text-primary-600 hover:text-primary-700 px-3 py-2 rounded-md text-sm font-medium transition-colors"
) do
"Sign out"

View File

@@ -90,7 +90,6 @@ class Components::User::ChangePasswordView < Components::Base
end
div(class: "ml-3") do
p(class: "text-sm text-gray-600") do
"After changing your password, you'll remain signed in on this device. "
"You may need to sign in again on other devices."
end
end

View File

@@ -57,48 +57,6 @@ class Components::User::ProfileEditView < Components::Base
end
end
# API Configuration Section
div(id: "api-settings", class: "px-6 py-6 border-t border-gray-200") do
h3(class: "text-lg font-medium text-gray-900 mb-6") { "API Configuration" }
form_with(url: "/profile/api_token", method: :patch, local: true, class: "space-y-6") do |api_form|
div do
api_form.label :thebookdb_api_token, "Personal TheBookDB API Token", class: "block text-sm font-medium text-gray-700"
p(class: "text-xs text-gray-500 mt-1 mb-2") do
plain "Enter your personal API token for TheBookDB.info service. "
if ENV["TBDB_API_TOKEN"].present?
plain "If left blank, the application will use the system default token."
else
plain "This is required as no system default is configured."
end
end
api_form.password_field :thebookdb_api_token,
value: @user.thebookdb_api_token,
placeholder: "Enter your personal API token...",
class: "mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
if @user.has_thebookdb_api_token?
p(class: "text-xs text-gray-400 mt-1") { "Leave blank to remove your personal token and use application default" }
end
end
div(class: "flex justify-end space-x-3") do
if @user.has_thebookdb_api_token?
a(
href: "/profile/api_token",
data: { method: :delete, confirm: "Are you sure you want to remove your personal API token?" },
class: "px-4 py-2 text-sm font-medium text-red-600 bg-white border border-red-300 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
) do
"Remove Token"
end
end
api_form.submit(@user.has_thebookdb_api_token? ? "Update Token" : "Set Token",
class: "px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
)
end
end
end
div(class: "px-6 py-6") do
form_with(model: @user, url: profile_path, method: :patch, local: true, class: "space-y-6") do |f|
div(class: "hidden") do

View File

@@ -1,8 +1,13 @@
class Components::User::ProfileView < Components::Base
include Phlex::Rails::Helpers::TimeAgoInWords
include Phlex::Rails::Helpers::DistanceOfTimeInWords
# include Rails.application.routes.url_helpers
def initialize(user:)
def initialize(user:, connection:, quota_status: nil)
@user = user
@connection = connection
@quota_status = quota_status
end
def view_template
@@ -58,13 +63,13 @@ class Components::User::ProfileView < Components::Base
# Settings Section
div(class: "px-6 py-6 border-t border-gray-200") do
h3(class: "text-lg font-medium text-gray-900 mb-6") { "Settings" }
form_with(
model: @user,
url: "/profile/settings",
method: :patch,
local: false,
data: { controller: "settings-form" },
data: {controller: "settings-form"},
class: "space-y-4"
) do |form|
# Hide invalid barcodes setting
@@ -79,7 +84,7 @@ class Components::User::ProfileView < Components::Base
:hide_invalid_barcodes,
{
checked: @user.hide_invalid_barcodes?,
data: { action: "change->settings-form#updateSetting" },
data: {action: "change->settings-form#updateSetting"},
class: "sr-only peer"
},
"true",
@@ -89,40 +94,120 @@ class Components::User::ProfileView < Components::Base
end
end
end
# Status message area
div(
id: "settings-status",
data: { settings_form_target: "status" },
data: {settings_form_target: "status"},
class: "hidden text-sm mt-2"
)
end
end
# API Configuration Section
div(class: "px-6 py-6 border-t border-gray-200") do
h3(class: "text-lg font-medium text-gray-900 mb-6") { "API Configuration" }
div(class: "space-y-4") do
div do
dt(class: "text-sm font-medium text-gray-700 mb-2") { "TheBookDB API Token" }
dd(class: "text-xs text-gray-500 mb-3") { "Personal API token for accessing TheBookDB.info service. Falls back to application default if not set." }
if @user.has_thebookdb_api_token?
div(class: "text-sm text-gray-900 font-mono bg-gray-50 px-3 py-2 rounded border") do
token = @user.thebookdb_api_token
masked_token = token[0..7] + "..." + token[-4..-1]
masked_token
end
div(class: "text-xs text-green-600 mt-1") { "Using personal token" }
else
if ENV["TBDB_API_TOKEN"].present?
div(class: "text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded border") do
"Using application default"
# API Configuration Section - Only show if no API token is configured
unless api_token_configured?
div(class: "px-6 py-6 border-t border-gray-200") do
h3(class: "text-lg font-medium text-gray-900 mb-6") { "TBDB Integration" }
div(class: "space-y-6") do
# TBDB Connection Status
div do
dt(class: "text-sm font-medium text-gray-700 mb-2") { "System Connection" }
# No API token - show OAuth connection status
dd(class: "text-xs text-gray-500 mb-3") { "Shared OAuth connection to TheBookDB.info for product data lookups." }
if @connection.status == "invalid"
# Show invalid connection state with error message
div(class: "space-y-3") do
div(class: "flex items-start justify-between p-3 bg-red-50 border border-red-200 rounded") do
div(class: "flex-1") do
div(class: "text-sm font-medium text-red-800") { "Connection Invalid" }
if @connection.last_error.present?
div(class: "text-xs text-red-600 mt-1") { @connection.last_error }
end
end
a(
href: auth_tbdb_path,
class: "inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-red-600 hover:bg-red-700"
) { "Reconnect" }
end
end
elsif @connection.connected?
div(class: "flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded") do
div(class: "flex-1 mr-4") do
div(class: "text-sm font-medium text-green-800") { "Connected to TBDB" }
div(class: "text-xs text-green-600 mt-1") do
if @connection.api_base_url.present?
plain "#{@connection.api_base_url}"
end
if @connection.token_expired?
plain "Auto-refreshing token as needed"
else
plain "Active • Expires #{@connection.expires_at.strftime("%b %d at %I:%M %p")}"
end
end
if @connection.verified_at.present?
div(class: "text-xs text-green-500 mt-1") do
plain "Last verified #{time_ago_in_words(@connection.verified_at)} ago"
end
end
end
a(
href: auth_tbdb_disconnect_path,
data: {
turbo_method: "delete",
turbo_confirm: "Are you sure you want to disconnect from TBDB? This will affect all users."
},
class: "text-sm text-red-600 hover:text-red-700 font-medium whitespace-nowrap"
) { "Disconnect" }
end
else
div(class: "text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-200") do
"No token configured"
div(class: "flex items-center justify-between p-3 bg-gray-50 border border-gray-200 rounded") do
div do
div(class: "text-sm font-medium text-gray-700") { "Not Connected" }
div(class: "text-xs text-gray-500 mt-1") { "Connect for product data access" }
end
a(
href: auth_tbdb_path,
class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
) { "Connect to TBDB" }
end
end
end
# API Quota Status
if @quota_status
div(class: "pt-6 border-t border-gray-200") do
dt(class: "text-sm font-medium text-gray-700 mb-2") { "Daily API Quota" }
dd(class: "text-xs text-gray-500 mb-3") { "Your current TBDB API usage for today" }
# Quota progress bar
div(class: "space-y-2") do
div(class: "flex items-center justify-between text-sm") do
span(class: "font-medium text-gray-900") do
plain "#{@quota_status[:remaining]} / #{@quota_status[:limit]} requests remaining"
end
span(class: quota_percentage_color) do
plain "#{@quota_status[:percentage]}%"
end
end
# Progress bar
div(class: "w-full bg-gray-200 rounded-full h-2") do
div(
class: "h-2 rounded-full #{quota_bar_color}",
style: "width: #{@quota_status[:percentage]}%"
)
end
# Reset time
if @quota_status[:reset_at]
div(class: "flex items-center justify-between text-xs text-gray-500") do
span { "Resets in #{quota_reset_time_text}" }
span { "Updated #{time_ago_in_words(@quota_status[:updated_at])} ago" }
end
end
end
end
end
@@ -149,4 +234,55 @@ class Components::User::ProfileView < Components::Base
end
end
end
private
def api_token_configured?
ENV["TBDB_API_TOKEN"].present?
end
def quota_reset_time_text
return "" unless @quota_status && @quota_status[:reset_at]
seconds = (@quota_status[:reset_at] - Time.current).to_i
return "soon" if seconds <= 0
hours = seconds / 3600
minutes = (seconds % 3600) / 60
if hours >= 24
days = hours / 24
"#{days} #{"day".pluralize(days)}"
elsif hours > 0
"#{hours} #{"hour".pluralize(hours)}"
else
"#{minutes} #{"minute".pluralize(minutes)}"
end
end
def quota_percentage_color
return "text-gray-600" unless @quota_status
percentage = @quota_status[:percentage]
if percentage >= 50
"text-green-600 font-medium"
elsif percentage >= 25
"text-amber-600 font-medium"
else
"text-red-600 font-medium"
end
end
def quota_bar_color
return "bg-gray-400" unless @quota_status
percentage = @quota_status[:percentage]
if percentage >= 50
"bg-green-500"
elsif percentage >= 25
"bg-amber-500"
else
"bg-red-500"
end
end
end

View File

@@ -20,7 +20,7 @@ class Components::Users::ShowView < Components::Base
end
div(class: "ml-4") do
h1(class: "text-2xl font-bold text-gray-900") { @user.email_address }
p(class: "text-gray-600") { "Member since #{@user.created_at.strftime('%B %d, %Y')}" }
p(class: "text-gray-600") { "Member since #{@user.created_at.strftime("%B %d, %Y")}" }
end
end
end
@@ -46,7 +46,7 @@ class Components::Users::ShowView < Components::Base
dd(class: "mt-1 text-sm text-gray-900") do
if @user.scans.any?
recent_scan = @user.scans.order(created_at: :desc).first
"Last scan: #{recent_scan.created_at.strftime('%B %d, %Y at %I:%M %p')}"
"Last scan: #{recent_scan.created_at.strftime("%B %d, %Y at %I:%M %p")}"
else
"No scans yet"
end

View File

@@ -6,7 +6,7 @@ class Api::V1::BaseController < ApplicationController
private
def render_json_error(message, status = :unprocessable_entity)
render json: { error: message }, status: status
render json: {error: message}, status: status
end
def render_json_success(data, status = :ok)

View File

@@ -1,6 +1,10 @@
class ApplicationController < ActionController::Base
include Authentication
include Pagy::Backend
# Enable Pagy array extra for paginating arrays
require "pagy/extras/array"
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern

View File

@@ -44,7 +44,7 @@ module Authentication
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
cookies.signed.permanent[:session_id] = {value: session.id, httponly: true, same_site: :lax}
end
end

View File

@@ -11,11 +11,22 @@ class LibrariesController < ApplicationController
# Filter out invalid barcodes if user has that setting enabled
if Current.user.hide_invalid_barcodes?
library_items = library_items.joins(:product).where(products: { valid_barcode: true })
library_items = library_items.joins(:product).where(products: {valid_barcode: true})
end
@pagy, @library_items = pagy(library_items, overflow: :last_page)
render Components::Libraries::ShowView.new(library: @library, library_items: @library_items, pagy: @pagy)
# Group library items by product for display
@grouped_items = library_items.group_by(&:product)
# Paginate by unique products, not individual items
products_array = @grouped_items.keys
@pagy, @products = pagy_array(products_array, overflow: :last_page)
render Components::Libraries::ShowView.new(
library: @library,
products: @products,
grouped_items: @grouped_items,
pagy: @pagy
)
end
def edit
@@ -25,15 +36,15 @@ class LibrariesController < ApplicationController
def update
@library = Library.find(params[:id])
# Handle bulk barcode input if provided
bulk_barcodes = params.dig(:library, :bulk_barcodes)
if bulk_barcodes.present?
begin
import_result = process_bulk_barcodes(@library, bulk_barcodes)
if @library.update(library_params.except(:bulk_barcodes))
redirect_to library_path(@library),
notice: "Library updated successfully! Added #{import_result[:created]} items, skipped #{import_result[:skipped]} duplicates."
redirect_to library_path(@library),
notice: "Library updated successfully! Added #{import_result[:created]} items, skipped #{import_result[:skipped]} duplicates."
else
render Components::Libraries::EditView.new(library: @library), status: :unprocessable_entity
end
@@ -41,21 +52,19 @@ class LibrariesController < ApplicationController
@library.errors.add(:bulk_barcodes, "Import failed: #{e.message}")
render Components::Libraries::EditView.new(library: @library), status: :unprocessable_entity
end
elsif @library.update(library_params)
redirect_to library_path(@library), notice: "Library updated successfully."
else
if @library.update(library_params)
redirect_to library_path(@library), notice: "Library updated successfully."
else
render Components::Libraries::EditView.new(library: @library), status: :unprocessable_entity
end
render Components::Libraries::EditView.new(library: @library), status: :unprocessable_entity
end
end
def import
@library = Library.find(params[:id])
if request.post?
file = params[:file]
if file.nil?
redirect_to import_library_path(@library), alert: "Please select a file to import."
return
@@ -63,9 +72,9 @@ class LibrariesController < ApplicationController
begin
import_result = LibraryImportService.new(@library, file, Current.user).call
redirect_to library_path(@library),
notice: "Import completed! Added #{import_result[:created]} items, skipped #{import_result[:skipped]} duplicates."
redirect_to library_path(@library),
notice: "Import completed! Added #{import_result[:created]} items, skipped #{import_result[:skipped]} duplicates."
rescue => e
redirect_to import_library_path(@library), alert: "Import failed: #{e.message}"
end
@@ -76,13 +85,13 @@ class LibrariesController < ApplicationController
def export
@library = Library.find(params[:id])
respond_to do |format|
format.csv do
csv_data = LibraryExportService.new(@library).call
send_data csv_data,
filename: "#{@library.name.parameterize}-#{Date.current}.csv",
type: 'text/csv'
send_data csv_data,
filename: "#{@library.name.parameterize}-#{Date.current}.csv",
type: "text/csv"
end
end
end
@@ -90,45 +99,44 @@ class LibrariesController < ApplicationController
private
def library_params
params.require(:library).permit(:name, :description, :bulk_barcodes)
params.expect(library: [:name, :description, :bulk_barcodes])
end
def process_bulk_barcodes(library, bulk_barcodes_text)
gtins = extract_gtins_from_text(bulk_barcodes_text)
created_count = 0
skipped_count = 0
gtins.each do |gtin|
next unless valid_gtin?(gtin)
# Skip if this GTIN already exists in the library
if library.library_items.joins(:product).exists?(products: { gtin: gtin })
if library.library_items.joins(:product).exists?(products: {gtin: gtin})
skipped_count += 1
next
end
# Find or create product
product = find_or_create_product(gtin)
next unless product
# Create library item
LibraryItem.create!(
library: library,
product: product,
user: Current.user
product: product
)
# Create scan record for the user
Scan.create!(
user: Current.user,
product: product,
scanned_at: Time.current
)
created_count += 1
end
{ created: created_count, skipped: skipped_count }
{created: created_count, skipped: skipped_count}
end
def extract_gtins_from_text(text)
@@ -141,14 +149,6 @@ class LibrariesController < ApplicationController
end
def find_or_create_product(gtin)
product = Product.find_by(gtin: gtin)
return product if product
# Create new product with minimal data
Product.create!(
gtin: gtin,
title: "Unknown Product (#{gtin})",
product_type: 'other'
)
Product.findd(gtin, title: "Unknown Product (#{gtin})", product_type: "other")
end
end

View File

@@ -1,15 +1,37 @@
class LibraryItemsController < ApplicationController
before_action :set_library_item, only: [:show, :edit, :update, :destroy]
def show
render Components::LibraryItems::ShowView.new(library_item: @library_item)
end
def edit
render Components::LibraryItems::EditView.new(library_item: @library_item)
end
def update
# Convert tags string to array if present
if params[:library_item][:tags].present?
params[:library_item][:tags] = params[:library_item][:tags].split(",").map(&:strip).reject(&:blank?)
end
if @library_item.update(library_item_params)
redirect_to library_path(@library_item.library), notice: "Item updated successfully."
else
render Components::LibraryItems::EditView.new(library_item: @library_item), status: :unprocessable_entity
end
end
def create
handle_exist_checkbox
end
def destroy
@library_item = LibraryItem.find(params[:id])
@product = @library_item.product
@library = @library_item.library
@library_item.destroy
respond_to do |format|
format.turbo_stream
format.html { redirect_back_or_to libraries_path, notice: "Removed from library." }
@@ -18,14 +40,41 @@ class LibraryItemsController < ApplicationController
private
def set_library_item
@library_item = LibraryItem.find(params[:id])
end
def library_item_params
params.require(:library_item).permit(
:location,
:condition_id,
:condition_notes,
:notes,
:private_notes,
:acquisition_date,
:acquisition_price,
:acquisition_source_id,
:ownership_status_id,
:item_status_id,
:copy_identifier,
:replacement_cost,
:original_retail_price,
:current_market_value,
:lent_to,
:due_date,
:is_favorite,
tags: []
)
end
def handle_exist_checkbox
@product = Product.find(params[:library_item][:product_id])
@library = Library.find(params[:library_item][:library_id])
# Find existing library_item
existing_item = LibraryItem.find_by(product: @product, library: @library)
if params[:library_item][:exist] == '1'
if params[:library_item][:exist] == "1"
# Checkbox is checked - create if doesn't exist
if existing_item.nil?
@library_item = LibraryItem.new(product: @product, library: @library)
@@ -33,7 +82,7 @@ class LibraryItemsController < ApplicationController
else
@library_item = existing_item
end
respond_to do |format|
format.turbo_stream
format.html { redirect_back_or_to product_path(@product), notice: "Added to library!" }
@@ -44,7 +93,7 @@ class LibraryItemsController < ApplicationController
@library_item = existing_item
@library_item.destroy
end
respond_to do |format|
format.turbo_stream { render :destroy }
format.html { redirect_back_or_to product_path(@product), notice: "Removed from library." }

View File

@@ -0,0 +1,69 @@
class OauthController < ApplicationController
before_action :require_authentication
def tbdb
oauth_service = Tbdb::OauthService.new
begin
authorization_url = oauth_service.authorization_url
redirect_to authorization_url, allow_other_host: true
rescue Tbdb::OauthService::OAuthError => e
Rails.logger.error "OAuth initiation failed: #{e.message}"
redirect_to profile_path, alert: "Failed to connect to TBDB: #{e.message}"
end
end
def tbdb_callback
code = params[:code]
state = params[:state]
error = params[:error]
error_hint = params[:error_hint]
if error.present?
# Handle case where OAuth client is invalid/not found on TBDB
if error == "invalid_client" && error_hint == "client_not_found"
Rails.logger.info "OAuth client not found on TBDB, clearing credentials and re-registering"
oauth_service = Tbdb::OauthService.new
begin
# Clear the invalid credentials
oauth_service.clear_client_credentials
# Redirect back to initiate OAuth flow, which will re-register
redirect_to auth_tbdb_path, notice: "Re-registering with TBDB..."
return
rescue => e
Rails.logger.error "Failed to handle client re-registration: #{e.message}"
redirect_to profile_path, alert: "OAuth client not found. Please try connecting again."
return
end
end
Rails.logger.error "OAuth callback error: #{error} (hint: #{error_hint})"
redirect_to profile_path, alert: "TBDB authorization failed: #{params[:error_description] || error}"
return
end
if code.blank?
redirect_to profile_path, alert: "No authorization code received from TBDB"
return
end
oauth_service = Tbdb::OauthService.new
begin
oauth_service.exchange_code_for_token(code, state)
redirect_to profile_path, notice: "Successfully connected to TBDB!"
rescue Tbdb::OauthService::OAuthError => e
Rails.logger.error "OAuth token exchange failed: #{e.message}"
redirect_to profile_path, alert: "Failed to complete TBDB connection: #{e.message}"
end
end
def tbdb_disconnect
oauth_service = Tbdb::OauthService.new
oauth_service.revoke_tokens
redirect_to profile_path, notice: "Disconnected from TBDB"
end
end

View File

@@ -1,6 +1,6 @@
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
before_action :set_user_by_token, only: %i[edit update]
def new
end
@@ -25,9 +25,10 @@ class PasswordsController < ApplicationController
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end

View File

@@ -1,11 +1,11 @@
class ProductsController < ApplicationController
before_action :find_or_create_product, only: [ :show ]
before_action :find_or_create_product, only: [:show]
def index
# Get recent products from user's scans
recent_products = if Current.user
Product.joins(:scans)
.where(scans: { user: Current.user })
.where(scans: {user: Current.user})
.order("scans.scanned_at DESC")
.distinct
.limit(5)
@@ -79,6 +79,13 @@ class ProductsController < ApplicationController
)
end
def refresh
@product = Product.find(params[:id])
ProductDataFetchJob.set(queue: :high_priority).perform_later(@product, true)
redirect_to "/#{@product.gtin}", notice: "Refreshing data for #{@product.safe_title}..."
end
def destroy
@product = Product.find(params[:id])
product_title = @product.safe_title

View File

@@ -4,7 +4,7 @@ class ScansController < ApplicationController
# Filter out invalid barcodes if user has that setting enabled
if Current.user.hide_invalid_barcodes?
recent_scans = recent_scans.joins(:product).where(products: { valid_barcode: true })
recent_scans = recent_scans.joins(:product).where(products: {valid_barcode: true})
end
render Components::Scans::IndexView.new(recent_scans: recent_scans)
@@ -63,7 +63,7 @@ class ScansController < ApplicationController
def destroy
@scan = Current.user.scans.find(params[:id])
@scan.destroy
redirect_to scans_path, notice: "Scan deleted successfully."
rescue ActiveRecord::RecordNotFound
redirect_to scans_path, alert: "Scan not found."

View File

@@ -1,5 +1,5 @@
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
allow_unauthenticated_access only: %i[new create]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }
def new

View File

@@ -1,7 +1,21 @@
class UserController < ApplicationController
def show
@user = Current.user
render Components::User::ProfileView.new(user: @user)
@connection = TbdbConnection.instance
quota_status = Tbdb.quota_status
# Fetch fresh quota from /me if cache is empty and system has OAuth connection
if quota_status.nil? && @connection.connected?
begin
client = Tbdb::Client.new
client.get_me # This will populate the cache via response headers
quota_status = Tbdb.quota_status
rescue => e
Rails.logger.error "Failed to fetch quota from /me: #{e.message}"
end
end
render Components::User::ProfileView.new(user: @user, connection: @connection, quota_status: quota_status)
end
def edit
@@ -45,46 +59,21 @@ class UserController < ApplicationController
setting_enabled = params[:user][:hide_invalid_barcodes] == "true"
success = @user.update_setting("hide_invalid_barcodes", setting_enabled)
if success
render json: {
success: true,
render json: {
success: true,
message: "Setting updated successfully",
setting_value: @user.hide_invalid_barcodes?
}
else
render json: {
success: false,
message: "Failed to update setting"
render json: {
success: false,
message: "Failed to update setting"
}, status: :unprocessable_entity
end
end
def update_api_token
@user = Current.user
token = params[:thebookdb_api_token].present? ? params[:thebookdb_api_token].strip : nil
if token.present?
# Basic validation
if token.length < 10
redirect_to edit_profile_path, alert: "API token appears to be too short. Please check your token."
return
end
@user.thebookdb_api_token = token
redirect_to profile_path, notice: "Personal API token updated successfully."
else
@user.thebookdb_api_token = nil
redirect_to profile_path, notice: "Personal API token removed. Using application default."
end
end
def delete_api_token
@user = Current.user
@user.thebookdb_api_token = nil
redirect_to profile_path, notice: "Personal API token removed. Using application default."
end
private
def user_params

View File

@@ -1,5 +1,5 @@
class UsersController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
allow_unauthenticated_access only: %i[new create]
def index
@users = User.all

View File

@@ -1,3 +1,4 @@
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
import "slim-select/styles"

View File

@@ -6,9 +6,11 @@ import HelloController from "./hello_controller"
import LibraryDropdownController from "./library_dropdown_controller"
import LibraryFormController from "./library_form_controller"
import SettingsFormController from "./settings_form_controller"
import SlimSelectController from "./slim_select_controller"
application.register("barcode-scanner", BarcodeScannerController)
application.register("hello", HelloController)
application.register("library-dropdown", LibraryDropdownController)
application.register("library-form", LibraryFormController)
application.register("settings-form", SettingsFormController)
application.register("settings-form", SettingsFormController)
application.register("slim-select", SlimSelectController)

View File

@@ -1,339 +0,0 @@
import { Controller } from "@hotwired/stimulus";
import { loadQuagga } from "quagga";
// Connects to data-controller="quagga2-scanner"
export default class extends Controller {
static targets = ["startButton", "buttonText", "flashButton", "flashText", "scannerContainer", "scanner", "status", "manualInput", "barcodeReticle"];
connect() {
console.log("Quagga2 scanner controller connecting...");
// Initialize state
this.isScanning = false;
this.flashOn = false;
this.lastResult = null;
this.lastResultTime = 0;
this.stableCount = 0;
this.stableThreshold = 2; // Require 2 consistent reads
this.stabilityWindow = 300; // ms
// Wait for Quagga2 to load from CDN
this.waitForQuagga();
}
disconnect() {
console.log("Quagga2 scanner controller disconnecting...");
this.stopCamera();
}
async waitForQuagga() {
if (typeof Quagga !== 'undefined') {
console.log("Quagga2 already loaded");
this.ready = true;
return;
}
try {
console.log("Loading Quagga2 via importmap...");
const Quagga = await loadQuagga();
console.log("Quagga2 loaded successfully via importmap");
window.Quagga = Quagga; // Ensure it's available globally
this.ready = true;
} catch (error) {
console.error("Failed to load Quagga2:", error);
this.showStatus("Scanner library failed to load. Please check your connection and refresh.", "text-red-600");
}
}
toggleCamera() {
if (!this.ready) {
this.showStatus("Scanner loading, please wait...", "text-yellow-600");
return;
}
if (this.isScanning) {
this.stopCamera();
} else {
this.startCamera();
}
}
async startCamera() {
try {
this.showStatus("Starting camera and scanner...", "text-blue-600");
// Update UI
this.buttonTextTarget.textContent = "Stop";
this.startButtonTarget.classList.remove("bg-booko", "hover:bg-booko-darker");
this.startButtonTarget.classList.add("bg-red-600", "hover:bg-red-700");
// Show scanner
this.scannerContainerTarget.style.display = "block";
if (this.hasStatusTarget) {
this.statusTarget.style.display = "block";
}
this.isScanning = true;
// Initialize Quagga2
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
target: this.scannerTarget,
constraints: {
width: { min: 640 },
height: { min: 480 },
aspectRatio: { min: 1, max: 2 },
facingMode: "environment" // Use back camera
},
area: { top: "12.5%", right: "7.8%", left: "7.8%", bottom: "12.5%" }, // Match reticle area
singleChannel: false
},
locator: {
patchSize: "large",
halfSample: false
},
numOfWorkers: navigator.hardwareConcurrency > 2 ? 2 : 1,
frequency: 10,
decoder: {
readers: [
"ean_reader", // GTIN-13 (13 digits) - Books, DVDs, LEGO, Pop Vinyls
"upc_reader", // UPC-A (12 digits) - North American products
],
debug: {
drawBoundingBox: true,
showFrequency: true,
drawScanline: true,
showPattern: true
}
},
locate: true
}, (err) => {
if (err) {
console.error('Quagga2 initialization error:', err);
this.showStatus(`Scanner failed to start: ${err.message}. Try using manual input below.`, "text-red-600");
this.stopCamera();
return;
}
console.log('Quagga2 initialized successfully');
Quagga.start();
this.showStatus('Scanner active! Point camera at a barcode...', "text-green-600");
// Check for torch support after camera is initialized
this.checkTorchSupport();
// Set up detection handler
this.setupDetectionHandler();
});
} catch (err) {
console.error('Camera error:', err);
this.showStatus(`Camera access failed: ${err.message}. Try manual input below.`, "text-red-600");
this.stopCamera();
}
}
stopCamera() {
if (typeof Quagga !== 'undefined' && this.isScanning) {
try {
Quagga.offDetected();
Quagga.stop();
} catch(e) {
console.warn("Quagga2 stop error:", e);
}
}
// Reset UI
this.buttonTextTarget.textContent = "Scan";
this.startButtonTarget.classList.remove("bg-red-600", "hover:bg-red-700");
this.startButtonTarget.classList.add("bg-booko", "hover:bg-booko-darker");
// Hide scanner
this.scannerContainerTarget.style.display = "none";
if (this.hasStatusTarget) {
this.statusTarget.style.display = "none";
}
this.flashButtonTarget.style.display = "none";
// Reset flash
this.flashOn = false;
this.flashTextTarget.textContent = "💡 Torch Off";
this.isScanning = false;
// Reset detection state
this.lastResult = null;
this.stableCount = 0;
}
setupDetectionHandler() {
Quagga.onDetected((result) => {
console.log("Quagga2 detection:", result);
const code = result.codeResult.code;
const currentTime = Date.now();
// Stability checking
if (code === this.lastResult && (currentTime - this.lastResultTime) < this.stabilityWindow) {
this.stableCount++;
} else {
this.stableCount = 1;
}
this.lastResult = code;
this.lastResultTime = currentTime;
if (this.stableCount >= this.stableThreshold) {
if (this.isValidBookBarcode(code)) {
console.log(`Confirmed barcode: ${code}`);
this.showStatus(`Found: ${code} - Searching...`, "text-green-600");
// Trigger green blink animation
this.blinkReticleSuccess();
// Stop scanning and navigate after brief delay to show animation
setTimeout(() => {
this.stopCamera();
this.searchBarcode(code);
}, 300);
// Reset for next scan
this.lastResult = null;
this.stableCount = 0;
}
} else {
this.showStatus(`Detected: ${code} (${this.stableCount}/${this.stableThreshold} confirmations)`, "text-blue-600");
}
});
}
async checkTorchSupport() {
try {
// Try to get the video element from the scanner
const videoElement = this.scannerTarget.querySelector('video');
if (videoElement && videoElement.srcObject) {
const stream = videoElement.srcObject;
const videoTrack = stream.getVideoTracks()[0];
const capabilities = videoTrack.getCapabilities();
console.log('Camera capabilities:', capabilities);
if (capabilities && capabilities.torch) {
console.log('Torch supported!');
this.flashButtonTarget.style.display = 'inline-flex';
} else {
console.log('Torch not supported');
this.flashButtonTarget.style.display = 'none';
}
} else {
console.log('No video stream found for torch check');
this.flashButtonTarget.style.display = 'none';
}
} catch (err) {
console.warn('Could not check torch support:', err);
this.flashButtonTarget.style.display = 'none';
}
}
async toggleFlash() {
try {
this.flashOn = !this.flashOn;
// Update button text
this.flashTextTarget.textContent = this.flashOn ? '💡 Flash On' : '💡 Flash Off';
// Apply torch constraint to the video track
const videoElement = this.scannerTarget.querySelector('video');
if (videoElement && videoElement.srcObject) {
const stream = videoElement.srcObject;
const videoTrack = stream.getVideoTracks()[0];
const capabilities = videoTrack.getCapabilities();
if (capabilities && capabilities.torch) {
await videoTrack.applyConstraints({
advanced: [{ torch: this.flashOn }]
});
console.log(`Flash ${this.flashOn ? 'enabled' : 'disabled'}`);
} else {
console.warn('Torch not supported on this device');
this.showStatus('Flash not supported on this device', "text-yellow-600");
}
} else {
console.warn('No video stream available for flash toggle');
this.showStatus('Flash not available', "text-yellow-600");
}
} catch (err) {
console.error('Flash toggle error:', err);
this.showStatus('Failed to toggle flash', "text-red-600");
}
}
searchManual() {
const barcode = this.manualInputTarget.value.trim();
if (barcode) {
if (this.isValidBookBarcode(barcode)) {
this.searchBarcode(barcode);
this.manualInputTarget.value = "";
} else {
this.showStatus("Please enter a valid barcode (8, 10, 12, or 13 digits)", "text-red-600");
}
} else {
this.showStatus("Please enter a barcode", "text-yellow-600");
}
}
handleEnterKey(event) {
if (event.key === 'Enter') {
this.searchManual();
}
}
isValidBookBarcode(code) {
// Remove any non-digit characters
const cleanCode = code.replace(/\D/g, '');
// Check for valid book barcode formats
const isISBN13 = cleanCode.length === 13 && (cleanCode.startsWith('978') || cleanCode.startsWith('979'));
const isISBN10 = cleanCode.length === 10;
const isUPC = cleanCode.length === 12;
const isEAN8 = cleanCode.length === 8;
return isISBN13 || isISBN10 || isUPC || isEAN8;
}
searchBarcode(barcode) {
console.log(`Searching for barcode: ${barcode}`);
this.showStatus(`Searching for ${barcode}...`, "text-blue-600");
// Navigate to the product page using Turbo
if (typeof Turbo !== 'undefined') {
Turbo.visit(`/${barcode}`, { action: "replace" });
} else {
// Fallback to regular navigation
window.location.href = `/${barcode}`;
}
}
blinkReticleSuccess() {
if (this.hasBarcodeReticleTarget) {
// Add success animation class
this.barcodeReticleTarget.classList.add('reticle-success');
// Remove the class after animation completes
setTimeout(() => {
this.barcodeReticleTarget.classList.remove('reticle-success');
}, 600); // Match animation duration
}
}
showStatus(message, colorClass = "text-gray-600") {
if (this.hasStatusTarget) {
this.statusTarget.textContent = message;
this.statusTarget.className = `text-center text-sm mb-4 ${colorClass}`;
this.statusTarget.style.display = "block";
} else {
console.log(`Scanner status: ${message}`);
}
}
}

View File

@@ -0,0 +1,86 @@
import { Controller } from "@hotwired/stimulus"
import SlimSelect from 'slim-select'
// Connects to data-controller="slim-select"
export default class extends Controller {
static values = {
allowDeselect: { type: Boolean, default: true },
searchable: { type: Boolean, default: true },
closeOnSelect: { type: Boolean, default: true },
allowCreate: { type: Boolean, default: false },
placeholder: String,
searchPlaceholder: { type: String, default: 'Search...' },
createUrl: String // URL to POST new options (for associations)
}
connect() {
this.select = new SlimSelect({
select: this.element,
settings: {
allowDeselect: this.allowDeselectValue,
searchable: this.searchableValue,
closeOnSelect: this.closeOnSelectValue,
placeholderText: this.placeholderValue || this.element.dataset.placeholder || 'Select...',
searchPlaceholder: this.searchPlaceholderValue,
searchText: 'No results found',
searchHighlight: true
},
events: {
addable: this.allowCreateValue ? (value) => this.handleCreate(value) : undefined,
afterChange: (newVal) => this.afterChange(newVal)
}
})
}
disconnect() {
if (this.select) {
this.select.destroy()
}
}
handleCreate(value) {
if (!value) return false
// If createUrl is provided, POST to create association
if (this.hasCreateUrlValue) {
return this.createAssociation(value)
}
// Otherwise, just add it as a new option (for simple text fields like location)
return value
}
async createAssociation(value) {
try {
const response = await fetch(this.createUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
},
body: JSON.stringify({ name: value })
})
if (response.ok) {
const data = await response.json()
// Return the new option with id and name
return {
text: data.name,
value: data.id.toString()
}
}
} catch (error) {
console.error('Error creating option:', error)
}
return false
}
afterChange(newVal) {
// Dispatch custom event for other controllers to listen to
this.element.dispatchEvent(new CustomEvent('slim-select:change', {
detail: { value: newVal },
bubbles: true
}))
}
}

View File

@@ -1,17 +1,14 @@
class ProductDataFetchJob < ApplicationJob
queue_as :default
limits_concurrency to: 1, key: "tbdb_api"
retry_on StandardError, wait: 30.seconds, attempts: 3 do |job, error|
Rails.logger.error("ProductDataFetchJob failed for product #{job.arguments.first&.gtin}: #{error.class} - #{error.message}")
Rails.logger.error(error.backtrace&.first(10)&.join("\n"))
end
# Handle rate limit errors with intelligent rescheduling
retry_on Tbdb::RateLimitError, wait: :exponential_longer, attempts: 5 do |job, error|
# Handle rate limit errors - use the retry_after from the API
retry_on Tbdb::RateLimitError, attempts: 5 do |job, error|
product = job.arguments.first
retry_time = error.reset_time || (Time.current + error.retry_after.seconds)
retry_after = error.retry_after || 60
retry_time = error.reset_time || (Time.current + retry_after.seconds)
Rails.logger.warn("ProductDataFetchJob rate limited for product #{product&.gtin}, rescheduling for #{retry_time}")
Rails.logger.warn("ProductDataFetchJob rate limited for product #{product&.gtin}, retrying in #{retry_after}s")
# Update product with rate limit status
if product
@@ -24,6 +21,67 @@ class ProductDataFetchJob < ApplicationJob
}
)
end
# Reschedule based on API's retry_after value
job.retry_job(wait: retry_after.seconds)
end
# Handle quota exhaustion - reschedule for when quota resets
retry_on Tbdb::QuotaExhaustedError, attempts: 10 do |job, error|
product = job.arguments.first
retry_after = error.retry_after || 3600 # Default to 1 hour if not specified
retry_time = error.reset_time || (Time.current + retry_after.seconds)
Rails.logger.warn("TBDB quota exhausted, rescheduling #{product&.gtin} for #{retry_after}s (#{retry_time})")
# Update product with quota status
if product
product.update!(
tbdb_data: {
fetched_at: Time.current.iso8601,
status: "quota_exhausted",
message: "Daily quota exhausted, retrying at #{retry_time.iso8601}",
retry_at: retry_time.iso8601
}
)
end
# Reschedule when quota resets
job.retry_job(wait: retry_after.seconds)
end
# Handle authentication errors - discard job and mark product
discard_on Tbdb::AuthenticationError do |job, error|
product = job.arguments.first
Rails.logger.error("TBDB authentication failed for #{product&.gtin}: #{error.message}")
if product
product.update!(
tbdb_data: {
fetched_at: Time.current.iso8601,
status: "authentication_failed",
message: "TBDB authentication failed. Please reconnect to TBDB.",
error: error.message
}
)
end
end
# Handle connection required errors - discard job and mark product
discard_on Tbdb::ConnectionRequiredError do |job, error|
product = job.arguments.first
Rails.logger.error("TBDB connection required for #{product&.gtin}: #{error.message}")
if product
product.update!(
tbdb_data: {
fetched_at: Time.current.iso8601,
status: "authentication_failed",
message: error.message,
error: error.message
}
)
end
end
discard_on ActiveJob::DeserializationError do |job, error|

View File

@@ -1,9 +1,9 @@
class AcquisitionSource < ApplicationRecord
has_many :library_items, dependent: :restrict_with_error
validates :name, presence: true, uniqueness: true
validates :active, inclusion: { in: [true, false] }
validates :active, inclusion: {in: [true, false]}
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
end

7
app/models/condition.rb Normal file
View File

@@ -0,0 +1,7 @@
class Condition < ApplicationRecord
has_many :library_items, dependent: :restrict_with_error
validates :name, presence: true, uniqueness: true
default_scope { order(:sort_order, :name) }
end

View File

@@ -1,9 +1,9 @@
class ItemStatus < ApplicationRecord
has_many :library_items, dependent: :restrict_with_error
validates :name, presence: true, uniqueness: true
validates :active, inclusion: { in: [true, false] }
validates :active, inclusion: {in: [true, false]}
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
end

View File

@@ -16,9 +16,9 @@ class Library < ApplicationRecord
def self.default_libraries
[
{ name: "Home", description: "Books and media at home", virtual: false },
{ name: "Work", description: "Books and media at work", virtual: false },
{ name: "Wishlist", description: "Items I want to acquire", virtual: true }
{name: "Home", description: "Books and media at home", virtual: false},
{name: "Work", description: "Books and media at work", virtual: false},
{name: "Wishlist", description: "Items I want to acquire", virtual: true}
]
end

View File

@@ -1,28 +1,32 @@
class LibraryItem < ApplicationRecord
belongs_to :product, foreign_key: :product_id
belongs_to :library, foreign_key: :library_id
belongs_to :condition, optional: true
belongs_to :item_status, optional: true
belongs_to :acquisition_source, optional: true
belongs_to :ownership_status, optional: true
serialize :tags, type: Array, coder: JSON
validates :product_id, presence: true
validates :library_id, presence: true
validates :acquisition_price, :replacement_cost, :original_retail_price, :current_market_value,
numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
numericality: {greater_than_or_equal_to: 0}, allow_nil: true
# Scopes
scope :recent, -> { order(date_added: :desc) }
scope :in_library, ->(library) { where(library: library) }
scope :virtual_items, -> { joins(:library).where(libraries: { virtual: true }) }
scope :physical_items, -> { joins(:library).where(libraries: { virtual: false }) }
scope :wishlist_items, -> { joins(:library).where(libraries: { name: "Wishlist" }) }
scope :owned_items, -> { joins(:library).where(libraries: { virtual: false }) }
scope :virtual_items, -> { joins(:library).where(libraries: {virtual: true}) }
scope :physical_items, -> { joins(:library).where(libraries: {virtual: false}) }
scope :wishlist_items, -> { joins(:library).where(libraries: {name: "Wishlist"}) }
scope :owned_items, -> { joins(:library).where(libraries: {virtual: false}) }
scope :favorites, -> { where(is_favorite: true) }
scope :by_condition, ->(condition) { where(condition: condition) }
scope :with_condition, ->(condition_name) { joins(:condition).where(conditions: {name: condition_name}) }
scope :overdue, -> { where("due_date < ?", Date.current) }
scope :with_status, ->(status_name) { joins(:item_status).where(item_statuses: { name: status_name }) }
scope :available, -> { joins(:item_status).where(item_statuses: { name: "Available" }) }
scope :checked_out, -> { joins(:item_status).where(item_statuses: { name: "Checked Out" }) }
scope :with_status, ->(status_name) { joins(:item_status).where(item_statuses: {name: status_name}) }
scope :available, -> { joins(:item_status).where(item_statuses: {name: "Available"}) }
scope :checked_out, -> { joins(:item_status).where(item_statuses: {name: "Checked Out"}) }
before_create :set_date_added
before_save :update_last_accessed
@@ -54,10 +58,10 @@ class LibraryItem < ApplicationRecord
def check_out_to(person, due_date = nil)
return false unless available_for_checkout?
return false if virtual_item?
checked_out_status = ItemStatus.find_by(name: "Checked Out")
return false unless checked_out_status
update(
item_status: checked_out_status,
lent_to: person,
@@ -68,10 +72,10 @@ class LibraryItem < ApplicationRecord
def check_in
return false unless checked_out?
return false if virtual_item?
available_status = ItemStatus.find_by(name: "Available")
return false unless available_status
update(
item_status: available_status,
lent_to: nil,
@@ -81,9 +85,12 @@ class LibraryItem < ApplicationRecord
def update_condition(new_condition, notes = nil)
return false if virtual_item?
condition_record = new_condition.is_a?(Condition) ? new_condition : Condition.find_by(name: new_condition)
return false unless condition_record
update(
condition: new_condition,
condition: condition_record,
condition_notes: notes,
last_condition_check: Date.current
)
@@ -99,16 +106,7 @@ class LibraryItem < ApplicationRecord
end
def condition_status
return "Unknown" if condition.blank?
condition.humanize
end
def tag_list
tags&.split(",")&.map(&:strip) || []
end
def tag_list=(tag_array)
self.tags = Array(tag_array).map(&:strip).reject(&:blank?).join(", ")
condition&.name || "Unknown"
end
private

View File

@@ -1,9 +1,9 @@
class OwnershipStatus < ApplicationRecord
has_many :library_items, dependent: :restrict_with_error
validates :name, presence: true, uniqueness: true
validates :active, inclusion: { in: [true, false] }
validates :active, inclusion: {in: [true, false]}
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
end

View File

@@ -5,7 +5,7 @@ class Product < ApplicationRecord
has_one_attached :cover_image
validates :gtin, presence: true, uniqueness: true, format: { with: /\A\d{13}\z/, message: "must be 13 digits" }
validates :gtin, presence: true, uniqueness: true, format: {with: /\A\d{13}\z/, message: "must be 13 digits"}
# validates :title, presence: true, on: :update
validates :product_type, presence: true

View File

@@ -0,0 +1,149 @@
class TbdbConnection < ApplicationRecord
# Singleton pattern - only one TBDB connection per ShelfLife instance
# This connection is used for all TBDB API product data lookups
# See app/services/tbdb for tbdb client, oauth service and error definitions.
VERIFICATION_TTL = 10.minutes
def self.instance
first_or_create!
end
# Check if API token authentication is being used
def api_token_authenticated?
ENV["TBDB_API_TOKEN"].present?
end
# Get current authentication method
def authentication_method
api_token_authenticated? ? :api_token : :oauth
end
# Check if OAuth app is registered with TBDB
def registered?
client_id.present? && client_secret.present?
end
# Check if we have a valid connection (OAuth or API token)
def connected?
if api_token_authenticated?
# API token mode - connection is always "connected" if token is present
true
else
# OAuth mode - check tokens and status
access_token.present? && status == "connected"
end
end
# Check if the authentication token has expired
def token_expired?
if api_token_authenticated?
# API tokens don't expire like OAuth tokens
false
else
# OAuth token expiration check
return true if expires_at.nil?
Time.current >= expires_at
end
end
# Check if connection was verified recently
def verified?
verified_at.present? && verified_at > VERIFICATION_TTL.ago
end
# Mark connection as verified
def mark_verified!
update!(
status: "connected",
verified_at: Time.current,
last_error: nil
)
end
# Mark connection as invalid with error message
def mark_invalid!(error_message)
update!(
status: "invalid",
last_error: error_message,
quota_remaining: nil,
quota_limit: nil,
quota_percentage: nil,
quota_reset_at: nil,
quota_updated_at: nil
)
end
# Clear all OAuth credentials (for disconnect/reset)
def clear_connection
update!(
access_token: nil,
refresh_token: nil,
expires_at: nil,
status: "connected",
verified_at: nil,
last_error: nil
)
end
# Clear OAuth app registration (for re-registration scenarios)
def clear_registration
update!(
client_id: nil,
client_secret: nil,
access_token: nil,
refresh_token: nil,
expires_at: nil,
api_base_url: nil,
status: "connected",
verified_at: nil,
last_error: nil,
quota_remaining: nil,
quota_limit: nil,
quota_percentage: nil,
quota_reset_at: nil,
quota_updated_at: nil
)
end
# Update quota information from API response
def update_quota(remaining:, limit:, reset_at: nil)
percentage = if limit && limit > 0
(remaining.to_f / limit * 100).round(1)
else
0.0
end
update!(
quota_remaining: remaining,
quota_limit: limit,
quota_percentage: percentage,
quota_reset_at: reset_at,
quota_updated_at: Time.current
)
end
# Clear quota information (when connection becomes invalid)
def clear_quota
update!(
quota_remaining: nil,
quota_limit: nil,
quota_percentage: nil,
quota_reset_at: nil,
quota_updated_at: nil
)
end
# Get quota status as a hash (for compatibility with cached format)
def quota_status
return nil unless quota_remaining && quota_limit
{
remaining: quota_remaining,
limit: quota_limit,
percentage: quota_percentage,
reset_at: quota_reset_at,
updated_at: quota_updated_at
}
end
end

View File

@@ -19,20 +19,8 @@ class User < ApplicationRecord
user_settings.fetch(key.to_s, default)
end
# API token management
def thebookdb_api_token
get_setting("thebookdb_api_token")
end
def thebookdb_api_token=(token)
update_setting("thebookdb_api_token", token.present? ? token.strip : nil)
end
def has_thebookdb_api_token?
thebookdb_api_token.present?
end
# Get effective API token (environment variable takes precedence)
def effective_thebookdb_api_token
has_thebookdb_api_token? ? thebookdb_api_token : ENV["TBDB_API_TOKEN"]
ENV["TBDB_API_TOKEN"]
end
end

View File

@@ -1,4 +1,4 @@
require 'csv'
require "csv"
class LibraryExportService
def initialize(library)
@@ -9,20 +9,20 @@ class LibraryExportService
CSV.generate(headers: true) do |csv|
# Add header row
csv << [
'GTIN',
'Title',
'Author',
'Product Type',
'Publisher',
'Publication Date',
'Condition',
'Location',
'Acquisition Date',
'Acquisition Source',
'Acquisition Price',
'Notes'
"GTIN",
"Title",
"Author",
"Product Type",
"Publisher",
"Publication Date",
"Condition",
"Location",
"Acquisition Date",
"Acquisition Source",
"Acquisition Price",
"Notes"
]
# Add data rows
@library.library_items.includes(:product).find_each do |library_item|
product = library_item.product
@@ -43,4 +43,4 @@ class LibraryExportService
end
end
end
end
end

View File

@@ -1,4 +1,4 @@
require 'csv'
require "csv"
class LibraryImportService
def initialize(library, file, user)
@@ -11,45 +11,45 @@ class LibraryImportService
gtins = extract_gtins_from_file
created_count = 0
skipped_count = 0
gtins.each do |gtin|
next unless valid_gtin?(gtin)
# Skip if this GTIN already exists in the library
if library_item_exists?(gtin)
skipped_count += 1
next
end
# Find or create product
product = find_or_create_product(gtin)
next unless product
# Create library item
LibraryItem.create!(
library: @library,
product: product,
product: product
)
# Create scan record for the user
Scan.create!(
user: @user,
product: product,
scanned_at: Time.current
)
created_count += 1
end
{ created: created_count, skipped: skipped_count }
{created: created_count, skipped: skipped_count}
end
private
def extract_gtins_from_file
content = @file.read.force_encoding('UTF-8')
content = @file.read.force_encoding("UTF-8")
gtins = []
# Try to parse as CSV first
begin
CSV.parse(content, headers: false) do |row|
@@ -63,27 +63,27 @@ class LibraryImportService
# If CSV parsing fails, treat as plain text
gtins = content.scan(/\d{13}/)
end
gtins.uniq
end
def valid_gtin?(gtin)
gtin.length == 13 && gtin.match?(/^\d{13}$/)
end
def library_item_exists?(gtin)
@library.library_items.joins(:product).exists?(products: { gtin: gtin })
@library.library_items.joins(:product).exists?(products: {gtin: gtin})
end
def find_or_create_product(gtin)
product = Product.find_by(gtin: gtin)
return product if product
# Create new product with minimal data
Product.create!(
gtin: gtin,
title: "Unknown Product (#{gtin})",
product_type: 'other'
product_type: "other"
)
end
end
end

View File

@@ -1,6 +1,6 @@
class ProductEnrichmentService
def initialize(tbdb_service: nil, user: nil)
@tbdb_service = tbdb_service || ShelfLife::TbdbService.new(user: user)
def initialize(tbdb_client: nil)
@tbdb_client = tbdb_client || Tbdb::Client.new
end
def call(product, force = false)
@@ -62,7 +62,7 @@ class ProductEnrichmentService
end
def fetch_tbdb_data(gtin)
tbdb_response = @tbdb_service.get_product(gtin)
tbdb_response = @tbdb_client.get_product(gtin)
return nil unless tbdb_response.present?
# Extract data from response structure
@@ -84,7 +84,7 @@ class ProductEnrichmentService
# Update basic product info if missing or improve existing
attributes[:title] = tbdb_data["title"] if tbdb_data["title"].present? && (product.title.blank? || product.title.start_with?("Unknown "))
attributes[:subtitle] = tbdb_data["subtitle"] if tbdb_data["subtitle"].present?
attributes[:author] = tbdb_data["authors"]&.first&.dig("name") if product.author.blank?
attributes[:author] = tbdb_data["author"] if product.author.blank?
attributes[:publisher] = tbdb_data["publisher"] if product.publisher.blank?
attributes[:description] = tbdb_data["description"] if product.description.blank?
attributes[:pages] = tbdb_data["pages"] if product.pages.blank?

View File

@@ -1,51 +0,0 @@
require_relative '../tbdb'
module ShelfLife
class TbdbService
attr_reader :client
def initialize(user: nil)
@user = user
token = determine_api_token(user)
@client = get_or_create_client(token, user)
end
# Delegate common methods to the TBDB client
def get_product(product_id)
client.get_product(product_id)
end
def search_products(query, options = {})
client.search_products(query, options)
end
# Convenience class method that uses Current.user
def self.current_user_client
new(user: Current.user)
end
private
def get_or_create_client(token, user)
# Create a cache key based on user ID or 'system' for ENV token
cache_key = if user&.id
"tbdb_client:#{user.id}"
else
"tbdb_client:system"
end
Rails.cache.fetch(cache_key, expires_in: 25.minutes) do
Tbdb::Client.new(api_token: token)
end
end
def determine_api_token(user)
if user&.respond_to?(:effective_thebookdb_api_token)
user.effective_thebookdb_api_token
else
# Fallback to Current.user if no user provided, then ENV
Current.user&.effective_thebookdb_api_token || ENV["TBDB_API_TOKEN"]
end
end
end
end

View File

@@ -1,234 +1,20 @@
require "net/http"
require "json"
require "uri"
# Tbdb module namespace loader
# Ensures all Tbdb classes are available for autoloading
module Tbdb
# Error classes
class RateLimitError < StandardError
attr_accessor :reset_time, :retry_after
end
class Client
VERSION = "0.3"
class QuotaExhaustedError < StandardError
attr_accessor :reset_time, :retry_after
end
# Use production TBDB API by default (will move to api.tbdb.info soon)
DEFAULT_BASE_URI = ENV.fetch("TBDB_API_URI", "https://api.thebookdb.info").freeze
class AuthenticationError < StandardError
end
attr_reader :api_token, :jwt_token, :jwt_expires_at, :base_uri, :last_request_time
def initialize(api_token: ENV["TBDB_API_TOKEN"], base_uri: DEFAULT_BASE_URI)
@api_token = api_token
@base_uri = URI(base_uri)
@jwt_token = nil
@jwt_expires_at = nil
@last_request_time = nil
validate_api_token!
ensure_valid_jwt
end
def user_agent
"ShelfLife-Bot/#{VERSION} (#{Rails.application.class.module_parent_name})"
end
# Main API methods
def get_product(product_id)
make_request("/api/v1/products/#{product_id}")
end
def search_products(query, options = {})
params = { q: query }
params[:ptype] = options[:product_type] if options[:product_type]
params[:per_page] = [ options[:per_page] || 20, 100 ].min
params[:page] = [ options[:page] || 1, 1 ].max
make_request("/search", method: :get, params: params)
end
def create_product(product_data)
make_request("/api/v1/products", method: :post, params: product_data)
end
def update_product(product_id, product_data)
make_request("/api/v1/products/#{product_id}", method: :patch, params: product_data)
end
private
def validate_api_token!
if @api_token.nil? || @api_token.empty?
raise ArgumentError, "TBDB_API_TOKEN environment variable is required"
end
end
def ensure_valid_jwt
if jwt_needs_refresh?
Rails.logger.debug "JWT missing or expired, exchanging for new token..."
exchange_token_for_jwt
else
Rails.logger.debug "Using cached JWT token"
end
end
def jwt_needs_refresh?
@jwt_token.nil? || @jwt_expires_at.nil? || Time.now >= @jwt_expires_at
end
def exchange_token_for_jwt
uri = URI.join(@base_uri.to_s.chomp("/") + "/", "api/tokens/exchange")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{@api_token}"
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request["User-Agent"] = user_agent
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
response = http.request(request)
if response.is_a?(Net::HTTPSuccess)
data = JSON.parse(response.body)
@jwt_token = data["access_token"]
expires_in = data["expires_in"] || 1800 # Default to 30 minutes
@jwt_expires_at = Time.now + expires_in - 60 # Refresh 1 minute early
Rails.logger.debug "JWT token obtained, expires in #{expires_in} seconds"
true
else
Rails.logger.error "Failed to exchange API token for JWT: #{response.code} - #{response.message}"
begin
error_data = JSON.parse(response.body)
Rails.logger.error "Error details: #{error_data.inspect}"
rescue JSON::ParserError
Rails.logger.error "Response: #{response.body}"
end
raise StandardError, "Failed to obtain JWT token from TBDB API"
end
end
def make_request(path, method: :get, params: {}, retry_count: 0)
ensure_valid_jwt
throttle_request
# Ensure path starts with /
api_path = path.start_with?("/") ? path : "/#{path}"
uri = URI.join(@base_uri.to_s.chomp("/") + "/", api_path.sub(/^\//, ""))
# Add query parameters for GET requests
if method == :get && params.any?
uri.query = URI.encode_www_form(params)
end
Rails.logger.debug "TBDB API Request: #{method.upcase} #{uri}"
# Create request object
request = case method
when :get then Net::HTTP::Get.new(uri)
when :post then Net::HTTP::Post.new(uri)
when :patch then Net::HTTP::Patch.new(uri)
when :delete then Net::HTTP::Delete.new(uri)
else raise ArgumentError, "Unsupported HTTP method: #{method}"
end
# Set headers
request["Authorization"] = "Bearer #{@jwt_token}"
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request["User-Agent"] = user_agent
# Add body for non-GET requests
if method != :get && params.any?
request.body = JSON.generate(params)
end
# Make the request
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
response = http.request(request)
@last_request_time = Time.now
# Handle response
if response.is_a?(Net::HTTPSuccess)
return {} if response.body.nil? || response.body.empty?
begin
JSON.parse(response.body)
rescue JSON::ParserError => e
Rails.logger.error "Failed to parse TBDB API response as JSON: #{e.message}"
nil
end
elsif response.code == "429" && retry_count < 3
# Handle rate limiting with exponential backoff
wait_time = calculate_backoff_time(retry_count)
Rails.logger.warn "Rate limited (429), retrying in #{wait_time}s (attempt #{retry_count + 1}/3)"
begin
error_data = JSON.parse(response.body)
Rails.logger.debug "Rate limit details: #{error_data.inspect}"
rescue JSON::ParserError
# Ignore parse errors for rate limit response
end
sleep(wait_time)
return make_request(path, method: method, params: params, retry_count: retry_count + 1)
else
Rails.logger.error "TBDB API request failed: #{response.code} - #{response.message}"
begin
error_data = JSON.parse(response.body)
Rails.logger.error "Error details: #{error_data.inspect}"
rescue JSON::ParserError
Rails.logger.error "Response: #{response.body}"
end
# Handle final rate limit failure - raise custom exception with retry info
if response.code == "429"
# Extract rate limit reset time from headers or default to 1 hour
reset_time = response["X-RateLimit-Reset"]&.to_i || (Time.now + 1.hour).to_i
retry_after = response["Retry-After"]&.to_i || 3600
# Cache rate limit status for user feedback
Rails.cache.write(
"tbdb_rate_limit_status",
{
limited: true,
reset_time: Time.at(reset_time),
retry_after: retry_after,
message: "TBDB API rate limit exceeded. Retries will resume automatically."
},
expires_in: retry_after.seconds
)
error = RateLimitError.new("TBDB API rate limit exceeded after #{retry_count + 1} attempts")
error.reset_time = Time.at(reset_time) if reset_time
error.retry_after = retry_after
raise error
else
nil # Return nil for other errors (404, 400, etc.)
end
end
end
def throttle_request
return unless @last_request_time
# TBDB API allows 1 request per second, so wait if needed
time_since_last = Time.now - @last_request_time
min_interval = 1.1 # Add small buffer to avoid edge cases
if time_since_last < min_interval
sleep_time = min_interval - time_since_last
Rails.logger.debug "Throttling request: sleeping #{sleep_time.round(2)}s"
sleep(sleep_time)
end
end
def calculate_backoff_time(retry_count)
# Exponential backoff: 2^retry_count + 1 second (1s buffer for rate limit)
base_wait = 2 ** retry_count
base_wait + 1
end
class ConnectionRequiredError < StandardError
end
# Convenience method for creating a client instance
@@ -244,4 +30,9 @@ module Tbdb
def self.search_products(query, options = {})
client.search_products(query, options)
end
# Retrieve quota status from the connection
def self.quota_status
TbdbConnection.instance.quota_status
end
end

418
app/services/tbdb/client.rb Normal file
View File

@@ -0,0 +1,418 @@
require "net/http"
require "json"
require "uri"
module Tbdb
class Client
VERSION = "0.4"
# Fallback base URI if connection doesn't specify one
DEFAULT_BASE_URI = ENV.fetch("TBDB_BASE_URL", "https://api.thebookdb.info").freeze
attr_reader :jwt_token, :jwt_expires_at, :base_uri, :last_request_time, :calculated_delay
def initialize(base_uri: nil)
@connection = TbdbConnection.instance
@last_request_time = nil
@calculated_delay = nil
# Use connection's base URL (set during OAuth), or fallback
effective_base_uri = base_uri || @connection.api_base_url || DEFAULT_BASE_URI
@base_uri = URI(effective_base_uri)
# Initialize token fields (will be validated on first request)
@jwt_token = nil
@jwt_expires_at = nil
@api_token_mode = api_token_authentication?
end
def user_agent
"ShelfLife-Bot/#{VERSION} (#{Rails.application.class.module_parent_name})"
end
def api_token_authentication?
ENV["TBDB_API_TOKEN"].present?
end
# Main API methods
def get_product(product_id)
make_request("/api/v1/products/#{product_id}")
end
def search_products(query, options = {})
params = {q: query}
params[:ptype] = options[:product_type] if options[:product_type]
params[:per_page] = [options[:per_page] || 20, 100].min
params[:page] = [options[:page] || 1, 1].max
make_request("/search", method: :get, params: params)
end
def create_product(product_data)
make_request("/api/v1/products", method: :post, params: product_data)
end
def update_product(product_id, product_data)
make_request("/api/v1/products/#{product_id}", method: :patch, params: product_data)
end
def get_me
make_request("/api/v1/me")
end
private
def ensure_connected!
if @api_token_mode
ensure_api_connected!
else
ensure_oauth_connected!
end
end
def ensure_api_connected!
# For API tokens, we just need to verify the token is present
api_token = ENV["TBDB_API_TOKEN"]
unless api_token.present?
error_msg = "TBDB_API_TOKEN environment variable not set"
Rails.logger.error error_msg
raise ConnectionRequiredError, error_msg
end
# Load API token for the request
load_api_token
Rails.logger.debug "Using API token authentication"
end
def ensure_oauth_connected!
# Check if we have OAuth tokens
unless @connection.access_token.present?
error_msg = "No TBDB OAuth connection. Please connect at /profile"
Rails.logger.error error_msg
raise ConnectionRequiredError, error_msg
end
# Check if token is expired - try to refresh regardless of status
if @connection.token_expired?
Rails.logger.debug "OAuth token expired, attempting refresh..."
refresh_oauth_token
return # Successfully refreshed
end
# If connection is marked invalid but token is NOT expired, try using it anyway
# A 401 response will re-mark it invalid, but if it works, we mark it verified
if @connection.status == "invalid"
Rails.logger.debug "Connection marked invalid but token not expired - will attempt request and verify on success"
end
# Load JWT from OAuth for the request
load_jwt_from_oauth
end
def verify_base_uri_match!
# Skip URI mismatch check for API token mode since no OAuth registration
return if @api_token_mode
# Warn if using different base URI than what connection was registered with
if @connection.api_base_url.present? && @connection.api_base_url != @base_uri.to_s
Rails.logger.warn "⚠️ Base URI mismatch: connection=#{@connection.api_base_url}, client=#{@base_uri}"
end
end
def load_jwt_from_oauth
# OAuth access tokens ARE JWTs - use directly
@jwt_token = @connection.access_token
@jwt_expires_at = @connection.expires_at
Rails.logger.debug "Using OAuth JWT (expires at #{@jwt_expires_at})"
end
def load_api_token
# Use the environment variable API token directly
@jwt_token = ENV["TBDB_API_TOKEN"]
# API tokens don't have expiration dates like JWT tokens
@jwt_expires_at = nil
Rails.logger.debug "Using API token from environment variable"
end
def refresh_oauth_token
oauth_service = Tbdb::OauthService.new
if oauth_service.refresh_access_token
# Reload connection to get fresh token
@connection.reload
@jwt_token = @connection.access_token
@jwt_expires_at = @connection.expires_at
# Mark connection as verified after successful refresh
@connection.mark_verified!
Rails.logger.debug "OAuth token refreshed successfully"
else
error_msg = "Failed to refresh OAuth token. Please reconnect at /profile"
@connection.mark_invalid!(error_msg)
Rails.logger.error error_msg
raise AuthenticationError, error_msg
end
end
def make_request(path, method: :get, params: {}, retry_count: 0)
# Ensure we have a valid connection before making request
ensure_connected!
throttle_request
# Ensure path starts with /
api_path = path.start_with?("/") ? path : "/#{path}"
uri = URI.join(@base_uri.to_s.chomp("/") + "/", api_path.sub(/^\//, ""))
# Add query parameters for GET requests
if method == :get && params.any?
uri.query = URI.encode_www_form(params)
end
Rails.logger.debug "TBDB API Request: #{method.upcase} #{uri}"
# Create request object
request = case method
when :get then Net::HTTP::Get.new(uri)
when :post then Net::HTTP::Post.new(uri)
when :patch then Net::HTTP::Patch.new(uri)
when :delete then Net::HTTP::Delete.new(uri)
else raise ArgumentError, "Unsupported HTTP method: #{method}"
end
# Set headers with OAuth JWT
request["Authorization"] = "Bearer #{@jwt_token}"
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request["User-Agent"] = user_agent
# Add body for non-GET requests
if method != :get && params.any?
request.body = JSON.generate(params)
end
# Make the request
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
response = http.request(request)
@last_request_time = Time.now
# Extract rate limit and quota info from headers
store_rate_limit_info(response)
check_quota_status(response)
# Handle response
handle_response(response, path, method, params, retry_count)
end
def handle_response(response, path, method, params, retry_count)
case response
when Net::HTTPSuccess
# Mark connection as verified on successful request
if @connection.status == "invalid"
Rails.logger.info "Request succeeded - marking connection as verified"
@connection.mark_verified!
end
return {} if response.body.nil? || response.body.empty?
begin
parsed_body = JSON.parse(response.body)
# Extract quota from response body if present (e.g., from /me endpoint)
check_quota_from_body(parsed_body)
parsed_body
rescue JSON::ParserError => e
Rails.logger.error "Failed to parse TBDB API response as JSON: #{e.message}"
nil
end
when Net::HTTPUnauthorized # 401
handle_401_error(response)
else
# Handle other status codes
case response.code
when "429"
handle_429_response(response, path, method, params, retry_count)
when "503"
handle_503_response(response)
else
Rails.logger.error "TBDB API request failed: #{response.code} - #{response.message}"
log_error_details(response)
nil # Return nil for other errors (404, 400, etc.)
end
end
end
def handle_401_error(response)
Rails.logger.error "TBDB API request failed: 401 - Unauthorized"
log_error_details(response)
if @api_token_mode
# API token authentication failed
error_msg = "API token authentication failed. Please check your TBDB_API_TOKEN environment variable."
Rails.logger.error "API token authentication failed: #{error_msg}"
raise AuthenticationError, error_msg
else
# Mark OAuth connection as invalid
error_msg = if @connection.api_base_url.present? && @connection.api_base_url != @base_uri.to_s
"OAuth tokens from #{@connection.api_base_url} cannot access #{@base_uri}. Please reconnect to the correct TBDB instance."
else
"OAuth tokens are invalid or expired. Please reconnect to TBDB."
end
Rails.logger.error "Marking OAuth connection as invalid: #{error_msg}"
@connection.mark_invalid!(error_msg)
raise AuthenticationError, error_msg
end
end
def log_error_details(response)
error_data = JSON.parse(response.body)
Rails.logger.error "Error details: #{error_data.inspect}"
rescue JSON::ParserError
Rails.logger.error "Response: #{response.body}"
end
def throttle_request
return unless @last_request_time
# Use dynamic delay from headers, fallback to 1.1s
min_interval = @calculated_delay || 1.1
time_since_last = Time.now - @last_request_time
if time_since_last < min_interval
sleep_time = min_interval - time_since_last
Rails.logger.debug "Throttling request: sleeping #{sleep_time.round(2)}s (interval: #{min_interval}s)"
sleep(sleep_time)
end
end
def calculate_backoff_time(retry_count)
# Exponential backoff: 2^retry_count + 1 second (1s buffer for rate limit)
base_wait = 2**retry_count
base_wait + 1
end
def store_rate_limit_info(response)
limit = response["X-RateLimit-Limit"]&.to_f
window = response["X-RateLimit-Window"]&.to_f
if limit && window && limit > 0
@calculated_delay = window / limit
Rails.logger.debug "Rate limit extracted: #{limit} requests per #{window}s = #{@calculated_delay}s delay"
end
end
def check_quota_status(response)
remaining = response["X-Quota-Remaining"]&.to_i
limit = response["X-Quota-Limit"]&.to_i
reset_time = response["X-Quota-Reset"]&.to_i
if remaining && limit && remaining > 0
store_quota_in_cache(remaining, limit, reset_time)
end
end
def check_quota_from_body(body)
# Extract quota from /me endpoint response body
return unless body.is_a?(Hash) && body["rate_limits"]
rate_limits = body["rate_limits"]
limits = rate_limits["limits"] || {}
usage = rate_limits["usage"] || {}
quota_max = limits["quota_max"]
current_usage = usage["current_quota"] || 0
quota_expires_at = usage["quota_expires_at"]
if quota_max
remaining = quota_max - current_usage
# Use quota_expires_at from API if present, otherwise fallback to quota_window calculation
reset_time = if quota_expires_at.present?
Time.parse(quota_expires_at).to_i
else
Time.now.to_i + (limits["quota_window"] || 86400)
end
Rails.logger.debug "Extracted quota from response body: #{remaining}/#{quota_max}, resets at #{Time.at(reset_time)}"
store_quota_in_cache(remaining, quota_max, reset_time)
end
end
def store_quota_in_cache(remaining, limit, reset_time)
percentage = if limit && limit > 0
(remaining.to_f / limit * 100).round(1)
else
0.0
end
Rails.logger.debug "TBDB quota: #{remaining}/#{limit || "unknown"} remaining (#{percentage}%)"
# Store quota info on the connection model
@connection.update_quota(
remaining: remaining,
limit: limit,
reset_at: reset_time ? Time.at(reset_time) : nil
)
if remaining == 0
Rails.logger.error "❌ TBDB quota exhausted: #{remaining}/#{limit || "unknown"} remaining"
elsif limit && limit > 0 && remaining < (limit * 0.1)
Rails.logger.warn "⚠️ TBDB quota low: #{remaining}/#{limit} remaining (#{percentage}%)"
end
end
def handle_429_response(response, path, method, params, retry_count)
retry_after = response["Retry-After"]&.to_i || 60
reset_time_header = response["X-Quota-Reset"]&.to_i
# Check if this is quota exhaustion (long retry) vs rate limit (short retry)
if retry_after > 60 # Quota exhausted
error = QuotaExhaustedError.new("TBDB daily quota exhausted")
error.reset_time = reset_time_header ? Time.at(reset_time_header) : (Time.now + retry_after)
error.retry_after = retry_after
Rails.logger.error "TBDB quota exhausted. Resets at #{error.reset_time}. Retry in #{retry_after}s"
raise error
elsif retry_count < 3 # Rate limit, retry with backoff
wait_time = calculate_backoff_time(retry_count)
Rails.logger.warn "Rate limited (429), retrying in #{wait_time}s (attempt #{retry_count + 1}/3)"
sleep(wait_time)
make_request(path, method: method, params: params, retry_count: retry_count + 1)
else # Rate limit but out of retries
error = RateLimitError.new("TBDB API rate limit exceeded after #{retry_count + 1} attempts")
error.retry_after = retry_after
error.reset_time = reset_time_header ? Time.at(reset_time_header) : nil
Rails.logger.error "Rate limit exceeded after #{retry_count + 1} attempts"
raise error
end
end
def handle_503_response(response)
# Parse retry time from response body (e.g., "service unavailable: Retry in 600")
retry_after = 600 # Default to 10 minutes
begin
body = response.body
if body =~ /Retry in (\d+)/
retry_after = $1.to_i
end
rescue
# Use default if parsing fails
end
error = QuotaExhaustedError.new("TBDB service unavailable")
error.reset_time = Time.now + retry_after
error.retry_after = retry_after
Rails.logger.error "TBDB service unavailable (503). Retry in #{retry_after}s (#{(retry_after / 60.0).round(1)} minutes)"
raise error
end
end
end

View File

@@ -0,0 +1,211 @@
require "net/http"
require "json"
require "uri"
require "securerandom"
module Tbdb
class OauthService
class OAuthError < StandardError; end
# OAuth endpoints (authorization, token exchange, etc.)
TBDB_OAUTH_URL = ENV.fetch("TBDB_OAUTH_URL", "https://thebookdb.info").freeze
# API endpoints (for making authenticated API calls)
TBDB_API_URL = ENV.fetch("TBDB_BASE_URL", "https://api.thebookdb.info").freeze
def initialize
@connection = TbdbConnection.instance
end
# Step 1: Register OAuth client dynamically if needed
def ensure_oauth_client
return if @connection.registered?
register_oauth_client
end
# Step 2: Generate authorization URL for user to visit
def authorization_url
ensure_oauth_client
state = SecureRandom.hex(16)
Rails.cache.write("oauth_state", state, expires_in: 10.minutes)
params = {
response_type: "code",
client_id: @connection.client_id,
redirect_uri: redirect_uri,
state: state,
scope: "data:read"
}
"#{TBDB_OAUTH_URL}/oauth/authorize?#{URI.encode_www_form(params)}"
end
# Step 3: Exchange authorization code for access token
def exchange_code_for_token(code, state)
# Verify state parameter
cached_state = Rails.cache.read("oauth_state")
raise OAuthError, "Invalid state parameter" if state != cached_state
Rails.cache.delete("oauth_state")
uri = URI("#{TBDB_OAUTH_URL}/oauth/token")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request.body = JSON.generate({
grant_type: "authorization_code",
client_id: @connection.client_id,
client_secret: @connection.client_secret,
code: code,
redirect_uri: redirect_uri
})
# Debug logging
Rails.logger.debug "=== OAuth Token Exchange Debug ==="
Rails.logger.debug "Client ID: #{@connection.client_id}"
Rails.logger.debug "Redirect URI: #{redirect_uri}"
Rails.logger.debug "Authorization Code: #{code[0..10]}..." # Only log first part for security
Rails.logger.debug "Request Body: #{request.body}"
response = make_http_request(uri, request)
Rails.logger.debug "Response Status: #{response.code}"
Rails.logger.debug "Response Body: #{response.body}"
token_data = JSON.parse(response.body)
if response.is_a?(Net::HTTPSuccess)
store_tokens(token_data)
else
raise OAuthError, "Token exchange failed: #{token_data["error"] || response.message}"
end
end
# Refresh access token using refresh token
def refresh_access_token
return false unless @connection.refresh_token.present?
uri = URI("#{TBDB_OAUTH_URL}/oauth/token")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request.body = JSON.generate({
grant_type: "refresh_token",
client_id: @connection.client_id,
client_secret: @connection.client_secret,
refresh_token: @connection.refresh_token
})
response = make_http_request(uri, request)
if response.is_a?(Net::HTTPSuccess)
token_data = JSON.parse(response.body)
store_tokens(token_data)
true
else
Rails.logger.error "OAuth token refresh failed: #{response.body}"
false
end
end
# Revoke OAuth tokens
def revoke_tokens
return unless @connection.access_token.present?
uri = URI("#{TBDB_OAUTH_URL}/oauth/revoke")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request.body = JSON.generate({
client_id: @connection.client_id,
client_secret: @connection.client_secret,
token: @connection.access_token
})
make_http_request(uri, request)
@connection.clear_connection
end
# Clear client credentials (for re-registration scenarios)
def clear_client_credentials
@connection.clear_registration
Rails.logger.info "Cleared OAuth client credentials"
end
private
def redirect_uri
# Generate redirect URI at runtime to avoid host issues
if Rails.env.development?
"http://localhost:4001/auth/tbdb/callback"
else
"#{Rails.application.routes.url_helpers.root_url.chomp("/")}/auth/tbdb/callback"
end
end
def register_oauth_client
uri = URI("#{TBDB_OAUTH_URL}/oauth/register")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
client_name = "ShelfLife Instance"
request.body = JSON.generate({
client_name: client_name,
redirect_uris: [redirect_uri], # Single redirect URI for this ShelfLife instance
scope: "data:read",
application_type: "web",
token_endpoint_auth_method: "client_secret_post"
})
response = make_http_request(uri, request)
if response.is_a?(Net::HTTPSuccess)
client_data = JSON.parse(response.body)
@connection.update!(
client_id: client_data["client_id"],
client_secret: client_data["client_secret"],
api_base_url: TBDB_API_URL
)
Rails.logger.info "Registered OAuth client for ShelfLife instance with #{TBDB_API_URL}"
else
error_data = begin
JSON.parse(response.body)
rescue
{"error" => response.message}
end
raise OAuthError, "Client registration failed: #{error_data["error"]}"
end
end
def store_tokens(token_data)
expires_at = if token_data["expires_in"]
Time.current + token_data["expires_in"].to_i.seconds
else
1.hour.from_now # Default fallback
end
@connection.update!(
access_token: token_data["access_token"],
refresh_token: token_data["refresh_token"],
expires_at: expires_at,
api_base_url: TBDB_API_URL, # Ensure base URL is always set
verified_at: Time.current # Mark as verified since we just got tokens
)
Rails.logger.info "Stored OAuth tokens for ShelfLife instance (#{TBDB_API_URL})"
end
def make_http_request(uri, request)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
http.request(request)
end
end
end

View File

@@ -27,7 +27,7 @@
<%= render Components::Shared::FlashMessagesView.new %>
<main class="container mx-auto mt-20 px-5 flex">
<main class="max-w-7xl mx-auto mt-20 px-4 sm:px-6 lg:px-8">
<%= yield %>
</main>
</body>

View File

@@ -1,115 +0,0 @@
<% content_for :tags do %>
<meta name="description" content="Advanced barcode scanner for book prices - find the cheapest books with Booko's improved scanner" >
<meta name="keywords" content="barcode scanner, book prices, ISBN scanner, quagga2, advanced scanner"/>
<meta property="og:description" content="Advanced Booko Barcode Scanner" >
<meta property="og:site_name" content="Booko" >
<!-- script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.min.js"></script-->
<% end %>
<div class="bg-white dark:bg-tertiary-800">
<div class="max-w-4xl mx-auto py-8 px-4 sm:py-12 sm:px-6 lg:px-8">
<!-- Scanner Container -->
<div class="bg-gray-50 dark:bg-tertiary-700 rounded-lg p-6 mb-6" data-controller="quagga2-scanner">
<!-- Scanner Controls -->
<div class="text-center mb-6">
<button data-quagga2-scanner-target="startButton" data-action="click->quagga2-scanner#toggleCamera"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-booko hover:bg-booko-darker focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-booko mr-4">
<svg class="w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M23 4.5V8h-1V4.5A1.502 1.502 0 0 0 20.5 3H17V2h3.5A2.503 2.503 0 0 1 23 4.5zM4.5 22A1.502 1.502 0 0 1 3 20.5V17H2v3.5A2.503 2.503 0 0 0 4.5 23H8v-1zM22 20.5a1.502 1.502 0 0 1-1.5 1.5H17v1h3.5a2.503 2.503 0 0 0 2.5-2.5V17h-1zM3 4.5A1.502 1.502 0 0 1 4.5 3H8V2H4.5A2.503 2.503 0 0 0 2 4.5V8h1zM10 19V6H9v13zM6 6v13h2V6zm8 13V6h-2v13zm3-13v13h2V6zm-2 0v13h1V6z"/>
</svg>
<span data-quagga2-scanner-target="buttonText">Start Scan</span>
</button>
<button data-quagga2-scanner-target="flashButton" data-action="click->quagga2-scanner#toggleFlash"
class="inline-flex items-center px-4 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-tertiary-600 hover:bg-gray-50 dark:hover:bg-tertiary-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-booko"
style="display: none;">
<span data-quagga2-scanner-target="flashText">💡 Torch Off</span>
</button>
</div>
<!-- Scanner Viewport -->
<div data-quagga2-scanner-target="scannerContainer"
class="relative bg-black rounded-lg overflow-hidden mb-4 mx-auto"
style="width: 320px; height: 240px; display: none;">
<div data-quagga2-scanner-target="scanner" class="w-full h-full"></div>
<!-- Scanner Overlay with Reticle -->
<div data-quagga2-scanner-target="overlayTop"
class="absolute top-0 left-0 right-0"
style="height: 30px; background: rgba(0, 0, 0, 0.3); z-index: 5; pointer-events: none;"></div>
<div data-quagga2-scanner-target="overlayBottom"
class="absolute bottom-0 left-0 right-0"
style="height: 30px; background: rgba(0, 0, 0, 0.3); z-index: 5; pointer-events: none;"></div>
<div data-quagga2-scanner-target="overlayLeft"
class="absolute left-0"
style="top: 30px; bottom: 30px; width: 25px; background: rgba(0, 0, 0, 0.3); z-index: 5; pointer-events: none;"></div>
<div data-quagga2-scanner-target="overlayRight"
class="absolute right-0"
style="top: 30px; bottom: 30px; width: 25px; background: rgba(0, 0, 0, 0.3); z-index: 5; pointer-events: none;"></div>
<!-- Barcode Reticle -->
<div data-quagga2-scanner-target="barcodeReticle"
class="absolute border-2 border-red-500 bg-transparent"
style="top: 30px; left: 25px; right: 25px; bottom: 30px;"></div>
</div>
<!-- Status Messages -->
<div data-quagga2-scanner-target="status"
class="text-center text-sm text-gray-600 dark:text-gray-300 mb-4"
style="display: none;">Ready to scan...</div>
</div>
<!-- Manual Input Section -->
<div class="bg-white dark:bg-tertiary-700 rounded-lg border border-gray-200 dark:border-tertiary-600 p-6 mb-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">
📝 Manual Entry
</h3>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
Can't scan? Enter the barcode manually:
</p>
<div class="flex gap-3">
<input data-quagga2-scanner-target="manualInput"
data-action="keypress->quagga2-scanner#handleEnterKey"
type="text"
placeholder="Enter ISBN or barcode (e.g., 9781234567890)"
class="flex-1 min-w-0 block w-full px-3 py-2 rounded-md border border-gray-300 dark:border-tertiary-500 bg-white dark:bg-tertiary-600 text-gray-900 dark:text-white focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<button data-action="click->quagga2-scanner#searchManual"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-booko hover:bg-booko-darker focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-booko">
Search
</button>
</div>
</div>
<!-- Help Section -->
<div class="bg-blue-50 dark:bg-blue-900 rounded-lg border border-blue-200 dark:border-blue-700 p-6 mb-6">
<h3 class="text-lg font-medium text-blue-900 dark:text-blue-100 mb-4">
💡 Scanning Tips
</h3>
<div class="grid md:grid-cols-2 gap-4 text-sm text-blue-800 dark:text-blue-200">
<ul class="list-disc list-inside space-y-2">
<li>Ensure good lighting on the barcode</li>
<li>Hold your device steady while scanning</li>
<li>Position barcode fully within the frame</li>
<li>Try different angles if scanning fails</li>
</ul>
<ul class="list-disc list-inside space-y-2">
<li>Clean your camera lens for best results</li>
<li>Works with ISBN-13, ISBN-10, and UPC codes</li>
<li>Use manual entry if camera scanning fails</li>
<li>Grant camera permissions when prompted</li>
</ul>
</div>
</div>
<!-- Navigation Links -->
<div class="text-center space-y-4">
<p class="text-xs text-gray-500 dark:text-gray-400">
Having issues? <a href="/faq" class="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400">Check our FAQ</a> or
<a href="mailto:support@booko.com.au" class="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400">contact support</a>
</p>
</div>
</div>
</div>

BIN
barcodes.pages Normal file

Binary file not shown.

View File

@@ -1,6 +1,45 @@
#!/bin/bash
# Default to both platforms if no argument provided
# Pass "amd64" or "arm64" to build just one platform
PLATFORMS=${1:-"linux/amd64,linux/arm64"}
if [ "$1" = "amd64" ]; then
PLATFORMS="linux/amd64"
elif [ "$1" = "arm64" ]; then
PLATFORMS="linux/arm64"
fi
AMD_host="dkam@100.111.180.71"
echo "Building for platforms: $PLATFORMS"
echo "Using multi-platform build with remote builders"
echo "AMD host: $AMD_host"
# Setup multi-builder if it doesn't exist
if ! docker buildx ls | grep -q "multi-builder"; then
echo "Creating multi-builder with remote AMD host..."
docker buildx create --name multi-builder \
--driver docker-container \
--platform linux/arm64 \
--use
docker buildx create --name multi-builder --append \
--driver docker-container \
--platform linux/amd64 \
ssh://$AMD_host
fi
# Use the multi-builder
docker buildx use multi-builder
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t shelflife/getshelflife:latest \
-t shelflife/getshelflife:$(git rev-parse HEAD) \
--platform "$PLATFORMS" \
-t git.booko.info/shelflife/shelflife:latest \
-t git.booko.info/shelflife/shelflife:$(git rev-parse HEAD) \
--no-cache \
--push .
# -t reg.tbdb.info/shelflife:latest \
# -t reg.tbdb.info/shelflife:$(git rev-parse HEAD) \
# -t ghcr.io/thebookdb/shelf-life:latest \
# -t ghcr.io/thebookdb/shelf-life:$(git rev-parse HEAD) \

6
bin/ci Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
require_relative "../config/boot"
require "active_support/continuous_integration"
CI = ActiveSupport::ContinuousIntegration
require_relative "../config/ci.rb"

View File

@@ -2,7 +2,7 @@
require "rubygems"
require "bundler/setup"
# explicit rubocop config increases performance slightly while avoiding config confusion.
# Explicit RuboCop config increases performance slightly while avoiding config confusion.
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
load Gem.bin_path("rubocop", "rubocop")

View File

@@ -15,9 +15,6 @@ FileUtils.chdir APP_ROOT do
puts "== Installing dependencies =="
system("bundle check") || system!("bundle install")
# Install JavaScript dependencies
system("yarn install --check-files")
# puts "\n== Copying sample files =="
# unless File.exist?("config/database.yml")
# FileUtils.cp "config/database.yml.sample", "config/database.yml"
@@ -25,6 +22,7 @@ FileUtils.chdir APP_ROOT do
puts "\n== Preparing database =="
system! "bin/rails db:prepare"
system! "bin/rails db:reset" if ARGV.include?("--reset")
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"

84
bun.lock Normal file
View File

@@ -0,0 +1,84 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "app",
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo-rails": "^8.0.16",
"html5-qrcode": "^2.3.8",
"slim-select": "^2.13.1",
},
"devDependencies": {
"esbuild": "^0.25.8",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="],
"@hotwired/stimulus": ["@hotwired/stimulus@3.2.2", "", {}, "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="],
"@hotwired/turbo": ["@hotwired/turbo@8.0.13", "", {}, "sha512-M7qXUqcGab6G5PKOiwhgbByTtrPgKPFCTMNQ52QhzUEXEqmp0/ApEguUesh/FPiUjrmFec+3lq98KsWnYY2C7g=="],
"@hotwired/turbo-rails": ["@hotwired/turbo-rails@8.0.16", "", { "dependencies": { "@hotwired/turbo": "^8.0.13", "@rails/actioncable": ">=7.0" } }, "sha512-Yxiy2x+N3eOIEDokvLzSrd08aI5RDKnFYDQFl2J/LuMEWTtPdY7oNP0F/gv/sSe5AV23Lwz4FitG/uNFXNM5tA=="],
"@rails/actioncable": ["@rails/actioncable@8.0.200", "", {}, "sha512-EDqWyxck22BHmv1e+mD8Kl6GmtNkhEPdRfGFT7kvsv1yoXd9iYrqHDVAaR8bKmU/syC5eEZ2I5aWWxtB73ukMw=="],
"esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": "bin/esbuild" }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="],
"html5-qrcode": ["html5-qrcode@2.3.8", "", {}, "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="],
"slim-select": ["slim-select@2.13.1", "", {}, "sha512-0/j1SAYzwaCgb4mWEOIr+QSzznVPEfT/o6FHAK3yk9qZTM+79DC/J3YRZz7lC4JvteZTzxoGXxPF6CAG7ezjyg=="],
}
}

37
compose.yaml Normal file
View File

@@ -0,0 +1,37 @@
services:
# Create a .env as per the .env.example file in the repository, or
web:
image: ghcr.io/dkam/shelf-life:latest
ports:
- "3000:80"
environment:
- RAILS_ENV=production
- SECRET_KEY_BASE=${SECRET_KEY_BASE}
- APPLICATION_HOST=${APPLICATION_HOST}
- SENTRY_DSN=${SENTRY_DSN}
- TBDB_API_TOKEN=${TBDB_API_TOKEN}
- TBDB_BASE_URL=${TBDB_BASE_URL}
volumes:
- ./storage:/rails/storage
- ./log:/rails/log
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/up"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
jobs:
image: ghcr.io/dkam/shelf-life:latest
environment:
- RAILS_ENV=production
- SECRET_KEY_BASE=${SECRET_KEY_BASE}
- APPLICATION_HOST=${APPLICATION_HOST}
- SENTRY_DSN=${SENTRY_DSN}
- TBDB_API_TOKEN=${TBDB_API_TOKEN}
- TBDB_BASE_URL=${TBDB_BASE_URL}
volumes:
- ./storage:/rails/storage
command: bundle exec bin/jobs
restart: unless-stopped

View File

@@ -10,6 +10,8 @@ module ShelfLife
class Application < Rails::Application
config.action_cable.mount_path = "/cable"
config.active_storage.variant_processor = :disabled
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.0

View File

@@ -9,6 +9,7 @@ development:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day
adapter: async
test:
adapter: test

View File

@@ -10,7 +10,6 @@ development:
<<: *default
test:
database: cache
<<: *default
production:

22
config/ci.rb Normal file
View File

@@ -0,0 +1,22 @@
# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
step "Tests: Rails", "bin/rails test"
step "Tests: System", "bin/rails test:system"
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end

View File

@@ -1 +0,0 @@
StHHXtuT6ogqPQKDDmgAP+D7purLRK26Zo2tiZOBp0ksmXFw7VcySVJwMTXNwv+pR8fM6Q8oDG+2FBskpm32Vot8S1NJwc++hCfQ8YngwvuQ0r1OwYkpGylYchi4qP08aj7xFRNnom+qCryZEiSDbJGcpgjhdqYIY8Mr/P2v1ZwDdV2tZtNjY5c5Wnm89urX1CXVLPVTWfQe474/S3HJOpsD7lYKtyKstbQz5dF6fX1X7SctPBQmJD8Ku0m10npbYEKmQP99zD7/x5FqExGLxBfilsdHQIuB9dMEK2GMqCAFaFctzEaElemuAUkr+Tmvjl3zOMWHkKbgxCLX8m5QKMNwFLHj8NYJZmFL3GUMkaQEWSWFTkHTIdJtolPQtU6ou9a4tna138JaeqBNwyhDP79n+yANEdjYLr/ZzsKjJ2jal3B/bVW8DOJLkvjP+9PXyzIcXpiS6qtxA3mt2ib47dX7N85dgI1TXuk6FQNysrvlgXzJIeLgOxFbAwAubNqpUoeE3p4RuPpdR0IfYa5/czyNiT0iF/sEYQfY8RUEe3Zs560BXmF8Lq4e4BaQKf3MzT8yrID3DtxXo7Ge7gnQxep+zoMm35FHH4fjYw==--NrmOXiGsCVApAem2--IVYrzAASqrLzJbpi0pd6UA==

View File

@@ -14,7 +14,7 @@ development:
<<: *default
database: storage/development.sqlite3
cache:
<<: *default
<<: *default
database: storage/development_cache.sqlite3
migrations_paths: db/cache_migrate
queue:

View File

@@ -20,16 +20,18 @@ Rails.application.configure do
if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"}
else
config.action_controller.perform_caching = false
end
# Change to :null_store to avoid any caching.
config.cache_store = :solid_cache_store
# Already specified in cache.yml
# config.solid_cache.connects_to = { database: { writing: :cache } }
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
config.solid_queue.connects_to = {database: {writing: :queue}}
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
@@ -41,7 +43,7 @@ Rails.application.configure do
config.action_mailer.perform_caching = false
# Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 4001 }
config.action_mailer.default_url_options = {host: "localhost", port: 4001}
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
@@ -60,10 +62,11 @@ Rails.application.configure do
# Add request ID to logs for better tracing
config.log_tags = [:request_id]
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Enhanced logging for job debugging
config.log_level = :debug
config.active_job.logger = Logger.new(STDOUT)
# Suppress logger output for asset requests.
config.assets.quiet = true
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
@@ -79,4 +82,6 @@ Rails.application.configure do
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
# config.generators.apply_rubocop_autocorrect_after_generate!
config.hosts << "4001.dev.booko.au"
end

View File

@@ -16,7 +16,7 @@ Rails.application.configure do
config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"}
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
@@ -34,10 +34,10 @@ Rails.application.configure do
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
config.log_tags = [:request_id]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
# Change to "debug" to log everything (including potentially personally-identifiable information!)
# Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Prevent health checks from clogging up the logs.
@@ -48,19 +48,26 @@ Rails.application.configure do
# Replace the default in-process memory cache store with a durable alternative.
config.cache_store = :solid_cache_store
# Already specified in cache.yml
# config.solid_cache.connects_to = { database: { writing: :cache } }
# Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
config.solid_queue.connects_to = {database: {writing: :queue}}
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" }
config.action_mailer.default_url_options = {host: ENV.fetch("APPLICATION_HOST", "example.com")}
# Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
# Set host to be used by routes URL helpers.
routes.default_url_options[:host] = ENV.fetch("APPLICATION_HOST", "example.com")
# Sentry configuration is handled in config/initializers/sentry.rb
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
# password: Rails.application.credentials.dig(:smtp, :password),
@@ -77,7 +84,7 @@ Rails.application.configure do
config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
config.active_record.attributes_for_inspect = [:id]
# Enable DNS rebinding protection and other `Host` header attacks.
# config.hosts = [

View File

@@ -16,7 +16,7 @@ Rails.application.configure do
config.eager_load = ENV["CI"].present?
# Configure public file server for tests with cache-control for performance.
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
config.public_file_server.headers = {"cache-control" => "public, max-age=3600"}
# Show full error reports.
config.consider_all_requests_local = true
@@ -37,7 +37,7 @@ Rails.application.configure do
config.action_mailer.delivery_method = :test
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" }
config.action_mailer.default_url_options = {host: "example.com"}
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr

View File

@@ -20,6 +20,10 @@
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end

View File

@@ -0,0 +1,74 @@
# Be sure to restart your server when you modify this file.
#
# This file eases your Rails 8.1 framework defaults upgrade.
#
# Uncomment each configuration one by one to switch to the new default.
# Once your application is ready to run with all new defaults, you can remove
# this file and set the `config.load_defaults` to `8.1`.
#
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
###
# Skips escaping HTML entities and line separators. When set to `false`, the
# JSON renderer no longer escapes these to improve performance.
#
# Example:
# class PostsController < ApplicationController
# def index
# render json: { key: "\u2028\u2029<>&" }
# end
# end
#
# Renders `{"key":"\u2028\u2029\u003c\u003e\u0026"}` with the previous default, but `{"key":"<>&"}` with the config
# set to `false`.
#
# Applications that want to keep the escaping behavior can set the config to `true`.
#++
# Rails.configuration.action_controller.escape_json_responses = false
###
# Skips escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON.
#
# Historically these characters were not valid inside JavaScript literal strings but that changed in ECMAScript 2019.
# As such it's no longer a concern in modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset.
#++
# Rails.configuration.active_support.escape_js_separators_in_json = false
###
# Raises an error when order dependent finder methods (e.g. `#first`, `#second`) are called without `order` values
# on the relation, and the model does not have any order columns (`implicit_order_column`, `query_constraints`, or
# `primary_key`) to fall back on.
#
# The current behavior of not raising an error has been deprecated, and this configuration option will be removed in
# Rails 8.2.
#++
# Rails.configuration.active_record.raise_on_missing_required_finder_order_columns = true
###
# Controls how Rails handles path relative URL redirects.
# When set to `:raise`, Rails will raise an `ActionController::Redirecting::UnsafeRedirectError`
# for relative URLs without a leading slash, which can help prevent open redirect vulnerabilities.
#
# Example:
# redirect_to "example.com" # Raises UnsafeRedirectError
# redirect_to "@attacker.com" # Raises UnsafeRedirectError
# redirect_to "/safe/path" # Works correctly
#
# Applications that want to allow these redirects can set the config to `:log` (previous default)
# to only log warnings, or `:notify` to send ActiveSupport notifications.
#++
# Rails.configuration.action_controller.action_on_path_relative_redirect = :raise
###
# Use a Ruby parser to track dependencies between Action View templates
#++
# Rails.configuration.action_view.render_tracker = :ruby
###
# When enabled, hidden inputs generated by `form_tag`, `token_tag`, `method_tag`, and the hidden parameter fields
# included in `button_to` forms will omit the `autocomplete="off"` attribute.
#
# Applications that want to keep generating the `autocomplete` attribute for those tags can set it to `false`.
#++
# Rails.configuration.action_view.remove_hidden_field_autocomplete = true

View File

@@ -0,0 +1,28 @@
# Sentry Error Tracking
# Only initialize in production environment and only if DSN is configured
Rails.application.configure do
if Rails.env.production? && ENV["SENTRY_DSN"].present?
Sentry.init do |config|
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
config.dsn = ENV["SENTRY_DSN"]
# Set the environment tag
config.environment = Rails.env
# Performance monitoring - enable with low sample rate
config.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
# Filter out sensitive data
config.send_default_pii = false
# Filter out certain exceptions that are typically noise
config.excluded_exceptions += [
"ActionController::InvalidAuthenticityToken",
"CGI::Session::CookieStore::TamperedWithCookie",
"ActionController::UnknownFormat"
]
end
Rails.logger.info "Sentry initialized in production environment"
end
end

View File

@@ -7,7 +7,8 @@
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1.
# default is already 1. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
@@ -33,8 +34,8 @@ port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
# Run the Solid Queue supervisor inside of Puma for single-server deployments
plugin :solid_queue
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.

View File

@@ -29,17 +29,21 @@ Rails.application.routes.draw do
delete "/signout", to: "sessions#destroy", as: :signout
# Product management routes
resources :products, only: [ :index, :show, :destroy ]
resources :products, only: [:index, :show, :destroy] do
member do
post :refresh
end
end
# Library management routes
resources :libraries, only: [ :index, :show, :edit, :update ] do
resources :libraries, only: [:index, :show, :edit, :update] do
member do
get :import
post :import
get :export
end
end
resources :library_items, only: [ :create, :destroy ]
resources :library_items, only: [:show, :edit, :update, :create, :destroy]
# Scanner routes
get "/scanner", to: "scanners#index", as: :scanner
@@ -47,30 +51,33 @@ Rails.application.routes.draw do
post "/scanner/set_library", to: "scanners#set_library", as: :set_scanner_library
# Scan routes
resources :scans, only: [ :index, :create, :destroy ]
resources :scans, only: [:index, :create, :destroy]
# Users route (user management)
resources :users, only: [ :index, :show, :new, :create ]
resources :users, only: [:index, :show, :new, :create]
# User profile routes (singular - current user)
get "/profile", to: "user#show", as: :profile
get "/profile/edit", to: "user#edit", as: :edit_profile
patch "/profile", to: "user#update"
patch "/profile/settings", to: "user#update_setting"
patch "/profile/api_token", to: "user#update_api_token"
delete "/profile/api_token", to: "user#delete_api_token"
get "/profile/change_password", to: "user#change_password", as: :change_password
patch "/profile/update_password", to: "user#update_password"
# OAuth routes
get "/auth/tbdb", to: "oauth#tbdb", as: :auth_tbdb
get "/auth/tbdb/callback", to: "oauth#tbdb_callback", as: :auth_tbdb_callback
delete "/auth/tbdb/disconnect", to: "oauth#tbdb_disconnect", as: :auth_tbdb_disconnect
# API routes
namespace :api do
namespace :v1 do
resources :products, only: [ :show, :index ]
resources :library_items, only: [ :index, :create, :destroy ]
resources :scans, only: [ :index, :create ]
resources :products, only: [:show, :index]
resources :library_items, only: [:index, :create, :destroy]
resources :scans, only: [:index, :create]
end
end
# GTIN route - must be last to avoid conflicts
get "/:gtin", to: "products#show", constraints: { gtin: /\d{13}/ }
get "/:gtin", to: "products#show", constraints: {gtin: /\d{13}/}
end

View File

@@ -4,7 +4,7 @@ test:
local:
service: Disk
root: <%= Rails.root.join("storage") %>
root: <%= Rails.root.join("storage/uploads") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:

View File

@@ -10,14 +10,5 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 1) do
create_table "solid_cable_messages", force: :cascade do |t|
t.binary "channel", limit: 1024, null: false
t.binary "payload", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "channel_hash", limit: 8, null: false
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
end
ActiveRecord::Schema[8.1].define(version: 0) do
end

Some files were not shown because too many files have changed in this diff Show More