Compare commits
27 Commits
1655334922
...
refactor_v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84485af5a2 | ||
|
|
e9f1eb8d2e | ||
|
|
c5796b57fb | ||
|
|
670a5ba473 | ||
|
|
fb34071478 | ||
|
|
858edcb8b5 | ||
|
|
fc10b9d5b3 | ||
|
|
bf10d0232a | ||
|
|
a09d8996da | ||
|
|
3e6220f66f | ||
|
|
425fe2d6da | ||
|
|
c3ffde807c | ||
|
|
7239291e73 | ||
|
|
b2a05ab69a | ||
|
|
2138d6ec33 | ||
|
|
1bf69a3d10 | ||
|
|
fac2c46f61 | ||
|
|
21c4312fa3 | ||
|
|
966d37af74 | ||
|
|
a04f1960a4 | ||
|
|
2a2a2322c6 | ||
|
|
828537de8a | ||
|
|
870e83e48d | ||
|
|
d8889a29c0 | ||
|
|
50b7db5ebe | ||
|
|
311ecafb74 | ||
|
|
6d74e7aff1 |
27
.env.example
Normal file
27
.env.example
Normal 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
8
.envrc
Normal 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
9
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.4.5
|
||||
4.0.1
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -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
14
Gemfile
@@ -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"
|
||||
|
||||
283
Gemfile.lock
283
Gemfile.lock
@@ -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
21
LICENSE
Normal 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
192
README.md
@@ -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
178
SITE.md
Normal 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
|
||||
|
||||
@@ -10,5 +10,4 @@
|
||||
*
|
||||
*= require_tree .
|
||||
*= require_self
|
||||
*= require choices
|
||||
*/
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
143
app/components/libraries/product_group_view.rb
Normal file
143
app/components/libraries/product_group_view.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
385
app/components/library_items/edit_view.rb
Normal file
385
app/components/library_items/edit_view.rb
Normal 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
|
||||
216
app/components/library_items/show_view.rb
Normal file
216
app/components/library_items/show_view.rb
Normal 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
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
29
app/components/shared/icon_view.rb
Normal file
29
app/components/shared/icon_view.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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." }
|
||||
|
||||
69
app/controllers/oauth_controller.rb
Normal file
69
app/controllers/oauth_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Entry point for the build script in your package.json
|
||||
import "@hotwired/turbo-rails"
|
||||
import "./controllers"
|
||||
import "slim-select/styles"
|
||||
|
||||
@@ -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)
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
app/javascript/controllers/slim_select_controller.js
Normal file
86
app/javascript/controllers/slim_select_controller.js
Normal 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
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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|
|
||||
|
||||
@@ -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
7
app/models/condition.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
149
app/models/tbdb_connection.rb
Normal file
149
app/models/tbdb_connection.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
@@ -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
418
app/services/tbdb/client.rb
Normal 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
|
||||
211
app/services/tbdb/oauth_service.rb
Normal file
211
app/services/tbdb/oauth_service.rb
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
BIN
barcodes.pages
Normal file
Binary file not shown.
45
bin/build
45
bin/build
@@ -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
6
bin/ci
Executable 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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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
84
bun.lock
Normal 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
37
compose.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ development:
|
||||
writing: cable
|
||||
polling_interval: 0.1.seconds
|
||||
message_retention: 1.day
|
||||
adapter: async
|
||||
|
||||
test:
|
||||
adapter: test
|
||||
|
||||
@@ -10,7 +10,6 @@ development:
|
||||
<<: *default
|
||||
|
||||
test:
|
||||
database: cache
|
||||
<<: *default
|
||||
|
||||
production:
|
||||
|
||||
22
config/ci.rb
Normal file
22
config/ci.rb
Normal 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
|
||||
@@ -1 +0,0 @@
|
||||
StHHXtuT6ogqPQKDDmgAP+D7purLRK26Zo2tiZOBp0ksmXFw7VcySVJwMTXNwv+pR8fM6Q8oDG+2FBskpm32Vot8S1NJwc++hCfQ8YngwvuQ0r1OwYkpGylYchi4qP08aj7xFRNnom+qCryZEiSDbJGcpgjhdqYIY8Mr/P2v1ZwDdV2tZtNjY5c5Wnm89urX1CXVLPVTWfQe474/S3HJOpsD7lYKtyKstbQz5dF6fX1X7SctPBQmJD8Ku0m10npbYEKmQP99zD7/x5FqExGLxBfilsdHQIuB9dMEK2GMqCAFaFctzEaElemuAUkr+Tmvjl3zOMWHkKbgxCLX8m5QKMNwFLHj8NYJZmFL3GUMkaQEWSWFTkHTIdJtolPQtU6ou9a4tna138JaeqBNwyhDP79n+yANEdjYLr/ZzsKjJ2jal3B/bVW8DOJLkvjP+9PXyzIcXpiS6qtxA3mt2ib47dX7N85dgI1TXuk6FQNysrvlgXzJIeLgOxFbAwAubNqpUoeE3p4RuPpdR0IfYa5/czyNiT0iF/sEYQfY8RUEe3Zs560BXmF8Lq4e4BaQKf3MzT8yrID3DtxXo7Ge7gnQxep+zoMm35FHH4fjYw==--NrmOXiGsCVApAem2--IVYrzAASqrLzJbpi0pd6UA==
|
||||
@@ -14,7 +14,7 @@ development:
|
||||
<<: *default
|
||||
database: storage/development.sqlite3
|
||||
cache:
|
||||
<<: *default
|
||||
<<: *default
|
||||
database: storage/development_cache.sqlite3
|
||||
migrations_paths: db/cache_migrate
|
||||
queue:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
74
config/initializers/new_framework_defaults_8_1.rb
Normal file
74
config/initializers/new_framework_defaults_8_1.rb
Normal 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
|
||||
28
config/initializers/sentry.rb
Normal file
28
config/initializers/sentry.rb
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user