Moving to github

This commit is contained in:
2026-03-28 19:24:29 -04:00
commit 036fa7ab33
302 changed files with 17838 additions and 0 deletions

51
.dockerignore Normal file
View File

@@ -0,0 +1,51 @@
# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.
# Ignore git directory.
/.git/
/.gitignore
# Ignore bundler config.
/.bundle
# Ignore all environment files.
/.env*
# Ignore all default key files.
/config/master.key
/config/credentials/*.key
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/.keep
# Ignore assets.
/node_modules/
/app/assets/builds/*
!/app/assets/builds/.keep
/public/assets
# Ignore CI service files.
/.github
# Ignore Kamal files.
/config/deploy*.yml
/.kamal
# Ignore development files
/.devcontainer
# Ignore Docker-related files
/.dockerignore
/Dockerfile*

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# Database Configuration
DATABASE_HOST=localhost
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
# Rails Configuration
RAILS_ENV=development
RAILS_MAX_THREADS=5
# Production secrets (set these in production)
# SECRET_KEY_BASE=
# DATABASE_PASSWORD=
# Optional: Email Configuration (for production)
# SMTP_ADDRESS=
# SMTP_PORT=
# SMTP_USERNAME=
# SMTP_PASSWORD=
# IGDB API Credentials (Backend only - DO NOT expose to frontend)
# Get credentials from: https://api-docs.igdb.com/#account-creation
# Register app at: https://dev.twitch.tv/console/apps
IGDB_CLIENT_ID=your_client_id_here
IGDB_CLIENT_SECRET=your_client_secret_here

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use nix

9
.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
# See https://git-scm.com/docs/gitattributes for more about git attribute files.
# Mark the database schema as having been generated.
db/schema.rb linguist-generated
# Mark any vendored files as having been vendored.
vendor/* linguist-vendored
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials

96
.github/DEVELOPMENT.md vendored Normal file
View File

@@ -0,0 +1,96 @@
# Development Setup
## Environment Management
This project uses **Nix with direnv** for reproducible development environments.
### What You Get Automatically
When you `cd` into the project directory, direnv automatically provides:
- Ruby 3.3
- Go Task (task runner)
- Docker & Docker Compose
- PostgreSQL 16 client
- Build tools (gcc, make, pkg-config)
- Required libraries (libyaml, libffi, openssl, zlib)
### First Time Setup
1. **Install Nix** (if not already installed)
```bash
sh <(curl -L https://nixos.org/nix/install) --daemon
```
2. **Install direnv** (if not already installed)
```bash
# macOS
brew install direnv
# Or via Nix
nix-env -iA nixpkgs.direnv
```
3. **Configure your shell** (add to `~/.bashrc` or `~/.zshrc`)
```bash
eval "$(direnv hook bash)" # for bash
eval "$(direnv hook zsh)" # for zsh
```
4. **Allow direnv in project**
```bash
cd turbovault-web
direnv allow
```
That's it! All dependencies are now available.
## Quick Start
```bash
# Setup everything
task setup
# Start development
task server
# See all available commands
task
```
## Available Tasks
Run `task` or `task --list` to see all available commands.
Key tasks:
- `task setup` - Complete initial setup
- `task server` - Start Rails server
- `task test` - Run tests
- `task db:reset` - Reset database
- `task docker:logs` - View PostgreSQL logs
## Nix Configuration
The `shell.nix` file defines all dependencies. If you need to add more tools:
1. Edit `shell.nix`
2. Reload: `direnv reload`
## Troubleshooting
### direnv not loading
```bash
direnv allow
```
### Missing dependencies
```bash
# Reload Nix shell
direnv reload
# Or exit and re-enter directory
cd .. && cd turbovault-web
```
### Update Nix packages
Edit the nixpkgs URL in `shell.nix` to a newer version if needed.

231
.github/SECRETS_SETUP.md vendored Normal file
View File

@@ -0,0 +1,231 @@
# GitHub Secrets Setup
This guide explains how to configure GitHub Secrets for the CI/CD workflows.
## Default: GitHub Container Registry
**Good news!** The default workflow uses GitHub Container Registry (ghcr.io), which requires **no secrets** to set up. It uses the built-in `GITHUB_TOKEN` automatically.
Just push a tag and it works:
```bash
git tag v1.0.0
git push origin v1.0.0
```
Your image will be at: `ghcr.io/YOUR_USERNAME/turbovault:v1.0.0`
## Optional: Use a Different Registry
If you want to use a different registry (Docker Hub, private registry, etc.), you'll need to modify the workflow and add secrets.
### Example: Docker Hub
Edit `.github/workflows/build-and-push.yml`:
```yaml
env:
REGISTRY: docker.io
IMAGE_NAME: your-username/turbovault
```
Add secrets:
- `DOCKERHUB_USERNAME` - Your Docker Hub username
- `DOCKERHUB_TOKEN` - Access token from Docker Hub
Update login step:
```yaml
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
```
### Example: Private Registry
Edit `.github/workflows/build-and-push.yml`:
```yaml
env:
REGISTRY: registry.example.com
IMAGE_NAME: turbovault
```
Add secrets:
- `REGISTRY_USERNAME` - Registry username
- `REGISTRY_PASSWORD` - Registry password/token
Update login step:
```yaml
- name: Log in to Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
```
## Verify Setup
### Test the Workflow
**Option A: Create a tag (triggers automatically)**
```bash
git tag v1.0.0
git push origin v1.0.0
```
The workflow will automatically build and push to GitHub Container Registry.
**Option B: Manual trigger**
1. Go to **Actions** tab
2. Click **Build and Push Docker Image**
3. Click **Run workflow**
4. Enter tag: `test`
5. Click **Run workflow**
6. Watch the build progress
### View Your Images
**GitHub Container Registry:**
1. Go to your GitHub profile
2. Click **Packages** tab
3. You'll see `turbovault` package
4. Images are at: `ghcr.io/YOUR_USERNAME/turbovault:TAG`
## Troubleshooting
### Error: "denied: permission_denied"
**Cause:** GitHub Container Registry permissions issue
**Fix:**
1. Make sure your repository has `packages: write` permission
2. The workflow already has this set:
```yaml
permissions:
contents: read
packages: write
```
### Error: "unable to login to registry"
**Cause:** Using custom registry without proper credentials
**Fix:**
1. Verify secrets are set correctly
2. Test locally:
```bash
docker login your-registry.com
```
3. Update workflow with correct registry URL and credentials
### Workflow doesn't trigger
**Cause:** Wrong tag format
**Fix:**
- Workflow triggers on tags like `v1.0.0`, `v2.1.0`
- Must start with `v`
- Or use manual trigger (workflow_dispatch)
### Image not appearing in packages
**Cause:** First time pushing to ghcr.io
**Fix:**
1. Go to your GitHub profile → Packages
2. Find `turbovault` package
3. Click **Package settings**
4. Make it public (if desired):
- Change visibility to **Public**
5. Link to repository for better discoverability
## Security Best Practices
### ✅ DO:
- Use access tokens (not passwords)
- Set minimum required permissions
- Rotate tokens regularly
- Use organization secrets for team projects
- Use GitHub Container Registry (free, built-in, secure)
### ❌ DON'T:
- Commit secrets to repository
- Share tokens publicly
- Use personal passwords
- Give tokens more permissions than needed
## Advanced: Environment Secrets
For multiple environments (staging, production):
1. Create **Environments** in GitHub:
- Settings → Environments → New environment
- Name: `production`
2. Add environment-specific secrets:
- `PRODUCTION_GITEA_TOKEN`
- `STAGING_GITEA_TOKEN`
3. Update workflow to use environment:
```yaml
jobs:
build:
environment: production
steps:
- run: echo "${{ secrets.PRODUCTION_GITEA_TOKEN }}"
```
## Workflow Files
Your repository includes these workflows:
### `.github/workflows/build-and-push.yml`
- **Triggers:** Git tags (v*) or manual
- **Purpose:** Build Docker image and push to GitHub Container Registry
- **Required secrets:** None (uses built-in `GITHUB_TOKEN`)
### `.github/workflows/ci.yml`
- **Triggers:** Push to main/develop, Pull Requests
- **Purpose:** Run tests, linting, security scans
- **Required secrets:** None (RAILS_MASTER_KEY optional)
## Getting Started Checklist
### Using GitHub Container Registry (Default - No Setup Required!)
- [ ] Push code to GitHub
- [ ] Create a tag: `git tag v1.0.0 && git push origin v1.0.0`
- [ ] Watch Actions tab for the build
- [ ] View image in GitHub Packages
- [ ] Update k8s deployment with new image
- [ ] Deploy to Kubernetes
### Using Custom Registry (Optional)
- [ ] Create registry access token
- [ ] Add registry secrets to GitHub
- [ ] Modify `.github/workflows/build-and-push.yml`
- [ ] Test workflow manually
- [ ] Verify image appears in your registry
- [ ] Deploy to Kubernetes
## Support
If you encounter issues:
1. Check the Actions logs: `https://github.com/YOUR_USERNAME/turbovault/actions`
2. Read the error messages carefully
3. For custom registries, test login locally first
4. Open an issue if you're stuck
---
**Next Steps:** Create a tag to trigger your first build:
```bash
git tag v1.0.0
git push origin v1.0.0
```
Then check the **Actions** tab and **Packages** tab to see your image! 🚀

185
.github/WHAT_TO_COMMIT.md vendored Normal file
View File

@@ -0,0 +1,185 @@
# What to Commit to GitHub (Open Source)
Quick reference for what should and shouldn't be committed to the public GitHub repository.
## ✅ Safe to Commit
### Source Code
- ✅ All Ruby files (`app/`, `lib/`, `config/`)
-`Gemfile` and `Gemfile.lock`
- ✅ Controllers, models, views
- ✅ Migrations (don't contain secrets)
- ✅ Seeds (use fake/example data only)
### Configuration
-`config/database.yml` (uses ENV vars)
-`config/routes.rb`
-`config/environments/*.rb`
-`.env.example` (template only)
-`Dockerfile`
-`docker-compose.yml` (development version)
### Kubernetes
-`k8s/deployment.yaml` (with placeholder image)
-`k8s/service.yaml`
-`k8s/ingress.yaml`
-`k8s/configmap.yaml` (example values)
-`k8s/namespace.yaml`
-`k8s/migrate-job.yaml`
-`k8s/*.yaml.example` (all templates)
-`k8s/README.md`
-`k8s/GITEA_SETUP.md`
### GitHub Actions
-`.github/workflows/*.yml`
-`.github/SECRETS_SETUP.md`
-`.github/WHAT_TO_COMMIT.md` (this file!)
### Documentation
-`README.md`
-`LICENSE`
-`DEPLOYMENT.md`
-`API_DOCUMENTATION.md`
- ✅ All other `.md` files
### Assets
- ✅ JavaScript controllers
- ✅ CSS/Tailwind files
- ✅ Images, icons
### Testing
-`test/` directory
- ✅ Test fixtures
-`.rubocop.yml`
## ❌ Never Commit (Already Gitignored)
### Secrets & Credentials
-`.env` (actual environment variables)
-`k8s/secrets.yaml` (actual Kubernetes secrets)
-`config/master.key`
-`config/credentials/*.key`
- ❌ Any file containing passwords, tokens, or API keys
### Generated Files
-`log/*.log`
-`tmp/**`
-`public/assets/**` (compiled assets)
-`node_modules/`
-`coverage/`
-`.byebug_history`
### Database
-`*.sqlite3`
- ❌ Database dumps
-`dump.rdb`
### Local Environment
-`.DS_Store`
-`.idea/` (IDE files)
-`.vscode/`
-`*.swp`, `*.swo`
### Docker
-`docker-compose.override.yml` (local overrides)
## 🔍 Current .gitignore
Your `.gitignore` file already covers all sensitive files:
```gitignore
/.env
/.env.local
/config/master.key
k8s/secrets.yaml
k8s/sealed-secrets.yaml
```
These patterns prevent accidental commits of secrets.
## 🛡️ Double Check Before Pushing
Before pushing to GitHub, always verify:
```bash
# Check what will be committed
git status
# Review changes
git diff
# Ensure no secrets
grep -r "password\|token\|secret\|key" --include="*.rb" --include="*.yml" | grep -v ".example"
```
## ⚠️ If You Accidentally Commit a Secret
1. **Immediately revoke the secret** (regenerate token, change password)
2. Remove from git history:
```bash
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch path/to/file' \
--prune-empty --tag-name-filter cat -- --all
```
3. Force push: `git push origin main --force`
4. Rotate all credentials
5. Consider the secret compromised
Better: Use [BFG Repo-Cleaner](https://rtyley.github.io/bfg-repo-cleaner/) or GitHub's secret scanning.
## 📦 What Gets Built vs What Gets Committed
### Committed to GitHub (Source)
```
Source Code (.rb, .js, .css)
Configuration Templates (.example files)
Kubernetes Manifests (with placeholders)
Documentation (.md files)
```
### Built by GitHub Actions (Artifacts)
```
Source Code
Docker Build
Docker Image
Pushed to Gitea Registry (PRIVATE)
Deployed to Kubernetes
```
## 🔄 Workflow
1. **Code** → Push to GitHub (public)
2. **GitHub Actions** → Build Docker image
3. **GitHub Actions** → Push to Gitea (private)
4. **Kubernetes** → Pull from Gitea
5. **Deploy** → Run your app
## ✨ Summary
| Item | GitHub | Gitea | k8s |
|------|--------|-------|-----|
| Source Code | ✅ Public | 🔄 Mirror | ❌ |
| Docker Images | ❌ | ✅ Private | 🔽 Pull |
| Secrets | ❌ | ❌ | ✅ Encrypted |
| Documentation | ✅ Public | 🔄 Mirror | ❌ |
| k8s Manifests | ✅ Templates | ❌ | ✅ Applied |
## Questions?
- "Can I commit database.yml?" → ✅ Yes (if it uses ENV vars, not hardcoded passwords)
- "Can I commit Dockerfile?" → ✅ Yes (it's build instructions, not secrets)
- "Can I commit my .env?" → ❌ NO! Use .env.example
- "Can I commit k8s/secrets.yaml?" → ❌ NO! Use secrets.yaml.example
- "Should I commit migrations?" → ✅ Yes
- "Should I commit seeds.rb?" → ✅ Yes (but use fake data, not real user data)
---
**Remember:** When in doubt, don't commit. You can always add files later, but removing secrets from history is painful.

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: bundler
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10

78
.github/workflows/build-and-push.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Build and Push Docker Image
on:
# Trigger on version tags
push:
tags:
- 'v*.*.*'
# Allow manual triggering
workflow_dispatch:
inputs:
tag:
description: 'Image tag (e.g., v1.0.0 or latest)'
required: true
default: 'latest'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Determine image tag
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image pushed successfully
run: |
echo "✅ Image built and pushed!"
echo "📦 Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}"
echo ""
echo "🚀 Deploy to Kubernetes:"
echo "kubectl set image deployment/turbovault turbovault=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }} -n turbovault"

100
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Run RuboCop (linting)
run: bundle exec rubocop --parallel
continue-on-error: true # Don't fail build on style issues
- name: Run Brakeman (security scan)
run: bundle exec brakeman -q --no-pager
continue-on-error: true
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: turbovault
POSTGRES_PASSWORD: postgres
POSTGRES_DB: turbovault_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_HOST: localhost
DATABASE_USERNAME: turbovault
DATABASE_PASSWORD: postgres
DATABASE_NAME: turbovault_test
RAILS_ENV: test
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpq-dev
- name: Setup database
run: |
bundle exec rails db:create
bundle exec rails db:schema:load
- name: Run tests
run: bundle exec rails test
- name: Report coverage
if: always()
run: echo "✅ Tests completed"
build-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image (test)
run: docker build -t turbovault:test .
- name: Test Docker image
run: |
docker run --rm turbovault:test bundle exec ruby --version
echo "✅ Docker image builds successfully"

82
.gitignore vendored Normal file
View File

@@ -0,0 +1,82 @@
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
# Dependencies
/node_modules
/vendor/bundle
# Environment variables
/.env
/.env.local
/.env*.local
# Ignore bundler config.
/.bundle
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
!/tmp/pids/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/.keep
# Ignore storage (uploaded files in development)
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/.keep
# Ignore assets.
/public/assets
/public/packs
/public/packs-test
# Ignore master key for decrypting credentials and more.
/config/master.key
/config/credentials/*.key
# Ignore database files
*.sqlite3
*.sqlite3-journal
*.sqlite3-*
# Ignore test coverage
/coverage/
# Ignore Byebug command history file.
.byebug_history
# Ignore node_modules
node_modules/
# Ignore yarn files
/yarn-error.log
yarn-debug.log*
.yarn-integrity
# Ignore uploaded files
/public/uploads
# Ignore Redis files
dump.rdb
# Ignore bootsnap cache
/tmp/cache/bootsnap*
# Ignore editor files
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# Ignore Docker files for local development
docker-compose.override.yml
# Ignore Kubernetes secrets
k8s/secrets.yaml
k8s/sealed-secrets.yaml

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Docker set up on $KAMAL_HOSTS..."

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."

14
.kamal/hooks/post-deploy.sample Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# A sample post-deploy hook
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."

51
.kamal/hooks/pre-build.sample Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/sh
# A sample pre-build hook
#
# Checks:
# 1. We have a clean checkout
# 2. A remote is configured
# 3. The branch has been pushed to the remote
# 4. The version we are deploying matches the remote
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
if [ -n "$(git status --porcelain)" ]; then
echo "Git checkout is not clean, aborting..." >&2
git status --porcelain >&2
exit 1
fi
first_remote=$(git remote)
if [ -z "$first_remote" ]; then
echo "No git remote set, aborting..." >&2
exit 1
fi
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
echo "Not on a git branch, aborting..." >&2
exit 1
fi
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
if [ -z "$remote_head" ]; then
echo "Branch not pushed to remote, aborting..." >&2
exit 1
fi
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
exit 1
fi
exit 0

47
.kamal/hooks/pre-connect.sample Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env ruby
# A sample pre-connect check
#
# Warms DNS before connecting to hosts in parallel
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
hosts = ENV["KAMAL_HOSTS"].split(",")
results = nil
max = 3
elapsed = Benchmark.realtime do
results = hosts.map do |host|
Thread.new do
tries = 1
begin
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
rescue SocketError
if tries < max
puts "Retrying DNS warmup: #{host}"
tries += 1
sleep rand
retry
else
puts "DNS warmup failed: #{host}"
host
end
end
tries
end
end.map(&:value)
end
retries = results.sum - hosts.size
nopes = results.count { |r| r == max }
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]

122
.kamal/hooks/pre-deploy.sample Executable file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env ruby
# A sample pre-deploy hook
#
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
#
# Fails unless the combined status is "success"
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_COMMAND
# KAMAL_SUBCOMMAND
# KAMAL_ROLES (if set)
# KAMAL_DESTINATION (if set)
# Only check the build status for production deployments
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
exit 0
end
require "bundler/inline"
# true = install gems so this is fast on repeat invocations
gemfile(true, quiet: true) do
source "https://rubygems.org"
gem "octokit"
gem "faraday-retry"
end
MAX_ATTEMPTS = 72
ATTEMPTS_GAP = 10
def exit_with_error(message)
$stderr.puts message
exit 1
end
class GithubStatusChecks
attr_reader :remote_url, :git_sha, :github_client, :combined_status
def initialize
@remote_url = github_repo_from_remote_url
@git_sha = `git rev-parse HEAD`.strip
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
refresh!
end
def refresh!
@combined_status = github_client.combined_status(remote_url, git_sha)
end
def state
combined_status[:state]
end
def first_status_url
first_status = combined_status[:statuses].find { |status| status[:state] == state }
first_status && first_status[:target_url]
end
def complete_count
combined_status[:statuses].count { |status| status[:state] != "pending"}
end
def total_count
combined_status[:statuses].count
end
def current_status
if total_count > 0
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
else
"Build not started..."
end
end
private
def github_repo_from_remote_url
url = `git config --get remote.origin.url`.strip.delete_suffix(".git")
if url.start_with?("https://github.com/")
url.delete_prefix("https://github.com/")
elsif url.start_with?("git@github.com:")
url.delete_prefix("git@github.com:")
else
url
end
end
end
$stdout.sync = true
begin
puts "Checking build status..."
attempts = 0
checks = GithubStatusChecks.new
loop do
case checks.state
when "success"
puts "Checks passed, see #{checks.first_status_url}"
exit 0
when "failure"
exit_with_error "Checks failed, see #{checks.first_status_url}"
when "pending"
attempts += 1
end
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
puts checks.current_status
sleep(ATTEMPTS_GAP)
checks.refresh!
end
rescue Octokit::NotFound
exit_with_error "Build status could not be found"
end

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."

20
.kamal/secrets Normal file
View File

@@ -0,0 +1,20 @@
# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets,
# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either
# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.
# Example of extracting secrets from 1password (or another compatible pw manager)
# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY)
# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS})
# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS})
# Example of extracting secrets from Rails credentials
# KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password)
# Use a GITHUB_TOKEN if private repositories are needed for the image
# GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
# Grab the registry password from ENV
# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
# Improve security by using a password manager. Never check config/master.key into git!
RAILS_MASTER_KEY=$(cat config/master.key)

8
.rubocop.yml Normal file
View File

@@ -0,0 +1,8 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false

1
.ruby-version Normal file
View File

@@ -0,0 +1 @@
ruby-3.3.10

102
DOCS_REORGANIZED.md Normal file
View File

@@ -0,0 +1,102 @@
# 📁 Documentation Reorganized
All markdown documentation has been moved to the `docs/` folder for better organization!
## What Changed
### Before:
```
turbovault-web/
├── README.md
├── LICENSE
├── API_DOCUMENTATION.md
├── DEPLOYMENT.md
├── DEPLOYMENT_CHECKLIST.md
├── DEVELOPMENT_GUIDE.md
├── GITHUB_ACTIONS_SETUP.md
├── IGDB_INTEGRATION.md
├── THEMES.md
└── ... (12+ more .md files in root)
```
### After:
```
turbovault-web/
├── README.md # Main README (updated with new links)
├── LICENSE
├── docs/ # 📁 All documentation here!
│ ├── README.md # Documentation index
│ ├── API_DOCUMENTATION.md
│ ├── DEPLOYMENT.md
│ ├── DEPLOYMENT_CHECKLIST.md
│ ├── DEVELOPMENT_GUIDE.md
│ ├── GITHUB_ACTIONS_SETUP.md
│ ├── IGDB_INTEGRATION.md
│ ├── THEMES.md
│ └── ... (all other docs)
├── .github/
│ ├── workflows/
│ ├── SECRETS_SETUP.md
│ └── WHAT_TO_COMMIT.md
└── k8s/
├── README.md
├── GITEA_SETUP.md
└── *.yaml
```
## ✅ All Links Updated
- ✅ Main `README.md` - Points to `docs/` folder
- ✅ All files in `docs/` - Cross-references updated
- ✅ Relative paths fixed:
- Same folder: `[link](FILENAME.md)`
- Root: `[link](../README.md)`
- .github: `[link](../.github/FILENAME.md)`
- k8s: `[link](../k8s/FILENAME.md)`
## 📖 Quick Access
**Main Documentation Index:** [docs/README.md](docs/README.md)
**Most Important Docs:**
- [Deployment Checklist](docs/DEPLOYMENT_CHECKLIST.md) - Start here for deployment
- [GitHub Actions Setup](docs/GITHUB_ACTIONS_SETUP.md) - CI/CD pipeline
- [Development Guide](docs/DEVELOPMENT_GUIDE.md) - Local development
- [API Documentation](docs/API_DOCUMENTATION.md) - API reference
## 🎯 Why This Change?
**Benefits:**
- ✅ Cleaner root directory
- ✅ Easier to find documentation
- ✅ Standard project structure
- ✅ Better organization
- ✅ Easier to maintain
**GitHub Standard:**
Most open-source projects keep documentation in a `docs/` folder, with only `README.md` and `LICENSE` in the root.
## 🔗 Updated Files
The following files have been updated with new paths:
1. **README.md** - Links to docs/ folder
2. **docs/*.md** - All cross-references updated
3. **docs/README.md** - NEW: Documentation index
No code changes, only documentation organization!
## ✨ Everything Still Works
All documentation is accessible and all links work correctly. Just:
- Visit [docs/README.md](docs/README.md) for the documentation index
- Or access files directly in the `docs/` folder
- Main README still has quick links to key documents
---
**Ready to commit?** All documentation is organized and ready for GitHub! 🚀

74
Dockerfile Normal file
View File

@@ -0,0 +1,74 @@
# TurboVault Production Dockerfile
# Multi-stage build for optimized image size
# Stage 1: Build environment
FROM ruby:3.3-slim as builder
# Install build dependencies
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
nodejs \
npm \
git \
curl && \
rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Install gems
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment 'true' && \
bundle config set --local without 'development test' && \
bundle install --jobs 4 --retry 3
# Copy application code
COPY . .
# Precompile assets
RUN bundle exec rails assets:precompile
# Stage 2: Runtime environment
FROM ruby:3.3-slim
# Install runtime dependencies
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
libpq5 \
curl \
ca-certificates && \
rm -rf /var/lib/apt/lists/*
# Create app user
RUN groupadd -r app && useradd -r -g app app
# Set working directory
WORKDIR /app
# Copy gems from builder
COPY --from=builder /usr/local/bundle /usr/local/bundle
# Copy application code
COPY --chown=app:app . .
# Copy precompiled assets from builder
COPY --from=builder --chown=app:app /app/public /app/public
# Create necessary directories
RUN mkdir -p tmp/pids tmp/cache tmp/sockets log && \
chown -R app:app tmp log
# Switch to app user
USER app
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/up || exit 1
# Start Rails server
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0", "-p", "3000"]

72
Gemfile Normal file
View File

@@ -0,0 +1,72 @@
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use postgresql as the database for Active Record
gem "pg", "~> 1.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
gem "bcrypt", "~> 3.1.7"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
gem "solid_cache"
gem "solid_queue"
gem "solid_cable"
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false
# Deploy this application anywhere as a Docker container [https://kamal-deploy.org]
gem "kamal", require: false
# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/]
gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
end
group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
end
group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
end
gem "tailwindcss-rails", "~> 4.4"
gem "kaminari", "~> 1.2"
gem "dotenv-rails", "~> 3.2"

454
Gemfile.lock Normal file
View File

@@ -0,0 +1,454 @@
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.18)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
timeout (>= 0.4.0)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0)
activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2)
bcrypt_pbkdf (1.1.2-arm64-darwin)
bcrypt_pbkdf (1.1.2-x86_64-darwin)
bigdecimal (4.1.0)
bindex (0.8.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (8.0.4)
racc
builder (3.3.0)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.2.0)
dotenv-rails (3.2.0)
dotenv (= 3.2.0)
railties (>= 6.1)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.2)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm-linux-gnu)
ffi (1.17.4-arm-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.19.3)
kamal (2.11.0)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2)
dotenv (~> 3.1)
ed25519 (~> 1.4)
net-ssh (~> 7.3)
sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
kaminari-activerecord (= 1.2.2)
kaminari-core (= 1.2.2)
kaminari-actionview (1.2.2)
actionview
kaminari-core (= 1.2.2)
kaminari-activerecord (1.2.2)
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.1.0)
matrix (0.4.3)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-scp (4.1.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1)
net-protocol
net-ssh (7.3.2)
nio4r (2.7.5)
nokogiri (1.19.2-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.2-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.2-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.2-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.3.1)
date
stringio
public_suffix (7.0.5)
puma (7.2.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.5)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
bundler (>= 1.15.0)
railties (= 8.1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rubocop (1.86.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.41.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.10)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.4.0)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sshkit (1.25.0)
base64
logger
net-scp (>= 1.1.2)
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
tailwindcss-rails (4.4.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.2.1)
tailwindcss-ruby (4.2.1-aarch64-linux-gnu)
tailwindcss-ruby (4.2.1-aarch64-linux-musl)
tailwindcss-ruby (4.2.1-arm64-darwin)
tailwindcss-ruby (4.2.1-x86_64-darwin)
tailwindcss-ruby (4.2.1-x86_64-linux-gnu)
tailwindcss-ruby (4.2.1-x86_64-linux-musl)
thor (1.5.0)
thruster (0.1.20)
thruster (0.1.20-aarch64-linux)
thruster (0.1.20-arm64-darwin)
thruster (0.1.20-x86_64-darwin)
thruster (0.1.20-x86_64-linux)
timeout (0.6.1)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
websocket (1.2.11)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.5)
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
bcrypt (~> 3.1.7)
bootsnap
brakeman
bundler-audit
capybara
debug
dotenv-rails (~> 3.2)
image_processing (~> 1.2)
importmap-rails
jbuilder
kamal
kaminari (~> 1.2)
pg (~> 1.1)
propshaft
puma (>= 5.0)
rails (~> 8.1.2)
rubocop-rails-omakase
selenium-webdriver
solid_cable
solid_cache
solid_queue
stimulus-rails
tailwindcss-rails (~> 4.4)
thruster
turbo-rails
tzinfo-data
web-console
BUNDLED WITH
2.5.22

21
LICENSE Normal file
View File

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

3
Procfile.dev Normal file
View File

@@ -0,0 +1,3 @@
web: bin/rails server
css: bin/rails tailwindcss:watch
jobs: bundle exec rake solid_queue:start

210
README.md Normal file
View File

@@ -0,0 +1,210 @@
# 🎮 TurboVault
> Your personal video game collection tracker and manager
[![Rails 8.1](https://img.shields.io/badge/Rails-8.1-red.svg)](https://rubyonrails.org/)
[![Ruby 3.3](https://img.shields.io/badge/Ruby-3.3-red.svg)](https://www.ruby-lang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
TurboVault is a modern, self-hosted web application for tracking and organizing your video game collection. Built with Rails 8 and Hotwire, it offers a fast, responsive experience for managing physical and digital games across all platforms.
## ✨ Features
- 📚 **Track Physical & Digital Games** - Manage both formats with detailed metadata
- 🎮 **IGDB Integration** - Automatic game matching with cover art and metadata
- 📊 **Collection Statistics** - Track spending, completion rates, and platform distribution
- 🗂️ **Collections** - Organize games into custom collections
- 🔍 **Smart Search** - Find games quickly with advanced filtering
- 📥 **CSV Import** - Bulk import your existing collection
- 🎨 **5 Beautiful Themes** - Light, Dark, Midnight, Retro, and Ocean
- 🔐 **RESTful API** - Programmatic access to your collection
- 📍 **Location Tracking** - Remember where your physical games are stored
-**Ratings & Status** - Track completion status and personal ratings
- 👥 **Public Profiles** - Optionally share your collection with others
## 🚀 Quick Start
### Prerequisites
- Ruby 3.3+
- PostgreSQL 15+
- Node.js 18+ (for asset compilation)
- Docker (optional, for containerized development)
### Local Development
1. **Clone the repository**
```bash
git clone https://github.com/yourusername/turbovault.git
cd turbovault
```
2. **Install dependencies**
```bash
bundle install
```
3. **Set up environment variables**
```bash
cp .env.example .env
# Edit .env with your configuration
```
4. **Start Docker services**
```bash
docker-compose up -d
```
5. **Set up the database**
```bash
rails db:prepare
```
6. **Start the development server**
```bash
task dev
# or: bin/dev
```
7. **Visit the app**
- App: http://localhost:3000
- Mailpit (email testing): http://localhost:8025
### Demo Account
A demo account is automatically created in development:
- **Email:** demo@turbovault.com
- **Password:** password123
## 🐳 Docker Deployment
TurboVault includes Docker and Kubernetes manifests for easy deployment.
### 📋 New to Deployment?
**Start here:** [DEPLOYMENT_CHECKLIST.md](docs/DEPLOYMENT_CHECKLIST.md) - Complete step-by-step deployment guide
### Kubernetes (Recommended for Production)
TurboVault is designed for Kubernetes with GitHub Actions CI/CD:
**Documentation:**
1. **📋 Deployment Checklist:** [DEPLOYMENT_CHECKLIST.md](docs/DEPLOYMENT_CHECKLIST.md) - Step-by-step guide
2. **🤖 GitHub Actions:** [GITHUB_ACTIONS_SETUP.md](docs/GITHUB_ACTIONS_SETUP.md) - Automated builds
3. **☸️ Kubernetes Guide:** [k8s README](k8s/README.md) - Full k8s documentation
**Quick Deploy:**
```bash
# 1. Push to GitHub
./scripts/setup-github.sh
# 2. Build image (automatic on tag push)
git tag v1.0.0 && git push origin v1.0.0
# 3. Update k8s manifests with your image
# Edit k8s/deployment.yaml: image: ghcr.io/your-username/turbovault:v1.0.0
# 4. Deploy to Kubernetes
./scripts/deploy-k8s.sh
```
### Docker Compose (Development/Testing)
```bash
docker-compose -f docker-compose.prod.yml up -d
```
## 🔧 Configuration
### IGDB Integration (Optional)
To enable automatic game metadata matching:
1. Create a Twitch developer account at https://dev.twitch.tv
2. Register an application to get your Client ID and Secret
3. Add to `.env`:
```bash
IGDB_CLIENT_ID=your_client_id
IGDB_CLIENT_SECRET=your_client_secret
```
IGDB sync is enabled by default for all users and runs every 30 minutes.
## 📚 Documentation
**All documentation is in the [`docs/`](docs/) folder.**
**Quick Links:**
- 📋 [Deployment Checklist](docs/DEPLOYMENT_CHECKLIST.md) - Step-by-step deployment
- 🤖 [GitHub Actions Setup](docs/GITHUB_ACTIONS_SETUP.md) - CI/CD pipeline
- 🚀 [Deployment Guide](docs/DEPLOYMENT.md) - Complete deployment reference
- 💻 [Development Guide](docs/DEVELOPMENT_GUIDE.md) - Local development setup
- 📖 [API Documentation](docs/API_DOCUMENTATION.md) - RESTful API reference
- 🎮 [IGDB Integration](docs/IGDB_INTEGRATION.md) - Game metadata matching
- 🎨 [Themes](docs/THEMES.md) - Theme customization
- 🎯 [Demo Account](docs/DEMO_ACCOUNT.md) - Try the demo
[**See all documentation →**](docs/README.md)
## 🛠️ Tech Stack
- **Framework:** Ruby on Rails 8.1
- **Frontend:** Hotwire (Turbo + Stimulus)
- **Styling:** Tailwind CSS
- **Database:** PostgreSQL with Row Level Security
- **Background Jobs:** Solid Queue
- **Deployment:** Docker, Kubernetes
- **APIs:** IGDB (game metadata)
## 🤖 Continuous Integration
TurboVault includes GitHub Actions workflows:
- **CI Pipeline** - Runs tests, linting, and security scans on every push
- **Build & Push** - Automatically builds Docker images and pushes to GitHub Container Registry
### Setup GitHub Actions
**No setup required!** The default workflow uses GitHub Container Registry (ghcr.io), which works out of the box with no secrets needed.
Just push a tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
Your image will be at: `ghcr.io/your-username/turbovault:v1.0.0`
**Want to use a different registry?** See [.github/SECRETS_SETUP.md](.github/SECRETS_SETUP.md) for examples (Docker Hub, private registry, etc.)
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
All PRs will automatically run CI tests.
## 📝 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- Game data provided by [IGDB](https://www.igdb.com/)
- Built with [Ruby on Rails](https://rubyonrails.org/)
- UI powered by [Tailwind CSS](https://tailwindcss.com/)
## 📧 Support
- 📖 [Documentation](docs/)
- 🐛 [Issue Tracker](https://github.com/yourusername/turbovault/issues)
- 💬 [Discussions](https://github.com/yourusername/turbovault/discussions)
---
Made with ❤️ for gamers and collectors

179
REGISTRY_SIMPLIFIED.md Normal file
View File

@@ -0,0 +1,179 @@
# ✅ Container Registry Simplified!
The deployment has been simplified to be registry-agnostic. You can now use any container registry you prefer!
## What Changed
### Before:
- Hardcoded for Gitea registry
- Required 4 GitHub secrets
- Complex setup instructions
- Registry-specific documentation
### After:
- **Default:** GitHub Container Registry (ghcr.io) - zero setup!
- **Flexible:** Works with any registry (Docker Hub, private registry, etc.)
- **Simple:** No secrets needed for default setup
- **Clean:** Registry-agnostic documentation
## 🎯 Default Setup: GitHub Container Registry
**No setup required!** Just push a tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
- ✅ Free
- ✅ Built into GitHub
- ✅ No secrets to configure
- ✅ Automatic authentication
- ✅ Public or private images
Your image will be at: `ghcr.io/YOUR_USERNAME/turbovault:v1.0.0`
## 🔧 Using a Different Registry (Optional)
Want to use Docker Hub, your own registry, or something else? Just modify the workflow:
### Docker Hub Example:
```yaml
# .github/workflows/build-and-push.yml
env:
REGISTRY: docker.io
IMAGE_NAME: your-username/turbovault
```
Add secrets: `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`
### Private Registry Example:
```yaml
env:
REGISTRY: registry.example.com
IMAGE_NAME: turbovault
```
Add secrets for your registry credentials.
See [.github/SECRETS_SETUP.md](.github/SECRETS_SETUP.md) for examples.
## 📦 Kubernetes Deployment
Update the image in `k8s/deployment.yaml`:
```yaml
# For GitHub Container Registry (default)
image: ghcr.io/your-username/turbovault:latest
# For Docker Hub
image: docker.io/your-username/turbovault:latest
# For private registry
image: registry.example.com/turbovault:latest
```
### Public Registry (ghcr.io public or Docker Hub public):
No `imagePullSecrets` needed!
### Private Registry:
```yaml
imagePullSecrets:
- name: registry-secret
```
Create secret:
```bash
kubectl create secret docker-registry registry-secret \
--docker-server=your-registry.com \
--docker-username=your-username \
--docker-password=your-token \
--namespace=turbovault
```
## 📁 Files Updated
### Removed:
-`k8s/GITEA_SETUP.md` - Gitea-specific guide (no longer needed)
-`k8s/gitea-registry-secret.yaml.example` - Gitea secret template
-`docs/.github-gitea-setup.md` - GitHub+Gitea architecture
### Updated:
-`.github/workflows/build-and-push.yml` - Uses ghcr.io by default
-`.github/SECRETS_SETUP.md` - Simplified, no secrets needed for default
-`k8s/deployment.yaml` - Example with ghcr.io
-`k8s/migrate-job.yaml` - Example with ghcr.io
-`k8s/README.md` - Registry-agnostic instructions
-`README.md` - Updated deployment steps
-`scripts/deploy-k8s.sh` - Generic registry prompts
## 🎉 Benefits
### For Users:
- ✅ Simpler setup (zero config for ghcr.io)
- ✅ Choice of any registry
- ✅ No mandatory external dependencies
- ✅ Standard GitHub workflow
### For Open Source:
- ✅ Contributors don't need private registries
- ✅ Works out of the box with GitHub
- ✅ Easy to fork and deploy
- ✅ Professional standard setup
## 📖 Updated Documentation
All documentation has been updated to:
- Use GitHub Container Registry as the default example
- Provide examples for other registries
- Remove Gitea-specific instructions
- Be registry-agnostic
**Key docs:**
- [.github/SECRETS_SETUP.md](.github/SECRETS_SETUP.md) - Registry options
- [k8s/README.md](k8s/README.md) - Kubernetes deployment
- [README.md](README.md) - Main project README
## 🚀 Quick Start (Recommended Path)
1. **Push code to GitHub:**
```bash
git push origin main
```
2. **Tag a release:**
```bash
git tag v1.0.0
git push origin v1.0.0
```
3. **Watch GitHub Actions:**
- Go to Actions tab
- See build complete
- Image pushed to ghcr.io/YOUR_USERNAME/turbovault:v1.0.0
4. **Update k8s manifests:**
```bash
# Edit k8s/deployment.yaml and k8s/migrate-job.yaml
image: ghcr.io/YOUR_USERNAME/turbovault:v1.0.0
```
5. **Deploy:**
```bash
./scripts/deploy-k8s.sh
```
Done! 🎉
## 💡 You Can Still Use Gitea (or any registry)
This change doesn't prevent you from using Gitea or any other registry. It just:
- Makes the default simpler (uses GitHub's built-in registry)
- Makes the code registry-agnostic
- Removes hardcoded assumptions
**To use Gitea:** Just modify the workflow and k8s manifests with your Gitea registry paths. It's all flexible now!
---
**Everything is simpler, cleaner, and more flexible!** 🚀

6
Rakefile Normal file
View File

@@ -0,0 +1,6 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative "config/application"
Rails.application.load_tasks

102
SECURITY_SCAN_RESULTS.md Normal file
View File

@@ -0,0 +1,102 @@
# 🔒 Security Scan Results
**Status:****SAFE TO COMMIT**
Scanned on: 2026-03-28
## ✅ Protected Files (Gitignored)
These sensitive files exist locally but are **NOT** being committed:
-`.env` - Environment variables (gitignored)
-`config/master.key` - Rails master key (gitignored)
-`k8s/secrets.yaml` - Does not exist (only .example exists)
## ✅ Configuration Files (Safe)
These files use environment variables or placeholders:
-`config/database.yml` - Uses `ENV["DATABASE_PASSWORD"]`
-`.env.example` - Contains only placeholders
-`k8s/secrets.yaml.example` - Contains only placeholders
-`k8s/configmap.yaml` - Contains example values only
## ✅ Development Passwords (Safe)
These are intentional demo/dev passwords and are safe to commit:
-`db/seeds.rb` - Demo account password: "password123" (documented)
-`config/database.yml` - Default dev password: "postgres" (standard)
-`docker-compose.yml` - Dev postgres password: "postgres" (standard)
## 🔍 What Was Scanned
1. **Secret files:** .env, master.key, secrets.yaml
2. **Hardcoded credentials:** Searched for API keys, tokens, passwords
3. **Configuration files:** database.yml, secrets examples
4. **Git status:** Verified sensitive files are not staged
5. **Gitignore:** Verified all sensitive patterns are covered
## 📝 Gitignore Coverage
Your `.gitignore` properly excludes:
```
/.env
/.env.local
/config/master.key
/config/credentials/*.key
k8s/secrets.yaml
k8s/sealed-secrets.yaml
```
## ⚠️ What to Remember
**Before committing:**
-`.env` stays local (already gitignored)
-`config/master.key` stays local (already gitignored)
- ✅ Never create `k8s/secrets.yaml` (create it only on your k8s cluster)
**Safe to commit:**
-`.env.example` - Has placeholders
-`k8s/secrets.yaml.example` - Template only
- ✅ All source code files
- ✅ All documentation
- ✅ All Kubernetes manifests (except secrets.yaml)
## 🚀 Ready to Commit
You can safely run:
```bash
git add .
git commit -m "Initial commit: TurboVault"
git push origin main
```
## 🔐 Post-Deployment Security
After deploying, remember to:
1. **Change default passwords** in production
2. **Use strong SECRET_KEY_BASE** (from `rails secret`)
3. **Store real secrets in k8s secrets** (not in git)
4. **Rotate IGDB credentials** periodically
5. **Use HTTPS** in production (cert-manager)
## 📋 Files That Will Be Committed
Total files to commit: ~200 files including:
- All Ruby/Rails source code
- All documentation (docs/)
- All Kubernetes manifests (k8s/)
- All GitHub Actions workflows (.github/)
- Configuration templates (.example files)
- Dockerfile and docker-compose.yml
- README, LICENSE, etc.
**None of these contain sensitive information.**
---
**Scan Complete!** You're safe to push to GitHub. 🎉

182
Taskfile.yml Normal file
View File

@@ -0,0 +1,182 @@
version: '3'
tasks:
default:
desc: "Show available tasks"
cmds:
- task --list
dev:
desc: "Start development environment (Docker, database, Rails + Tailwind + Jobs)"
cmds:
- task: docker:up
- task: db:prepare
- rails tailwindcss:build
- echo "🚀 Starting TurboVault..."
- echo "📱 App at http://localhost:3000"
- echo "📧 Mailpit at http://localhost:8025"
- echo "⚙️ Background jobs (Solid Queue) enabled"
- echo ""
- echo "👤 Demo Account (development):"
- echo " Email demo@turbovault.com"
- echo " Password password123"
- echo ""
- bin/dev
setup:
desc: "Initial project setup (Docker, dependencies, database)"
cmds:
- task: docker:up
- task: install
- task: db:setup
install:
desc: "Install Ruby dependencies"
cmds:
- bundle install
docker:up:
desc: "Start PostgreSQL and Mailpit containers"
cmds:
- docker compose up -d
- echo "Waiting for services to be ready..."
- sleep 3
- echo "PostgreSQL at localhost:5432"
- echo "Mailpit UI at http://localhost:8025"
docker:down:
desc: "Stop all Docker containers"
cmds:
- docker compose down
docker:logs:
desc: "View Docker logs (all services)"
cmds:
- docker compose logs -f
docker:logs:postgres:
desc: "View PostgreSQL logs only"
cmds:
- docker compose logs -f postgres
docker:logs:mailpit:
desc: "View Mailpit logs only"
cmds:
- docker compose logs -f mailpit
docker:reset:
desc: "Stop containers and remove all data"
cmds:
- docker compose down -v
mailpit:
desc: "Open Mailpit web UI in browser"
cmds:
- echo "Opening Mailpit at http://localhost:8025"
- open http://localhost:8025 || xdg-open http://localhost:8025 || echo "Please visit http://localhost:8025 in your browser"
db:create:
desc: "Create database"
cmds:
- rails db:create
db:migrate:
desc: "Run database migrations"
cmds:
- rails db:migrate
db:seed:
desc: "Seed database with initial data"
cmds:
- rails db:seed
db:setup:
desc: "Setup database (create, migrate, seed)"
cmds:
- task: db:create
- task: db:migrate
- task: db:seed
db:prepare:
desc: "Prepare database (creates if needed, runs pending migrations)"
cmds:
- rails db:prepare
db:reset:
desc: "Reset database (drop, create, migrate, seed)"
cmds:
- rails db:drop
- task: db:setup
db:rollback:
desc: "Rollback last migration"
cmds:
- rails db:rollback
server:
desc: "Start Rails server"
cmds:
- rails server
dev:
desc: "Start Rails server with bin/dev (if using Procfile.dev)"
cmds:
- bin/dev
console:
desc: "Start Rails console"
cmds:
- rails console
test:
desc: "Run all tests"
cmds:
- rails test
test:system:
desc: "Run system tests"
cmds:
- rails test:system
lint:
desc: "Run RuboCop linter"
cmds:
- bundle exec rubocop
lint:fix:
desc: "Auto-fix RuboCop issues"
cmds:
- bundle exec rubocop -A
security:
desc: "Run security checks (Brakeman + Bundler Audit)"
cmds:
- bundle exec brakeman
- bundle exec bundler-audit check --update
routes:
desc: "Show all routes"
cmds:
- rails routes
clean:
desc: "Clean temporary files and logs"
cmds:
- rm -rf tmp/cache/*
- rm -rf log/*.log
- echo "Cleaned tmp and logs"
igdb:sync:
desc: "Manually run IGDB sync job (for testing)"
cmds:
- rake igdb:sync
igdb:status:
desc: "Check IGDB sync status"
cmds:
- rake igdb:status
igdb:clear:
desc: "Clear IGDB sync lock (if job is stuck)"
cmds:
- rake igdb:clear_lock

0
app/assets/builds/.keep Normal file
View File

File diff suppressed because one or more lines are too long

0
app/assets/images/.keep Normal file
View File

View 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.
*/

