mirror of
https://github.com/ryankazokas/turbovault-app.git
synced 2026-04-16 22:12:53 +00:00
Moving to github
This commit is contained in:
0
app/assets/builds/.keep
Normal file
0
app/assets/builds/.keep
Normal file
2
app/assets/builds/tailwind.css
Normal file
2
app/assets/builds/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
0
app/assets/images/.keep
Normal file
0
app/assets/images/.keep
Normal file
10
app/assets/stylesheets/application.css
Normal file
10
app/assets/stylesheets/application.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* This is a manifest file that'll be compiled into application.css.
|
||||
*
|
||||
* With Propshaft, assets are served efficiently without preprocessing steps. You can still include
|
||||
* application-wide styles in this file, but keep in mind that CSS precedence will follow the standard
|
||||
* cascading order, meaning styles declared later in the document or manifest will override earlier ones,
|
||||
* depending on specificity.
|
||||
*
|
||||
* Consider organizing styles into separate files for maintainability.
|
||||
*/
|
||||
551
app/assets/stylesheets/themes.css
Normal file
551
app/assets/stylesheets/themes.css
Normal file
@@ -0,0 +1,551 @@
|
||||
/* Light Theme (Default) */
|
||||
.theme-light {
|
||||
/* Already uses default Tailwind classes */
|
||||
}
|
||||
|
||||
/* Dark Theme */
|
||||
.theme-dark {
|
||||
background-color: #1a202c;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.theme-dark .bg-gray-50 {
|
||||
background-color: #2d3748 !important;
|
||||
}
|
||||
|
||||
.theme-dark .bg-white {
|
||||
background-color: #1a202c !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.theme-dark .bg-gray-100 {
|
||||
background-color: #2d3748 !important;
|
||||
}
|
||||
|
||||
.theme-dark .bg-gray-800 {
|
||||
background-color: #0f1419 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-gray-900 {
|
||||
color: #f7fafc !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-gray-700 {
|
||||
color: #cbd5e0 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-gray-600 {
|
||||
color: #a0aec0 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-gray-500 {
|
||||
color: #718096 !important;
|
||||
}
|
||||
|
||||
.theme-dark .border-gray-200,
|
||||
.theme-dark .border-gray-300 {
|
||||
border-color: #4a5568 !important;
|
||||
}
|
||||
|
||||
.theme-dark input,
|
||||
.theme-dark textarea,
|
||||
.theme-dark select {
|
||||
background-color: #2d3748 !important;
|
||||
color: #e2e8f0 !important;
|
||||
border-color: #4a5568 !important;
|
||||
}
|
||||
|
||||
.theme-dark input:focus,
|
||||
.theme-dark textarea:focus,
|
||||
.theme-dark select:focus {
|
||||
border-color: #667eea !important;
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Midnight Theme - Deep Blues */
|
||||
.theme-midnight {
|
||||
background-color: #0a1929;
|
||||
color: #b0c4de;
|
||||
}
|
||||
|
||||
.theme-midnight .bg-gray-50 {
|
||||
background-color: #132f4c !important;
|
||||
}
|
||||
|
||||
.theme-midnight .bg-white {
|
||||
background-color: #0a1929 !important;
|
||||
color: #b0c4de !important;
|
||||
}
|
||||
|
||||
.theme-midnight .bg-gray-100 {
|
||||
background-color: #1a2332 !important;
|
||||
}
|
||||
|
||||
.theme-midnight .bg-gray-800 {
|
||||
background-color: #05101c !important;
|
||||
}
|
||||
|
||||
.theme-midnight .text-gray-900 {
|
||||
color: #e3f2fd !important;
|
||||
}
|
||||
|
||||
.theme-midnight .text-gray-700 {
|
||||
color: #90caf9 !important;
|
||||
}
|
||||
|
||||
.theme-midnight .text-gray-600 {
|
||||
color: #64b5f6 !important;
|
||||
}
|
||||
|
||||
.theme-midnight .text-gray-500 {
|
||||
color: #42a5f5 !important;
|
||||
}
|
||||
|
||||
.theme-midnight .border-gray-200,
|
||||
.theme-midnight .border-gray-300 {
|
||||
border-color: #1e3a5f !important;
|
||||
}
|
||||
|
||||
.theme-midnight input,
|
||||
.theme-midnight textarea,
|
||||
.theme-midnight select {
|
||||
background-color: #132f4c !important;
|
||||
color: #b0c4de !important;
|
||||
border-color: #1e3a5f !important;
|
||||
}
|
||||
|
||||
/* Retro Theme - Classic Gaming */
|
||||
.theme-retro {
|
||||
background-color: #2b1b17;
|
||||
color: #f4e8c1;
|
||||
}
|
||||
|
||||
.theme-retro .bg-gray-50 {
|
||||
background-color: #3d2c24 !important;
|
||||
}
|
||||
|
||||
.theme-retro .bg-white {
|
||||
background-color: #2b1b17 !important;
|
||||
color: #f4e8c1 !important;
|
||||
}
|
||||
|
||||
.theme-retro .bg-gray-100 {
|
||||
background-color: #4a3428 !important;
|
||||
}
|
||||
|
||||
.theme-retro .bg-gray-800 {
|
||||
background-color: #1a0f0c !important;
|
||||
}
|
||||
|
||||
.theme-retro .bg-indigo-600 {
|
||||
background-color: #d4af37 !important;
|
||||
}
|
||||
|
||||
.theme-retro .bg-indigo-100 {
|
||||
background-color: #5a4a2f !important;
|
||||
}
|
||||
|
||||
.theme-retro .text-indigo-600,
|
||||
.theme-retro .text-indigo-700 {
|
||||
color: #ffd700 !important;
|
||||
}
|
||||
|
||||
.theme-retro .text-gray-900 {
|
||||
color: #f4e8c1 !important;
|
||||
}
|
||||
|
||||
.theme-retro .text-gray-700 {
|
||||
color: #e8d7a8 !important;
|
||||
}
|
||||
|
||||
.theme-retro .text-gray-600 {
|
||||
color: #d4c18f !important;
|
||||
}
|
||||
|
||||
.theme-retro .border-gray-200,
|
||||
.theme-retro .border-gray-300 {
|
||||
border-color: #5a4a2f !important;
|
||||
}
|
||||
|
||||
.theme-retro input,
|
||||
.theme-retro textarea,
|
||||
.theme-retro select {
|
||||
background-color: #3d2c24 !important;
|
||||
color: #f4e8c1 !important;
|
||||
border-color: #5a4a2f !important;
|
||||
}
|
||||
|
||||
/* Ocean Theme - Blue/Teal */
|
||||
.theme-ocean {
|
||||
background-color: #0d2d3d;
|
||||
color: #d4f4ff;
|
||||
}
|
||||
|
||||
.theme-ocean .bg-gray-50 {
|
||||
background-color: #164e63 !important;
|
||||
}
|
||||
|
||||
.theme-ocean .bg-white {
|
||||
background-color: #0d2d3d !important;
|
||||
color: #d4f4ff !important;
|
||||
}
|
||||
|
||||
.theme-ocean .bg-gray-100 {
|
||||
background-color: #1a4150 !important;
|
||||
}
|
||||
|
||||
.theme-ocean .bg-gray-800 {
|
||||
background-color: #051c28 !important;
|
||||
}
|
||||
|
||||
.theme-ocean .bg-indigo-600 {
|
||||
background-color: #06b6d4 !important;
|
||||
}
|
||||
|
||||
.theme-ocean .bg-indigo-100 {
|
||||
background-color: #1e4a5a !important;
|
||||
}
|
||||
|
||||
.theme-ocean .text-indigo-600,
|
||||
.theme-ocean .text-indigo-700 {
|
||||
color: #22d3ee !important;
|
||||
}
|
||||
|
||||
.theme-ocean .text-gray-900 {
|
||||
color: #e0f2fe !important;
|
||||
}
|
||||
|
||||
.theme-ocean .text-gray-700 {
|
||||
color: #bae6fd !important;
|
||||
}
|
||||
|
||||
.theme-ocean .text-gray-600 {
|
||||
color: #7dd3fc !important;
|
||||
}
|
||||
|
||||
.theme-ocean .border-gray-200,
|
||||
.theme-ocean .border-gray-300 {
|
||||
border-color: #1e4a5a !important;
|
||||
}
|
||||
|
||||
.theme-ocean input,
|
||||
.theme-ocean textarea,
|
||||
.theme-ocean select {
|
||||
background-color: #164e63 !important;
|
||||
color: #d4f4ff !important;
|
||||
border-color: #1e4a5a !important;
|
||||
}
|
||||
|
||||
/* Common overrides for all dark themes */
|
||||
.theme-dark input::placeholder,
|
||||
.theme-midnight input::placeholder,
|
||||
.theme-retro input::placeholder,
|
||||
.theme-ocean input::placeholder,
|
||||
.theme-dark textarea::placeholder,
|
||||
.theme-midnight textarea::placeholder,
|
||||
.theme-retro textarea::placeholder,
|
||||
.theme-ocean textarea::placeholder {
|
||||
color: #718096;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Ensure hover states work on dark themes */
|
||||
.theme-dark a:hover,
|
||||
.theme-midnight a:hover,
|
||||
.theme-retro a:hover,
|
||||
.theme-ocean a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Navigation fixes for all dark themes */
|
||||
.theme-dark nav,
|
||||
.theme-midnight nav,
|
||||
.theme-retro nav,
|
||||
.theme-ocean nav {
|
||||
background-color: rgba(0, 0, 0, 0.3) !important;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.theme-dark nav a,
|
||||
.theme-midnight nav a,
|
||||
.theme-retro nav a,
|
||||
.theme-ocean nav a {
|
||||
color: #cbd5e0 !important;
|
||||
}
|
||||
|
||||
.theme-dark nav a:hover,
|
||||
.theme-midnight nav a:hover,
|
||||
.theme-retro nav a:hover,
|
||||
.theme-ocean nav a:hover {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Logo/brand text - keep it vibrant */
|
||||
.theme-dark nav .text-indigo-600,
|
||||
.theme-midnight nav .text-indigo-600,
|
||||
.theme-ocean nav .text-indigo-600 {
|
||||
color: #818cf8 !important;
|
||||
}
|
||||
|
||||
.theme-retro nav .text-indigo-600 {
|
||||
color: #ffd700 !important;
|
||||
}
|
||||
|
||||
/* Navigation button (logout) */
|
||||
.theme-dark nav button,
|
||||
.theme-midnight nav button,
|
||||
.theme-retro nav button,
|
||||
.theme-ocean nav button {
|
||||
background-color: #4a5568 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.theme-dark nav button:hover,
|
||||
.theme-midnight nav button:hover,
|
||||
.theme-retro nav button:hover,
|
||||
.theme-ocean nav button:hover {
|
||||
background-color: #718096 !important;
|
||||
}
|
||||
|
||||
/* Button overrides for dark themes */
|
||||
.theme-dark button,
|
||||
.theme-midnight button,
|
||||
.theme-retro button,
|
||||
.theme-ocean button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.theme-dark .bg-gray-200,
|
||||
.theme-midnight .bg-gray-200,
|
||||
.theme-retro .bg-gray-200,
|
||||
.theme-ocean .bg-gray-200 {
|
||||
background-color: #4a5568 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.theme-dark .bg-gray-200:hover,
|
||||
.theme-midnight .bg-gray-200:hover,
|
||||
.theme-retro .bg-gray-200:hover,
|
||||
.theme-ocean .bg-gray-200:hover {
|
||||
background-color: #718096 !important;
|
||||
}
|
||||
|
||||
/* Specific button text colors to ensure readability */
|
||||
.theme-dark .bg-gray-200 button,
|
||||
.theme-midnight .bg-gray-200 button,
|
||||
.theme-retro .bg-gray-200 button,
|
||||
.theme-ocean .bg-gray-200 button,
|
||||
.theme-dark button.bg-gray-200,
|
||||
.theme-midnight button.bg-gray-200,
|
||||
.theme-retro button.bg-gray-200,
|
||||
.theme-ocean button.bg-gray-200 {
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* Ensure all colored buttons have white text */
|
||||
.theme-dark .bg-yellow-100,
|
||||
.theme-midnight .bg-yellow-100,
|
||||
.theme-retro .bg-yellow-100,
|
||||
.theme-ocean .bg-yellow-100 {
|
||||
background-color: #78350f !important;
|
||||
color: #fef3c7 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-red-600,
|
||||
.theme-midnight .text-red-600,
|
||||
.theme-retro .text-red-600,
|
||||
.theme-ocean .text-red-600 {
|
||||
color: #f87171 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-green-600,
|
||||
.theme-midnight .text-green-600,
|
||||
.theme-retro .text-green-600,
|
||||
.theme-ocean .text-green-600 {
|
||||
color: #4ade80 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-blue-600,
|
||||
.theme-midnight .text-blue-600,
|
||||
.theme-retro .text-blue-600,
|
||||
.theme-ocean .text-blue-600 {
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
|
||||
/* Ensure buttons with explicit colors stay readable */
|
||||
.theme-dark .bg-indigo-600,
|
||||
.theme-midnight .bg-indigo-600,
|
||||
.theme-retro .bg-indigo-600,
|
||||
.theme-ocean .bg-indigo-600,
|
||||
.theme-dark .bg-green-600,
|
||||
.theme-midnight .bg-green-600,
|
||||
.theme-retro .bg-green-600,
|
||||
.theme-ocean .bg-green-600,
|
||||
.theme-dark .bg-red-600,
|
||||
.theme-midnight .bg-red-600,
|
||||
.theme-retro .bg-red-600,
|
||||
.theme-ocean .bg-red-600,
|
||||
.theme-dark .bg-blue-600,
|
||||
.theme-midnight .bg-blue-600,
|
||||
.theme-retro .bg-blue-600,
|
||||
.theme-ocean .bg-blue-600 {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Text color overrides for important elements */
|
||||
.theme-dark .text-gray-700,
|
||||
.theme-midnight .text-gray-700,
|
||||
.theme-retro .text-gray-700,
|
||||
.theme-ocean .text-gray-700 {
|
||||
color: #cbd5e0 !important;
|
||||
}
|
||||
|
||||
/* Badge/notification overrides */
|
||||
.theme-dark .bg-red-500,
|
||||
.theme-midnight .bg-red-500,
|
||||
.theme-retro .bg-red-500,
|
||||
.theme-ocean .bg-red-500 {
|
||||
background-color: #ef4444 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Footer overrides */
|
||||
.theme-dark footer,
|
||||
.theme-midnight footer,
|
||||
.theme-retro footer,
|
||||
.theme-ocean footer {
|
||||
background-color: #111827 !important;
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.theme-dark footer a,
|
||||
.theme-midnight footer a,
|
||||
.theme-retro footer a,
|
||||
.theme-ocean footer a {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.theme-dark footer a:hover,
|
||||
.theme-midnight footer a:hover,
|
||||
.theme-retro footer a:hover,
|
||||
.theme-ocean footer a:hover {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Shadow improvements for dark themes */
|
||||
.theme-dark .shadow,
|
||||
.theme-dark .shadow-lg,
|
||||
.theme-midnight .shadow,
|
||||
.theme-midnight .shadow-lg,
|
||||
.theme-retro .shadow,
|
||||
.theme-retro .shadow-lg,
|
||||
.theme-ocean .shadow,
|
||||
.theme-ocean .shadow-lg {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Card hover effects for dark themes */
|
||||
.theme-dark .hover\:shadow-md:hover,
|
||||
.theme-midnight .hover\:shadow-md:hover,
|
||||
.theme-retro .hover\:shadow-md:hover,
|
||||
.theme-ocean .hover\:shadow-md:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Statistic cards text colors */
|
||||
.theme-dark .text-blue-600,
|
||||
.theme-midnight .text-blue-600 {
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
|
||||
.theme-ocean .text-blue-600 {
|
||||
color: #06b6d4 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-green-600,
|
||||
.theme-midnight .text-green-600,
|
||||
.theme-ocean .text-green-600 {
|
||||
color: #4ade80 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-purple-600,
|
||||
.theme-midnight .text-purple-600,
|
||||
.theme-ocean .text-purple-600 {
|
||||
color: #c084fc !important;
|
||||
}
|
||||
|
||||
.theme-retro .text-blue-600,
|
||||
.theme-retro .text-green-600,
|
||||
.theme-retro .text-purple-600 {
|
||||
color: #ffd700 !important;
|
||||
}
|
||||
|
||||
/* Badge colors for dark themes */
|
||||
.theme-dark .bg-blue-100,
|
||||
.theme-midnight .bg-blue-100,
|
||||
.theme-retro .bg-blue-100,
|
||||
.theme-ocean .bg-blue-100 {
|
||||
background-color: #1e3a8a !important;
|
||||
color: #dbeafe !important;
|
||||
}
|
||||
|
||||
.theme-dark .bg-green-100,
|
||||
.theme-midnight .bg-green-100,
|
||||
.theme-retro .bg-green-100,
|
||||
.theme-ocean .bg-green-100 {
|
||||
background-color: #14532d !important;
|
||||
color: #dcfce7 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-blue-800,
|
||||
.theme-midnight .text-blue-800,
|
||||
.theme-retro .text-blue-800,
|
||||
.theme-ocean .text-blue-800 {
|
||||
color: #dbeafe !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-green-800,
|
||||
.theme-midnight .text-green-800,
|
||||
.theme-retro .text-green-800,
|
||||
.theme-ocean .text-green-800 {
|
||||
color: #dcfce7 !important;
|
||||
}
|
||||
|
||||
/* Red badges/alerts */
|
||||
.theme-dark .bg-red-100,
|
||||
.theme-midnight .bg-red-100,
|
||||
.theme-retro .bg-red-100,
|
||||
.theme-ocean .bg-red-100 {
|
||||
background-color: #7f1d1d !important;
|
||||
color: #fee2e2 !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-red-800,
|
||||
.theme-midnight .text-red-800,
|
||||
.theme-retro .text-red-800,
|
||||
.theme-ocean .text-red-800 {
|
||||
color: #fee2e2 !important;
|
||||
}
|
||||
|
||||
/* Indigo badges */
|
||||
.theme-dark .bg-indigo-100,
|
||||
.theme-midnight .bg-indigo-100,
|
||||
.theme-retro .bg-indigo-100,
|
||||
.theme-ocean .bg-indigo-100 {
|
||||
background-color: #312e81 !important;
|
||||
color: #e0e7ff !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-indigo-700,
|
||||
.theme-midnight .text-indigo-700,
|
||||
.theme-retro .text-indigo-700,
|
||||
.theme-ocean .text-indigo-700 {
|
||||
color: #c7d2fe !important;
|
||||
}
|
||||
|
||||
.theme-dark .text-indigo-800,
|
||||
.theme-midnight .text-indigo-800,
|
||||
.theme-retro .text-indigo-800,
|
||||
.theme-ocean .text-indigo-800 {
|
||||
color: #e0e7ff !important;
|
||||
}
|
||||
1
app/assets/tailwind/application.css
Normal file
1
app/assets/tailwind/application.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
38
app/controllers/api/v1/base_controller.rb
Normal file
38
app/controllers/api/v1/base_controller.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
module Api
|
||||
module V1
|
||||
class BaseController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token
|
||||
before_action :authenticate_api_token
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
|
||||
|
||||
private
|
||||
|
||||
def authenticate_api_token
|
||||
token = request.headers["Authorization"]&.split(" ")&.last
|
||||
@api_token = ApiToken.active.find_by(token: token)
|
||||
|
||||
if @api_token
|
||||
@api_token.touch_last_used!
|
||||
@current_user = @api_token.user
|
||||
set_rls_user_id(@current_user.id)
|
||||
else
|
||||
render json: { error: "Invalid or missing API token" }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user
|
||||
end
|
||||
|
||||
def not_found(exception)
|
||||
render json: { error: exception.message }, status: :not_found
|
||||
end
|
||||
|
||||
def unprocessable_entity(exception)
|
||||
render json: { errors: exception.record.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
49
app/controllers/api/v1/collections_controller.rb
Normal file
49
app/controllers/api/v1/collections_controller.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
module Api
|
||||
module V1
|
||||
class CollectionsController < BaseController
|
||||
before_action :set_collection, only: [ :show, :update, :destroy ]
|
||||
|
||||
def index
|
||||
@collections = current_user.collections.includes(:games).order(:name)
|
||||
render json: @collections, include: :games
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @collection, include: :games
|
||||
end
|
||||
|
||||
def create
|
||||
@collection = current_user.collections.build(collection_params)
|
||||
|
||||
if @collection.save
|
||||
render json: @collection, status: :created
|
||||
else
|
||||
render json: { errors: @collection.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @collection.update(collection_params)
|
||||
render json: @collection
|
||||
else
|
||||
render json: { errors: @collection.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@collection.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collection
|
||||
@collection = current_user.collections.find(params[:id])
|
||||
end
|
||||
|
||||
def collection_params
|
||||
params.require(:collection).permit(:name, :description, :parent_collection_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
95
app/controllers/api/v1/games_controller.rb
Normal file
95
app/controllers/api/v1/games_controller.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
module Api
|
||||
module V1
|
||||
class GamesController < BaseController
|
||||
before_action :set_game, only: [ :show, :update, :destroy ]
|
||||
|
||||
def index
|
||||
@games = current_user.games.includes(:platform, :genres, :collections)
|
||||
|
||||
# Filtering
|
||||
@games = @games.by_platform(params[:platform_id]) if params[:platform_id].present?
|
||||
@games = @games.by_genre(params[:genre_id]) if params[:genre_id].present?
|
||||
@games = @games.where(format: params[:format]) if params[:format].present?
|
||||
@games = @games.where(completion_status: params[:completion_status]) if params[:completion_status].present?
|
||||
@games = @games.search(params[:search]) if params[:search].present?
|
||||
|
||||
# Sorting
|
||||
@games = case params[:sort]
|
||||
when "alphabetical" then @games.alphabetical
|
||||
when "recent" then @games.recent
|
||||
when "rated" then @games.rated
|
||||
else @games.recent
|
||||
end
|
||||
|
||||
# Pagination
|
||||
page = params[:page] || 1
|
||||
per_page = params[:per_page] || 25
|
||||
|
||||
@games = @games.page(page).per(per_page)
|
||||
|
||||
render json: @games, include: [ :platform, :genres, :collections ]
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @game, include: [ :platform, :genres, :collections ]
|
||||
end
|
||||
|
||||
def create
|
||||
@game = current_user.games.build(game_params)
|
||||
|
||||
if @game.save
|
||||
render json: @game, status: :created, include: [ :platform, :genres ]
|
||||
else
|
||||
render json: { errors: @game.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @game.update(game_params)
|
||||
render json: @game, include: [ :platform, :genres, :collections ]
|
||||
else
|
||||
render json: { errors: @game.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@game.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def bulk
|
||||
results = { created: [], failed: [] }
|
||||
games_data = params[:games] || []
|
||||
|
||||
games_data.each do |game_data|
|
||||
game = current_user.games.build(game_data.permit!)
|
||||
if game.save
|
||||
results[:created] << game
|
||||
else
|
||||
results[:failed] << { data: game_data, errors: game.errors.full_messages }
|
||||
end
|
||||
end
|
||||
|
||||
render json: {
|
||||
created: results[:created].count,
|
||||
failed: results[:failed].count,
|
||||
details: results
|
||||
}, status: :created
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_game
|
||||
@game = current_user.games.find(params[:id])
|
||||
end
|
||||
|
||||
def game_params
|
||||
params.require(:game).permit(
|
||||
:title, :platform_id, :format, :date_added, :completion_status,
|
||||
:user_rating, :notes, :condition, :price_paid, :location,
|
||||
:digital_store, :custom_entry, :igdb_id, genre_ids: []
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
17
app/controllers/api/v1/genres_controller.rb
Normal file
17
app/controllers/api/v1/genres_controller.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module Api
|
||||
module V1
|
||||
class GenresController < BaseController
|
||||
skip_before_action :authenticate_api_token, only: [ :index, :show ]
|
||||
|
||||
def index
|
||||
@genres = Genre.order(:name)
|
||||
render json: @genres
|
||||
end
|
||||
|
||||
def show
|
||||
@genre = Genre.find(params[:id])
|
||||
render json: @genre
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
17
app/controllers/api/v1/platforms_controller.rb
Normal file
17
app/controllers/api/v1/platforms_controller.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module Api
|
||||
module V1
|
||||
class PlatformsController < BaseController
|
||||
skip_before_action :authenticate_api_token, only: [ :index, :show ]
|
||||
|
||||
def index
|
||||
@platforms = Platform.order(:name)
|
||||
render json: @platforms
|
||||
end
|
||||
|
||||
def show
|
||||
@platform = Platform.find(params[:id])
|
||||
render json: @platform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
31
app/controllers/api_tokens_controller.rb
Normal file
31
app/controllers/api_tokens_controller.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
class ApiTokensController < ApplicationController
|
||||
before_action :require_authentication
|
||||
|
||||
def index
|
||||
@api_tokens = current_user.api_tokens.order(created_at: :desc)
|
||||
@api_token = ApiToken.new
|
||||
end
|
||||
|
||||
def create
|
||||
@api_token = current_user.api_tokens.build(api_token_params)
|
||||
|
||||
if @api_token.save
|
||||
redirect_to settings_api_tokens_path, notice: "API token created successfully. Make sure to copy it now!"
|
||||
else
|
||||
@api_tokens = current_user.api_tokens.order(created_at: :desc)
|
||||
render :index, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@api_token = current_user.api_tokens.find(params[:id])
|
||||
@api_token.destroy
|
||||
redirect_to settings_api_tokens_path, notice: "API token was deleted."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def api_token_params
|
||||
params.require(:api_token).permit(:name, :expires_at)
|
||||
end
|
||||
end
|
||||
9
app/controllers/application_controller.rb
Normal file
9
app/controllers/application_controller.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
# Changes to the importmap will invalidate the etag for HTML responses
|
||||
stale_when_importmap_changes
|
||||
end
|
||||
65
app/controllers/collections_controller.rb
Normal file
65
app/controllers/collections_controller.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
class CollectionsController < ApplicationController
|
||||
before_action :require_authentication
|
||||
before_action :set_collection, only: [ :show, :edit, :update, :destroy, :games ]
|
||||
|
||||
def index
|
||||
@root_collections = current_user.collections.root_collections.order(:name)
|
||||
end
|
||||
|
||||
def show
|
||||
@games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25)
|
||||
end
|
||||
|
||||
def new
|
||||
@collection = current_user.collections.build
|
||||
@collections = current_user.collections.root_collections.order(:name)
|
||||
end
|
||||
|
||||
def create
|
||||
@collection = current_user.collections.build(collection_params)
|
||||
|
||||
if @collection.save
|
||||
redirect_to @collection, notice: "Collection was successfully created."
|
||||
else
|
||||
@collections = current_user.collections.root_collections.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@collections = current_user.collections.root_collections.where.not(id: @collection.id).order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
if @collection.update(collection_params)
|
||||
redirect_to @collection, notice: "Collection was successfully updated."
|
||||
else
|
||||
@collections = current_user.collections.root_collections.where.not(id: @collection.id).order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@collection.destroy
|
||||
redirect_to collections_path, notice: "Collection was successfully deleted."
|
||||
end
|
||||
|
||||
def games
|
||||
# Same as show, but maybe with different view
|
||||
@games = @collection.games.includes(:platform, :genres).page(params[:page]).per(25)
|
||||
render :show
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collection
|
||||
@collection = current_user.collections.find(params[:id])
|
||||
end
|
||||
|
||||
def collection_params
|
||||
permitted = params.require(:collection).permit(:name, :description, :parent_collection_id)
|
||||
# Convert empty string to nil for parent_collection_id
|
||||
permitted[:parent_collection_id] = nil if permitted[:parent_collection_id].blank?
|
||||
permitted
|
||||
end
|
||||
end
|
||||
0
app/controllers/concerns/.keep
Normal file
0
app/controllers/concerns/.keep
Normal file
66
app/controllers/concerns/authentication.rb
Normal file
66
app/controllers/concerns/authentication.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_current_user
|
||||
helper_method :current_user, :user_signed_in?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
|
||||
end
|
||||
|
||||
def user_signed_in?
|
||||
current_user.present?
|
||||
end
|
||||
|
||||
def require_authentication
|
||||
unless user_signed_in?
|
||||
redirect_to login_path, alert: "You must be signed in to access this page."
|
||||
end
|
||||
end
|
||||
|
||||
def require_no_authentication
|
||||
if user_signed_in?
|
||||
redirect_to root_path, notice: "You are already signed in."
|
||||
end
|
||||
end
|
||||
|
||||
def sign_in(user)
|
||||
reset_session
|
||||
session[:user_id] = user.id
|
||||
set_rls_user_id(user.id)
|
||||
end
|
||||
|
||||
def sign_out
|
||||
reset_session
|
||||
@current_user = nil
|
||||
clear_rls_user_id
|
||||
end
|
||||
|
||||
def set_current_user
|
||||
if current_user
|
||||
set_rls_user_id(current_user.id)
|
||||
else
|
||||
clear_rls_user_id
|
||||
end
|
||||
end
|
||||
|
||||
def set_rls_user_id(user_id)
|
||||
return unless ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
|
||||
ActiveRecord::Base.connection.execute("SET LOCAL app.current_user_id = #{ActiveRecord::Base.connection.quote(user_id)}")
|
||||
rescue ActiveRecord::StatementInvalid => e
|
||||
Rails.logger.warn("Failed to set RLS user_id: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def clear_rls_user_id
|
||||
return unless ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
|
||||
ActiveRecord::Base.connection.execute("RESET app.current_user_id")
|
||||
rescue ActiveRecord::StatementInvalid => e
|
||||
Rails.logger.warn("Failed to clear RLS user_id: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
26
app/controllers/dashboard_controller.rb
Normal file
26
app/controllers/dashboard_controller.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class DashboardController < ApplicationController
|
||||
before_action :require_authentication
|
||||
|
||||
def index
|
||||
@recently_added_games = current_user.games.recent.limit(5)
|
||||
@currently_playing_games = current_user.games.currently_playing.limit(5)
|
||||
@total_games = current_user.games.count
|
||||
@physical_games = current_user.games.physical_games.count
|
||||
@digital_games = current_user.games.digital_games.count
|
||||
@completed_games = current_user.games.completed.count
|
||||
@backlog_games = current_user.games.backlog.count
|
||||
@total_spent = current_user.games.sum(:price_paid) || 0
|
||||
|
||||
@games_by_platform = current_user.games.joins(:platform)
|
||||
.group("platforms.name")
|
||||
.count
|
||||
.sort_by { |_, count| -count }
|
||||
.first(5)
|
||||
|
||||
@games_by_genre = current_user.games.joins(:genres)
|
||||
.group("genres.name")
|
||||
.count
|
||||
.sort_by { |_, count| -count }
|
||||
.first(5)
|
||||
end
|
||||
end
|
||||
384
app/controllers/games_controller.rb
Normal file
384
app/controllers/games_controller.rb
Normal file
@@ -0,0 +1,384 @@
|
||||
class GamesController < ApplicationController
|
||||
before_action :require_authentication
|
||||
before_action :set_game, only: [ :show, :edit, :update, :destroy ]
|
||||
|
||||
def index
|
||||
@games = current_user.games.includes(:platform, :genres, :collections)
|
||||
|
||||
# Filtering
|
||||
@games = @games.by_platform(params[:platform_id]) if params[:platform_id].present?
|
||||
@games = @games.by_genre(params[:genre_id]) if params[:genre_id].present?
|
||||
@games = @games.where(format: params[:format]) if params[:format].present?
|
||||
@games = @games.where(completion_status: params[:completion_status]) if params[:completion_status].present?
|
||||
@games = @games.search(params[:search]) if params[:search].present?
|
||||
|
||||
# Sorting
|
||||
@games = case params[:sort]
|
||||
when "alphabetical" then @games.alphabetical
|
||||
when "recent" then @games.recent
|
||||
when "rated" then @games.rated
|
||||
else @games.alphabetical
|
||||
end
|
||||
|
||||
@games = @games.page(params[:page]).per(25)
|
||||
|
||||
# For filters
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@game = current_user.games.build
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
@collections = current_user.collections.order(:name)
|
||||
end
|
||||
|
||||
def create
|
||||
@game = current_user.games.build(game_params)
|
||||
|
||||
if @game.save
|
||||
# If game was created with IGDB ID, sync the metadata
|
||||
sync_igdb_metadata_after_create if @game.igdb_id.present?
|
||||
|
||||
redirect_to @game, notice: "Game was successfully created."
|
||||
else
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
@collections = current_user.collections.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
@collections = current_user.collections.order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
if @game.update(game_params)
|
||||
redirect_to @game, notice: "Game was successfully updated."
|
||||
else
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
@collections = current_user.collections.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@game.destroy
|
||||
redirect_to games_path, notice: "Game was successfully deleted."
|
||||
end
|
||||
|
||||
def import
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
@collections = current_user.collections.order(:name)
|
||||
end
|
||||
|
||||
def bulk_edit
|
||||
@game_ids = params[:game_ids] || []
|
||||
|
||||
if @game_ids.empty?
|
||||
redirect_to games_path, alert: "Please select at least one game to edit."
|
||||
return
|
||||
end
|
||||
|
||||
@games = current_user.games.where(id: @game_ids)
|
||||
@platforms = Platform.order(:name)
|
||||
@genres = Genre.order(:name)
|
||||
@collections = current_user.collections.order(:name)
|
||||
end
|
||||
|
||||
def bulk_update
|
||||
@game_ids = params[:game_ids] || []
|
||||
|
||||
if @game_ids.empty?
|
||||
redirect_to games_path, alert: "No games selected."
|
||||
return
|
||||
end
|
||||
|
||||
@games = current_user.games.where(id: @game_ids)
|
||||
updated_count = 0
|
||||
|
||||
@games.each do |game|
|
||||
updates = {}
|
||||
|
||||
# Only update fields that have values provided
|
||||
updates[:completion_status] = params[:completion_status] if params[:completion_status].present?
|
||||
updates[:location] = params[:location] if params[:location].present?
|
||||
updates[:condition] = params[:condition] if params[:condition].present?
|
||||
|
||||
# Handle collection assignment
|
||||
if params[:collection_action].present?
|
||||
case params[:collection_action]
|
||||
when "add"
|
||||
if params[:collection_ids].present?
|
||||
game.collection_ids = (game.collection_ids + params[:collection_ids].map(&:to_i)).uniq
|
||||
end
|
||||
when "remove"
|
||||
if params[:collection_ids].present?
|
||||
game.collection_ids = game.collection_ids - params[:collection_ids].map(&:to_i)
|
||||
end
|
||||
when "replace"
|
||||
game.collection_ids = params[:collection_ids] if params[:collection_ids].present?
|
||||
end
|
||||
end
|
||||
|
||||
# Handle genre assignment
|
||||
if params[:genre_action].present?
|
||||
case params[:genre_action]
|
||||
when "add"
|
||||
if params[:genre_ids].present?
|
||||
game.genre_ids = (game.genre_ids + params[:genre_ids].map(&:to_i)).uniq
|
||||
end
|
||||
when "remove"
|
||||
if params[:genre_ids].present?
|
||||
game.genre_ids = game.genre_ids - params[:genre_ids].map(&:to_i)
|
||||
end
|
||||
when "replace"
|
||||
game.genre_ids = params[:genre_ids] if params[:genre_ids].present?
|
||||
end
|
||||
end
|
||||
|
||||
if game.update(updates)
|
||||
updated_count += 1
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to games_path, notice: "Successfully updated #{updated_count} game(s)."
|
||||
end
|
||||
|
||||
def bulk_create
|
||||
require "csv"
|
||||
|
||||
results = { created: 0, failed: 0, errors: [] }
|
||||
|
||||
if params[:csv_file].present?
|
||||
csv_text = params[:csv_file].read
|
||||
csv = CSV.parse(csv_text, headers: true)
|
||||
|
||||
csv.each_with_index do |row, index|
|
||||
platform = Platform.find_by(name: row["platform"]) || Platform.find_by(abbreviation: row["platform"])
|
||||
|
||||
unless platform
|
||||
results[:failed] += 1
|
||||
results[:errors] << "Row #{index + 2}: Platform '#{row['platform']}' not found"
|
||||
next
|
||||
end
|
||||
|
||||
game = current_user.games.build(
|
||||
title: row["title"],
|
||||
platform: platform,
|
||||
format: row["format"]&.downcase || "physical",
|
||||
date_added: row["date_added"] || Date.current,
|
||||
completion_status: row["completion_status"]&.downcase,
|
||||
user_rating: row["user_rating"],
|
||||
condition: row["condition"]&.downcase,
|
||||
price_paid: row["price_paid"],
|
||||
location: row["location"],
|
||||
digital_store: row["digital_store"],
|
||||
notes: row["notes"]
|
||||
)
|
||||
|
||||
# Handle genres
|
||||
if row["genres"].present?
|
||||
genre_names = row["genres"].split("|").map(&:strip)
|
||||
genres = Genre.where(name: genre_names)
|
||||
game.genres = genres
|
||||
end
|
||||
|
||||
if game.save
|
||||
results[:created] += 1
|
||||
else
|
||||
results[:failed] += 1
|
||||
results[:errors] << "Row #{index + 2}: #{game.errors.full_messages.join(", ")}"
|
||||
end
|
||||
end
|
||||
|
||||
flash[:notice] = "Created #{results[:created]} games. Failed: #{results[:failed]}"
|
||||
flash[:alert] = results[:errors].join("<br>").html_safe if results[:errors].any?
|
||||
redirect_to games_path
|
||||
else
|
||||
redirect_to import_games_path, alert: "Please select a CSV file to upload."
|
||||
end
|
||||
end
|
||||
|
||||
def search_igdb
|
||||
query = params[:q].to_s.strip
|
||||
platform_id = params[:platform_id]
|
||||
|
||||
if query.length < 2
|
||||
render json: []
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
service = IgdbService.new
|
||||
platform = platform_id.present? ? Platform.find_by(id: platform_id) : nil
|
||||
|
||||
# Search IGDB (limit to 10 results for autocomplete)
|
||||
results = service.search_game(query, platform, 10)
|
||||
|
||||
# Format results for autocomplete
|
||||
formatted_results = results.map do |result|
|
||||
# Map IGDB genres to our local genre IDs
|
||||
genre_ids = map_igdb_genres_to_ids(result[:genres] || [])
|
||||
|
||||
{
|
||||
igdb_id: result[:igdb_id],
|
||||
name: result[:name],
|
||||
platform: result[:platform_name],
|
||||
year: result[:release_year],
|
||||
cover_url: result[:cover_url],
|
||||
summary: result[:summary],
|
||||
genres: result[:genres],
|
||||
genre_ids: genre_ids,
|
||||
confidence: result[:confidence_score]
|
||||
}
|
||||
end
|
||||
|
||||
render json: formatted_results
|
||||
rescue => e
|
||||
Rails.logger.error("IGDB search error: #{e.message}")
|
||||
render json: [], status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
def search_locations
|
||||
query = params[:q].to_s.strip
|
||||
|
||||
# Get unique locations from user's games that match the query
|
||||
locations = current_user.games
|
||||
.where.not(location: [nil, ""])
|
||||
.where("location ILIKE ?", "%#{query}%")
|
||||
.select(:location)
|
||||
.distinct
|
||||
.order(:location)
|
||||
.limit(10)
|
||||
.pluck(:location)
|
||||
|
||||
render json: locations
|
||||
end
|
||||
|
||||
def search_stores
|
||||
query = params[:q].to_s.strip
|
||||
|
||||
# Get unique digital stores from user's games that match the query
|
||||
stores = current_user.games
|
||||
.where.not(digital_store: [nil, ""])
|
||||
.where("digital_store ILIKE ?", "%#{query}%")
|
||||
.select(:digital_store)
|
||||
.distinct
|
||||
.order(:digital_store)
|
||||
.limit(10)
|
||||
.pluck(:digital_store)
|
||||
|
||||
render json: stores
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_game
|
||||
@game = current_user.games.includes(:igdb_game).find(params[:id])
|
||||
end
|
||||
|
||||
def sync_igdb_metadata_after_create
|
||||
# Fetch full game data from IGDB
|
||||
service = IgdbService.new
|
||||
igdb_data = service.get_game(@game.igdb_id)
|
||||
|
||||
return unless igdb_data
|
||||
|
||||
# Create or update IgdbGame record
|
||||
igdb_game = IgdbGame.find_or_create_by!(igdb_id: @game.igdb_id) do |ig|
|
||||
ig.name = igdb_data["name"]
|
||||
ig.slug = igdb_data["slug"]
|
||||
ig.summary = igdb_data["summary"]
|
||||
ig.first_release_date = igdb_data["first_release_date"] ? Time.at(igdb_data["first_release_date"]).to_date : nil
|
||||
|
||||
# Extract cover URL
|
||||
cover_url = igdb_data.dig("cover", "url")&.split("/")&.last&.sub(".jpg", "")
|
||||
ig.cover_url = cover_url
|
||||
|
||||
ig.last_synced_at = Time.current
|
||||
end
|
||||
|
||||
igdb_game.increment_match_count!
|
||||
|
||||
# Update game with IGDB metadata
|
||||
@game.update(
|
||||
igdb_matched_at: Time.current,
|
||||
igdb_match_status: "matched",
|
||||
igdb_match_confidence: 100.0
|
||||
)
|
||||
|
||||
# Map and assign genres
|
||||
if igdb_data["genres"].present?
|
||||
genre_names = igdb_data["genres"].map { |g| g["name"] }
|
||||
assign_igdb_genres_to_game(genre_names)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to sync IGDB metadata: #{e.message}")
|
||||
end
|
||||
|
||||
def map_igdb_genres_to_ids(genre_names)
|
||||
return [] if genre_names.blank?
|
||||
|
||||
# Genre mapping (same as in IgdbMatchSuggestion)
|
||||
genre_mappings = {
|
||||
"Role-playing (RPG)" => "RPG",
|
||||
"Fighting" => "Fighting",
|
||||
"Shooter" => "Shooter",
|
||||
"Platform" => "Platformer",
|
||||
"Puzzle" => "Puzzle",
|
||||
"Racing" => "Racing",
|
||||
"Real Time Strategy (RTS)" => "Strategy",
|
||||
"Simulator" => "Simulation",
|
||||
"Sport" => "Sports",
|
||||
"Strategy" => "Strategy",
|
||||
"Adventure" => "Adventure",
|
||||
"Indie" => "Indie",
|
||||
"Arcade" => "Arcade",
|
||||
"Hack and slash/Beat 'em up" => "Action"
|
||||
}
|
||||
|
||||
genre_ids = []
|
||||
genre_names.each do |igdb_genre_name|
|
||||
# Try exact match
|
||||
local_genre = Genre.find_by("LOWER(name) = ?", igdb_genre_name.downcase)
|
||||
|
||||
# Try mapped name
|
||||
if local_genre.nil? && genre_mappings[igdb_genre_name]
|
||||
mapped_name = genre_mappings[igdb_genre_name]
|
||||
local_genre = Genre.find_by("LOWER(name) = ?", mapped_name.downcase)
|
||||
end
|
||||
|
||||
genre_ids << local_genre.id if local_genre
|
||||
end
|
||||
|
||||
genre_ids
|
||||
end
|
||||
|
||||
def assign_igdb_genres_to_game(genre_names)
|
||||
genre_ids = map_igdb_genres_to_ids(genre_names)
|
||||
|
||||
genre_ids.each do |genre_id|
|
||||
genre = Genre.find(genre_id)
|
||||
@game.genres << genre unless @game.genres.include?(genre)
|
||||
end
|
||||
end
|
||||
|
||||
def game_params
|
||||
params.require(:game).permit(
|
||||
:title, :platform_id, :format, :date_added, :completion_status,
|
||||
:user_rating, :notes, :condition, :price_paid, :location,
|
||||
:digital_store, :custom_entry, :igdb_id, genre_ids: [], collection_ids: []
|
||||
)
|
||||
end
|
||||
end
|
||||
69
app/controllers/igdb_matches_controller.rb
Normal file
69
app/controllers/igdb_matches_controller.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
class IgdbMatchesController < ApplicationController
|
||||
before_action :require_authentication
|
||||
before_action :set_suggestion, only: [ :approve, :reject ]
|
||||
|
||||
def index
|
||||
# Only show suggestions for games that haven't been matched yet
|
||||
@pending_suggestions = current_user.igdb_match_suggestions
|
||||
.pending_review
|
||||
.joins(:game)
|
||||
.where(games: { igdb_id: nil })
|
||||
.includes(game: :platform)
|
||||
.group_by(&:game)
|
||||
|
||||
@matched_games = current_user.games.igdb_matched.count
|
||||
@unmatched_games = current_user.games.igdb_unmatched.count
|
||||
@pending_review_count = current_user.igdb_match_suggestions
|
||||
.status_pending
|
||||
.joins(:game)
|
||||
.where(games: { igdb_id: nil })
|
||||
.count
|
||||
end
|
||||
|
||||
def approve
|
||||
if @suggestion.approve!
|
||||
redirect_to igdb_matches_path, notice: "Match approved! #{@suggestion.game.title} linked to IGDB."
|
||||
else
|
||||
redirect_to igdb_matches_path, alert: "Failed to approve match."
|
||||
end
|
||||
end
|
||||
|
||||
def reject
|
||||
if @suggestion.reject!
|
||||
redirect_to igdb_matches_path, notice: "Match rejected."
|
||||
else
|
||||
redirect_to igdb_matches_path, alert: "Failed to reject match."
|
||||
end
|
||||
end
|
||||
|
||||
def sync_now
|
||||
# Auto-enable sync if not already enabled
|
||||
unless current_user.igdb_sync_enabled?
|
||||
current_user.update(igdb_sync_enabled: true)
|
||||
end
|
||||
|
||||
# Check if games need syncing
|
||||
unmatched_count = current_user.games.igdb_unmatched.where(igdb_match_status: [nil, "failed"]).count
|
||||
|
||||
if unmatched_count == 0
|
||||
redirect_to igdb_matches_path, alert: "All games are already matched or being processed!"
|
||||
return
|
||||
end
|
||||
|
||||
# Try to run job immediately for faster feedback
|
||||
begin
|
||||
IgdbSyncJob.perform_later
|
||||
|
||||
redirect_to igdb_matches_path, notice: "IGDB sync started! Processing #{unmatched_count} games. This may take a few minutes - the page will auto-refresh."
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to start IGDB sync: #{e.message}")
|
||||
redirect_to igdb_matches_path, alert: "Failed to start sync. Make sure background jobs are running."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_suggestion
|
||||
@suggestion = current_user.igdb_match_suggestions.find(params[:id])
|
||||
end
|
||||
end
|
||||
59
app/controllers/items_controller.rb
Normal file
59
app/controllers/items_controller.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class ItemsController < ApplicationController
|
||||
before_action :require_authentication
|
||||
before_action :set_item, only: [ :show, :edit, :update, :destroy ]
|
||||
|
||||
def index
|
||||
@items = current_user.items.includes(:platform).order(date_added: :desc).page(params[:page]).per(25)
|
||||
@platforms = Platform.order(:name)
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@item = current_user.items.build
|
||||
@platforms = Platform.order(:name)
|
||||
end
|
||||
|
||||
def create
|
||||
@item = current_user.items.build(item_params)
|
||||
|
||||
if @item.save
|
||||
redirect_to @item, notice: "Item was successfully created."
|
||||
else
|
||||
@platforms = Platform.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@platforms = Platform.order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
if @item.update(item_params)
|
||||
redirect_to @item, notice: "Item was successfully updated."
|
||||
else
|
||||
@platforms = Platform.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@item.destroy
|
||||
redirect_to items_path, notice: "Item was successfully deleted."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_item
|
||||
@item = current_user.items.find(params[:id])
|
||||
end
|
||||
|
||||
def item_params
|
||||
params.require(:item).permit(
|
||||
:name, :item_type, :platform_id, :condition, :price_paid,
|
||||
:location, :date_added, :notes, :igdb_id
|
||||
)
|
||||
end
|
||||
end
|
||||
11
app/controllers/pages_controller.rb
Normal file
11
app/controllers/pages_controller.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class PagesController < ApplicationController
|
||||
def home
|
||||
if user_signed_in?
|
||||
redirect_to dashboard_path
|
||||
end
|
||||
end
|
||||
|
||||
def api_docs
|
||||
# API documentation page - publicly accessible
|
||||
end
|
||||
end
|
||||
46
app/controllers/password_resets_controller.rb
Normal file
46
app/controllers/password_resets_controller.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
class PasswordResetsController < ApplicationController
|
||||
before_action :require_no_authentication, only: [ :new, :create, :edit, :update ]
|
||||
before_action :set_user_by_token, only: [ :edit, :update ]
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.find_by(email: params[:email].downcase)
|
||||
|
||||
if user
|
||||
user.generate_password_reset_token
|
||||
PasswordResetMailer.reset_password(user).deliver_later
|
||||
end
|
||||
|
||||
# Always show success message to prevent email enumeration
|
||||
redirect_to login_path, notice: "If an account exists with that email, you will receive password reset instructions."
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @user.update(password_params)
|
||||
@user.update_columns(password_reset_token: nil, password_reset_sent_at: nil)
|
||||
sign_in(@user)
|
||||
redirect_to dashboard_path, notice: "Your password has been reset successfully."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user_by_token
|
||||
@user = User.find_by(password_reset_token: params[:id])
|
||||
|
||||
unless @user && !@user.password_reset_expired?
|
||||
redirect_to new_password_reset_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
end
|
||||
|
||||
def password_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
end
|
||||
24
app/controllers/profiles_controller.rb
Normal file
24
app/controllers/profiles_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class ProfilesController < ApplicationController
|
||||
def show
|
||||
@user = User.find_by!(username: params[:username])
|
||||
|
||||
unless @user.profile_public? || @user == current_user
|
||||
redirect_to root_path, alert: "This profile is private."
|
||||
return
|
||||
end
|
||||
|
||||
@total_games = @user.games.count
|
||||
@physical_games = @user.games.physical_games.count
|
||||
@digital_games = @user.games.digital_games.count
|
||||
@completed_games = @user.games.completed.count
|
||||
|
||||
@games_by_platform = @user.games.joins(:platform)
|
||||
.group("platforms.name")
|
||||
.count
|
||||
.sort_by { |_, count| -count }
|
||||
.first(10)
|
||||
|
||||
@public_collections = @user.collections.root_collections.order(:name)
|
||||
@recent_games = @user.games.includes(:platform).recent.limit(10)
|
||||
end
|
||||
end
|
||||
24
app/controllers/sessions_controller.rb
Normal file
24
app/controllers/sessions_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class SessionsController < ApplicationController
|
||||
before_action :require_no_authentication, only: [ :new, :create ]
|
||||
before_action :require_authentication, only: [ :destroy ]
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.find_by(email: params[:email].downcase)
|
||||
|
||||
if user && user.authenticate(params[:password])
|
||||
sign_in(user)
|
||||
redirect_to dashboard_path, notice: "Welcome back, #{user.username}!"
|
||||
else
|
||||
flash.now[:alert] = "Invalid email or password"
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
sign_out
|
||||
redirect_to root_path, notice: "You have been signed out."
|
||||
end
|
||||
end
|
||||
46
app/controllers/users_controller.rb
Normal file
46
app/controllers/users_controller.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
class UsersController < ApplicationController
|
||||
before_action :require_no_authentication, only: [ :new, :create ]
|
||||
before_action :require_authentication, only: [ :edit, :update, :settings ]
|
||||
before_action :set_user, only: [ :edit, :update ]
|
||||
|
||||
def new
|
||||
@user = User.new
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
|
||||
if @user.save
|
||||
sign_in(@user)
|
||||
redirect_to dashboard_path, notice: "Welcome to TurboVault, #{@user.username}!"
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @user.update(user_params)
|
||||
redirect_to settings_path, notice: "Your profile has been updated."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def settings
|
||||
@user = current_user
|
||||
@api_tokens = current_user.api_tokens.order(created_at: :desc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = current_user
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:email, :username, :password, :password_confirmation, :bio, :profile_public, :igdb_sync_enabled, :theme)
|
||||
end
|
||||
end
|
||||
2
app/helpers/api_tokens_helper.rb
Normal file
2
app/helpers/api_tokens_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module ApiTokensHelper
|
||||
end
|
||||
2
app/helpers/application_helper.rb
Normal file
2
app/helpers/application_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module ApplicationHelper
|
||||
end
|
||||
2
app/helpers/collections_helper.rb
Normal file
2
app/helpers/collections_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module CollectionsHelper
|
||||
end
|
||||
2
app/helpers/dashboard_helper.rb
Normal file
2
app/helpers/dashboard_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module DashboardHelper
|
||||
end
|
||||
12
app/helpers/games_helper.rb
Normal file
12
app/helpers/games_helper.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module GamesHelper
|
||||
def filter_params_for_sort(sort_value)
|
||||
{
|
||||
sort: sort_value,
|
||||
search: params[:search],
|
||||
platform_id: params[:platform_id],
|
||||
genre_id: params[:genre_id],
|
||||
format: params[:format],
|
||||
completion_status: params[:completion_status]
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
2
app/helpers/igdb_matches_helper.rb
Normal file
2
app/helpers/igdb_matches_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module IgdbMatchesHelper
|
||||
end
|
||||
2
app/helpers/items_helper.rb
Normal file
2
app/helpers/items_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module ItemsHelper
|
||||
end
|
||||
2
app/helpers/pages_helper.rb
Normal file
2
app/helpers/pages_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module PagesHelper
|
||||
end
|
||||
2
app/helpers/password_resets_helper.rb
Normal file
2
app/helpers/password_resets_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module PasswordResetsHelper
|
||||
end
|
||||
2
app/helpers/profiles_helper.rb
Normal file
2
app/helpers/profiles_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module ProfilesHelper
|
||||
end
|
||||
2
app/helpers/sessions_helper.rb
Normal file
2
app/helpers/sessions_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module SessionsHelper
|
||||
end
|
||||
2
app/helpers/users_helper.rb
Normal file
2
app/helpers/users_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module UsersHelper
|
||||
end
|
||||
3
app/javascript/application.js
Normal file
3
app/javascript/application.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
import "@hotwired/turbo-rails"
|
||||
import "controllers"
|
||||
9
app/javascript/controllers/application.js
Normal file
9
app/javascript/controllers/application.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
|
||||
const application = Application.start()
|
||||
|
||||
// Configure Stimulus development experience
|
||||
application.debug = true
|
||||
window.Stimulus = application
|
||||
|
||||
export { application }
|
||||
7
app/javascript/controllers/hello_controller.js
Normal file
7
app/javascript/controllers/hello_controller.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = "Hello World!"
|
||||
}
|
||||
}
|
||||
282
app/javascript/controllers/igdb_search_controller.js
Normal file
282
app/javascript/controllers/igdb_search_controller.js
Normal file
@@ -0,0 +1,282 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"query",
|
||||
"results",
|
||||
"igdbId",
|
||||
"title",
|
||||
"summary",
|
||||
"platformSelect",
|
||||
"customGameToggle",
|
||||
"igdbSection",
|
||||
"manualSection"
|
||||
]
|
||||
|
||||
static values = {
|
||||
url: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
console.log("IGDB Search controller connected!")
|
||||
console.log("Search URL:", this.urlValue)
|
||||
console.log("Query target exists:", this.hasQueryTarget)
|
||||
console.log("Results target exists:", this.hasResultsTarget)
|
||||
console.log("Title target exists:", this.hasTitleTarget)
|
||||
this.timeout = null
|
||||
this.selectedGame = null
|
||||
this.searchResults = []
|
||||
}
|
||||
|
||||
search() {
|
||||
console.log("Search triggered")
|
||||
clearTimeout(this.timeout)
|
||||
|
||||
const query = this.queryTarget.value.trim()
|
||||
console.log("Query:", query)
|
||||
|
||||
if (query.length < 2) {
|
||||
console.log("Query too short, hiding results")
|
||||
this.hideResults()
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Starting search timeout...")
|
||||
this.timeout = setTimeout(() => {
|
||||
this.performSearch(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async performSearch(query) {
|
||||
const platformId = this.hasPlatformSelectTarget ? this.platformSelectTarget.value : ""
|
||||
const url = `${this.urlValue}?q=${encodeURIComponent(query)}&platform_id=${platformId}`
|
||||
|
||||
console.log("Fetching:", url)
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
console.log("Response status:", response.status)
|
||||
const results = await response.json()
|
||||
console.log("Results:", results)
|
||||
this.displayResults(results)
|
||||
} catch (error) {
|
||||
console.error("IGDB search error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
displayResults(results) {
|
||||
if (results.length === 0) {
|
||||
this.resultsTarget.innerHTML = `
|
||||
<div class="p-4 text-sm text-gray-500 text-center border-t">
|
||||
No games found. <a href="#" data-action="click->igdb-search#showManualEntry" class="text-indigo-600 hover:text-indigo-800">Add custom game</a>
|
||||
</div>
|
||||
`
|
||||
this.resultsTarget.classList.remove("hidden")
|
||||
return
|
||||
}
|
||||
|
||||
// Store results in memory and use indices instead of embedding JSON
|
||||
this.searchResults = results
|
||||
|
||||
const html = results.map((game, index) => {
|
||||
// HTML escape function
|
||||
const escapeHtml = (text) => {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="p-3 hover:bg-gray-50 cursor-pointer border-b last:border-b-0 flex gap-3"
|
||||
data-action="click->igdb-search#selectGame"
|
||||
data-index="${index}">
|
||||
${game.cover_url ? `
|
||||
<img src="https://images.igdb.com/igdb/image/upload/t_thumb/${game.cover_url}.jpg"
|
||||
class="w-12 h-16 object-cover rounded"
|
||||
alt="${escapeHtml(game.name)}">
|
||||
` : `
|
||||
<div class="w-12 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
<span class="text-gray-400 text-xs">No<br>Cover</span>
|
||||
</div>
|
||||
`}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-gray-900 truncate">${escapeHtml(game.name)}</div>
|
||||
<div class="text-sm text-gray-600">${escapeHtml(game.platform)}${game.year ? ` • ${game.year}` : ''}</div>
|
||||
${game.genres && game.genres.length > 0 ? `
|
||||
<div class="flex gap-1 mt-1 flex-wrap">
|
||||
${game.genres.slice(0, 3).map(genre => `
|
||||
<span class="px-1.5 py-0.5 bg-indigo-100 text-indigo-700 text-xs rounded">${escapeHtml(genre)}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs text-gray-500">${Math.round(game.confidence)}% match</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
this.resultsTarget.innerHTML = html
|
||||
this.resultsTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
selectGame(event) {
|
||||
const index = parseInt(event.currentTarget.dataset.index)
|
||||
const gameData = this.searchResults[index]
|
||||
this.selectedGame = gameData
|
||||
|
||||
console.log("Selected game:", gameData)
|
||||
|
||||
// Fill in the form fields
|
||||
if (this.hasTitleTarget) {
|
||||
this.titleTarget.value = gameData.name
|
||||
console.log("Set title to:", gameData.name)
|
||||
} else {
|
||||
console.warn("Title target not found")
|
||||
}
|
||||
|
||||
if (this.hasIgdbIdTarget) {
|
||||
this.igdbIdTarget.value = gameData.igdb_id
|
||||
console.log("Set IGDB ID to:", gameData.igdb_id)
|
||||
} else {
|
||||
console.warn("IGDB ID target not found")
|
||||
}
|
||||
|
||||
if (this.hasSummaryTarget && gameData.summary) {
|
||||
this.summaryTarget.value = gameData.summary
|
||||
console.log("Set summary")
|
||||
}
|
||||
|
||||
// Set genres
|
||||
if (gameData.genre_ids && gameData.genre_ids.length > 0) {
|
||||
this.setGenres(gameData.genre_ids)
|
||||
}
|
||||
|
||||
// Update the query field to show selected game
|
||||
this.queryTarget.value = gameData.name
|
||||
|
||||
// Hide results
|
||||
this.hideResults()
|
||||
|
||||
// Show IGDB badge/info
|
||||
this.showIgdbInfo(gameData)
|
||||
}
|
||||
|
||||
setGenres(genreIds) {
|
||||
console.log("Setting genres:", genreIds)
|
||||
|
||||
// Find all genre checkboxes
|
||||
const genreCheckboxes = document.querySelectorAll('input[name="game[genre_ids][]"]')
|
||||
|
||||
// Uncheck all first
|
||||
genreCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = false
|
||||
})
|
||||
|
||||
// Check the matched genres
|
||||
genreIds.forEach(genreId => {
|
||||
const checkbox = document.querySelector(`input[name="game[genre_ids][]"][value="${genreId}"]`)
|
||||
if (checkbox) {
|
||||
checkbox.checked = true
|
||||
console.log("Checked genre:", genreId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
showIgdbInfo(gameData) {
|
||||
// Add a visual indicator that this is from IGDB
|
||||
const badge = document.createElement('div')
|
||||
badge.className = 'mt-2 space-y-2'
|
||||
|
||||
let genreText = ''
|
||||
if (gameData.genres && gameData.genres.length > 0) {
|
||||
genreText = `
|
||||
<div class="text-xs text-green-700">
|
||||
<strong>Genres auto-set:</strong> ${gameData.genres.join(', ')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
badge.innerHTML = `
|
||||
<div class="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 text-xs rounded">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path 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"/>
|
||||
</svg>
|
||||
IGDB Match (${Math.round(gameData.confidence)}% confidence)
|
||||
</div>
|
||||
${genreText}
|
||||
`
|
||||
|
||||
// Insert after query field
|
||||
const existingBadge = this.queryTarget.parentElement.querySelector('.igdb-match-badge')
|
||||
if (existingBadge) {
|
||||
existingBadge.remove()
|
||||
}
|
||||
badge.classList.add('igdb-match-badge')
|
||||
this.queryTarget.parentElement.appendChild(badge)
|
||||
}
|
||||
|
||||
showManualEntry(event) {
|
||||
event.preventDefault()
|
||||
|
||||
// Clear IGDB fields
|
||||
if (this.hasIgdbIdTarget) {
|
||||
this.igdbIdTarget.value = ""
|
||||
}
|
||||
|
||||
// Focus on title field
|
||||
if (this.hasTitleTarget) {
|
||||
this.titleTarget.focus()
|
||||
}
|
||||
|
||||
this.hideResults()
|
||||
|
||||
// Remove IGDB badge
|
||||
const badge = this.queryTarget.parentElement.querySelector('.igdb-match-badge')
|
||||
if (badge) {
|
||||
badge.remove()
|
||||
}
|
||||
}
|
||||
|
||||
hideResults() {
|
||||
if (this.hasResultsTarget) {
|
||||
this.resultsTarget.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.queryTarget.value = ""
|
||||
this.hideResults()
|
||||
|
||||
if (this.hasTitleTarget) {
|
||||
this.titleTarget.value = ""
|
||||
}
|
||||
|
||||
if (this.hasIgdbIdTarget) {
|
||||
this.igdbIdTarget.value = ""
|
||||
}
|
||||
|
||||
if (this.hasSummaryTarget) {
|
||||
this.summaryTarget.value = ""
|
||||
}
|
||||
|
||||
// Uncheck all genres
|
||||
const genreCheckboxes = document.querySelectorAll('input[name="game[genre_ids][]"]')
|
||||
genreCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = false
|
||||
})
|
||||
|
||||
const badge = this.queryTarget.parentElement.querySelector('.igdb-match-badge')
|
||||
if (badge) {
|
||||
badge.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// Hide results when clicking outside
|
||||
clickOutside(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
this.hideResults()
|
||||
}
|
||||
}
|
||||
}
|
||||
4
app/javascript/controllers/index.js
Normal file
4
app/javascript/controllers/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// Import and register all your controllers from the importmap via controllers/**/*_controller
|
||||
import { application } from "controllers/application"
|
||||
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||
eagerLoadControllersFrom("controllers", application)
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "results"]
|
||||
static values = { url: String }
|
||||
|
||||
connect() {
|
||||
this.timeout = null
|
||||
console.log("Location autocomplete connected")
|
||||
}
|
||||
|
||||
search() {
|
||||
clearTimeout(this.timeout)
|
||||
|
||||
const query = this.inputTarget.value.trim()
|
||||
|
||||
if (query.length < 1) {
|
||||
this.hideResults()
|
||||
return
|
||||
}
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.performSearch(query)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
async performSearch(query) {
|
||||
const url = `${this.urlValue}?q=${encodeURIComponent(query)}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const locations = await response.json()
|
||||
this.displayResults(locations)
|
||||
} catch (error) {
|
||||
console.error("Location search error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
displayResults(locations) {
|
||||
if (locations.length === 0) {
|
||||
this.hideResults()
|
||||
return
|
||||
}
|
||||
|
||||
const html = locations.map(location => `
|
||||
<div class="px-4 py-2 hover:bg-indigo-50 cursor-pointer text-sm"
|
||||
data-action="click->location-autocomplete#selectLocation"
|
||||
data-location="${this.escapeHtml(location)}">
|
||||
${this.escapeHtml(location)}
|
||||
</div>
|
||||
`).join('')
|
||||
|
||||
this.resultsTarget.innerHTML = html
|
||||
this.resultsTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
selectLocation(event) {
|
||||
const location = event.currentTarget.dataset.location
|
||||
this.inputTarget.value = location
|
||||
this.hideResults()
|
||||
}
|
||||
|
||||
hideResults() {
|
||||
if (this.hasResultsTarget) {
|
||||
this.resultsTarget.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
// Hide results when clicking outside
|
||||
clickOutside(event) {
|
||||
if (!this.element.contains(event.target)) {
|
||||
this.hideResults()
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/jobs/application_job.rb
Normal file
7
app/jobs/application_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
end
|
||||
138
app/jobs/igdb_sync_job.rb
Normal file
138
app/jobs/igdb_sync_job.rb
Normal file
@@ -0,0 +1,138 @@
|
||||
class IgdbSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
# Ensure only one instance runs at a time
|
||||
def self.running?
|
||||
Rails.cache.exist?("igdb_sync_job:running")
|
||||
end
|
||||
|
||||
def self.mark_running!
|
||||
Rails.cache.write("igdb_sync_job:running", true, expires_in: 2.hours)
|
||||
end
|
||||
|
||||
def self.mark_finished!
|
||||
Rails.cache.delete("igdb_sync_job:running")
|
||||
end
|
||||
|
||||
def perform
|
||||
# Prevent multiple instances
|
||||
if self.class.running?
|
||||
Rails.logger.info("IgdbSyncJob already running, skipping...")
|
||||
return
|
||||
end
|
||||
|
||||
self.class.mark_running!
|
||||
|
||||
begin
|
||||
sync_users_with_igdb
|
||||
ensure
|
||||
self.class.mark_finished!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_users_with_igdb
|
||||
users = User.where(igdb_sync_enabled: true)
|
||||
|
||||
Rails.logger.info("Starting IGDB sync for #{users.count} users")
|
||||
|
||||
users.find_each do |user|
|
||||
sync_user_games(user)
|
||||
rescue => e
|
||||
Rails.logger.error("Error syncing user #{user.id}: #{e.message}")
|
||||
next
|
||||
end
|
||||
|
||||
Rails.logger.info("IGDB sync completed")
|
||||
end
|
||||
|
||||
def sync_user_games(user)
|
||||
# Get games that need IGDB matching
|
||||
games = user.games
|
||||
.igdb_unmatched
|
||||
.where(igdb_match_status: [nil, "failed"])
|
||||
.includes(:platform)
|
||||
|
||||
return if games.empty?
|
||||
|
||||
Rails.logger.info("Syncing #{games.count} games for user #{user.id}")
|
||||
|
||||
igdb_service = IgdbService.new
|
||||
games_synced = 0
|
||||
|
||||
games.find_each do |game|
|
||||
process_game_matching(game, igdb_service)
|
||||
games_synced += 1
|
||||
|
||||
# Rate limiting: Additional sleep every 10 games
|
||||
sleep(1) if games_synced % 10 == 0
|
||||
rescue => e
|
||||
Rails.logger.error("Error processing game #{game.id}: #{e.message}")
|
||||
game.update(igdb_match_status: "failed")
|
||||
next
|
||||
end
|
||||
|
||||
user.update(igdb_last_synced_at: Time.current)
|
||||
end
|
||||
|
||||
def process_game_matching(game, igdb_service)
|
||||
Rails.logger.info("Searching IGDB for: #{game.title} (#{game.platform.name})")
|
||||
|
||||
# Try searching WITH platform first
|
||||
results = igdb_service.search_game(game.title, game.platform, 3)
|
||||
|
||||
# If no results, try WITHOUT platform (broader search)
|
||||
if results.empty?
|
||||
Rails.logger.info("No results with platform, trying without platform filter...")
|
||||
results = igdb_service.search_game(game.title, nil, 3)
|
||||
end
|
||||
|
||||
if results.empty?
|
||||
Rails.logger.info("No IGDB matches found for game #{game.id}")
|
||||
game.update(igdb_match_status: "no_results")
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info("Found #{results.count} potential matches for game #{game.id}")
|
||||
|
||||
# Create match suggestions for user review
|
||||
results.each do |result|
|
||||
create_match_suggestion(game, result)
|
||||
end
|
||||
|
||||
# Update game status
|
||||
if results.first[:confidence_score] >= 95.0
|
||||
# Very high confidence - could auto-approve, but we'll let user review
|
||||
game.update(igdb_match_status: "high_confidence")
|
||||
elsif results.first[:confidence_score] >= 70.0
|
||||
game.update(igdb_match_status: "medium_confidence")
|
||||
else
|
||||
game.update(igdb_match_status: "low_confidence")
|
||||
end
|
||||
end
|
||||
|
||||
def create_match_suggestion(game, result)
|
||||
# Skip if suggestion already exists
|
||||
existing = IgdbMatchSuggestion.find_by(game: game, igdb_id: result[:igdb_id])
|
||||
return if existing
|
||||
|
||||
IgdbMatchSuggestion.create!(
|
||||
game: game,
|
||||
igdb_id: result[:igdb_id],
|
||||
igdb_name: result[:name],
|
||||
igdb_slug: result[:slug],
|
||||
igdb_cover_url: result[:cover_url],
|
||||
igdb_summary: result[:summary],
|
||||
igdb_release_date: result[:release_date],
|
||||
igdb_platform_name: result[:platform_name],
|
||||
igdb_genres: result[:genres] || [],
|
||||
confidence_score: result[:confidence_score],
|
||||
status: "pending"
|
||||
)
|
||||
|
||||
Rails.logger.info("Created match suggestion: #{result[:name]} (confidence: #{result[:confidence_score]}%)")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.warn("Failed to create match suggestion: #{e.message}")
|
||||
end
|
||||
end
|
||||
4
app/mailers/application_mailer.rb
Normal file
4
app/mailers/application_mailer.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
layout "mailer"
|
||||
end
|
||||
13
app/mailers/password_reset_mailer.rb
Normal file
13
app/mailers/password_reset_mailer.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class PasswordResetMailer < ApplicationMailer
|
||||
default from: ENV.fetch("MAILER_FROM_ADDRESS") { "noreply@turbovault.com" }
|
||||
|
||||
def reset_password(user)
|
||||
@user = user
|
||||
@reset_url = edit_password_reset_url(@user.password_reset_token)
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: "TurboVault - Password Reset Instructions"
|
||||
)
|
||||
end
|
||||
end
|
||||
27
app/models/api_token.rb
Normal file
27
app/models/api_token.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
class ApiToken < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
# Validations
|
||||
validates :token, presence: true, uniqueness: true
|
||||
|
||||
# Callbacks
|
||||
before_validation :generate_token, on: :create
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
|
||||
# Instance methods
|
||||
def expired?
|
||||
expires_at.present? && expires_at < Time.current
|
||||
end
|
||||
|
||||
def touch_last_used!
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
self.token ||= SecureRandom.urlsafe_base64(32)
|
||||
end
|
||||
end
|
||||
3
app/models/application_record.rb
Normal file
3
app/models/application_record.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
47
app/models/collection.rb
Normal file
47
app/models/collection.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class Collection < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :user
|
||||
belongs_to :parent_collection, class_name: "Collection", optional: true
|
||||
has_many :subcollections, class_name: "Collection", foreign_key: "parent_collection_id", dependent: :destroy
|
||||
has_many :collection_games, dependent: :destroy
|
||||
has_many :games, through: :collection_games
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true
|
||||
validate :cannot_be_own_parent
|
||||
validate :subcollection_depth_limit
|
||||
|
||||
# Scopes
|
||||
scope :root_collections, -> { where(parent_collection_id: nil) }
|
||||
|
||||
# Instance methods
|
||||
def game_count
|
||||
games.count
|
||||
end
|
||||
|
||||
def total_game_count
|
||||
game_count + subcollections.sum(&:total_game_count)
|
||||
end
|
||||
|
||||
def root?
|
||||
parent_collection_id.nil?
|
||||
end
|
||||
|
||||
def subcollection?
|
||||
parent_collection_id.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cannot_be_own_parent
|
||||
if parent_collection_id.present? && parent_collection_id == id
|
||||
errors.add(:parent_collection_id, "cannot be itself")
|
||||
end
|
||||
end
|
||||
|
||||
def subcollection_depth_limit
|
||||
if parent_collection.present? && parent_collection.parent_collection.present?
|
||||
errors.add(:parent_collection_id, "cannot nest more than one level deep")
|
||||
end
|
||||
end
|
||||
end
|
||||
7
app/models/collection_game.rb
Normal file
7
app/models/collection_game.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class CollectionGame < ApplicationRecord
|
||||
belongs_to :collection
|
||||
belongs_to :game
|
||||
|
||||
# Validations
|
||||
validates :game_id, uniqueness: { scope: :collection_id, message: "already in this collection" }
|
||||
end
|
||||
0
app/models/concerns/.keep
Normal file
0
app/models/concerns/.keep
Normal file
66
app/models/game.rb
Normal file
66
app/models/game.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
class Game < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :user
|
||||
belongs_to :platform
|
||||
has_many :game_genres, dependent: :destroy
|
||||
has_many :genres, through: :game_genres
|
||||
has_many :collection_games, dependent: :destroy
|
||||
has_many :collections, through: :collection_games
|
||||
has_many :igdb_match_suggestions, dependent: :destroy
|
||||
belongs_to :igdb_game, foreign_key: :igdb_id, primary_key: :igdb_id, optional: true
|
||||
|
||||
# Enums
|
||||
enum :format, { physical: "physical", digital: "digital" }
|
||||
enum :completion_status, {
|
||||
backlog: "backlog",
|
||||
currently_playing: "currently_playing",
|
||||
completed: "completed",
|
||||
on_hold: "on_hold",
|
||||
not_playing: "not_playing"
|
||||
}, prefix: true
|
||||
|
||||
enum :condition, {
|
||||
cib: "cib", # Complete in Box
|
||||
loose: "loose",
|
||||
sealed: "sealed",
|
||||
good: "good",
|
||||
fair: "fair"
|
||||
}, prefix: true
|
||||
|
||||
# Validations
|
||||
validates :title, presence: true
|
||||
validates :format, presence: true
|
||||
validates :date_added, presence: true
|
||||
validates :user_rating, inclusion: { in: 1..5, message: "must be between 1 and 5" }, allow_nil: true
|
||||
validates :condition, presence: true, if: :physical?
|
||||
validates :digital_store, presence: true, if: :digital?
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_date_added, on: :create
|
||||
|
||||
# Scopes
|
||||
scope :physical_games, -> { where(format: "physical") }
|
||||
scope :digital_games, -> { where(format: "digital") }
|
||||
scope :currently_playing, -> { where(completion_status: "currently_playing") }
|
||||
scope :completed, -> { where(completion_status: "completed") }
|
||||
scope :backlog, -> { where(completion_status: "backlog") }
|
||||
scope :by_platform, ->(platform_id) { where(platform_id: platform_id) }
|
||||
scope :by_genre, ->(genre_id) { joins(:genres).where(genres: { id: genre_id }) }
|
||||
scope :recent, -> { order(date_added: :desc) }
|
||||
scope :alphabetical, -> { order(:title) }
|
||||
scope :rated, -> { where.not(user_rating: nil).order(user_rating: :desc) }
|
||||
scope :igdb_matched, -> { where.not(igdb_id: nil) }
|
||||
scope :igdb_unmatched, -> { where(igdb_id: nil) }
|
||||
scope :needs_igdb_review, -> { joins(:igdb_match_suggestions).where(igdb_match_suggestions: { status: "pending" }).distinct }
|
||||
|
||||
# Class methods
|
||||
def self.search(query)
|
||||
where("title ILIKE ?", "%#{sanitize_sql_like(query)}%")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_date_added
|
||||
self.date_added ||= Date.current
|
||||
end
|
||||
end
|
||||
7
app/models/game_genre.rb
Normal file
7
app/models/game_genre.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class GameGenre < ApplicationRecord
|
||||
belongs_to :game
|
||||
belongs_to :genre
|
||||
|
||||
# Validations
|
||||
validates :game_id, uniqueness: { scope: :genre_id, message: "already has this genre" }
|
||||
end
|
||||
8
app/models/genre.rb
Normal file
8
app/models/genre.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class Genre < ApplicationRecord
|
||||
# Associations
|
||||
has_many :game_genres, dependent: :destroy
|
||||
has_many :games, through: :game_genres
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||
end
|
||||
25
app/models/igdb_game.rb
Normal file
25
app/models/igdb_game.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class IgdbGame < ApplicationRecord
|
||||
# Associations
|
||||
has_many :igdb_match_suggestions
|
||||
has_many :games, foreign_key: :igdb_id, primary_key: :igdb_id
|
||||
|
||||
# Validations
|
||||
validates :igdb_id, presence: true, uniqueness: true
|
||||
validates :name, presence: true
|
||||
|
||||
# Scopes
|
||||
scope :popular, -> { order(match_count: :desc) }
|
||||
scope :recent, -> { order(last_synced_at: :desc) }
|
||||
|
||||
# Instance methods
|
||||
def increment_match_count!
|
||||
increment!(:match_count)
|
||||
end
|
||||
|
||||
def cover_image_url(size = "cover_big")
|
||||
return nil unless cover_url.present?
|
||||
# IGDB uses image IDs like "co1234"
|
||||
# We need to construct the full URL
|
||||
"https://images.igdb.com/igdb/image/upload/t_#{size}/#{cover_url}.jpg"
|
||||
end
|
||||
end
|
||||
148
app/models/igdb_match_suggestion.rb
Normal file
148
app/models/igdb_match_suggestion.rb
Normal file
@@ -0,0 +1,148 @@
|
||||
class IgdbMatchSuggestion < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :game
|
||||
belongs_to :igdb_game, optional: true
|
||||
|
||||
# Enums
|
||||
enum :status, {
|
||||
pending: "pending",
|
||||
approved: "approved",
|
||||
rejected: "rejected"
|
||||
}, prefix: true
|
||||
|
||||
# Validations
|
||||
validates :igdb_id, presence: true
|
||||
validates :igdb_name, presence: true
|
||||
validates :game_id, uniqueness: { scope: :igdb_id }
|
||||
|
||||
# Scopes
|
||||
scope :pending_review, -> { status_pending.order(confidence_score: :desc, created_at: :asc) }
|
||||
scope :for_user, ->(user) { joins(:game).where(games: { user_id: user.id }) }
|
||||
scope :high_confidence, -> { where("confidence_score >= ?", 80.0) }
|
||||
|
||||
# Instance methods
|
||||
def approve!
|
||||
transaction do
|
||||
update!(status: "approved", reviewed_at: Time.current)
|
||||
|
||||
# Update the game with the matched IGDB ID
|
||||
game.update!(
|
||||
igdb_id: igdb_id,
|
||||
igdb_matched_at: Time.current,
|
||||
igdb_match_status: "matched",
|
||||
igdb_match_confidence: confidence_score
|
||||
)
|
||||
|
||||
# Find or create the IgdbGame record with full data
|
||||
igdb_game_record = IgdbGame.find_or_create_by!(igdb_id: igdb_id) do |ig|
|
||||
ig.name = igdb_name
|
||||
ig.slug = igdb_slug
|
||||
ig.cover_url = igdb_cover_url
|
||||
ig.summary = igdb_summary
|
||||
ig.first_release_date = igdb_release_date
|
||||
ig.last_synced_at = Time.current
|
||||
end
|
||||
|
||||
# Update summary if it wasn't already set
|
||||
if igdb_game_record.summary.blank? && igdb_summary.present?
|
||||
igdb_game_record.update(summary: igdb_summary)
|
||||
end
|
||||
|
||||
igdb_game_record.increment_match_count!
|
||||
|
||||
# Map and assign IGDB genres to the game
|
||||
sync_genres_to_game if igdb_genres.present?
|
||||
|
||||
# Reject all other pending suggestions for this game
|
||||
game.igdb_match_suggestions
|
||||
.where.not(id: id)
|
||||
.status_pending
|
||||
.update_all(
|
||||
status: "rejected",
|
||||
reviewed_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def reject!
|
||||
transaction do
|
||||
update!(
|
||||
status: "rejected",
|
||||
reviewed_at: Time.current
|
||||
)
|
||||
|
||||
# Reject all other pending suggestions for this game
|
||||
game.igdb_match_suggestions
|
||||
.where.not(id: id)
|
||||
.status_pending
|
||||
.update_all(
|
||||
status: "rejected",
|
||||
reviewed_at: Time.current
|
||||
)
|
||||
|
||||
# Mark game as manually reviewed with no match (only if all suggestions rejected)
|
||||
if game.igdb_match_suggestions.status_pending.none?
|
||||
game.update!(
|
||||
igdb_match_status: "no_match",
|
||||
igdb_matched_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def cover_image_url(size = "cover_big")
|
||||
return nil unless igdb_cover_url.present?
|
||||
"https://images.igdb.com/igdb/image/upload/t_#{size}/#{igdb_cover_url}.jpg"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_genres_to_game
|
||||
return unless igdb_genres.is_a?(Array) && igdb_genres.any?
|
||||
|
||||
# Genre mapping: IGDB genre names to our genre names
|
||||
genre_mappings = {
|
||||
"Role-playing (RPG)" => "RPG",
|
||||
"Fighting" => "Fighting",
|
||||
"Shooter" => "Shooter",
|
||||
"Music" => "Music",
|
||||
"Platform" => "Platformer",
|
||||
"Puzzle" => "Puzzle",
|
||||
"Racing" => "Racing",
|
||||
"Real Time Strategy (RTS)" => "Strategy",
|
||||
"Simulator" => "Simulation",
|
||||
"Sport" => "Sports",
|
||||
"Strategy" => "Strategy",
|
||||
"Turn-based strategy (TBS)" => "Strategy",
|
||||
"Tactical" => "Strategy",
|
||||
"Hack and slash/Beat 'em up" => "Action",
|
||||
"Quiz/Trivia" => "Puzzle",
|
||||
"Pinball" => "Arcade",
|
||||
"Adventure" => "Adventure",
|
||||
"Indie" => "Indie",
|
||||
"Arcade" => "Arcade",
|
||||
"Visual Novel" => "Adventure",
|
||||
"Card & Board Game" => "Puzzle",
|
||||
"MOBA" => "Strategy",
|
||||
"Point-and-click" => "Adventure"
|
||||
}
|
||||
|
||||
# Find or create matching genres
|
||||
igdb_genres.each do |igdb_genre_name|
|
||||
# Try exact match first
|
||||
local_genre = Genre.find_by("LOWER(name) = ?", igdb_genre_name.downcase)
|
||||
|
||||
# Try mapped name
|
||||
if local_genre.nil? && genre_mappings[igdb_genre_name]
|
||||
mapped_name = genre_mappings[igdb_genre_name]
|
||||
local_genre = Genre.find_by("LOWER(name) = ?", mapped_name.downcase)
|
||||
end
|
||||
|
||||
# Add genre to game if found and not already assigned
|
||||
if local_genre && !game.genres.include?(local_genre)
|
||||
game.genres << local_genre
|
||||
Rails.logger.info("Added genre '#{local_genre.name}' to game #{game.id} from IGDB")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
54
app/models/igdb_platform_mapping.rb
Normal file
54
app/models/igdb_platform_mapping.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
class IgdbPlatformMapping < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :platform
|
||||
|
||||
# Validations
|
||||
validates :platform_id, presence: true
|
||||
validates :igdb_platform_id, presence: true, uniqueness: { scope: :platform_id }
|
||||
|
||||
# Class methods
|
||||
def self.igdb_id_for_platform(platform)
|
||||
find_by(platform: platform)&.igdb_platform_id
|
||||
end
|
||||
|
||||
def self.seed_common_mappings!
|
||||
mappings = {
|
||||
"Nintendo 64" => 4,
|
||||
"PlayStation" => 7,
|
||||
"PlayStation 2" => 8,
|
||||
"PlayStation 3" => 9,
|
||||
"PlayStation 4" => 48,
|
||||
"PlayStation 5" => 167,
|
||||
"Xbox" => 11,
|
||||
"Xbox 360" => 12,
|
||||
"Xbox One" => 49,
|
||||
"Xbox Series X/S" => 169,
|
||||
"Nintendo Switch" => 130,
|
||||
"Wii" => 5,
|
||||
"Wii U" => 41,
|
||||
"GameCube" => 21,
|
||||
"Super Nintendo Entertainment System" => 19,
|
||||
"Nintendo Entertainment System" => 18,
|
||||
"Game Boy" => 33,
|
||||
"Game Boy Color" => 22,
|
||||
"Game Boy Advance" => 24,
|
||||
"Nintendo DS" => 20,
|
||||
"Nintendo 3DS" => 37,
|
||||
"PC" => 6,
|
||||
"Sega Genesis" => 29,
|
||||
"Sega Dreamcast" => 23,
|
||||
"PlayStation Portable" => 38,
|
||||
"PlayStation Vita" => 46
|
||||
}
|
||||
|
||||
mappings.each do |platform_name, igdb_id|
|
||||
platform = Platform.find_by(name: platform_name)
|
||||
next unless platform
|
||||
|
||||
find_or_create_by!(platform: platform) do |mapping|
|
||||
mapping.igdb_platform_id = igdb_id
|
||||
mapping.igdb_platform_name = platform_name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
34
app/models/item.rb
Normal file
34
app/models/item.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
class Item < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :user
|
||||
belongs_to :platform, optional: true
|
||||
|
||||
# Enums
|
||||
enum :item_type, {
|
||||
console: "console",
|
||||
controller: "controller",
|
||||
accessory: "accessory",
|
||||
other: "other"
|
||||
}, prefix: true
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true
|
||||
validates :item_type, presence: true
|
||||
validates :date_added, presence: true
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_date_added, on: :create
|
||||
|
||||
# Scopes
|
||||
scope :consoles, -> { where(item_type: "console") }
|
||||
scope :controllers, -> { where(item_type: "controller") }
|
||||
scope :accessories, -> { where(item_type: "accessory") }
|
||||
scope :by_platform, ->(platform_id) { where(platform_id: platform_id) }
|
||||
scope :recent, -> { order(date_added: :desc) }
|
||||
|
||||
private
|
||||
|
||||
def set_date_added
|
||||
self.date_added ||= Date.current
|
||||
end
|
||||
end
|
||||
8
app/models/platform.rb
Normal file
8
app/models/platform.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class Platform < ApplicationRecord
|
||||
# Associations
|
||||
has_many :games, dependent: :restrict_with_error
|
||||
has_many :items, dependent: :restrict_with_error
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||
end
|
||||
42
app/models/user.rb
Normal file
42
app/models/user.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class User < ApplicationRecord
|
||||
has_secure_password
|
||||
|
||||
# Associations
|
||||
has_many :games, dependent: :destroy
|
||||
has_many :collections, dependent: :destroy
|
||||
has_many :items, dependent: :destroy
|
||||
has_many :api_tokens, dependent: :destroy
|
||||
has_many :igdb_match_suggestions, through: :games
|
||||
|
||||
# Validations
|
||||
validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :username, presence: true, uniqueness: { case_sensitive: false },
|
||||
length: { minimum: 3, maximum: 30 },
|
||||
format: { with: /\A[a-zA-Z0-9_]+\z/, message: "only allows letters, numbers, and underscores" }
|
||||
validates :password, length: { minimum: 8 }, if: -> { password.present? }
|
||||
validates :theme, presence: true, inclusion: { in: %w[light dark midnight retro ocean] }
|
||||
|
||||
# Callbacks
|
||||
before_save :downcase_email
|
||||
|
||||
# Instance methods
|
||||
def generate_password_reset_token
|
||||
self.password_reset_token = SecureRandom.urlsafe_base64
|
||||
self.password_reset_sent_at = Time.current
|
||||
save!
|
||||
end
|
||||
|
||||
def password_reset_expired?
|
||||
password_reset_sent_at.nil? || password_reset_sent_at < 2.hours.ago
|
||||
end
|
||||
|
||||
def theme_class
|
||||
"theme-#{theme}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def downcase_email
|
||||
self.email = email.downcase if email.present?
|
||||
end
|
||||
end
|
||||
235
app/services/igdb_service.rb
Normal file
235
app/services/igdb_service.rb
Normal file
@@ -0,0 +1,235 @@
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
require 'uri'
|
||||
|
||||
class IgdbService
|
||||
BASE_URL = "https://api.igdb.com/v4"
|
||||
TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
||||
CACHE_KEY = "igdb_access_token"
|
||||
|
||||
class ApiError < StandardError; end
|
||||
class RateLimitError < StandardError; end
|
||||
|
||||
def initialize
|
||||
@client_id = ENV.fetch("IGDB_CLIENT_ID")
|
||||
@client_secret = ENV.fetch("IGDB_CLIENT_SECRET")
|
||||
@access_token = get_or_refresh_token
|
||||
end
|
||||
|
||||
# Search for games by title and platform
|
||||
# Returns array of matches with confidence scores
|
||||
def search_game(title, platform = nil, limit = 3)
|
||||
platform_filter = platform_filter_query(platform)
|
||||
|
||||
query = <<~QUERY
|
||||
search "#{sanitize_search_term(title)}";
|
||||
fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name;
|
||||
#{platform_filter}
|
||||
limit #{limit};
|
||||
QUERY
|
||||
|
||||
results = post("/games", query)
|
||||
|
||||
return [] if results.empty?
|
||||
|
||||
# Calculate confidence scores and format results
|
||||
results.map do |game|
|
||||
confidence = calculate_confidence(title, game["name"], platform, game["platforms"])
|
||||
format_game_result(game, confidence)
|
||||
end.sort_by { |g| -g[:confidence_score] }
|
||||
rescue => e
|
||||
Rails.logger.error("IGDB search error: #{e.message}")
|
||||
[]
|
||||
end
|
||||
|
||||
# Get specific game by IGDB ID
|
||||
def get_game(igdb_id)
|
||||
query = <<~QUERY
|
||||
fields id, name, slug, cover.url, summary, first_release_date, platforms.name, genres.name;
|
||||
where id = #{igdb_id};
|
||||
QUERY
|
||||
|
||||
results = post("/games", query)
|
||||
results.first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Get cached token or generate a new one
|
||||
def get_or_refresh_token
|
||||
# Check if we have a cached token
|
||||
cached_token = Rails.cache.read(CACHE_KEY)
|
||||
return cached_token if cached_token
|
||||
|
||||
# Generate new token
|
||||
generate_access_token
|
||||
end
|
||||
|
||||
# Generate a new access token from Twitch
|
||||
def generate_access_token
|
||||
uri = URI(TOKEN_URL)
|
||||
uri.query = URI.encode_www_form({
|
||||
client_id: @client_id,
|
||||
client_secret: @client_secret,
|
||||
grant_type: 'client_credentials'
|
||||
})
|
||||
|
||||
response = Net::HTTP.post(uri, '')
|
||||
|
||||
if response.code.to_i == 200
|
||||
data = JSON.parse(response.body)
|
||||
token = data['access_token']
|
||||
expires_in = data['expires_in'] # seconds until expiration (usually ~5 million seconds / ~60 days)
|
||||
|
||||
# Cache token for 90% of its lifetime to be safe
|
||||
cache_duration = (expires_in * 0.9).to_i
|
||||
Rails.cache.write(CACHE_KEY, token, expires_in: cache_duration)
|
||||
|
||||
Rails.logger.info("Generated new IGDB access token (expires in #{expires_in / 86400} days)")
|
||||
|
||||
token
|
||||
else
|
||||
raise ApiError, "Failed to get IGDB token: #{response.code} - #{response.body}"
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to generate IGDB token: #{e.message}")
|
||||
raise ApiError, "Cannot authenticate with IGDB: #{e.message}"
|
||||
end
|
||||
|
||||
def post(endpoint, body, retry_count = 0)
|
||||
uri = URI("#{BASE_URL}#{endpoint}")
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
request = Net::HTTP::Post.new(uri.path)
|
||||
request["Client-ID"] = @client_id
|
||||
request["Authorization"] = "Bearer #{@access_token}"
|
||||
request["Content-Type"] = "text/plain"
|
||||
request.body = body
|
||||
|
||||
Rails.logger.info("IGDB Request: #{body}")
|
||||
|
||||
# Rate limiting: sleep to avoid hitting limits (4 req/sec)
|
||||
sleep(0.3)
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
Rails.logger.info("IGDB Response: #{response.code} - #{response.body[0..200]}")
|
||||
|
||||
case response.code.to_i
|
||||
when 200
|
||||
JSON.parse(response.body)
|
||||
when 401
|
||||
# Token expired or invalid - refresh and retry once
|
||||
if retry_count == 0
|
||||
Rails.logger.warn("IGDB token invalid, refreshing...")
|
||||
Rails.cache.delete(CACHE_KEY)
|
||||
@access_token = generate_access_token
|
||||
return post(endpoint, body, retry_count + 1)
|
||||
else
|
||||
Rails.logger.error("IGDB authentication failed after token refresh")
|
||||
raise ApiError, "IGDB authentication failed"
|
||||
end
|
||||
when 429
|
||||
raise RateLimitError, "IGDB rate limit exceeded"
|
||||
when 400..499
|
||||
Rails.logger.error("IGDB API error: #{response.code} - #{response.body}")
|
||||
raise ApiError, "IGDB API error: #{response.code}"
|
||||
else
|
||||
Rails.logger.error("IGDB unexpected response: #{response.code} - #{response.body}")
|
||||
[]
|
||||
end
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error("IGDB JSON parse error: #{e.message}")
|
||||
[]
|
||||
end
|
||||
|
||||
def platform_filter_query(platform)
|
||||
return "" unless platform
|
||||
|
||||
igdb_platform_id = IgdbPlatformMapping.igdb_id_for_platform(platform)
|
||||
return "" unless igdb_platform_id
|
||||
|
||||
"where platforms = (#{igdb_platform_id});"
|
||||
end
|
||||
|
||||
def sanitize_search_term(term)
|
||||
# Escape quotes and remove special characters that might break the query
|
||||
term.gsub('"', '\\"').gsub(/[^\w\s:'-]/, "")
|
||||
end
|
||||
|
||||
def calculate_confidence(search_title, result_title, search_platform, result_platforms)
|
||||
score = 0.0
|
||||
|
||||
# Title similarity (0-70 points)
|
||||
search_clean = search_title.downcase.strip
|
||||
result_clean = result_title.downcase.strip
|
||||
|
||||
if search_clean == result_clean
|
||||
score += 70
|
||||
elsif result_clean.include?(search_clean) || search_clean.include?(result_clean)
|
||||
score += 50
|
||||
else
|
||||
# Levenshtein distance or similar could be used here
|
||||
# For now, check if major words match
|
||||
search_words = search_clean.split(/\W+/)
|
||||
result_words = result_clean.split(/\W+/)
|
||||
common_words = search_words & result_words
|
||||
score += (common_words.length.to_f / search_words.length) * 40
|
||||
end
|
||||
|
||||
# Platform match (0-30 points)
|
||||
if search_platform && result_platforms
|
||||
platform_names = result_platforms.map { |p| p["name"].downcase }
|
||||
igdb_platform_id = IgdbPlatformMapping.igdb_id_for_platform(search_platform)
|
||||
|
||||
# Check if our platform is in the result platforms
|
||||
if igdb_platform_id
|
||||
# Exact platform match
|
||||
score += 30
|
||||
elsif platform_names.any? { |name| name.include?(search_platform.name.downcase) }
|
||||
# Partial platform match
|
||||
score += 20
|
||||
end
|
||||
end
|
||||
|
||||
score.round(2)
|
||||
end
|
||||
|
||||
def format_game_result(game, confidence)
|
||||
cover_id = game.dig("cover", "url")&.split("/")&.last&.sub(".jpg", "")
|
||||
|
||||
platform_name = if game["platforms"]&.any?
|
||||
game["platforms"].first["name"]
|
||||
else
|
||||
"Unknown"
|
||||
end
|
||||
|
||||
release_date = if game["first_release_date"]
|
||||
Time.at(game["first_release_date"]).to_date
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
# Extract genre names
|
||||
genre_names = if game["genres"]&.any?
|
||||
game["genres"].map { |g| g["name"] }
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
{
|
||||
igdb_id: game["id"],
|
||||
name: game["name"],
|
||||
slug: game["slug"],
|
||||
cover_url: cover_id,
|
||||
summary: game["summary"],
|
||||
release_date: release_date,
|
||||
release_year: release_date&.year,
|
||||
platform_name: platform_name,
|
||||
genres: genre_names,
|
||||
confidence_score: confidence
|
||||
}
|
||||
end
|
||||
end
|
||||
4
app/views/api_tokens/create.html.erb
Normal file
4
app/views/api_tokens/create.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">ApiTokens#create</h1>
|
||||
<p>Find me in app/views/api_tokens/create.html.erb</p>
|
||||
</div>
|
||||
4
app/views/api_tokens/destroy.html.erb
Normal file
4
app/views/api_tokens/destroy.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">ApiTokens#destroy</h1>
|
||||
<p>Find me in app/views/api_tokens/destroy.html.erb</p>
|
||||
</div>
|
||||
118
app/views/api_tokens/index.html.erb
Normal file
118
app/views/api_tokens/index.html.erb
Normal file
@@ -0,0 +1,118 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">API Tokens</h1>
|
||||
|
||||
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700">
|
||||
<strong>Important:</strong> Your API token will only be shown once when created. Make sure to copy it and store it securely!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Create New Token</h2>
|
||||
|
||||
<%= form_with model: @api_token, url: api_tokens_path, class: "space-y-4" do |f| %>
|
||||
<% if @api_token.errors.any? %>
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<ul>
|
||||
<% @api_token.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= f.label :name, "Token Name (e.g., 'Mobile App', 'Third Party Integration')", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :expires_at, "Expiration Date (optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.datetime_local_field :expires_at, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Leave blank for tokens that never expire</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.submit "Create Token", class: "px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Your API Tokens</h2>
|
||||
|
||||
<% if @api_tokens.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @api_tokens.each do |token| %>
|
||||
<div class="border border-gray-200 p-4 rounded-lg">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-lg"><%= token.name || "Unnamed Token" %></div>
|
||||
|
||||
<div class="mt-2 space-y-1 text-sm text-gray-600">
|
||||
<div>
|
||||
<strong>Token:</strong>
|
||||
<code class="bg-gray-100 px-2 py-1 rounded font-mono text-xs">
|
||||
<%= token.token[0..15] %>...
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div><strong>Created:</strong> <%= token.created_at.strftime("%B %d, %Y at %I:%M %p") %></div>
|
||||
|
||||
<% if token.last_used_at %>
|
||||
<div><strong>Last Used:</strong> <%= time_ago_in_words(token.last_used_at) %> ago</div>
|
||||
<% else %>
|
||||
<div><strong>Last Used:</strong> Never</div>
|
||||
<% end %>
|
||||
|
||||
<% if token.expires_at %>
|
||||
<div class="<%= token.expired? ? 'text-red-600' : '' %>">
|
||||
<strong>Expires:</strong> <%= token.expires_at.strftime("%B %d, %Y") %>
|
||||
<%= " (EXPIRED)" if token.expired? %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div><strong>Expires:</strong> Never</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= button_to "Delete", api_token_path(token), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this API token? Apps using this token will stop working. This action cannot be undone." }, class: "px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-500">You haven't created any API tokens yet. Create one above to start using the API.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 bg-blue-50 border-l-4 border-blue-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
<strong>API Documentation:</strong> See <code class="bg-blue-100 px-2 py-1 rounded">API_DOCUMENTATION.md</code> for complete API reference and usage examples.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<%= link_to "← Back to Settings", settings_path, class: "text-indigo-600 hover:text-indigo-800" %>
|
||||
</div>
|
||||
</div>
|
||||
32
app/views/collections/_form.html.erb
Normal file
32
app/views/collections/_form.html.erb
Normal file
@@ -0,0 +1,32 @@
|
||||
<%= form_with model: collection, class: "space-y-6" do |f| %>
|
||||
<% if collection.errors.any? %>
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<ul>
|
||||
<% collection.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.text_area :description, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :parent_collection_id, "Parent Collection (optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.collection_select :parent_collection_id, @collections, :id, :name, { include_blank: "None (Root Collection)" }, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Subcollections can only be one level deep</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<%= f.submit class: "px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700" %>
|
||||
<%= link_to "Cancel", collection.persisted? ? collection : collections_path, class: "px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300" %>
|
||||
</div>
|
||||
<% end %>
|
||||
4
app/views/collections/create.html.erb
Normal file
4
app/views/collections/create.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Collections#create</h1>
|
||||
<p>Find me in app/views/collections/create.html.erb</p>
|
||||
</div>
|
||||
4
app/views/collections/destroy.html.erb
Normal file
4
app/views/collections/destroy.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Collections#destroy</h1>
|
||||
<p>Find me in app/views/collections/destroy.html.erb</p>
|
||||
</div>
|
||||
7
app/views/collections/edit.html.erb
Normal file
7
app/views/collections/edit.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Edit Collection</h1>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<%= render "form", collection: @collection %>
|
||||
</div>
|
||||
</div>
|
||||
4
app/views/collections/games.html.erb
Normal file
4
app/views/collections/games.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Collections#games</h1>
|
||||
<p>Find me in app/views/collections/games.html.erb</p>
|
||||
</div>
|
||||
51
app/views/collections/index.html.erb
Normal file
51
app/views/collections/index.html.erb
Normal file
@@ -0,0 +1,51 @@
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">My Collections</h1>
|
||||
<%= link_to "New Collection", new_collection_path, class: "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700" %>
|
||||
</div>
|
||||
|
||||
<% if @root_collections.any? %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% @root_collections.each do |collection| %>
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-2">
|
||||
<%= link_to collection.name, collection, class: "text-indigo-600 hover:text-indigo-800" %>
|
||||
</h2>
|
||||
|
||||
<% if collection.description.present? %>
|
||||
<p class="text-gray-600 mb-4"><%= truncate(collection.description, length: 100) %></p>
|
||||
<% end %>
|
||||
|
||||
<div class="text-sm text-gray-500 mb-4">
|
||||
<%= pluralize(collection.game_count, "game") %>
|
||||
</div>
|
||||
|
||||
<% if collection.subcollections.any? %>
|
||||
<div class="mb-4">
|
||||
<p class="text-sm font-medium text-gray-700 mb-2">Subcollections:</p>
|
||||
<div class="space-y-1">
|
||||
<% collection.subcollections.each do |subcollection| %>
|
||||
<div class="text-sm">
|
||||
<%= link_to subcollection.name, subcollection, class: "text-indigo-600 hover:text-indigo-800" %>
|
||||
(<%= pluralize(subcollection.game_count, "game") %>)
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex space-x-2 mt-4">
|
||||
<%= link_to "View", collection, class: "text-indigo-600 hover:text-indigo-800 text-sm" %>
|
||||
<%= link_to "Edit", edit_collection_path(collection), class: "text-blue-600 hover:text-blue-800 text-sm" %>
|
||||
<%= button_to "Delete", collection, method: :delete, data: { turbo_confirm: "Are you sure you want to delete the collection '#{collection.name}'? Games in this collection will not be deleted." }, class: "text-red-600 hover:text-red-800 text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white p-8 rounded-lg shadow text-center">
|
||||
<p class="text-gray-500 mb-4">You haven't created any collections yet.</p>
|
||||
<%= link_to "Create Your First Collection", new_collection_path, class: "text-indigo-600 hover:text-indigo-800" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
7
app/views/collections/new.html.erb
Normal file
7
app/views/collections/new.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">New Collection</h1>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<%= render "form", collection: @collection %>
|
||||
</div>
|
||||
</div>
|
||||
104
app/views/collections/show.html.erb
Normal file
104
app/views/collections/show.html.erb
Normal file
@@ -0,0 +1,104 @@
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold"><%= @collection.name %></h1>
|
||||
<% if @collection.subcollection? %>
|
||||
<p class="text-gray-600 mt-1">
|
||||
Subcollection of <%= link_to @collection.parent_collection.name, @collection.parent_collection, class: "text-indigo-600 hover:text-indigo-800" %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<%= link_to "Edit", edit_collection_path(@collection), class: "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700" %>
|
||||
<%= button_to "Delete", @collection, method: :delete, data: { turbo_confirm: "Are you sure you want to delete '#{@collection.name}'? This will permanently remove the collection but games in it will not be deleted." }, class: "px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @collection.description.present? %>
|
||||
<div class="bg-white p-6 rounded-lg shadow mb-6">
|
||||
<p class="text-gray-700"><%= @collection.description %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow mb-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-gray-500 text-sm">Total Games</div>
|
||||
<div class="text-3xl font-bold"><%= @collection.game_count %></div>
|
||||
</div>
|
||||
|
||||
<% if @collection.subcollections.any? %>
|
||||
<div>
|
||||
<div class="text-gray-500 text-sm">Subcollections</div>
|
||||
<div class="text-3xl font-bold"><%= @collection.subcollections.count %></div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @collection.subcollections.any? %>
|
||||
<div class="bg-white p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Subcollections</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<% @collection.subcollections.each do |subcollection| %>
|
||||
<div class="border border-gray-200 p-4 rounded-lg">
|
||||
<%= link_to subcollection.name, subcollection, class: "font-semibold text-indigo-600 hover:text-indigo-800" %>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
<%= pluralize(subcollection.game_count, "game") %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Games in Collection</h2>
|
||||
|
||||
<% if @games.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @games.each do |game| %>
|
||||
<div class="border-b pb-4 last:border-b-0">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<%= link_to game.title, game, class: "text-lg font-semibold text-indigo-600 hover:text-indigo-800" %>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
<%= game.platform.name %>
|
||||
·
|
||||
<span class="<%= game.physical? ? 'text-blue-600' : 'text-green-600' %>">
|
||||
<%= game.format.titleize %>
|
||||
</span>
|
||||
<% if game.completion_status %>
|
||||
· <%= game.completion_status.titleize %>
|
||||
<% end %>
|
||||
<% if game.user_rating %>
|
||||
· ⭐ <%= game.user_rating %>/5
|
||||
<% end %>
|
||||
</div>
|
||||
<% if game.genres.any? %>
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
<% game.genres.each do |genre| %>
|
||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs"><%= genre.name %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @games.respond_to?(:total_pages) && @games.total_pages > 1 %>
|
||||
<div class="mt-6">
|
||||
<%#= paginate @games %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-gray-500">No games in this collection yet. <%= link_to "Add games", games_path, class: "text-indigo-600 hover:text-indigo-800" %> to get started.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<%= link_to "← Back to Collections", collections_path, class: "text-indigo-600 hover:text-indigo-800" %>
|
||||
</div>
|
||||
</div>
|
||||
4
app/views/collections/update.html.erb
Normal file
4
app/views/collections/update.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Collections#update</h1>
|
||||
<p>Find me in app/views/collections/update.html.erb</p>
|
||||
</div>
|
||||
116
app/views/dashboard/index.html.erb
Normal file
116
app/views/dashboard/index.html.erb
Normal file
@@ -0,0 +1,116 @@
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold">Dashboard</h1>
|
||||
<%= link_to "Add Game", new_game_path, class: "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700" %>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-gray-500 text-sm">Total Games</div>
|
||||
<div class="text-3xl font-bold"><%= @total_games %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-gray-500 text-sm">Physical Games</div>
|
||||
<div class="text-3xl font-bold"><%= @physical_games %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-gray-500 text-sm">Digital Games</div>
|
||||
<div class="text-3xl font-bold"><%= @digital_games %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-gray-500 text-sm">Total Spent</div>
|
||||
<div class="text-3xl font-bold">$<%= sprintf("%.2f", @total_spent || 0) %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-gray-500 text-sm">Completed</div>
|
||||
<div class="text-3xl font-bold"><%= @completed_games %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-gray-500 text-sm">Backlog</div>
|
||||
<div class="text-3xl font-bold"><%= @backlog_games %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Top Platforms</h2>
|
||||
<% if @games_by_platform.any? %>
|
||||
<ul class="space-y-2">
|
||||
<% @games_by_platform.each do |platform, count| %>
|
||||
<li class="flex justify-between">
|
||||
<span><%= platform %></span>
|
||||
<span class="font-bold"><%= count %></span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-gray-500">No games yet</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Top Genres</h2>
|
||||
<% if @games_by_genre.any? %>
|
||||
<ul class="space-y-2">
|
||||
<% @games_by_genre.each do |genre, count| %>
|
||||
<li class="flex justify-between">
|
||||
<span><%= genre %></span>
|
||||
<span class="font-bold"><%= count %></span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-gray-500">No games yet</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recently Added Games -->
|
||||
<div class="bg-white p-6 rounded-lg shadow mb-8">
|
||||
<h2 class="text-xl font-bold mb-4">Recently Added</h2>
|
||||
<% if @recently_added_games.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @recently_added_games.each do |game| %>
|
||||
<div class="flex justify-between items-center border-b pb-2">
|
||||
<div>
|
||||
<%= link_to game.title, game, class: "font-semibold text-indigo-600 hover:text-indigo-800" %>
|
||||
<div class="text-sm text-gray-500"><%= game.platform.name %></div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500"><%= game.date_added.strftime("%b %d, %Y") %></div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to "View All Games", games_path, class: "mt-4 inline-block text-indigo-600 hover:text-indigo-800" %>
|
||||
<% else %>
|
||||
<p class="text-gray-500">No games in your collection yet. <%= link_to "Add your first game", new_game_path, class: "text-indigo-600 hover:text-indigo-800" %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Currently Playing -->
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Currently Playing</h2>
|
||||
<% if @currently_playing_games.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @currently_playing_games.each do |game| %>
|
||||
<div class="flex justify-between items-center border-b pb-2">
|
||||
<div>
|
||||
<%= link_to game.title, game, class: "font-semibold text-indigo-600 hover:text-indigo-800" %>
|
||||
<div class="text-sm text-gray-500"><%= game.platform.name %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-500">Not currently playing anything</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
259
app/views/games/_form.html.erb
Normal file
259
app/views/games/_form.html.erb
Normal file
@@ -0,0 +1,259 @@
|
||||
<% igdb_enabled = game.new_record? && current_user.igdb_sync_enabled? %>
|
||||
|
||||
<% if igdb_enabled %>
|
||||
<div data-controller="igdb-search"
|
||||
data-igdb-search-url-value="<%= search_igdb_games_path %>"
|
||||
data-action="click@window->igdb-search#clickOutside">
|
||||
<% else %>
|
||||
<div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: game, class: "space-y-6" do |f| %>
|
||||
<% if game.errors.any? %>
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<ul>
|
||||
<% game.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if igdb_enabled %>
|
||||
<!-- IGDB Search Section (only for new games) -->
|
||||
<div class="bg-indigo-50 border-l-4 border-indigo-500 p-4 mb-6">
|
||||
|
||||
<h3 class="text-lg font-semibold text-indigo-900 mb-3">🔍 Search IGDB Database</h3>
|
||||
<p class="text-sm text-indigo-700 mb-4">
|
||||
Start typing to search the IGDB game database. Select a match to auto-fill details, or add a custom game manually.
|
||||
</p>
|
||||
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
placeholder="Search for a game (e.g., 'Zelda Ocarina', 'Mario 64')..."
|
||||
class="w-full px-4 py-3 pr-10 rounded-lg border-2 border-indigo-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
data-igdb-search-target="query"
|
||||
data-action="input->igdb-search#search"
|
||||
autocomplete="off">
|
||||
|
||||
<button type="button"
|
||||
class="absolute right-2 top-2 p-2 text-gray-400 hover:text-gray-600"
|
||||
data-action="click->igdb-search#clearSearch"
|
||||
title="Clear search">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results dropdown -->
|
||||
<div class="hidden absolute z-10 w-full mt-1 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
|
||||
data-igdb-search-target="results">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden field for IGDB ID -->
|
||||
<%= f.hidden_field :igdb_id, "data-igdb-search-target": "igdbId" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= f.label :title, class: "block text-sm font-medium text-gray-700" %>
|
||||
<% if game.new_record? && current_user.igdb_sync_enabled? %>
|
||||
<%= f.text_field :title,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
|
||||
"data-igdb-search-target": "title" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Auto-filled from IGDB or enter manually</p>
|
||||
<% else %>
|
||||
<%= f.text_field :title,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :platform_id, class: "block text-sm font-medium text-gray-700" %>
|
||||
<% if game.new_record? && current_user.igdb_sync_enabled? %>
|
||||
<%= f.collection_select :platform_id, @platforms, :id, :name,
|
||||
{ prompt: "Select Platform" },
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
|
||||
"data-igdb-search-target": "platformSelect",
|
||||
"data-action": "change->igdb-search#search" %>
|
||||
<% else %>
|
||||
<%= f.collection_select :platform_id, @platforms, :id, :name,
|
||||
{ prompt: "Select Platform" },
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :format, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.select :format, [["Physical", "physical"], ["Digital", "digital"]], { prompt: "Select Format" }, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :date_added, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.date_field :date_added, value: game.date_added || Date.current, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :completion_status, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.select :completion_status, [["Backlog", "backlog"], ["Currently Playing", "currently_playing"], ["Completed", "completed"], ["On Hold", "on_hold"], ["Not Playing", "not_playing"]], { include_blank: "Select Status" }, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :user_rating, "Rating (1-5 stars)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.number_field :user_rating, min: 1, max: 5, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :genre_ids, "Genres", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.collection_check_boxes :genre_ids, @genres, :id, :name do |b| %>
|
||||
<div class="inline-block mr-4">
|
||||
<%= b.check_box class: "rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" %>
|
||||
<%= b.label class: "ml-2 text-sm text-gray-700" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div id="physical-fields" style="<%= 'display: none;' if game.digital? %>">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Physical Game Details</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<%= f.label :condition, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.select :condition, [["CIB (Complete in Box)", "cib"], ["Loose", "loose"], ["Sealed", "sealed"], ["Good", "good"], ["Fair", "fair"]], { include_blank: "Select Condition" }, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :price_paid, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.number_field :price_paid, step: 0.01, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div data-controller="location-autocomplete"
|
||||
data-location-autocomplete-url-value="<%= search_locations_games_path %>"
|
||||
data-action="click@window->location-autocomplete#clickOutside">
|
||||
<%= f.label :location, class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="relative">
|
||||
<%= f.text_field :location,
|
||||
placeholder: "e.g., Bedroom Shelf A",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
|
||||
autocomplete: "off",
|
||||
data: {
|
||||
location_autocomplete_target: "input",
|
||||
action: "input->location-autocomplete#search"
|
||||
} %>
|
||||
|
||||
<!-- Autocomplete results dropdown -->
|
||||
<div class="hidden absolute z-20 w-full mt-1 bg-white rounded-md shadow-lg border border-gray-200 max-h-48 overflow-y-auto"
|
||||
data-location-autocomplete-target="results">
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Start typing to see previously used locations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="digital-fields" style="<%= 'display: none;' if game.physical? %>">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Digital Game Details</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div data-controller="location-autocomplete"
|
||||
data-location-autocomplete-url-value="<%= search_stores_games_path %>"
|
||||
data-action="click@window->location-autocomplete#clickOutside">
|
||||
<%= f.label :digital_store, "Digital Store/Platform", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="relative">
|
||||
<%= f.text_field :digital_store,
|
||||
placeholder: "e.g., Steam, PlayStation Store",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
|
||||
autocomplete: "off",
|
||||
data: {
|
||||
location_autocomplete_target: "input",
|
||||
action: "input->location-autocomplete#search"
|
||||
} %>
|
||||
|
||||
<!-- Autocomplete results dropdown -->
|
||||
<div class="hidden absolute z-20 w-full mt-1 bg-white rounded-md shadow-lg border border-gray-200 max-h-48 overflow-y-auto"
|
||||
data-location-autocomplete-target="results">
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Start typing to see previously used stores</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :price_paid, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= f.number_field :price_paid, step: 0.01, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :collection_ids, "Collections", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<% if defined?(@collections) && @collections.any? %>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto border border-gray-300 rounded-md p-3 bg-gray-50">
|
||||
<% @collections.each do |collection| %>
|
||||
<div class="flex items-start">
|
||||
<%= check_box_tag "game[collection_ids][]", collection.id,
|
||||
game.collection_ids.include?(collection.id),
|
||||
id: "game_collection_ids_#{collection.id}",
|
||||
class: "rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 mt-1" %>
|
||||
<%= label_tag "game_collection_ids_#{collection.id}", class: "ml-2 text-sm" do %>
|
||||
<span class="font-medium text-gray-900"><%= collection.name %></span>
|
||||
<% if collection.subcollection? %>
|
||||
<span class="text-gray-500 text-xs">(subcollection of <%= collection.parent_collection.name %>)</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">Select one or more collections for this game</p>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm">
|
||||
No collections yet. <%= link_to "Create a collection", new_collection_path, class: "text-indigo-600 hover:text-indigo-800" %> to organize your games.
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :notes, class: "block text-sm font-medium text-gray-700" %>
|
||||
<% if game.new_record? && current_user.igdb_sync_enabled? %>
|
||||
<%= f.text_area :notes,
|
||||
rows: 4,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
|
||||
placeholder: "Optional notes about this game (can be auto-filled from IGDB)",
|
||||
"data-igdb-search-target": "summary" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Can be auto-filled from IGDB or add your own notes</p>
|
||||
<% else %>
|
||||
<%= f.text_area :notes,
|
||||
rows: 4,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500",
|
||||
placeholder: "Optional notes about this game" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<%= f.submit class: "px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700" %>
|
||||
<%= link_to "Cancel", game.persisted? ? game : games_path, class: "px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('turbo:load', function() {
|
||||
const formatField = document.querySelector('#game_format');
|
||||
const physicalFields = document.querySelector('#physical-fields');
|
||||
const digitalFields = document.querySelector('#digital-fields');
|
||||
|
||||
if (formatField) {
|
||||
formatField.addEventListener('change', function() {
|
||||
if (this.value === 'physical') {
|
||||
physicalFields.style.display = 'block';
|
||||
digitalFields.style.display = 'none';
|
||||
} else if (this.value === 'digital') {
|
||||
physicalFields.style.display = 'none';
|
||||
digitalFields.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
4
app/views/games/bulk_create.html.erb
Normal file
4
app/views/games/bulk_create.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Games#bulk_create</h1>
|
||||
<p>Find me in app/views/games/bulk_create.html.erb</p>
|
||||
</div>
|
||||
148
app/views/games/bulk_edit.html.erb
Normal file
148
app/views/games/bulk_edit.html.erb
Normal file
@@ -0,0 +1,148 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Bulk Edit <%= pluralize(@games.count, "Game") %></h1>
|
||||
|
||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
<strong>Tip:</strong> Only fill in the fields you want to update. Empty fields will be left unchanged.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Selected Games</h2>
|
||||
<div class="space-y-1">
|
||||
<% @games.each do |game| %>
|
||||
<div class="text-sm text-gray-700">• <%= game.title %> (<%= game.platform.name %>)</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with url: bulk_update_games_path, method: :patch, class: "bg-white p-6 rounded-lg shadow space-y-6" do |f| %>
|
||||
<% @game_ids.each do |id| %>
|
||||
<%= hidden_field_tag "game_ids[]", id %>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= label_tag :completion_status, "Completion Status", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= select_tag :completion_status,
|
||||
options_for_select([
|
||||
["Don't Change", ""],
|
||||
["Backlog", "backlog"],
|
||||
["Currently Playing", "currently_playing"],
|
||||
["Completed", "completed"],
|
||||
["On Hold", "on_hold"],
|
||||
["Not Playing", "not_playing"]
|
||||
]),
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag :condition, "Condition (Physical Games)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= select_tag :condition,
|
||||
options_for_select([
|
||||
["Don't Change", ""],
|
||||
["CIB (Complete in Box)", "cib"],
|
||||
["Loose", "loose"],
|
||||
["Sealed", "sealed"],
|
||||
["Good", "good"],
|
||||
["Fair", "fair"]
|
||||
]),
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<%= label_tag :location, "Location", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag :location, "",
|
||||
placeholder: "Leave empty to not change",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Will update all selected games to this location</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Collections</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<%= label_tag :collection_action, "Collection Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= select_tag :collection_action,
|
||||
options_for_select([
|
||||
["Don't Change", ""],
|
||||
["Add to Collections", "add"],
|
||||
["Remove from Collections", "remove"],
|
||||
["Replace Collections", "replace"]
|
||||
]),
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<strong>Add:</strong> Adds to existing collections |
|
||||
<strong>Remove:</strong> Removes from selected collections |
|
||||
<strong>Replace:</strong> Sets to only selected collections
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% if @collections.any? %>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto border border-gray-300 rounded-md p-3 bg-gray-50">
|
||||
<% @collections.each do |collection| %>
|
||||
<div class="flex items-start">
|
||||
<%= check_box_tag "collection_ids[]", collection.id, false,
|
||||
id: "collection_ids_#{collection.id}",
|
||||
class: "rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 mt-1" %>
|
||||
<%= label_tag "collection_ids_#{collection.id}", class: "ml-2 text-sm" do %>
|
||||
<span class="font-medium text-gray-900"><%= collection.name %></span>
|
||||
<% if collection.subcollection? %>
|
||||
<span class="text-gray-500 text-xs">(subcollection of <%= collection.parent_collection.name %>)</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm">
|
||||
No collections yet. <%= link_to "Create a collection", new_collection_path, class: "text-indigo-600 hover:text-indigo-800" %> first.
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Genres</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<%= label_tag :genre_action, "Genre Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= select_tag :genre_action,
|
||||
options_for_select([
|
||||
["Don't Change", ""],
|
||||
["Add Genres", "add"],
|
||||
["Remove Genres", "remove"],
|
||||
["Replace Genres", "replace"]
|
||||
]),
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto border border-gray-300 rounded-md p-3 bg-gray-50">
|
||||
<% @genres.each do |genre| %>
|
||||
<div class="inline-block mr-4">
|
||||
<%= check_box_tag "genre_ids[]", genre.id, false,
|
||||
id: "genre_ids_#{genre.id}",
|
||||
class: "rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" %>
|
||||
<%= label_tag "genre_ids_#{genre.id}", genre.name, class: "ml-2 text-sm text-gray-700" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-6 border-t">
|
||||
<%= submit_tag "Update #{pluralize(@games.count, 'Game')}",
|
||||
class: "px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700",
|
||||
data: { confirm: "Are you sure you want to update #{@games.count} game(s)?" } %>
|
||||
<%= link_to "Cancel", games_path, class: "px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
4
app/views/games/create.html.erb
Normal file
4
app/views/games/create.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Games#create</h1>
|
||||
<p>Find me in app/views/games/create.html.erb</p>
|
||||
</div>
|
||||
4
app/views/games/destroy.html.erb
Normal file
4
app/views/games/destroy.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Games#destroy</h1>
|
||||
<p>Find me in app/views/games/destroy.html.erb</p>
|
||||
</div>
|
||||
7
app/views/games/edit.html.erb
Normal file
7
app/views/games/edit.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Edit Game</h1>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<%= render "form", game: @game %>
|
||||
</div>
|
||||
</div>
|
||||
50
app/views/games/import.html.erb
Normal file
50
app/views/games/import.html.erb
Normal file
@@ -0,0 +1,50 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Import Games from CSV</h1>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">CSV Format</h2>
|
||||
<p class="text-gray-600 mb-4">Your CSV file should have the following columns:</p>
|
||||
|
||||
<div class="bg-gray-100 p-4 rounded mb-4 overflow-x-auto">
|
||||
<code class="text-sm">
|
||||
title,platform,format,genres,completion_status,user_rating,condition,price_paid,location,digital_store,date_added,notes
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm text-gray-600">
|
||||
<p><strong>Required fields:</strong> title, platform, format</p>
|
||||
<p><strong>Platform:</strong> Use the full name or abbreviation (e.g., "Nintendo 64" or "N64")</p>
|
||||
<p><strong>Format:</strong> Either "physical" or "digital"</p>
|
||||
<p><strong>Genres:</strong> Separate multiple genres with | (e.g., "Action|Adventure")</p>
|
||||
<p><strong>Completion Status:</strong> backlog, currently_playing, completed, on_hold, not_playing</p>
|
||||
<p><strong>User Rating:</strong> Number from 1 to 5</p>
|
||||
<p><strong>Condition (physical only):</strong> cib, loose, sealed, good, fair</p>
|
||||
<p><strong>Date Added:</strong> YYYY-MM-DD format (defaults to today if not provided)</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<h3 class="font-bold mb-2">Example CSV:</h3>
|
||||
<div class="bg-gray-100 p-4 rounded overflow-x-auto">
|
||||
<pre class="text-xs">title,platform,format,genres,completion_status,user_rating,condition,price_paid,location,digital_store,date_added,notes
|
||||
The Legend of Zelda: Ocarina of Time,N64,physical,Action|Adventure,completed,5,cib,45.00,Shelf A,,2024-01-15,One of the best games ever
|
||||
Elden Ring,PS5,digital,Action|RPG,currently_playing,5,,,PlayStation Store,2024-03-01,Amazing open world</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Upload CSV File</h2>
|
||||
|
||||
<%= form_with url: bulk_create_games_path, multipart: true, class: "space-y-4" do |f| %>
|
||||
<div>
|
||||
<%= f.label :csv_file, "Select CSV File", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.file_field :csv_file, accept: ".csv", required: true, class: "block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" %>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<%= f.submit "Import Games", class: "px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700" %>
|
||||
<%= link_to "Cancel", games_path, class: "px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
155
app/views/games/index.html.erb
Normal file
155
app/views/games/index.html.erb
Normal file
@@ -0,0 +1,155 @@
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">My Games</h1>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div id="bulk-actions" style="display: none;" class="mr-4">
|
||||
<button type="button" onclick="bulkEdit()" class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700">
|
||||
Bulk Edit (<span id="selected-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
<%= link_to "Import CSV", import_games_path, class: "px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700" %>
|
||||
<%= link_to "Add Game", new_game_path, class: "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<%= form_with url: games_path, method: :get, class: "bg-white p-4 rounded-lg shadow mb-6" do |f| %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<%= f.text_field :search, placeholder: "Search games...", value: params[:search], class: "w-full rounded-md border-gray-300" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.select :platform_id, options_from_collection_for_select(@platforms, :id, :name, params[:platform_id]), { include_blank: "All Platforms" }, class: "w-full rounded-md border-gray-300" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.select :genre_id, options_from_collection_for_select(@genres, :id, :name, params[:genre_id]), { include_blank: "All Genres" }, class: "w-full rounded-md border-gray-300" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.select :format, options_for_select([["Physical", "physical"], ["Digital", "digital"]], params[:format]), { include_blank: "All Formats" }, class: "w-full rounded-md border-gray-300" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.select :completion_status, options_for_select([["Backlog", "backlog"], ["Currently Playing", "currently_playing"], ["Completed", "completed"], ["On Hold", "on_hold"], ["Not Playing", "not_playing"]], params[:completion_status]), { include_blank: "All Statuses" }, class: "w-full rounded-md border-gray-300" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-between">
|
||||
<%= f.submit "Filter", class: "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700" %>
|
||||
<%= link_to "Clear Filters", games_path, class: "px-4 py-2 bg-gray-200 rounded hover:bg-gray-300" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="mb-4">
|
||||
<span class="text-gray-600 mr-2">Sort by:</span>
|
||||
<%= link_to "Alphabetical", games_path(filter_params_for_sort("alphabetical")), class: "text-indigo-600 hover:text-indigo-800 mr-4" %>
|
||||
<%= link_to "Recently Added", games_path(filter_params_for_sort("recent")), class: "text-indigo-600 hover:text-indigo-800 mr-4" %>
|
||||
<%= link_to "Highest Rated", games_path(filter_params_for_sort("rated")), class: "text-indigo-600 hover:text-indigo-800" %>
|
||||
</div>
|
||||
|
||||
<!-- Games List -->
|
||||
<% if @games.any? %>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" onchange="toggleAll(this)">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Platform</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Format</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rating</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @games.each do |game| %>
|
||||
<tr>
|
||||
<td class="px-6 py-4">
|
||||
<input type="checkbox" name="game_ids[]" value="<%= game.id %>" class="game-checkbox rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" onchange="updateBulkActions()">
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<%= link_to game.title, game, class: "text-indigo-600 hover:text-indigo-800 font-medium" %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= game.platform.name %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full <%= game.physical? ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800' %>">
|
||||
<%= game.format.titleize %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= game.completion_status&.titleize || "N/A" %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= game.user_rating ? "⭐ #{game.user_rating}/5" : "Not rated" %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<%= link_to "View", game, class: "text-indigo-600 hover:text-indigo-900 mr-2" %>
|
||||
<%= link_to "Edit", edit_game_path(game), class: "text-blue-600 hover:text-blue-900 mr-2" %>
|
||||
<%= button_to "Delete", game, method: :delete, data: { turbo_confirm: "Are you sure you want to delete '#{game.title}'? This action cannot be undone." }, class: "text-red-600 hover:text-red-900" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination would go here -->
|
||||
<div class="mt-4">
|
||||
<%#= paginate @games %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white p-8 rounded-lg shadow text-center">
|
||||
<p class="text-gray-500 mb-4">No games found matching your filters.</p>
|
||||
<%= link_to "Clear Filters", games_path, class: "text-indigo-600 hover:text-indigo-800" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleAll(checkbox) {
|
||||
const gameCheckboxes = document.querySelectorAll('.game-checkbox');
|
||||
gameCheckboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkedBoxes = document.querySelectorAll('.game-checkbox:checked');
|
||||
const count = checkedBoxes.length;
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.style.display = 'block';
|
||||
selectedCount.textContent = count;
|
||||
} else {
|
||||
bulkActions.style.display = 'none';
|
||||
document.getElementById('select-all').checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bulkEdit() {
|
||||
const checkedBoxes = document.querySelectorAll('.game-checkbox:checked');
|
||||
const gameIds = Array.from(checkedBoxes).map(cb => cb.value);
|
||||
|
||||
if (gameIds.length === 0) {
|
||||
alert('Please select at least one game');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build URL with game IDs
|
||||
const params = new URLSearchParams();
|
||||
gameIds.forEach(id => params.append('game_ids[]', id));
|
||||
|
||||
window.location.href = '<%= bulk_edit_games_path %>?' + params.toString();
|
||||
}
|
||||
</script>
|
||||
7
app/views/games/new.html.erb
Normal file
7
app/views/games/new.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Add New Game</h1>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<%= render "form", game: @game %>
|
||||
</div>
|
||||
</div>
|
||||
169
app/views/games/show.html.erb
Normal file
169
app/views/games/show.html.erb
Normal file
@@ -0,0 +1,169 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div class="flex gap-6">
|
||||
<!-- IGDB Cover Image -->
|
||||
<% if @game.igdb_game&.cover_url.present? %>
|
||||
<div class="flex-shrink-0">
|
||||
<%= image_tag @game.igdb_game.cover_image_url("cover_big"),
|
||||
alt: @game.title,
|
||||
class: "w-32 h-44 object-cover rounded-lg shadow-lg" %>
|
||||
<div class="mt-2 text-center">
|
||||
<span class="text-xs text-gray-500 flex items-center justify-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z"/>
|
||||
</svg>
|
||||
IGDB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Title and Platform -->
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2"><%= @game.title %></h1>
|
||||
<p class="text-gray-600 mb-2"><%= @game.platform.name %></p>
|
||||
|
||||
<!-- IGDB Match Status -->
|
||||
<% if @game.igdb_id.present? %>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="px-2 py-1 bg-green-100 text-green-800 text-xs font-semibold rounded">
|
||||
✓ IGDB Matched
|
||||
</span>
|
||||
<% if @game.igdb_match_confidence %>
|
||||
<span class="text-xs text-gray-500">
|
||||
<%= @game.igdb_match_confidence.to_i %>% confidence
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- IGDB Release Date -->
|
||||
<% if @game.igdb_game&.first_release_date %>
|
||||
<p class="text-sm text-gray-600">
|
||||
<strong>Released:</strong> <%= @game.igdb_game.first_release_date.strftime("%B %Y") %>
|
||||
</p>
|
||||
<% end %>
|
||||
<% elsif current_user.igdb_sync_enabled? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
No IGDB Match
|
||||
</span>
|
||||
<%= link_to "Find Match", igdb_matches_path, class: "text-xs text-indigo-600 hover:text-indigo-800" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<%= link_to "Edit", edit_game_path(@game), class: "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700" %>
|
||||
<%= button_to "Delete", @game, method: :delete, data: { turbo_confirm: "Are you sure you want to delete '#{@game.title}'? This will permanently remove it from your collection." }, class: "px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IGDB Summary -->
|
||||
<% if @game.igdb_game&.summary.present? %>
|
||||
<div class="mb-6 p-4 bg-gray-50 rounded-lg border-l-4 border-indigo-500">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">About this game (from IGDB)</h3>
|
||||
<p class="text-gray-700 text-sm leading-relaxed"><%= @game.igdb_game.summary %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Format</label>
|
||||
<span class="px-3 py-1 inline-flex text-sm font-semibold rounded-full <%= @game.physical? ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800' %>">
|
||||
<%= @game.format.titleize %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Date Added</label>
|
||||
<p class="text-gray-900"><%= @game.date_added.strftime("%B %d, %Y") %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Completion Status</label>
|
||||
<p class="text-gray-900"><%= @game.completion_status&.titleize || "Not set" %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Rating</label>
|
||||
<p class="text-gray-900"><%= @game.user_rating ? "⭐ #{@game.user_rating}/5" : "Not rated" %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @game.genres.any? %>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Genres</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% @game.genres.each do |genre| %>
|
||||
<span class="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-sm"><%= genre.name %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @game.physical? %>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-3">Physical Details</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Condition</label>
|
||||
<p class="text-gray-900"><%= @game.condition&.titleize || "Not specified" %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Price Paid</label>
|
||||
<p class="text-gray-900"><%= @game.price_paid ? "$#{sprintf("%.2f", @game.price_paid)}" : "Not specified" %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Location</label>
|
||||
<p class="text-gray-900"><%= @game.location || "Not specified" %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @game.digital? %>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-3">Digital Details</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Digital Store</label>
|
||||
<p class="text-gray-900"><%= @game.digital_store || "Not specified" %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Price Paid</label>
|
||||
<p class="text-gray-900"><%= @game.price_paid ? "$#{sprintf("%.2f", @game.price_paid)}" : "Not specified" %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @game.notes.present? %>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||
<p class="text-gray-900 whitespace-pre-wrap"><%= @game.notes %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Collections</label>
|
||||
<% if @game.collections.any? %>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% @game.collections.each do |collection| %>
|
||||
<%= link_to collection.name, collection, class: "px-3 py-1 bg-indigo-100 text-indigo-700 rounded-full text-sm hover:bg-indigo-200" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm">Not in any collections. <%= link_to "Edit game", edit_game_path(@game), class: "text-indigo-600 hover:text-indigo-800" %> to add to collections.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-6 border-t">
|
||||
<%= link_to "← Back to Games", games_path, class: "text-indigo-600 hover:text-indigo-800" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
4
app/views/games/update.html.erb
Normal file
4
app/views/games/update.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Games#update</h1>
|
||||
<p>Find me in app/views/games/update.html.erb</p>
|
||||
</div>
|
||||
4
app/views/igdb_matches/approve.html.erb
Normal file
4
app/views/igdb_matches/approve.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">IgdbMatches#approve</h1>
|
||||
<p>Find me in app/views/igdb_matches/approve.html.erb</p>
|
||||
</div>
|
||||
193
app/views/igdb_matches/index.html.erb
Normal file
193
app/views/igdb_matches/index.html.erb
Normal file
@@ -0,0 +1,193 @@
|
||||
<%
|
||||
# Auto-refresh if user just started a sync or has games being processed
|
||||
has_unmatched = @unmatched_games > 0 && @pending_review_count == 0
|
||||
just_synced = current_user.igdb_last_synced_at && current_user.igdb_last_synced_at > 5.minutes.ago
|
||||
should_auto_refresh = has_unmatched && just_synced
|
||||
%>
|
||||
|
||||
<% if should_auto_refresh %>
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<div class="bg-blue-100 border-l-4 border-blue-500 p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="animate-spin h-5 w-5 text-blue-500 mr-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-blue-700">Processing games... Page will auto-refresh every 30 seconds.</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">IGDB Game Matching</h1>
|
||||
<%= button_to "Sync Now", sync_now_igdb_matches_path, method: :post, class: "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700" %>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-gray-500 text-sm">Matched Games</div>
|
||||
<div class="text-3xl font-bold text-green-600"><%= @matched_games %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-gray-500 text-sm">Unmatched Games</div>
|
||||
<div class="text-3xl font-bold text-gray-600"><%= @unmatched_games %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-6 rounded-lg shadow">
|
||||
<div class="text-gray-500 text-sm">Pending Review</div>
|
||||
<div class="text-3xl font-bold text-yellow-600"><%= @pending_review_count %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if current_user.igdb_last_synced_at %>
|
||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6">
|
||||
<p class="text-sm text-blue-700">
|
||||
<strong>Last synced:</strong> <%= time_ago_in_words(current_user.igdb_last_synced_at) %> ago
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @pending_suggestions.empty? %>
|
||||
<div class="bg-white p-8 rounded-lg shadow text-center">
|
||||
<% if @unmatched_games > 0 %>
|
||||
<p class="text-gray-600 mb-4">No pending matches to review. Click "Sync Now" to search IGDB for your games!</p>
|
||||
<%= button_to "Sync Now", sync_now_igdb_matches_path, method: :post, class: "px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700" %>
|
||||
<% else %>
|
||||
<p class="text-gray-600">All your games are matched with IGDB! 🎉</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-8">
|
||||
<% @pending_suggestions.each do |game, suggestions| %>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<!-- Game Header -->
|
||||
<div class="bg-gray-50 px-6 py-4 border-b">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900"><%= game.title %></h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
<%= game.platform.name %> · <%= game.format.titleize %>
|
||||
<% if game.date_added %>
|
||||
· Added <%= game.date_added.strftime("%b %Y") %>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full <%=
|
||||
case game.igdb_match_status
|
||||
when 'high_confidence' then 'bg-green-100 text-green-800'
|
||||
when 'medium_confidence' then 'bg-yellow-100 text-yellow-800'
|
||||
else 'bg-gray-100 text-gray-800'
|
||||
end
|
||||
%>">
|
||||
<%= game.igdb_match_status&.titleize || 'Needs Review' %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggestions -->
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">
|
||||
Suggested Matches from IGDB:
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% suggestions.each do |suggestion| %>
|
||||
<div class="border rounded-lg p-4 hover:bg-gray-50 transition">
|
||||
<div class="flex gap-4">
|
||||
<!-- Cover Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<% if suggestion.igdb_cover_url.present? %>
|
||||
<%= image_tag suggestion.cover_image_url("cover_big"),
|
||||
alt: suggestion.igdb_name,
|
||||
class: "w-24 h-32 object-cover rounded shadow" %>
|
||||
<% else %>
|
||||
<div class="w-24 h-32 bg-gray-200 rounded flex items-center justify-center">
|
||||
<span class="text-gray-400 text-xs">No Cover</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Match Info -->
|
||||
<div class="flex-1">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-lg font-semibold text-gray-900"><%= suggestion.igdb_name %></h4>
|
||||
<div class="text-sm text-gray-600 space-x-2 mb-2">
|
||||
<span><%= suggestion.igdb_platform_name %></span>
|
||||
<% if suggestion.igdb_release_date %>
|
||||
<span>·</span>
|
||||
<span><%= suggestion.igdb_release_date.year %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<% if suggestion.igdb_summary.present? %>
|
||||
<p class="text-sm text-gray-700 leading-relaxed mb-2" style="display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;">
|
||||
<%= suggestion.igdb_summary %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<!-- Genres -->
|
||||
<% if suggestion.igdb_genres.present? && suggestion.igdb_genres.any? %>
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
<% suggestion.igdb_genres.first(5).each do |genre| %>
|
||||
<span class="px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded text-xs">
|
||||
<%= genre %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Confidence Score -->
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold <%=
|
||||
if suggestion.confidence_score >= 80
|
||||
'text-green-600'
|
||||
elsif suggestion.confidence_score >= 60
|
||||
'text-yellow-600'
|
||||
else
|
||||
'text-gray-600'
|
||||
end
|
||||
%>">
|
||||
<%= suggestion.confidence_score.to_i %>%
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">match</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-2 mt-4">
|
||||
<%= button_to "✓ Approve This Match", approve_igdb_match_path(suggestion),
|
||||
method: :post,
|
||||
class: "px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm font-medium",
|
||||
data: { turbo_confirm: "Link '#{suggestion.game.title}' to '#{suggestion.igdb_name}'? This will import cover art, genres, and metadata." } %>
|
||||
|
||||
<%= button_to "✗ Not This One", reject_igdb_match_path(suggestion),
|
||||
method: :post,
|
||||
class: "px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-sm font-medium",
|
||||
data: { turbo_confirm: "Reject this match for '#{suggestion.game.title}'?" } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Reject All Option -->
|
||||
<div class="mt-6 pt-6 border-t">
|
||||
<p class="text-sm text-gray-600 mb-2">None of these match?</p>
|
||||
<%= button_to "Reject All Suggestions", reject_igdb_match_path(suggestions.first),
|
||||
method: :post,
|
||||
class: "px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200 text-sm font-medium",
|
||||
data: { turbo_confirm: "Reject all #{suggestions.count} suggestions for '#{game.title}'? You can manually match it later if needed." } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
4
app/views/igdb_matches/reject.html.erb
Normal file
4
app/views/igdb_matches/reject.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">IgdbMatches#reject</h1>
|
||||
<p>Find me in app/views/igdb_matches/reject.html.erb</p>
|
||||
</div>
|
||||
4
app/views/igdb_matches/sync_now.html.erb
Normal file
4
app/views/igdb_matches/sync_now.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">IgdbMatches#sync_now</h1>
|
||||
<p>Find me in app/views/igdb_matches/sync_now.html.erb</p>
|
||||
</div>
|
||||
4
app/views/items/create.html.erb
Normal file
4
app/views/items/create.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Items#create</h1>
|
||||
<p>Find me in app/views/items/create.html.erb</p>
|
||||
</div>
|
||||
4
app/views/items/destroy.html.erb
Normal file
4
app/views/items/destroy.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Items#destroy</h1>
|
||||
<p>Find me in app/views/items/destroy.html.erb</p>
|
||||
</div>
|
||||
4
app/views/items/edit.html.erb
Normal file
4
app/views/items/edit.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Items#edit</h1>
|
||||
<p>Find me in app/views/items/edit.html.erb</p>
|
||||
</div>
|
||||
4
app/views/items/index.html.erb
Normal file
4
app/views/items/index.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Items#index</h1>
|
||||
<p>Find me in app/views/items/index.html.erb</p>
|
||||
</div>
|
||||
4
app/views/items/new.html.erb
Normal file
4
app/views/items/new.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Items#new</h1>
|
||||
<p>Find me in app/views/items/new.html.erb</p>
|
||||
</div>
|
||||
4
app/views/items/show.html.erb
Normal file
4
app/views/items/show.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Items#show</h1>
|
||||
<p>Find me in app/views/items/show.html.erb</p>
|
||||
</div>
|
||||
4
app/views/items/update.html.erb
Normal file
4
app/views/items/update.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<h1 class="font-bold text-4xl">Items#update</h1>
|
||||
<p>Find me in app/views/items/update.html.erb</p>
|
||||
</div>
|
||||
13
app/views/layouts/_flash.html.erb
Normal file
13
app/views/layouts/_flash.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<div class="container mx-auto px-4 mt-4">
|
||||
<% if flash[:notice] %>
|
||||
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<span class="block sm:inline"><%= flash[:notice] %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if flash[:alert] %>
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<span class="block sm:inline"><%= flash[:alert] %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
77
app/views/layouts/_footer.html.erb
Normal file
77
app/views/layouts/_footer.html.erb
Normal file
@@ -0,0 +1,77 @@
|
||||
<footer class="bg-gray-800 text-white mt-12">
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- About -->
|
||||
<div>
|
||||
<h3 class="text-lg font-bold mb-4">TurboVault</h3>
|
||||
<p class="text-gray-400 text-sm">
|
||||
Your personal video game collection tracker. Organize, track, and showcase your gaming library.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div>
|
||||
<h3 class="text-lg font-bold mb-4">Quick Links</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<% if user_signed_in? %>
|
||||
<li><%= link_to "Dashboard", dashboard_path, class: "text-gray-400 hover:text-white" %></li>
|
||||
<li><%= link_to "Games", games_path, class: "text-gray-400 hover:text-white" %></li>
|
||||
<li><%= link_to "Collections", collections_path, class: "text-gray-400 hover:text-white" %></li>
|
||||
<li><%= link_to "Settings", settings_path, class: "text-gray-400 hover:text-white" %></li>
|
||||
<% else %>
|
||||
<li><%= link_to "Home", root_path, class: "text-gray-400 hover:text-white" %></li>
|
||||
<li><%= link_to "Sign Up", signup_path, class: "text-gray-400 hover:text-white" %></li>
|
||||
<li><%= link_to "Login", login_path, class: "text-gray-400 hover:text-white" %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div>
|
||||
<h3 class="text-lg font-bold mb-4">Features</h3>
|
||||
<ul class="space-y-2 text-sm text-gray-400">
|
||||
<li>📚 Track Physical & Digital Games</li>
|
||||
<li>🎮 IGDB Integration</li>
|
||||
<li>📊 Collection Statistics</li>
|
||||
<li>🔐 RESTful API Access</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Resources -->
|
||||
<div>
|
||||
<h3 class="text-lg font-bold mb-4">Resources</h3>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><%= link_to "API Documentation", api_docs_path, class: "text-gray-400 hover:text-white" %></li>
|
||||
<li>
|
||||
<a href="https://github.com/yourusername/turbovault" target="_blank" class="text-gray-400 hover:text-white">
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.igdb.com" target="_blank" class="text-gray-400 hover:text-white">
|
||||
Powered by IGDB
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
<div class="border-t border-gray-700 mt-8 pt-8 text-center text-sm text-gray-400">
|
||||
<p>© <%= Time.current.year %> TurboVault. Built with Rails 8 & Hotwire.</p>
|
||||
<p class="mt-1">
|
||||
Game data supplied by
|
||||
<a href="https://www.igdb.com/" target="_blank" rel="noopener noreferrer" class="text-indigo-400 hover:text-indigo-300">
|
||||
IGDB
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<% if user_signed_in? %>
|
||||
Logged in as <strong><%= current_user.username %></strong>
|
||||
<% else %>
|
||||
<%= link_to "Create an account", signup_path, class: "text-indigo-400 hover:text-indigo-300" %> to get started
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
47
app/views/layouts/_navigation.html.erb
Normal file
47
app/views/layouts/_navigation.html.erb
Normal file
@@ -0,0 +1,47 @@
|
||||
<nav class="bg-white shadow-lg">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center py-4">
|
||||
<div class="flex items-center space-x-8">
|
||||
<%= link_to "TurboVault", root_path, class: "text-2xl font-bold text-indigo-600" %>
|
||||
|
||||
<% if user_signed_in? %>
|
||||
<div class="hidden md:flex space-x-4">
|
||||
<%= link_to "Dashboard", dashboard_path, class: "text-gray-700 hover:text-indigo-600" %>
|
||||
<%= link_to "Games", games_path, class: "text-gray-700 hover:text-indigo-600" %>
|
||||
<%= link_to "Collections", collections_path, class: "text-gray-700 hover:text-indigo-600" %>
|
||||
<% if current_user.igdb_sync_enabled? %>
|
||||
<%
|
||||
# Count games with pending suggestions (not total suggestions)
|
||||
pending_count = current_user.games
|
||||
.igdb_unmatched
|
||||
.joins(:igdb_match_suggestions)
|
||||
.where(igdb_match_suggestions: { status: 'pending' })
|
||||
.distinct
|
||||
.count
|
||||
%>
|
||||
<%= link_to igdb_matches_path, class: "text-gray-700 hover:text-indigo-600 relative" do %>
|
||||
IGDB
|
||||
<% if pending_count > 0 %>
|
||||
<span class="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
|
||||
<%= pending_count %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<% if user_signed_in? %>
|
||||
<%= link_to "Profile", profile_path(current_user.username), class: "text-gray-700 hover:text-indigo-600" %>
|
||||
<%= link_to "Settings", settings_path, class: "text-gray-700 hover:text-indigo-600" %>
|
||||
<%= button_to "Logout", logout_path, method: :delete, class: "px-4 py-2 bg-gray-200 rounded hover:bg-gray-300" %>
|
||||
<% else %>
|
||||
<%= link_to "Login", login_path, class: "text-gray-700 hover:text-indigo-600" %>
|
||||
<%= link_to "Sign Up", signup_path, class: "px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user