View 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;
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,2 @@
module ApiTokensHelper
end

View File

@@ -0,0 +1,2 @@
module ApplicationHelper
end

View File

@@ -0,0 +1,2 @@
module CollectionsHelper
end

View File

@@ -0,0 +1,2 @@
module DashboardHelper
end

View 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

View File

@@ -0,0 +1,2 @@
module IgdbMatchesHelper
end

View File

@@ -0,0 +1,2 @@
module ItemsHelper
end

View File

@@ -0,0 +1,2 @@
module PagesHelper
end

View File

@@ -0,0 +1,2 @@
module PasswordResetsHelper
end

View File

@@ -0,0 +1,2 @@
module ProfilesHelper
end

View File

@@ -0,0 +1,2 @@
module SessionsHelper
end

View File

@@ -0,0 +1,2 @@
module UsersHelper
end

View 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"

View 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 }

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}

View 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()
}
}
}

View 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)

View File

@@ -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()
}
}
}

View 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
View 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

View File

@@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
end

View 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
View 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

View File

@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end

47
app/models/collection.rb Normal file
View 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

View 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

View File

66
app/models/game.rb Normal file
View 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
View 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
View 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
View 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

View 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

View 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
View 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
View 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
View 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

View 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

View 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>

View 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>

View 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>

View 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 %>

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