Commit 235928c3 by Ivan

feat: init

parents
# 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*
# 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
version: 2
updates:
- package-ecosystem: bundler
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
name: CI
on:
pull_request:
push:
branches: [ main ]
jobs:
scan_ruby:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Scan for common Rails security vulnerabilities using static analysis
run: bin/brakeman --no-pager
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: .ruby-version
bundler-cache: true
- name: Lint code for consistent style
run: bin/rubocop -f github
test:
runs-on: ubuntu-latest
# services:
# redis:
# image: redis
# ports:
# - 6379:6379
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git pkg-config google-chrome-stable
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Run tests
env:
RAILS_ENV: test
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test test:system
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
if: failure()
with:
name: screenshots
path: ${{ github.workspace }}/tmp/screenshots
if-no-files-found: ignore
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# Temporary files generated by your text editor or operating system
# belong in git's global ignore instead:
# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore`
# Ignore bundler config.
/.bundle
# Ignore all environment files.
/.env*
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/
!/tmp/storage/.keep
/public/assets
# Ignore master key for decrypting credentials and more.
/config/master.key
# Vite Ruby
/public/vite*
node_modules
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files
*.local
#!/bin/sh
echo "Docker set up on $KAMAL_HOSTS..."
#!/bin/sh
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."
#!/bin/sh
# A sample post-deploy hook
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLE (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"
#!/bin/sh
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"
#!/bin/sh
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."
#!/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_ROLE (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
#!/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_ROLE (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 ]
#!/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_ROLE (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 = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/")
@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
end
$stdout.sync = true
puts "Checking build status..."
attempts = 0
checks = GithubStatusChecks.new
begin
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
#!/bin/sh
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."
# 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})
# 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)
# 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
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t img_manager .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name img_manager img_manager
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.2.2
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Final stage for app image
FROM base
# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
chown -R rails:rails db log storage tmp
USER 1000:1000
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.0.1"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# 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"
# Search functionality
gem "ransack", "~> 4.1"
gem "kaminari"
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"
# 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 "inertia_rails-contrib", "~> 0.4.0"
gem "vite_rails", "~> 3.0"
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.1)
actionpack (= 8.0.1)
activesupport (= 8.0.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.1)
actionpack (= 8.0.1)
activejob (= 8.0.1)
activerecord (= 8.0.1)
activestorage (= 8.0.1)
activesupport (= 8.0.1)
mail (>= 2.8.0)
actionmailer (8.0.1)
actionpack (= 8.0.1)
actionview (= 8.0.1)
activejob (= 8.0.1)
activesupport (= 8.0.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.1)
actionview (= 8.0.1)
activesupport (= 8.0.1)
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.0.1)
actionpack (= 8.0.1)
activerecord (= 8.0.1)
activestorage (= 8.0.1)
activesupport (= 8.0.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.1)
activesupport (= 8.0.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.1)
activesupport (= 8.0.1)
globalid (>= 0.3.6)
activemodel (8.0.1)
activesupport (= 8.0.1)
activerecord (8.0.1)
activemodel (= 8.0.1)
activesupport (= 8.0.1)
timeout (>= 0.4.0)
activestorage (8.0.1)
actionpack (= 8.0.1)
activejob (= 8.0.1)
activerecord (= 8.0.1)
activesupport (= 8.0.1)
marcel (~> 1.0)
activesupport (8.0.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
base64 (0.2.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin)
bcrypt_pbkdf (1.1.1-x86_64-darwin)
benchmark (0.4.0)
bigdecimal (3.1.9)
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (7.0.0)
racc
builder (3.3.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.5)
connection_pool (2.5.0)
crass (1.0.6)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.7)
drb (2.2.1)
dry-cli (1.2.0)
ed25519 (1.3.0)
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm-linux-gnu)
ffi (1.17.1-arm-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
inertia_rails (3.6.1)
railties (>= 6)
inertia_rails-contrib (0.4.0)
inertia_rails (>= 3.5.0)
io-console (0.8.0)
irb (1.15.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.13.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
json (2.10.1)
kamal (2.5.3)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2)
dotenv (~> 3.1)
ed25519 (~> 1.2)
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.4)
lint_roller (1.1.0)
logger (1.6.6)
loofah (2.24.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
matrix (0.4.2)
mini_magick (5.2.0)
benchmark
logger
mini_mime (1.1.5)
minitest (5.25.4)
msgpack (1.8.0)
mutex_m (0.3.0)
net-imap (0.5.6)
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.0)
nio4r (2.7.4)
nokogiri (1.18.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.3-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.3-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.3-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.1)
parallel (1.26.3)
parser (3.3.7.1)
ast (~> 2.4.1)
racc
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
psych (5.2.3)
date
stringio
public_suffix (6.0.1)
puma (6.6.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.11)
rack-proxy (0.7.7)
rack
rack-session (2.1.0)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (8.0.1)
actioncable (= 8.0.1)
actionmailbox (= 8.0.1)
actionmailer (= 8.0.1)
actionpack (= 8.0.1)
actiontext (= 8.0.1)
actionview (= 8.0.1)
activejob (= 8.0.1)
activemodel (= 8.0.1)
activerecord (= 8.0.1)
activestorage (= 8.0.1)
activesupport (= 8.0.1)
bundler (>= 1.15.0)
railties (= 8.0.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.1)
actionpack (= 8.0.1)
activesupport (= 8.0.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
ransack (4.3.0)
activerecord (>= 6.1.5)
activesupport (>= 6.1.5)
i18n
rdoc (6.12.0)
psych (>= 4.0.0)
regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
rexml (3.4.1)
rubocop (1.73.2)
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.38.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.1)
parser (>= 3.3.1.0)
rubocop-performance (1.24.0)
lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.30.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.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.2.3)
ffi (~> 1.12)
logger
rubyzip (2.4.1)
securerandom (0.4.1)
selenium-webdriver (4.29.1)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
solid_cable (3.0.7)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.7)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.1.3)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0)
railties (>= 7.1)
thor (~> 1.3.1)
sqlite3 (2.6.0-aarch64-linux-gnu)
sqlite3 (2.6.0-aarch64-linux-musl)
sqlite3 (2.6.0-arm-linux-gnu)
sqlite3 (2.6.0-arm-linux-musl)
sqlite3 (2.6.0-arm64-darwin)
sqlite3 (2.6.0-x86_64-darwin)
sqlite3 (2.6.0-x86_64-linux-gnu)
sqlite3 (2.6.0-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
net-scp (>= 1.1.2)
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
stringio (3.1.5)
thor (1.3.2)
thruster (0.1.11)
thruster (0.1.11-aarch64-linux)
thruster (0.1.11-arm64-darwin)
thruster (0.1.11-x86_64-darwin)
thruster (0.1.11-x86_64-linux)
timeout (0.4.3)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
useragent (0.16.11)
vite_rails (3.0.19)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.9.1)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
websocket (1.2.11)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.2)
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
capybara
debug
image_processing (~> 1.2)
inertia_rails-contrib (~> 0.4.0)
jbuilder
kamal
kaminari
puma (>= 5.0)
rails (~> 8.0.1)
ransack (~> 4.1)
rubocop-rails-omakase
selenium-webdriver
solid_cable
solid_cache
solid_queue
sqlite3 (>= 2.1)
thruster
tzinfo-data
vite_rails (~> 3.0)
web-console
BUNDLED WITH
2.5.23
vite: bin/vite dev
web: bin/rails s
# README
This README would normally document whatever steps are necessary to get the
application up and running.
Things you may want to cover:
* Ruby version
* System dependencies
* Configuration
* Database creation
* Database initialization
* How to run the test suite
* Services (job queues, cache servers, search engines, etc.)
* Deployment instructions
* ...
# 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
/* Application styles */
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/*
@tailwind base;
@tailwind components;
@tailwind utilities; */
\ No newline at end of file
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
end
class Admin::DashboardController < ApplicationController
def index
end
end
class Admin::ImagesController < ApplicationController
before_action :authorize_admin
before_action :set_image, only: [:show, :update, :destroy, :approve, :reject, :add_tags, :remove_tag]
def index
@q = Image.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: 'admin/images/Index', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
}
end
def show
render inertia: 'admin/images/Show', props: {
image: @image.as_json(include: [:user, :tags], methods: [:file_url]),
tags: Tag.all.pluck(:name)
}
end
def update
if @image.update(image_params)
redirect_to admin_image_path(@image), notice: 'Image was successfully updated.'
else
render inertia: 'admin/images/Show', props: {
image: @image.as_json(include: [:user, :tags], methods: [:file_url, :errors]),
tags: Tag.all.pluck(:name),
errors: @image.errors
}, status: :unprocessable_entity
end
end
def destroy
@image.destroy
redirect_to admin_images_path, notice: 'Image was successfully deleted.'
end
def pending
@q = Image.pending.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: 'admin/images/Pending', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
}
end
def approved
@q = Image.approved.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: 'admin/images/Approved', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
}
end
def rejected
@q = Image.rejected.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: 'admin/images/Rejected', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
}
end
def approve
@image.approved!
redirect_to admin_image_path(@image), notice: 'Image was successfully approved.'
end
def reject
@image.rejected!
redirect_to admin_image_path(@image), notice: 'Image was rejected.'
end
def add_tags
if params[:tags].present?
@image.add_tags(params[:tags].split(','))
redirect_to admin_image_path(@image), notice: 'Tags were successfully added.'
else
redirect_to admin_image_path(@image), alert: 'No tags were provided.'
end
end
def remove_tag
tag = Tag.find(params[:tag_id])
@image.tags.delete(tag)
redirect_to admin_image_path(@image), notice: 'Tag was successfully removed.'
end
private
def set_image
@image = Image.find(params[:id])
end
def image_params
params.require(:image).permit(:title)
end
def authorize_admin
unless Current.user&.admin?
redirect_to root_path, alert: 'You are not authorized to access this area.'
end
end
end
class Admin::UsersController < ApplicationController
def index
end
def show
end
def edit
end
def update
end
def destroy
end
end
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
end
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
end
class ImagesController < ApplicationController
# Allow unauthenticated access to index and show actions
allow_unauthenticated_access only: [:index, :show, :search]
before_action :set_image, only: [:show, :edit, :update, :destroy, :approve, :reject]
before_action :authorize_admin, only: [:approve, :reject, :destroy]
def index
@q = Image.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(12)
render inertia: 'images/Index', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
filters: params[:q] || {}
}
end
def show
render inertia: 'images/Show', props: {
image: @image.as_json(include: [:user, :tags], methods: [:file_url]),
can_edit: Current.user == @image.user,
can_approve: Current.user&.admin?
}
end
def new
@image = Image.new
render inertia: 'images/New'
end
def create
@image = Current.user.images.new(image_params)
if @image.save
if params[:tags].present?
@image.add_tags(params[:tags].split(','))
end
redirect_to image_path(@image), notice: 'Image was successfully uploaded and is pending review.'
else
render inertia: 'images/New', props: {
image: @image.as_json(methods: [:errors]),
errors: @image.errors
}, status: :unprocessable_entity
end
end
def edit
authorize_user
render inertia: 'images/Edit', props: {
image: @image.as_json(include: [:tags], methods: [:file_url]),
tags: @image.tags.pluck(:name).join(', ')
}
end
def update
authorize_user
if @image.update(image_params)
if params[:tags].present?
@image.tags.clear
@image.add_tags(params[:tags].split(','))
end
redirect_to image_path(@image), notice: 'Image was successfully updated.'
else
render inertia: 'images/Edit', props: {
image: @image.as_json(include: [:tags], methods: [:file_url, :errors]),
tags: params[:tags],
errors: @image.errors
}, status: :unprocessable_entity
end
end
def destroy
@image.destroy
redirect_to images_path, notice: 'Image was successfully deleted.'
end
def search
@q = Image.approved.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(12)
render inertia: 'images/Search', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
filters: params[:q] || {},
tags: Tag.all.pluck(:name)
}
end
def approve
@image.approved!
redirect_to image_path(@image), notice: 'Image was successfully approved.'
end
def reject
@image.rejected!
redirect_to image_path(@image), notice: 'Image was rejected.'
end
private
def set_image
@image = Image.find(params[:id])
end
def image_params
params.require(:image).permit(:title, :file)
end
def authorize_user
unless Current.user == @image.user
redirect_to images_path, alert: 'You are not authorized to perform this action.'
end
end
def authorize_admin
unless Current.user&.admin?
redirect_to images_path, alert: 'You are not authorized to perform this action.'
end
end
end
# frozen_string_literal: true
class InertiaExampleController < ApplicationController
def index
render inertia: 'InertiaExample', props: {
name: params.fetch(:name, 'World'),
}
end
end
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> {
flash.now[:alert] = "Too many login attempts. Try again later."
render inertia: "sessions/New", props: { flash: flash.to_h }
}
def new
render inertia: "sessions/New", props: {
flash: flash.to_h
}
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
# Send notification email for new login if enabled
# if user.notify_on_new_login? && !from_familiar_device?(user)
# SessionMailer.new_login_notification(user, request.remote_ip, request.user_agent).deliver_later
# end
redirect_to after_authentication_url
else
# For Inertia.js, we need to render the component with flash messages
flash.now[:alert] = "Try another email address or password."
render inertia: "sessions/New", props: {
flash: flash.to_h
}
end
end
def destroy
terminate_session
redirect_to new_session_path, notice: "You have been logged out."
end
def index
@sessions = current_user.sessions.order(updated_at: :desc)
render inertia: "sessions/Index", props: {
sessions: @sessions.as_json(methods: [ :is_current ]),
flash: flash.to_h
}
end
def show
@session = current_user.sessions.find(params[:id])
render inertia: "sessions/Show", props: {
session: @session.as_json(methods: [ :is_current ]),
flash: flash.to_h
}
rescue ActiveRecord::RecordNotFound
redirect_to sessions_path, alert: "Session not found."
end
def destroy_session
@session = current_user.sessions.find(params[:id])
if @session.is_current?
redirect_to sessions_path, alert: "You cannot terminate your current session."
else
@session.destroy
redirect_to sessions_path, notice: "Session terminated successfully."
end
rescue ActiveRecord::RecordNotFound
redirect_to sessions_path, alert: "Session not found."
end
def terminate_all
current_user.sessions.where.not(id: current_session.id).destroy_all
redirect_to sessions_path, notice: "All other sessions have been terminated."
end
def security
render inertia: "sessions/Security", props: {
security_settings: current_user.security_settings,
flash: flash.to_h
}
end
def update_security
if current_user.update(security_params)
redirect_to security_sessions_path, notice: "Security settings updated successfully."
else
render inertia: "sessions/Security", props: {
security_settings: current_user.security_settings,
errors: current_user.errors,
flash: flash.to_h
}
end
end
private
def from_familiar_device?(user)
# Check if this device has been used before by this user
user.sessions.where(user_agent: request.user_agent, ip_address: request.remote_ip).exists?
end
def security_params
params.require(:user).permit(
:require_two_factor,
:session_timeout_minutes,
:notify_on_new_login,
:max_sessions
)
end
end
class TagsController < ApplicationController
def index
end
def new
end
def create
end
def edit
end
def update
end
def destroy
end
end
import React from 'react'
export default function ErrorMessage({ title, message }) {
return (
<div className="rounded-md bg-red-50 p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
{title && (
<h3 className="text-sm font-medium text-red-800">{title}</h3>
)}
<div className="text-sm text-red-700">
{typeof message === 'string' ? (
<p>{message}</p>
) : (
<ul className="list-disc pl-5 space-y-1">
{Array.isArray(message) ? (
message.map((msg, index) => <li key={index}>{msg}</li>)
) : (
Object.entries(message).map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))
)}
</ul>
)}
</div>
</div>
</div>
</div>
)
}
import React from 'react'
import { Link } from '@inertiajs/react'
export default function ImageCard({ image, showActions = true }) {
const statusColors = {
pending: 'bg-yellow-100 text-yellow-800',
approved: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
}
return (
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="relative pb-[75%]">
<img
src={image.file_url}
alt={image.title}
className="absolute h-full w-full object-cover"
/>
</div>
<div className="px-4 py-4">
<h3 className="text-lg font-medium text-gray-900 truncate" title={image.title}>
{image.title}
</h3>
<p className="mt-1 text-sm text-gray-500">
Uploaded by {image.user?.name || 'Unknown'} on{' '}
{new Date(image.created_at).toLocaleDateString()}
</p>
{image.status && (
<span className={`inline-flex mt-2 items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusColors[image.status] || 'bg-gray-100 text-gray-800'}`}>
{image.status.charAt(0).toUpperCase() + image.status.slice(1)}
</span>
)}
{image.tags && image.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{image.tags.slice(0, 3).map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800"
>
{tag.name}
</span>
))}
{image.tags.length > 3 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
+{image.tags.length - 3} more
</span>
)}
</div>
)}
{showActions && (
<div className="mt-4 flex space-x-2">
<Link
href={`/images/${image.id}`}
className="inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
View
</Link>
<Link
href={`/images/${image.id}/edit`}
className="inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Edit
</Link>
</div>
)}
</div>
</div>
)
}
import React from 'react'
import { Link } from '@inertiajs/react'
export default function Pagination({ links }) {
if (!links || links.length <= 3) {
return null
}
return (
<nav className="border-t border-gray-200 px-4 flex items-center justify-between sm:px-0">
<div className="w-0 flex-1 flex">
{links[0].url ? (
<Link
href={links[0].url}
className="border-t-2 border-transparent pt-4 pr-1 inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300"
>
<svg
className="mr-3 h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
Previous
</Link>
) : (
<span className="border-t-2 border-transparent pt-4 pr-1 inline-flex items-center text-sm font-medium text-gray-300">
<svg
className="mr-3 h-5 w-5 text-gray-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
Previous
</span>
)}
</div>
<div className="hidden md:flex">
{links.slice(1, -1).map((link, i) => (
<React.Fragment key={i}>
{link.url ? (
<Link
href={link.url}
className={`border-t-2 pt-4 px-4 inline-flex items-center text-sm font-medium ${
link.active
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
) : (
<span
className="border-transparent text-gray-500 border-t-2 pt-4 px-4 inline-flex items-center text-sm font-medium"
dangerouslySetInnerHTML={{ __html: link.label }}
/>
)}
</React.Fragment>
))}
</div>
<div className="w-0 flex-1 flex justify-end">
{links[links.length - 1].url ? (
<Link
href={links[links.length - 1].url}
className="border-t-2 border-transparent pt-4 pl-1 inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300"
>
Next
<svg
className="ml-3 h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Link>
) : (
<span className="border-t-2 border-transparent pt-4 pl-1 inline-flex items-center text-sm font-medium text-gray-300">
Next
<svg
className="ml-3 h-5 w-5 text-gray-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</div>
</nav>
)
}
import React from 'react'
export default function SuccessMessage({ title, message }) {
return (
<div className="rounded-md bg-green-50 p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
{title && (
<h3 className="text-sm font-medium text-green-800">{title}</h3>
)}
<div className="text-sm text-green-700">
<p>{message}</p>
</div>
</div>
</div>
</div>
)
}
import React, { useState, useEffect } from 'react'
import * as Popover from '@radix-ui/react-popover'
export default function TagSelector({ availableTags = [], selectedTags = [], onChange, disabled = false }) {
const [searchQuery, setSearchQuery] = useState('')
const [filteredTags, setFilteredTags] = useState(availableTags)
useEffect(() => {
if (searchQuery) {
setFilteredTags(
availableTags.filter(tag =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
)
)
} else {
setFilteredTags(availableTags)
}
}, [searchQuery, availableTags])
const handleSelectTag = (tag) => {
if (!selectedTags.some(t => t.id === tag.id)) {
const newSelectedTags = [...selectedTags, tag]
onChange(newSelectedTags)
}
setSearchQuery('')
}
const handleRemoveTag = (tagId) => {
const newSelectedTags = selectedTags.filter(tag => tag.id !== tagId)
onChange(newSelectedTags)
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2 min-h-[2.5rem] p-2 border border-gray-300 rounded-md bg-white">
{selectedTags.length === 0 && (
<div className="text-gray-400 text-sm">No tags selected</div>
)}
{selectedTags.map(tag => (
<div
key={tag.id}
className="flex items-center bg-indigo-100 text-indigo-800 text-sm rounded-full px-3 py-1"
>
<span>{tag.name}</span>
{!disabled && (
<button
type="button"
onClick={() => handleRemoveTag(tag.id)}
className="ml-1.5 text-indigo-600 hover:text-indigo-800 focus:outline-none"
>
<span className="sr-only">Remove tag {tag.name}</span>
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
))}
</div>
{!disabled && (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Add Tags
<svg className="ml-2 -mr-0.5 h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="bg-white rounded-md shadow-lg p-4 w-72"
sideOffset={5}
>
<div className="space-y-4">
<div>
<label htmlFor="tag-search" className="sr-only">
Search tags
</label>
<input
type="text"
id="tag-search"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Search tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="max-h-60 overflow-y-auto">
{filteredTags.length > 0 ? (
<div className="space-y-1">
{filteredTags.map(tag => {
const isSelected = selectedTags.some(t => t.id === tag.id)
return (
<button
key={tag.id}
type="button"
onClick={() => handleSelectTag(tag)}
disabled={isSelected}
className={`w-full text-left px-3 py-2 text-sm rounded-md ${
isSelected
? 'bg-indigo-100 text-indigo-800 cursor-not-allowed'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{tag.name}
</button>
)
})}
</div>
) : (
<div className="text-center py-4 text-sm text-gray-500">
{searchQuery ? 'No matching tags found' : 'No available tags'}
</div>
)}
</div>
</div>
<Popover.Arrow className="fill-white" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)}
</div>
)
}
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
import { createInertiaApp } from '@inertiajs/react'
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
import './application.css'
createInertiaApp({
// Set default page title
// see https://inertia-rails.netlify.app/guide/title-and-meta
//
// title: title => title ? `${title} - App` : 'App',
// Disable progress bar
//
// see https://inertia-rails.netlify.app/guide/progress-indicators
// progress: false,
resolve: (name) => {
const pages = import.meta.glob('../pages/**/*.jsx', {
eager: true,
})
const page = pages[`../pages/${name}.jsx`]
if (!page) {
console.error(`Missing Inertia page component: '${name}.jsx'`)
}
// To use a default layout, import the Layout component
// and use the following lines.
// see https://inertia-rails.netlify.app/guide/pages#default-layouts
//
// page.default.layout ||= (page) => createElement(Layout, null, page)
return page
},
setup({ el, App, props }) {
if (el) {
createRoot(el).render(createElement(App, props))
} else {
console.error(
'Missing root element.\n\n' +
'If you see this error, it probably means you load Inertia.js on non-Inertia pages.\n' +
'Consider moving <%= vite_javascript_tag "inertia" %> to the Inertia-specific layout instead.',
)
}
},
})
import { Head, Link } from '@inertiajs/react'
import Layout from './Layout'
export default function Forbidden({ auth }) {
return (
<Layout user={auth?.user}>
<Head title="403 - Forbidden" />
<div className="min-h-[70vh] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full text-center">
<div className="text-6xl font-extrabold text-red-600 mb-4">403</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Access Denied</h1>
<p className="text-gray-600 mb-8">
Sorry, you don't have permission to access this page.
</p>
<div className="flex justify-center space-x-4">
<Link
href="/"
className="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Go to home page
</Link>
{auth?.user ? (
<Link
href="/images"
className="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
My Images
</Link>
) : (
<Link
href="/login"
className="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Log in
</Link>
)}
</div>
</div>
</div>
</Layout>
)
}
import { Head, Link } from '@inertiajs/react'
import Layout from './Layout'
export default function Home({ auth, featured_images = [] }) {
return (
<Layout user={auth.user}>
<Head title="Welcome" />
<div className="relative bg-white overflow-hidden">
<div className="max-w-7xl mx-auto">
<div className="relative z-10 pb-8 bg-white sm:pb-16 md:pb-20 lg:max-w-2xl lg:w-full lg:pb-28 xl:pb-32">
<svg
className="hidden lg:block absolute right-0 inset-y-0 h-full w-48 text-white transform translate-x-1/2"
fill="currentColor"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<polygon points="50,0 100,0 50,100 0,100" />
</svg>
<main className="mt-10 mx-auto max-w-7xl px-4 sm:mt-12 sm:px-6 md:mt-16 lg:mt-20 lg:px-8 xl:mt-28">
<div className="sm:text-center lg:text-left">
<h1 className="text-4xl tracking-tight font-extrabold text-gray-900 sm:text-5xl md:text-6xl">
<span className="block xl:inline">Manage and share your</span>{' '}
<span className="block text-indigo-600 xl:inline">
image collection
</span>
</h1>
<p className="mt-3 text-base text-gray-500 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0">
Upload, categorize, and share your images with our powerful image management platform.
Organize with tags, search with ease, and collaborate with others.
</p>
<div className="mt-5 sm:mt-8 sm:flex sm:justify-center lg:justify-start">
{auth.user ? (
<div className="rounded-md shadow">
<Link
href="/images"
className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10"
>
My Images
</Link>
</div>
) : (
<>
<div className="rounded-md shadow">
<Link
href="/register"
className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10"
>
Get started
</Link>
</div>
<div className="mt-3 sm:mt-0 sm:ml-3">
<Link
href="/login"
className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 md:py-4 md:text-lg md:px-10"
>
Log in
</Link>
</div>
</>
)}
</div>
</div>
</main>
</div>
</div>
<div className="lg:absolute lg:inset-y-0 lg:right-0 lg:w-1/2">
<div className="h-56 w-full bg-indigo-100 sm:h-72 md:h-96 lg:w-full lg:h-full grid grid-cols-2 gap-1">
{featured_images.slice(0, 4).map((image, index) => (
<div key={image.id} className={`${index % 2 === 0 ? 'col-span-1' : 'col-span-1'}`}>
<img
className="h-full w-full object-cover"
src={image.file_url}
alt={image.title}
/>
</div>
))}
</div>
</div>
</div>
{/* Features section */}
<div className="py-12 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="lg:text-center">
<h2 className="text-base text-indigo-600 font-semibold tracking-wide uppercase">
Features
</h2>
<p className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl">
A better way to manage your images
</p>
<p className="mt-4 max-w-2xl text-xl text-gray-500 lg:mx-auto">
Our platform provides all the tools you need to organize, categorize, and share your image collection.
</p>
</div>
<div className="mt-10">
<dl className="space-y-10 md:space-y-0 md:grid md:grid-cols-2 md:gap-x-8 md:gap-y-10">
<div className="relative">
<dt>
<div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-indigo-500 text-white">
<svg
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<p className="ml-16 text-lg leading-6 font-medium text-gray-900">
Upload and Store
</p>
</dt>
<dd className="mt-2 ml-16 text-base text-gray-500">
Easily upload and store your images in a secure cloud environment.
Access your collection from anywhere, anytime.
</dd>
</div>
<div className="relative">
<dt>
<div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-indigo-500 text-white">
<svg
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
</div>
<p className="ml-16 text-lg leading-6 font-medium text-gray-900">
Tag and Categorize
</p>
</dt>
<dd className="mt-2 ml-16 text-base text-gray-500">
Organize your images with custom tags and categories.
Create a structured system that works for your specific needs.
</dd>
</div>
<div className="relative">
<dt>
<div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-indigo-500 text-white">
<svg
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<p className="ml-16 text-lg leading-6 font-medium text-gray-900">
Search and Filter
</p>
</dt>
<dd className="mt-2 ml-16 text-base text-gray-500">
Powerful search capabilities let you find images by title, tags, date, and more.
Filter your collection to quickly locate what you need.
</dd>
</div>
<div className="relative">
<dt>
<div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-indigo-500 text-white">
<svg
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<p className="ml-16 text-lg leading-6 font-medium text-gray-900">
User Management
</p>
</dt>
<dd className="mt-2 ml-16 text-base text-gray-500">
Admin controls for managing users and content.
Approval workflows ensure quality control for all uploaded images.
</dd>
</div>
</dl>
</div>
</div>
</div>
{/* CTA section */}
<div className="bg-indigo-50">
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:py-16 lg:px-8 lg:flex lg:items-center lg:justify-between">
<h2 className="text-3xl font-extrabold tracking-tight text-gray-900 sm:text-4xl">
<span className="block">Ready to get started?</span>
<span className="block text-indigo-600">Create your account today.</span>
</h2>
<div className="mt-8 flex lg:mt-0 lg:flex-shrink-0">
{auth.user ? (
<div className="inline-flex rounded-md shadow">
<Link
href="/images/new"
className="inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
Upload New Image
</Link>
</div>
) : (
<>
<div className="inline-flex rounded-md shadow">
<Link
href="/register"
className="inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
Sign up
</Link>
</div>
<div className="ml-3 inline-flex rounded-md shadow">
<Link
href="/login"
className="inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-indigo-600 bg-white hover:bg-indigo-50"
>
Log in
</Link>
</div>
</>
)}
</div>
</div>
</div>
</Layout>
)
}
import { useState } from 'react'
import { Link } from '@inertiajs/react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
export default function Layout({ children, user }) {
const [isMenuOpen, setIsMenuOpen] = useState(false)
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<Link href="/" className="text-xl font-bold text-indigo-600">
Image Manager
</Link>
</div>
<nav className="hidden sm:ml-6 sm:flex sm:space-x-8">
<Link
href="/"
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Home
</Link>
<Link
href="/images/search"
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Search
</Link>
{user && (
<Link
href="/images/new"
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Upload
</Link>
)}
{user?.role === 'admin' && (
<Link
href="/admin"
className="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
>
Admin
</Link>
)}
</nav>
</div>
<div className="hidden sm:ml-6 sm:flex sm:items-center">
{user ? (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="bg-white rounded-full flex text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<span className="sr-only">Open user menu</span>
<div className="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-800 font-semibold">
{user.name?.charAt(0).toUpperCase()}
</div>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[220px] bg-white rounded-md p-1 shadow-lg"
sideOffset={5}
>
<DropdownMenu.Item className="text-sm text-gray-700 px-4 py-2 rounded hover:bg-gray-100">
{user.name}
</DropdownMenu.Item>
<DropdownMenu.Item className="text-sm text-gray-700 px-4 py-2 rounded hover:bg-gray-100">
<Link href="/images" className="block w-full text-left">
My Images
</Link>
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
<DropdownMenu.Item className="text-sm text-gray-700 px-4 py-2 rounded hover:bg-gray-100">
<Link
href="/session"
method="delete"
as="button"
className="block w-full text-left"
>
Sign out
</Link>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
) : (
<div className="flex space-x-4">
<Link
href="/session/new"
className="text-gray-500 hover:text-gray-700 px-3 py-2 rounded-md text-sm font-medium"
>
Sign in
</Link>
<Link
href="/users/new"
className="bg-indigo-600 text-white hover:bg-indigo-700 px-3 py-2 rounded-md text-sm font-medium"
>
Sign up
</Link>
</div>
)}
</div>
<div className="-mr-2 flex items-center sm:hidden">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
>
<span className="sr-only">Open main menu</span>
<svg
className={`${isMenuOpen ? 'hidden' : 'block'} h-6 w-6`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
<svg
className={`${isMenuOpen ? 'block' : 'hidden'} h-6 w-6`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
{/* Mobile menu */}
<div className={`${isMenuOpen ? 'block' : 'hidden'} sm:hidden`}>
<div className="pt-2 pb-3 space-y-1">
<Link
href="/"
className="bg-indigo-50 border-indigo-500 text-indigo-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium"
>
Home
</Link>
<Link
href="/images/search"
className="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium"
>
Search
</Link>
{user && (
<Link
href="/images/new"
className="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium"
>
Upload
</Link>
)}
{user?.role === 'admin' && (
<Link
href="/admin"
className="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium"
>
Admin
</Link>
)}
</div>
{user ? (
<div className="pt-4 pb-3 border-t border-gray-200">
<div className="flex items-center px-4">
<div className="flex-shrink-0">
<div className="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-800 font-semibold">
{user.name?.charAt(0).toUpperCase()}
</div>
</div>
<div className="ml-3">
<div className="text-base font-medium text-gray-800">
{user.name}
</div>
<div className="text-sm font-medium text-gray-500">
{user.email_address}
</div>
</div>
</div>
<div className="mt-3 space-y-1">
<Link
href="/images"
className="block px-4 py-2 text-base font-medium text-gray-500 hover:text-gray-800 hover:bg-gray-100"
>
My Images
</Link>
<Link
href="/session"
method="delete"
as="button"
className="block w-full text-left px-4 py-2 text-base font-medium text-gray-500 hover:text-gray-800 hover:bg-gray-100"
>
Sign out
</Link>
</div>
</div>
) : (
<div className="pt-4 pb-3 border-t border-gray-200">
<div className="flex items-center justify-around">
<Link
href="/session/new"
className="text-gray-500 hover:text-gray-700 px-3 py-2 text-base font-medium"
>
Sign in
</Link>
<Link
href="/users/new"
className="bg-indigo-600 text-white hover:bg-indigo-700 px-3 py-2 rounded-md text-base font-medium"
>
Sign up
</Link>
</div>
</div>
)}
</div>
</header>
<main>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">{children}</div>
</main>
<footer className="bg-white border-t border-gray-200 py-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-center text-gray-500 text-sm">
&copy; {new Date().getFullYear()} Image Manager. All rights reserved.
</p>
</div>
</footer>
</div>
)
}
import { Head, Link } from '@inertiajs/react'
import Layout from './Layout'
export default function NotFound({ auth }) {
return (
<Layout user={auth?.user}>
<Head title="404 - Not Found" />
<div className="min-h-[70vh] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full text-center">
<div className="text-6xl font-extrabold text-indigo-600 mb-4">404</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Page not found</h1>
<p className="text-gray-600 mb-8">
Sorry, we couldn't find the page you're looking for.
</p>
<div className="flex justify-center space-x-4">
<Link
href="/"
className="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Go to home page
</Link>
<Link
href="/images"
className="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Browse images
</Link>
</div>
</div>
</div>
</Layout>
)
}
import { Head } from '@inertiajs/react'
import Layout from '../Layout'
import { Link } from '@inertiajs/react'
export default function AdminDashboard({ stats, auth }) {
return (
<Layout user={auth.user}>
<Head title="Admin Dashboard" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Overview of system statistics and management options
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:p-6">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{/* Total Images Card */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-indigo-500 rounded-md p-3">
<svg
className="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Total Images
</dt>
<dd>
<div className="text-lg font-medium text-gray-900">
{stats.total_images}
</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
<Link
href="/admin/images"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
View all images
</Link>
</div>
</div>
</div>
{/* Pending Images Card */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-yellow-500 rounded-md p-3">
<svg
className="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Pending Review
</dt>
<dd>
<div className="text-lg font-medium text-gray-900">
{stats.pending_images}
</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
<Link
href="/admin/images?status=pending"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Review pending images
</Link>
</div>
</div>
</div>
{/* Total Users Card */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-green-500 rounded-md p-3">
<svg
className="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Total Users
</dt>
<dd>
<div className="text-lg font-medium text-gray-900">
{stats.total_users}
</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
<Link
href="/admin/users"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Manage users
</Link>
</div>
</div>
</div>
{/* Total Tags Card */}
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0 bg-purple-500 rounded-md p-3">
<svg
className="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
Total Tags
</dt>
<dd>
<div className="text-lg font-medium text-gray-900">
{stats.total_tags}
</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-4 sm:px-6">
<div className="text-sm">
<Link
href="/admin/tags"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Manage tags
</Link>
</div>
</div>
</div>
{/* Recent Activity Card */}
<div className="bg-white overflow-hidden shadow rounded-lg sm:col-span-2">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Recent Activity
</h3>
{stats.recent_images && stats.recent_images.length > 0 ? (
<div className="mt-5 flow-root">
<ul className="-my-5 divide-y divide-gray-200">
{stats.recent_images.map((image) => (
<li key={image.id} className="py-4">
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<img
className="h-8 w-8 rounded-full object-cover"
src={image.file_url}
alt=""
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{image.title}
</p>
<p className="text-sm text-gray-500 truncate">
Uploaded by {image.user.name}
</p>
</div>
<div>
<Link
href={`/images/${image.id}`}
className="inline-flex items-center shadow-sm px-2.5 py-0.5 border border-gray-300 text-sm leading-5 font-medium rounded-full text-gray-700 bg-white hover:bg-gray-50"
>
View
</Link>
</div>
</div>
</li>
))}
</ul>
</div>
) : (
<p className="mt-3 text-sm text-gray-500">
No recent activity to display.
</p>
)}
</div>
</div>
</div>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<h2 className="text-lg font-medium text-gray-900">Quick Actions</h2>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<Link
href="/admin/images/new"
className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Upload New Image
</Link>
</div>
<div>
<Link
href="/admin/tags/new"
className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
>
Create New Tag
</Link>
</div>
<div>
<Link
href="/admin/users/new"
className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Create New User
</Link>
</div>
<div>
<Link
href="/admin/images?status=pending"
className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500"
>
Review Pending Images
</Link>
</div>
</div>
</div>
</div>
</Layout>
)
}
import { useState } from 'react'
import { Head, Link } from '@inertiajs/react'
import Layout from '../../Layout'
import * as Tabs from '@radix-ui/react-tabs'
import * as Form from '@radix-ui/react-form'
export default function AdminImagesIndex({ images, filters, auth }) {
const [searchParams, setSearchParams] = useState({
title_cont: filters.title_cont || '',
user_name_cont: filters.user_name_cont || '',
status_eq: filters.status_eq || '',
tags_name_cont: filters.tags_name_cont || '',
created_at_gteq: filters.created_at_gteq || '',
created_at_lteq: filters.created_at_lteq || '',
})
const handleInputChange = (e) => {
const { name, value } = e.target
setSearchParams({ ...searchParams, [name]: value })
}
return (
<Layout user={auth.user}>
<Head title="Admin - Manage Images" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Manage Images</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Review and manage all user-uploaded images
</p>
</div>
</div>
<Tabs.Root defaultValue="all" className="px-4 sm:px-6 pb-5">
<Tabs.List className="flex space-x-4 border-b border-gray-200 mb-4">
<Tabs.Trigger
value="all"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
All Images
</Tabs.Trigger>
<Tabs.Trigger
value="pending"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Pending Review
</Tabs.Trigger>
<Tabs.Trigger
value="approved"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Approved
</Tabs.Trigger>
<Tabs.Trigger
value="rejected"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Rejected
</Tabs.Trigger>
<Tabs.Trigger
value="search"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Advanced Search
</Tabs.Trigger>
</Tabs.List>
{['all', 'pending', 'approved', 'rejected'].map((tab) => (
<Tabs.Content key={tab} value={tab} className="focus:outline-none">
{images
.filter(
(image) => tab === 'all' || image.status === tab
)
.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Image
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Title
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Uploader
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Status
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Upload Date
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{images
.filter(
(image) => tab === 'all' || image.status === tab
)
.map((image) => (
<tr key={image.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-16 w-16 relative">
<img
src={image.file_url}
alt={image.title}
className="h-16 w-16 object-cover rounded"
/>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{image.title}
</div>
<div className="text-sm text-gray-500 mt-1 flex flex-wrap gap-1">
{image.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800"
>
{tag.name}
</span>
))}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{image.user.name}
</div>
<div className="text-sm text-gray-500">
{image.user.email_address}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
image.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: image.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{image.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(image.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex space-x-2">
<Link
href={`/images/${image.id}`}
className="text-indigo-600 hover:text-indigo-900"
>
View
</Link>
{image.status === 'pending' && (
<>
<Link
href={`/admin/images/${image.id}/approve`}
method="patch"
as="button"
className="text-green-600 hover:text-green-900"
>
Approve
</Link>
<Link
href={`/admin/images/${image.id}/reject`}
method="patch"
as="button"
className="text-red-600 hover:text-red-900"
>
Reject
</Link>
</>
)}
<Link
href={`/admin/images/${image.id}`}
method="delete"
as="button"
className="text-gray-600 hover:text-gray-900"
onClick={(e) => {
if (!confirm('Are you sure you want to delete this image?')) {
e.preventDefault()
}
}}
>
Delete
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No images found
</h3>
<p className="mt-1 text-sm text-gray-500">
{tab === 'pending'
? 'There are no pending images to review.'
: tab === 'approved'
? 'There are no approved images.'
: tab === 'rejected'
? 'There are no rejected images.'
: 'There are no images in the system.'}
</p>
</div>
)}
</Tabs.Content>
))}
<Tabs.Content value="search" className="focus:outline-none">
<Form.Root
className="space-y-6"
action="/admin/images"
method="get"
>
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<Form.Field name="q[title_cont]">
<Form.Label className="block text-sm font-medium text-gray-700">
Title
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="q[title_cont]"
value={searchParams.title_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Search by title"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="q[user_name_cont]">
<Form.Label className="block text-sm font-medium text-gray-700">
Uploader
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="q[user_name_cont]"
value={searchParams.user_name_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Search by uploader name"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-2">
<Form.Field name="q[status_eq]">
<Form.Label className="block text-sm font-medium text-gray-700">
Status
</Form.Label>
<Form.Control asChild>
<select
name="q[status_eq]"
value={searchParams.status_eq}
onChange={handleInputChange}
className="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Any status</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-4">
<Form.Field name="q[tags_name_cont]">
<Form.Label className="block text-sm font-medium text-gray-700">
Tags
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="q[tags_name_cont]"
value={searchParams.tags_name_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Search by tag"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="q[created_at_gteq]">
<Form.Label className="block text-sm font-medium text-gray-700">
From Date
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="q[created_at_gteq]"
value={searchParams.created_at_gteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="q[created_at_lteq]">
<Form.Label className="block text-sm font-medium text-gray-700">
To Date
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="q[created_at_lteq]"
value={searchParams.created_at_lteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Search
</button>
</div>
</Form.Root>
</Tabs.Content>
</Tabs.Root>
</div>
</Layout>
)
}
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import Layout from '../../Layout'
import * as Dialog from '@radix-ui/react-dialog'
import * as Form from '@radix-ui/react-form'
export default function AdminTagsIndex({ tags, auth, errors = {} }) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [editingTag, setEditingTag] = useState(null)
const { data: createData, setData: setCreateData, post: createTag, processing: createProcessing, reset: resetCreate } = useForm({
name: '',
})
const { data: editData, setData: setEditData, patch: updateTag, processing: editProcessing, reset: resetEdit } = useForm({
name: '',
})
const handleCreateSubmit = (e) => {
e.preventDefault()
createTag('/admin/tags', {
onSuccess: () => {
setIsCreateModalOpen(false)
resetCreate()
},
})
}
const handleEditSubmit = (e) => {
e.preventDefault()
updateTag(`/admin/tags/${editingTag.id}`, {
onSuccess: () => {
setIsEditModalOpen(false)
setEditingTag(null)
resetEdit()
},
})
}
const openEditModal = (tag) => {
setEditingTag(tag)
setEditData({ name: tag.name })
setIsEditModalOpen(true)
}
return (
<Layout user={auth.user}>
<Head title="Admin - Manage Tags" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Manage Tags</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Create, edit, and delete tags for image categorization
</p>
</div>
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create New Tag
</button>
</div>
<div className="border-t border-gray-200">
{tags.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Image Count
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Created At
</th>
<th
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tags.map((tag) => (
<tr key={tag.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{tag.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{tag.images_count} images
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(tag.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex space-x-2 justify-end">
<button
onClick={() => openEditModal(tag)}
className="text-indigo-600 hover:text-indigo-900"
>
Edit
</button>
<a
href={`/admin/tags/${tag.id}`}
data-method="delete"
data-confirm="Are you sure you want to delete this tag? This will remove the tag from all associated images."
className="text-red-600 hover:text-red-900"
>
Delete
</a>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No tags
</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating a new tag.
</p>
<div className="mt-6">
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create New Tag
</button>
</div>
</div>
)}
</div>
</div>
{/* Create Tag Modal */}
<Dialog.Root open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<Dialog.Title className="text-lg font-medium text-gray-900">
Create New Tag
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
Create a new tag for categorizing images
</Dialog.Description>
<Form.Root className="mt-4" onSubmit={handleCreateSubmit}>
<Form.Field name="name" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Tag Name
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="name"
value={createData.name}
onChange={(e) => setCreateData('name', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter tag name"
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<Form.Submit asChild>
<button
type="submit"
disabled={createProcessing}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
{createProcessing ? 'Creating...' : 'Create'}
</button>
</Form.Submit>
<Dialog.Close asChild>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancel
</button>
</Dialog.Close>
</div>
</Form.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{/* Edit Tag Modal */}
<Dialog.Root open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<Dialog.Title className="text-lg font-medium text-gray-900">
Edit Tag
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
Update the tag name
</Dialog.Description>
<Form.Root className="mt-4" onSubmit={handleEditSubmit}>
<Form.Field name="name" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Tag Name
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="name"
value={editData.name}
onChange={(e) => setEditData('name', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter tag name"
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<Form.Submit asChild>
<button
type="submit"
disabled={editProcessing}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
{editProcessing ? 'Saving...' : 'Save Changes'}
</button>
</Form.Submit>
<Dialog.Close asChild>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancel
</button>
</Dialog.Close>
</div>
</Form.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Layout>
)
}
import { useState } from 'react'
import { Head, Link, useForm } from '@inertiajs/react'
import Layout from '../../Layout'
import * as Dialog from '@radix-ui/react-dialog'
import * as Form from '@radix-ui/react-form'
import * as Switch from '@radix-ui/react-switch'
export default function AdminUsersIndex({ users, auth, errors = {} }) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const { data: createData, setData: setCreateData, post: createUser, processing: createProcessing, reset: resetCreate } = useForm({
name: '',
email_address: '',
password: '',
password_confirmation: '',
admin: false,
})
const { data: editData, setData: setEditData, patch: updateUser, processing: editProcessing, reset: resetEdit } = useForm({
name: '',
email_address: '',
password: '',
password_confirmation: '',
admin: false,
})
const handleCreateSubmit = (e) => {
e.preventDefault()
createUser('/admin/users', {
onSuccess: () => {
setIsCreateModalOpen(false)
resetCreate()
},
})
}
const handleEditSubmit = (e) => {
e.preventDefault()
updateUser(`/admin/users/${editingUser.id}`, {
onSuccess: () => {
setIsEditModalOpen(false)
setEditingUser(null)
resetEdit()
},
})
}
const openEditModal = (user) => {
setEditingUser(user)
setEditData({
name: user.name,
email_address: user.email_address,
password: '',
password_confirmation: '',
admin: user.admin,
})
setIsEditModalOpen(true)
}
return (
<Layout user={auth.user}>
<Head title="Admin - Manage Users" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Manage Users</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Add, edit, and manage user accounts
</p>
</div>
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create New User
</button>
</div>
<div className="border-t border-gray-200">
{users.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Email
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Role
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Images
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Created At
</th>
<th
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{user.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{user.email_address}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.admin
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{user.admin ? 'Admin' : 'User'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.images_count || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex space-x-2 justify-end">
<button
onClick={() => openEditModal(user)}
className="text-indigo-600 hover:text-indigo-900"
>
Edit
</button>
{user.id !== auth.user.id && (
<Link
href={`/admin/users/${user.id}`}
method="delete"
as="button"
className="text-red-600 hover:text-red-900"
onClick={(e) => {
if (!confirm('Are you sure you want to delete this user? This will also delete all their images.')) {
e.preventDefault()
}
}}
>
Delete
</Link>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No users
</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating a new user.
</p>
<div className="mt-6">
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create New User
</button>
</div>
</div>
)}
</div>
</div>
{/* Create User Modal */}
<Dialog.Root open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<Dialog.Title className="text-lg font-medium text-gray-900">
Create New User
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
Create a new user account
</Dialog.Description>
<Form.Root className="mt-4 space-y-4" onSubmit={handleCreateSubmit}>
<Form.Field name="name" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Name
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="name"
value={createData.name}
onChange={(e) => setCreateData('name', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter user name"
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<Form.Field name="email_address" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Email Address
</Form.Label>
<Form.Control asChild>
<input
type="email"
name="email_address"
value={createData.email_address}
onChange={(e) => setCreateData('email_address', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter email address"
/>
</Form.Control>
{errors.email_address && (
<Form.Message className="text-sm text-red-600">
{errors.email_address}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Password
</Form.Label>
<Form.Control asChild>
<input
type="password"
name="password"
value={createData.password}
onChange={(e) => setCreateData('password', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter password"
/>
</Form.Control>
{errors.password && (
<Form.Message className="text-sm text-red-600">
{errors.password}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password_confirmation" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Confirm Password
</Form.Label>
<Form.Control asChild>
<input
type="password"
name="password_confirmation"
value={createData.password_confirmation}
onChange={(e) => setCreateData('password_confirmation', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Confirm password"
/>
</Form.Control>
</Form.Field>
<div className="flex items-center">
<Form.Field name="admin" className="flex items-center">
<Form.Label className="mr-3 text-sm font-medium text-gray-700">
Admin User
</Form.Label>
<Form.Control asChild>
<Switch.Root
checked={createData.admin}
onCheckedChange={(checked) => setCreateData('admin', checked)}
className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-200 data-[state=checked]:bg-indigo-600"
>
<Switch.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-1" />
</Switch.Root>
</Form.Control>
</Form.Field>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<Form.Submit asChild>
<button
type="submit"
disabled={createProcessing}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
{createProcessing ? 'Creating...' : 'Create'}
</button>
</Form.Submit>
<Dialog.Close asChild>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancel
</button>
</Dialog.Close>
</div>
</Form.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{/* Edit User Modal */}
<Dialog.Root open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<Dialog.Title className="text-lg font-medium text-gray-900">
Edit User
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
Update user account details
</Dialog.Description>
<Form.Root className="mt-4 space-y-4" onSubmit={handleEditSubmit}>
<Form.Field name="name" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Name
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="name"
value={editData.name}
onChange={(e) => setEditData('name', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter user name"
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<Form.Field name="email_address" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Email Address
</Form.Label>
<Form.Control asChild>
<input
type="email"
name="email_address"
value={editData.email_address}
onChange={(e) => setEditData('email_address', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter email address"
/>
</Form.Control>
{errors.email_address && (
<Form.Message className="text-sm text-red-600">
{errors.email_address}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
New Password (leave blank to keep current)
</Form.Label>
<Form.Control asChild>
<input
type="password"
name="password"
value={editData.password}
onChange={(e) => setEditData('password', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter new password"
/>
</Form.Control>
{errors.password && (
<Form.Message className="text-sm text-red-600">
{errors.password}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password_confirmation" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Confirm New Password
</Form.Label>
<Form.Control asChild>
<input
type="password"
name="password_confirmation"
value={editData.password_confirmation}
onChange={(e) => setEditData('password_confirmation', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Confirm new password"
/>
</Form.Control>
</Form.Field>
<div className="flex items-center">
<Form.Field name="admin" className="flex items-center">
<Form.Label className="mr-3 text-sm font-medium text-gray-700">
Admin User
</Form.Label>
<Form.Control asChild>
<Switch.Root
checked={editData.admin}
onCheckedChange={(checked) => setEditData('admin', checked)}
className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-200 data-[state=checked]:bg-indigo-600"
>
<Switch.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-1" />
</Switch.Root>
</Form.Control>
</Form.Field>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<Form.Submit asChild>
<button
type="submit"
disabled={editProcessing}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
{editProcessing ? 'Saving...' : 'Save Changes'}
</button>
</Form.Submit>
<Dialog.Close asChild>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancel
</button>
</Dialog.Close>
</div>
</Form.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Layout>
)
}
import { useState } from 'react'
import { Head, Link, useForm } from '@inertiajs/react'
import * as Form from '@radix-ui/react-form'
export default function ForgotPassword({ status, errors = {} }) {
const { data, setData, post, processing } = useForm({
email_address: '',
})
const handleSubmit = (e) => {
e.preventDefault()
post('/forgot-password')
}
return (
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8 bg-gray-50">
<Head title="Forgot Password" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Forgot your password?
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Enter your email address and we'll send you a link to reset your password.
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{status && (
<div className="mb-4 font-medium text-sm text-green-600">
{status}
</div>
)}
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="email_address" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Email address
</Form.Label>
<Form.Control asChild>
<input
id="email_address"
name="email_address"
type="email"
autoComplete="email"
required
value={data.email_address}
onChange={(e) => setData('email_address', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.email_address && (
<Form.Message className="text-sm text-red-600">
{errors.email_address}
</Form.Message>
)}
</Form.Field>
<div>
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Sending...' : 'Send Password Reset Link'}
</button>
</Form.Submit>
</div>
<div className="flex items-center justify-center">
<div className="text-sm">
<Link
href="/login"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Back to login
</Link>
</div>
</div>
</Form.Root>
</div>
</div>
</div>
)
}
import { useState } from 'react'
import { Head, Link, useForm } from '@inertiajs/react'
import * as Form from '@radix-ui/react-form'
export default function Login({ errors = {} }) {
const { data, setData, post, processing } = useForm({
email_address: '',
password: '',
remember: false,
})
const handleSubmit = (e) => {
e.preventDefault()
post('/login')
}
return (
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8 bg-gray-50">
<Head title="Log in" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Log in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
href="/register"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
create a new account
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="email_address" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Email address
</Form.Label>
<Form.Control asChild>
<input
id="email_address"
name="email_address"
type="email"
autoComplete="email"
required
value={data.email_address}
onChange={(e) => setData('email_address', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.email_address && (
<Form.Message className="text-sm text-red-600">
{errors.email_address}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Password
</Form.Label>
<Form.Control asChild>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={data.password}
onChange={(e) => setData('password', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.password && (
<Form.Message className="text-sm text-red-600">
{errors.password}
</Form.Message>
)}
</Form.Field>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember"
name="remember"
type="checkbox"
checked={data.remember}
onChange={(e) => setData('remember', e.target.checked)}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label
htmlFor="remember"
className="ml-2 block text-sm text-gray-900"
>
Remember me
</label>
</div>
<div className="text-sm">
<Link
href="/forgot-password"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Forgot your password?
</Link>
</div>
</div>
<div>
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Logging in...' : 'Log in'}
</button>
</Form.Submit>
</div>
</Form.Root>
</div>
</div>
</div>
)
}
import { useState } from 'react'
import { Head, Link, useForm } from '@inertiajs/react'
import * as Form from '@radix-ui/react-form'
export default function Register({ errors = {} }) {
const { data, setData, post, processing } = useForm({
name: '',
email_address: '',
password: '',
password_confirmation: '',
})
const handleSubmit = (e) => {
e.preventDefault()
post('/register')
}
return (
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8 bg-gray-50">
<Head title="Register" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create a new account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
href="/login"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
log in to your existing account
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="name" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Name
</Form.Label>
<Form.Control asChild>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<Form.Field name="email_address" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Email address
</Form.Label>
<Form.Control asChild>
<input
id="email_address"
name="email_address"
type="email"
autoComplete="email"
required
value={data.email_address}
onChange={(e) => setData('email_address', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.email_address && (
<Form.Message className="text-sm text-red-600">
{errors.email_address}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Password
</Form.Label>
<Form.Control asChild>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={data.password}
onChange={(e) => setData('password', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.password && (
<Form.Message className="text-sm text-red-600">
{errors.password}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password_confirmation" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Confirm Password
</Form.Label>
<Form.Control asChild>
<input
id="password_confirmation"
name="password_confirmation"
type="password"
autoComplete="new-password"
required
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
</Form.Field>
<div>
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Creating account...' : 'Create account'}
</button>
</Form.Submit>
</div>
</Form.Root>
</div>
</div>
</div>
)
}
import { useState } from 'react'
import { Head, Link, useForm } from '@inertiajs/react'
import * as Form from '@radix-ui/react-form'
export default function ResetPassword({ token, email, errors = {} }) {
const { data, setData, post, processing } = useForm({
token: token,
email_address: email,
password: '',
password_confirmation: '',
})
const handleSubmit = (e) => {
e.preventDefault()
post('/reset-password')
}
return (
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8 bg-gray-50">
<Head title="Reset Password" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Enter a new password for your account
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="email_address" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Email address
</Form.Label>
<Form.Control asChild>
<input
id="email_address"
name="email_address"
type="email"
autoComplete="email"
readOnly
value={data.email_address}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-gray-50 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.email_address && (
<Form.Message className="text-sm text-red-600">
{errors.email_address}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
New Password
</Form.Label>
<Form.Control asChild>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={data.password}
onChange={(e) => setData('password', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.password && (
<Form.Message className="text-sm text-red-600">
{errors.password}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password_confirmation" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Confirm New Password
</Form.Label>
<Form.Control asChild>
<input
id="password_confirmation"
name="password_confirmation"
type="password"
autoComplete="new-password"
required
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.password_confirmation && (
<Form.Message className="text-sm text-red-600">
{errors.password_confirmation}
</Form.Message>
)}
</Form.Field>
<div>
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Resetting...' : 'Reset Password'}
</button>
</Form.Submit>
</div>
<div className="flex items-center justify-center">
<div className="text-sm">
<Link
href="/login"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Back to login
</Link>
</div>
</div>
</Form.Root>
</div>
</div>
</div>
)
}
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import Layout from '../Layout'
import * as Form from '@radix-ui/react-form'
export default function Edit({ image, tags, auth, errors = {} }) {
const { data, setData, patch, processing } = useForm({
title: image.title || '',
tags: tags || '',
})
const handleSubmit = (e) => {
e.preventDefault()
patch(`/images/${image.id}`)
}
return (
<Layout user={auth.user}>
<Head title={`Edit ${image.title}`} />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-2xl font-bold text-gray-900">Edit Image</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Update image details
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<div className="mb-6">
<img
src={image.file_url}
alt={image.title}
className="max-h-64 mx-auto object-contain"
/>
</div>
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="title" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Title
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="title"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter image title"
/>
</Form.Control>
{errors.title && (
<Form.Message className="text-sm text-red-600">
{errors.title}
</Form.Message>
)}
</Form.Field>
<Form.Field name="tags" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Tags
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="tags"
value={data.tags}
onChange={(e) => setData('tags', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter tags separated by commas (e.g. nature, landscape, mountains)"
/>
</Form.Control>
<p className="text-xs text-gray-500">
Enter tags separated by commas
</p>
</Form.Field>
<div className="flex justify-end space-x-3">
<a
href={`/images/${image.id}`}
className="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Cancel
</a>
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Saving...' : 'Save Changes'}
</button>
</Form.Submit>
</div>
</Form.Root>
</div>
</div>
</Layout>
)
}
import { useState } from 'react'
import { Head, Link } from '@inertiajs/react'
import Layout from '../Layout'
import * as Tabs from '@radix-ui/react-tabs'
import * as Form from '@radix-ui/react-form'
export default function Index({ images, filters, auth }) {
const [searchParams, setSearchParams] = useState({
title_cont: filters.title_cont || '',
tags_name_cont: filters.tags_name_cont || '',
created_at_gteq: filters.created_at_gteq || '',
created_at_lteq: filters.created_at_lteq || '',
})
const handleInputChange = (e) => {
const { name, value } = e.target
setSearchParams({ ...searchParams, [name]: value })
}
return (
<Layout user={auth.user}>
<Head title="My Images" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">My Images</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Manage your uploaded images
</p>
</div>
{auth.user && (
<Link
href="/images/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Upload New Image
</Link>
)}
</div>
<Tabs.Root defaultValue="all" className="px-4 sm:px-6 pb-5">
<Tabs.List className="flex space-x-4 border-b border-gray-200 mb-4">
<Tabs.Trigger
value="all"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
All Images
</Tabs.Trigger>
<Tabs.Trigger
value="search"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Search
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="all" className="focus:outline-none">
{images.length > 0 ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{images.map((image) => (
<div
key={image.id}
className="bg-white overflow-hidden shadow rounded-lg"
>
<div className="relative pb-[75%]">
<img
src={image.file_url}
alt={image.title}
className="absolute h-full w-full object-cover"
/>
<div className="absolute top-2 right-2">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
image.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: image.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{image.status}
</span>
</div>
</div>
<div className="px-4 py-4">
<h3 className="text-lg font-medium text-gray-900 truncate">
{image.title}
</h3>
<div className="mt-2 flex flex-wrap gap-1">
{image.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
{tag.name}
</span>
))}
</div>
<div className="mt-4 flex justify-between">
<Link
href={`/images/${image.id}`}
className="text-sm text-indigo-600 hover:text-indigo-900"
>
View details
</Link>
<span className="text-sm text-gray-500">
{new Date(image.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No images
</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by uploading a new image.
</p>
<div className="mt-6">
<Link
href="/images/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Upload New Image
</Link>
</div>
</div>
)}
</Tabs.Content>
<Tabs.Content value="search" className="focus:outline-none">
<Form.Root
className="space-y-6"
action="/images"
method="get"
>
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<Form.Field name="q[title_cont]">
<Form.Label className="block text-sm font-medium text-gray-700">
Title
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="q[title_cont]"
value={searchParams.title_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Search by title"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="q[tags_name_cont]">
<Form.Label className="block text-sm font-medium text-gray-700">
Tags
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="q[tags_name_cont]"
value={searchParams.tags_name_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Search by tag"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="q[created_at_gteq]">
<Form.Label className="block text-sm font-medium text-gray-700">
From Date
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="q[created_at_gteq]"
value={searchParams.created_at_gteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="q[created_at_lteq]">
<Form.Label className="block text-sm font-medium text-gray-700">
To Date
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="q[created_at_lteq]"
value={searchParams.created_at_lteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Search
</button>
</div>
</Form.Root>
</Tabs.Content>
</Tabs.Root>
</div>
</Layout>
)
}
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import Layout from '../Layout'
import * as Form from '@radix-ui/react-form'
export default function New({ auth, errors = {} }) {
const [preview, setPreview] = useState(null)
const { data, setData, post, processing } = useForm({
title: '',
file: null,
tags: '',
})
const handleSubmit = (e) => {
e.preventDefault()
post('/images')
}
const handleFileChange = (e) => {
const file = e.target.files[0]
setData('file', file)
if (file) {
const reader = new FileReader()
reader.onloadend = () => {
setPreview(reader.result)
}
reader.readAsDataURL(file)
} else {
setPreview(null)
}
}
return (
<Layout user={auth.user}>
<Head title="Upload New Image" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-2xl font-bold text-gray-900">Upload New Image</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Upload a new image for review
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="title" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Title
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="title"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter image title"
/>
</Form.Control>
{errors.title && (
<Form.Message className="text-sm text-red-600">
{errors.title}
</Form.Message>
)}
</Form.Field>
<Form.Field name="file" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Image File
</Form.Label>
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
<div className="space-y-1 text-center">
{preview ? (
<div className="mb-4">
<img
src={preview}
alt="Preview"
className="mx-auto h-64 object-contain"
/>
</div>
) : (
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
<div className="flex text-sm text-gray-600">
<label
htmlFor="file-upload"
className="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"
>
<span>Upload a file</span>
<Form.Control asChild>
<input
id="file-upload"
name="file"
type="file"
className="sr-only"
accept="image/*"
onChange={handleFileChange}
/>
</Form.Control>
</label>
<p className="pl-1">or drag and drop</p>
</div>
<p className="text-xs text-gray-500">
PNG, JPG, GIF up to 10MB
</p>
</div>
</div>
{errors.file && (
<Form.Message className="text-sm text-red-600">
{errors.file}
</Form.Message>
)}
</Form.Field>
<Form.Field name="tags" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Tags
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="tags"
value={data.tags}
onChange={(e) => setData('tags', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter tags separated by commas (e.g. nature, landscape, mountains)"
/>
</Form.Control>
<p className="text-xs text-gray-500">
Enter tags separated by commas
</p>
</Form.Field>
<div className="flex justify-end">
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Uploading...' : 'Upload Image'}
</button>
</Form.Submit>
</div>
</Form.Root>
</div>
</div>
</Layout>
)
}
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import Layout from '../Layout'
import * as Form from '@radix-ui/react-form'
import * as Checkbox from '@radix-ui/react-checkbox'
export default function Search({ images, filters, tags, auth }) {
const { data, setData, get, processing } = useForm({
'q[title_cont]': filters['title_cont'] || '',
'q[tags_name_in][]': filters['tags_name_in'] || [],
'q[created_at_gteq]': filters['created_at_gteq'] || '',
'q[created_at_lteq]': filters['created_at_lteq'] || '',
})
const handleSubmit = (e) => {
e.preventDefault()
get('/images/search', {
preserveState: true,
})
}
const handleTagChange = (tagName) => {
const currentTags = [...data['q[tags_name_in][]']]
const tagIndex = currentTags.indexOf(tagName)
if (tagIndex === -1) {
currentTags.push(tagName)
} else {
currentTags.splice(tagIndex, 1)
}
setData('q[tags_name_in][]', currentTags)
}
const isTagSelected = (tagName) => {
return data['q[tags_name_in][]'].includes(tagName)
}
return (
<Layout user={auth.user}>
<Head title="Search Images" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-2xl font-bold text-gray-900">Search Images</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Find images by title, tags, or date
</p>
</div>
<div className="border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-4">
<div className="md:col-span-1 p-4 border-b md:border-b-0 md:border-r border-gray-200">
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="q[title_cont]" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Title
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="q[title_cont]"
value={data['q[title_cont]']}
onChange={(e) => setData('q[title_cont]', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Search by title"
/>
</Form.Control>
</Form.Field>
<div className="space-y-2">
<h3 className="block text-sm font-medium text-gray-700">Tags</h3>
<div className="mt-2 space-y-2 max-h-60 overflow-y-auto">
{tags.map((tag) => (
<div key={tag} className="flex items-center">
<Checkbox.Root
id={`tag-${tag}`}
checked={isTagSelected(tag)}
onCheckedChange={() => handleTagChange(tag)}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
>
<Checkbox.Indicator>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-indigo-600"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</Checkbox.Indicator>
</Checkbox.Root>
<label
htmlFor={`tag-${tag}`}
className="ml-2 text-sm text-gray-700"
>
{tag}
</label>
</div>
))}
</div>
</div>
<Form.Field name="q[created_at_gteq]" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
From Date
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="q[created_at_gteq]"
value={data['q[created_at_gteq]']}
onChange={(e) => setData('q[created_at_gteq]', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
<Form.Field name="q[created_at_lteq]" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
To Date
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="q[created_at_lteq]"
value={data['q[created_at_lteq]']}
onChange={(e) => setData('q[created_at_lteq]', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
<div className="flex justify-end">
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Searching...' : 'Search'}
</button>
</Form.Submit>
</div>
</Form.Root>
</div>
<div className="md:col-span-3 p-4">
{images.length > 0 ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{images.map((image) => (
<div
key={image.id}
className="bg-white overflow-hidden shadow rounded-lg border border-gray-200"
>
<div className="relative pb-[75%]">
<img
src={image.file_url}
alt={image.title}
className="absolute h-full w-full object-cover"
/>
</div>
<div className="px-4 py-4">
<h3 className="text-lg font-medium text-gray-900 truncate">
{image.title}
</h3>
<div className="mt-2 flex flex-wrap gap-1">
{image.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
{tag.name}
</span>
))}
</div>
<div className="mt-4 flex justify-between">
<a
href={`/images/${image.id}`}
className="text-sm text-indigo-600 hover:text-indigo-900"
>
View details
</a>
<span className="text-sm text-gray-500">
{new Date(image.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No images found
</h3>
<p className="mt-1 text-sm text-gray-500">
Try adjusting your search criteria.
</p>
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
)
}
import { Head, Link } from '@inertiajs/react'
import Layout from '../Layout'
import * as Dialog from '@radix-ui/react-dialog'
import { useState } from 'react'
export default function Show({ image, can_edit, can_approve, auth }) {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<Layout user={auth.user}>
<Head title={image.title} />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">{image.title}</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Uploaded by {image.user.name} on{' '}
{new Date(image.created_at).toLocaleDateString()}
</p>
</div>
<div className="flex space-x-2">
{can_edit && (
<Link
href={`/images/${image.id}/edit`}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Edit
</Link>
)}
{can_approve && image.status === 'pending' && (
<>
<Link
href={`/images/${image.id}/approve`}
method="patch"
as="button"
className="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Approve
</Link>
<Link
href={`/images/${image.id}/reject`}
method="patch"
as="button"
className="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Reject
</Link>
</>
)}
{can_approve && (
<Link
href={`/images/${image.id}`}
method="delete"
as="button"
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={(e) => {
if (!confirm('Are you sure you want to delete this image?')) {
e.preventDefault()
}
}}
>
Delete
</Link>
)}
</div>
</div>
<div className="border-t border-gray-200">
<div className="flex flex-col md:flex-row">
<div className="md:w-2/3 p-4">
<div className="relative pb-[75%]">
<img
src={image.file_url}
alt={image.title}
className="absolute h-full w-full object-contain cursor-pointer"
onClick={() => setIsModalOpen(true)}
/>
</div>
</div>
<div className="md:w-1/3 p-4 border-t md:border-t-0 md:border-l border-gray-200">
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900">Status</h3>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
image.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: image.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{image.status}
</span>
</div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900">Tags</h3>
<div className="mt-2 flex flex-wrap gap-1">
{image.tags.length > 0 ? (
image.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
{tag.name}
</span>
))
) : (
<p className="text-sm text-gray-500">No tags</p>
)}
</div>
</div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900">Uploader</h3>
<p className="text-sm text-gray-500">{image.user.name}</p>
<p className="text-sm text-gray-500">
{image.user.email_address}
</p>
</div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900">
Upload Date
</h3>
<p className="text-sm text-gray-500">
{new Date(image.created_at).toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
</div>
{/* Full screen image modal */}
<Dialog.Root open={isModalOpen} onOpenChange={setIsModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed inset-0 flex items-center justify-center">
<div className="relative w-full h-full max-w-screen-xl max-h-screen p-4">
<img
src={image.file_url}
alt={image.title}
className="w-full h-full object-contain"
/>
<Dialog.Close asChild>
<button
className="absolute top-4 right-4 p-2 rounded-full bg-white/80 hover:bg-white text-gray-800"
aria-label="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Layout>
)
}
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import Layout from '../Layout'
import * as Form from '@radix-ui/react-form'
import * as Tabs from '@radix-ui/react-tabs'
export default function ProfileShow({ auth, user_stats, errors = {} }) {
const { data, setData, patch, processing, reset } = useForm({
name: auth.user.name || '',
email_address: auth.user.email_address || '',
current_password: '',
password: '',
password_confirmation: '',
})
const handleSubmit = (e) => {
e.preventDefault()
patch('/profile', {
onSuccess: () => {
reset('current_password', 'password', 'password_confirmation')
},
})
}
return (
<Layout user={auth.user}>
<Head title="My Profile" />
<Tabs.Root defaultValue="profile" className="bg-white shadow overflow-hidden sm:rounded-lg">
<Tabs.List className="flex border-b border-gray-200">
<Tabs.Trigger
value="profile"
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Profile
</Tabs.Trigger>
<Tabs.Trigger
value="activity"
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Activity
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="profile" className="focus:outline-none">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-2xl font-bold text-gray-900">My Profile</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Update your account information
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="name" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Name
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<Form.Field name="email_address" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Email Address
</Form.Label>
<Form.Control asChild>
<input
type="email"
name="email_address"
value={data.email_address}
onChange={(e) => setData('email_address', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
{errors.email_address && (
<Form.Message className="text-sm text-red-600">
{errors.email_address}
</Form.Message>
)}
</Form.Field>
<div className="pt-5 border-t border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Change Password</h3>
<p className="mt-1 text-sm text-gray-500">
Leave these fields blank if you don't want to change your password
</p>
</div>
<Form.Field name="current_password" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Current Password
</Form.Label>
<Form.Control asChild>
<input
type="password"
name="current_password"
value={data.current_password}
onChange={(e) => setData('current_password', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
{errors.current_password && (
<Form.Message className="text-sm text-red-600">
{errors.current_password}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
New Password
</Form.Label>
<Form.Control asChild>
<input
type="password"
name="password"
value={data.password}
onChange={(e) => setData('password', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
{errors.password && (
<Form.Message className="text-sm text-red-600">
{errors.password}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password_confirmation" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Confirm New Password
</Form.Label>
<Form.Control asChild>
<input
type="password"
name="password_confirmation"
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
<div className="flex justify-end">
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Saving...' : 'Save Changes'}
</button>
</Form.Submit>
</div>
</Form.Root>
</div>
</Tabs.Content>
<Tabs.Content value="activity" className="focus:outline-none">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-2xl font-bold text-gray-900">My Activity</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Your account statistics and recent activity
</p>
</div>
<div className="border-t border-gray-200">
<dl className="sm:divide-y sm:divide-gray-200">
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Account created</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{new Date(auth.user.created_at).toLocaleDateString()}
</dd>
</div>
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Total images</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{user_stats.total_images}
</dd>
</div>
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Pending review</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{user_stats.pending_images}
</dd>
</div>
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Approved images</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{user_stats.approved_images}
</dd>
</div>
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Rejected images</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{user_stats.rejected_images}
</dd>
</div>
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Most used tags</dt>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<div className="flex flex-wrap gap-1">
{user_stats.top_tags && user_stats.top_tags.length > 0 ? (
user_stats.top_tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
{tag.name} ({tag.count})
</span>
))
) : (
<span className="text-gray-500">No tags used yet</span>
)}
</div>
</dd>
</div>
</dl>
</div>
{user_stats.recent_images && user_stats.recent_images.length > 0 && (
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<h3 className="text-lg font-medium text-gray-900">Recent Images</h3>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{user_stats.recent_images.map((image) => (
<div
key={image.id}
className="relative rounded-lg border border-gray-300 bg-white px-6 py-5 shadow-sm flex items-center space-x-3 hover:border-gray-400"
>
<div className="flex-shrink-0">
<img
className="h-10 w-10 rounded-full object-cover"
src={image.file_url}
alt=""
/>
</div>
<div className="flex-1 min-w-0">
<a href={`/images/${image.id}`} className="focus:outline-none">
<span className="absolute inset-0" aria-hidden="true" />
<p className="text-sm font-medium text-gray-900">{image.title}</p>
<p className="text-sm text-gray-500 truncate">
{new Date(image.created_at).toLocaleDateString()} - {image.status}
</p>
</a>
</div>
</div>
))}
</div>
</div>
)}
</Tabs.Content>
</Tabs.Root>
</Layout>
)
}
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import Layout from '../Layout'
import SuccessMessage from '../../components/SuccessMessage'
import ErrorMessage from '../../components/ErrorMessage'
export default function SessionsIndex({ auth, sessions, flash }) {
const { delete: destroy, processing } = useForm()
const [confirmingSessionId, setConfirmingSessionId] = useState(null)
const handleTerminateSession = (id) => {
destroy(`/sessions/${id}`, {
preserveScroll: true,
onSuccess: () => setConfirmingSessionId(null),
})
}
const formatLastActiveTime = (time) => {
const date = new Date(time)
return new Intl.DateTimeFormat('default', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date)
}
const isCurrentSession = (session) => {
return session.is_current
}
return (
<Layout user={auth.user}>
<Head title="Active Sessions" />
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-lg leading-6 font-medium text-gray-900">Active Sessions</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
View and manage your currently active sessions across different devices.
</p>
</div>
{flash.success && (
<div className="px-4 sm:px-6">
<SuccessMessage message={flash.success} />
</div>
)}
{flash.error && (
<div className="px-4 sm:px-6">
<ErrorMessage message={flash.error} />
</div>
)}
<div className="border-t border-gray-200">
<ul className="divide-y divide-gray-200">
{sessions.map((session) => (
<li key={session.id} className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0">
{isCurrentSession(session) ? (
<span className="inline-flex items-center justify-center h-10 w-10 rounded-full bg-green-100">
<svg className="h-6 w-6 text-green-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
) : (
<span className="inline-flex items-center justify-center h-10 w-10 rounded-full bg-gray-100">
<svg className="h-6 w-6 text-gray-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</span>
)}
</div>
<div className="ml-4">
<div className="flex items-center">
<h3 className="text-sm font-medium text-gray-900">
{session.user_agent || 'Unknown Device'}
</h3>
{isCurrentSession(session) && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Current Session
</span>
)}
</div>
<div className="mt-1 text-sm text-gray-500">
<div>IP: {session.ip_address || 'Unknown'}</div>
<div>Last active: {formatLastActiveTime(session.updated_at)}</div>
</div>
</div>
</div>
<div>
{!isCurrentSession(session) ? (
confirmingSessionId === session.id ? (
<div className="flex space-x-2">
<button
type="button"
className="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
onClick={() => handleTerminateSession(session.id)}
disabled={processing}
>
{processing ? 'Terminating...' : 'Confirm'}
</button>
<button
type="button"
className="inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => setConfirmingSessionId(null)}
>
Cancel
</button>
</div>
) : (
<button
type="button"
className="inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => setConfirmingSessionId(session.id)}
>
Terminate
</button>
)
) : (
<span className="text-xs text-gray-500">Active</span>
)}
</div>
</div>
</li>
))}
{sessions.length === 0 && (
<li className="px-4 py-6 sm:px-6 text-center text-gray-500">
No active sessions found.
</li>
)}
</ul>
</div>
<div className="px-4 py-4 sm:px-6 bg-gray-50 border-t border-gray-200">
<div className="text-sm">
<p className="font-medium text-gray-700">About sessions</p>
<p className="mt-1 text-gray-500">
Sessions are created when you log in to your account. You can terminate any session
except your current one. If you notice any suspicious activity, terminate the session
and change your password immediately.
</p>
</div>
</div>
</div>
</div>
</div>
</Layout>
)
}
import { useState } from 'react'
import { Head, Link, useForm } from '@inertiajs/react'
import * as Form from '@radix-ui/react-form'
import ErrorMessage from '../../components/ErrorMessage'
export default function SessionsNew({ flash }) {
const { data, setData, post, processing, errors } = useForm({
email_address: '',
password: '',
remember: false,
})
const handleSubmit = (e) => {
e.preventDefault()
post('/session')
}
return (
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8 bg-gray-50">
<Head title="Log in" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Log in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
href="/users/new"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
create a new account
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{flash?.alert && (
<div className="mb-4">
<ErrorMessage message={flash.alert} />
</div>
)}
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="email_address" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Email address
</Form.Label>
<Form.Control asChild>
<input
id="email_address"
name="email_address"
type="email"
autoComplete="email"
required
value={data.email_address}
onChange={(e) => setData('email_address', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.email_address && (
<Form.Message className="text-sm text-red-600">
{errors.email_address}
</Form.Message>
)}
</Form.Field>
<Form.Field name="password" className="space-y-1">
<Form.Label className="block text-sm font-medium text-gray-700">
Password
</Form.Label>
<Form.Control asChild>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={data.password}
onChange={(e) => setData('password', e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</Form.Control>
{errors.password && (
<Form.Message className="text-sm text-red-600">
{errors.password}
</Form.Message>
)}
</Form.Field>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember"
name="remember"
type="checkbox"
checked={data.remember}
onChange={(e) => setData('remember', e.target.checked)}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label
htmlFor="remember"
className="ml-2 block text-sm text-gray-900"
>
Remember me
</label>
</div>
<div className="text-sm">
<Link
href="/passwords/new"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Forgot your password?
</Link>
</div>
</div>
<div>
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Logging in...' : 'Log in'}
</button>
</Form.Submit>
</div>
</Form.Root>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">
Or continue with
</span>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-3">
<button
type="button"
disabled
title="GitHub login is not available yet"
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-400 opacity-60 cursor-not-allowed"
>
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
<span>GitHub</span>
</button>
</div>
</div>
</div>
</div>
</div>
)
}
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import Layout from '../Layout'
import SuccessMessage from '../../components/SuccessMessage'
import ErrorMessage from '../../components/ErrorMessage'
export default function SessionsSecurity({ auth, security_settings, flash }) {
const { data, setData, patch, processing, errors } = useForm({
require_two_factor: security_settings?.require_two_factor || false,
session_timeout_minutes: security_settings?.session_timeout_minutes || 60,
notify_on_new_login: security_settings?.notify_on_new_login || true,
max_sessions: security_settings?.max_sessions || 5,
})
const handleSubmit = (e) => {
e.preventDefault()
patch('/sessions/security', {
preserveScroll: true,
})
}
return (
<Layout user={auth.user}>
<Head title="Session Security" />
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-lg leading-6 font-medium text-gray-900">Session Security Settings</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Manage your account security preferences.
</p>
</div>
{flash?.success && (
<div className="px-4 sm:px-6">
<SuccessMessage message={flash.success} />
</div>
)}
{flash?.error && (
<div className="px-4 sm:px-6">
<ErrorMessage message={flash.error} />
</div>
)}
<div className="border-t border-gray-200 px-4 py-5 sm:p-6">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="require_two_factor"
name="require_two_factor"
type="checkbox"
checked={data.require_two_factor}
onChange={(e) => setData('require_two_factor', e.target.checked)}
className="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="require_two_factor" className="font-medium text-gray-700">
Require two-factor authentication
</label>
<p className="text-gray-500">
When enabled, you'll be required to provide a verification code in addition to your password when logging in.
</p>
</div>
</div>
{errors.require_two_factor && (
<p className="mt-2 text-sm text-red-600">{errors.require_two_factor}</p>
)}
</div>
<div>
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="notify_on_new_login"
name="notify_on_new_login"
type="checkbox"
checked={data.notify_on_new_login}
onChange={(e) => setData('notify_on_new_login', e.target.checked)}
className="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="notify_on_new_login" className="font-medium text-gray-700">
Email notifications for new logins
</label>
<p className="text-gray-500">
Receive an email notification when a new device logs into your account.
</p>
</div>
</div>
{errors.notify_on_new_login && (
<p className="mt-2 text-sm text-red-600">{errors.notify_on_new_login}</p>
)}
</div>
<div>
<label htmlFor="session_timeout_minutes" className="block text-sm font-medium text-gray-700">
Session timeout (minutes)
</label>
<div className="mt-1">
<select
id="session_timeout_minutes"
name="session_timeout_minutes"
value={data.session_timeout_minutes}
onChange={(e) => setData('session_timeout_minutes', e.target.value)}
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
<option value="120">2 hours</option>
<option value="240">4 hours</option>
<option value="480">8 hours</option>
<option value="1440">24 hours</option>
</select>
</div>
<p className="mt-2 text-sm text-gray-500">
Your session will automatically expire after this period of inactivity.
</p>
{errors.session_timeout_minutes && (
<p className="mt-2 text-sm text-red-600">{errors.session_timeout_minutes}</p>
)}
</div>
<div>
<label htmlFor="max_sessions" className="block text-sm font-medium text-gray-700">
Maximum active sessions
</label>
<div className="mt-1">
<select
id="max_sessions"
name="max_sessions"
value={data.max_sessions}
onChange={(e) => setData('max_sessions', e.target.value)}
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
>
<option value="1">1 session</option>
<option value="2">2 sessions</option>
<option value="3">3 sessions</option>
<option value="5">5 sessions</option>
<option value="10">10 sessions</option>
<option value="0">Unlimited</option>
</select>
</div>
<p className="mt-2 text-sm text-gray-500">
Limit the number of devices that can be logged into your account simultaneously. When the limit is reached, the oldest session will be terminated.
</p>
{errors.max_sessions && (
<p className="mt-2 text-sm text-red-600">{errors.max_sessions}</p>
)}
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={processing}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{processing ? 'Saving...' : 'Save Settings'}
</button>
</div>
</form>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6 bg-gray-50">
<h3 className="text-lg font-medium leading-6 text-gray-900">Security Actions</h3>
<div className="mt-4 space-y-4">
<div>
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
onClick={() => {
if (confirm('Are you sure you want to terminate all other sessions? This will log out all devices except your current one.')) {
window.location.href = '/sessions/terminate_all'
}
}}
>
Terminate All Other Sessions
</button>
<p className="mt-2 text-sm text-gray-500">
This will immediately log out all devices except your current one.
</p>
</div>
<div>
<button
type="button"
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => {
window.location.href = '/sessions'
}}
>
View Active Sessions
</button>
<p className="mt-2 text-sm text-gray-500">
See all devices currently logged into your account.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</Layout>
)
}
module Admin::DashboardHelper
end
module Admin::ImagesHelper
end
module Admin::UsersHelper
end
module ApplicationHelper
end
module ImagesHelper
end
module TagsHelper
end
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
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
end
class PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
end
class SessionMailer < ApplicationMailer
def new_login_notification(user, ip_address, user_agent)
@user = user
@ip_address = ip_address
@user_agent = user_agent
@login_time = Time.current
@manage_sessions_url = url_for(controller: 'sessions', action: 'index', only_path: false)
mail(
to: user.email_address,
subject: "New login detected on your account"
)
end
def session_expired_notification(user)
@user = user
@expired_time = Time.current
mail(
to: user.email_address,
subject: "Your session has expired"
)
end
def security_settings_updated(user)
@user = user
@updated_time = Time.current
mail(
to: user.email_address,
subject: "Security settings updated for your account"
)
end
end
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end
class Image < ApplicationRecord
belongs_to :user
has_many :image_tags, dependent: :destroy
has_many :tags, through: :image_tags
has_one_attached :file
validates :title, presence: true
validates :file, presence: true, on: :create
# Status for review process
# enum status: { pending: 0, approved: 1, rejected: 2 }, _default: :pending
# Scopes for filtering
scope :pending, -> { where(status: :pending) }
scope :approved, -> { where(status: :approved) }
scope :rejected, -> { where(status: :rejected) }
# Method to add tags to an image
def add_tags(tag_names)
tag_names.each do |name|
tag = Tag.find_or_create_by(name: name.downcase.strip)
tags << tag unless tags.include?(tag)
end
end
end
class ImageTag < ApplicationRecord
belongs_to :image
belongs_to :tag
# Ensure uniqueness of tag per image
validates :tag_id, uniqueness: { scope: :image_id }
end
class Session < ApplicationRecord
belongs_to :user
validates :user_id, presence: true
before_create :set_user_agent_and_ip
attr_accessor :current_session_id
def is_current
id.to_s == Current.session&.id.to_s
end
def self.cleanup_expired(timeout_minutes = 60)
where('updated_at < ?', timeout_minutes.minutes.ago).destroy_all
end
private
def set_user_agent_and_ip
self.user_agent ||= Current.user_agent if Current.respond_to?(:user_agent)
self.ip_address ||= Current.ip_address if Current.respond_to?(:ip_address)
end
end
class Tag < ApplicationRecord
has_many :image_tags, dependent: :destroy
has_many :images, through: :image_tags
validates :name, presence: true, uniqueness: true
before_save :downcase_name
private
def downcase_name
self.name = name.downcase if name.present?
end
end
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :images, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
validates :email_address, presence: true, uniqueness: true
validates :name, presence: true
# Session security settings
attribute :require_two_factor, :boolean, default: false
attribute :session_timeout_minutes, :integer, default: 60
attribute :notify_on_new_login, :boolean, default: true
attribute :max_sessions, :integer, default: 5
# Admin role
# enum role: { user: 0, admin: 1 }, _default: :user
def admin?
role == "admin"
end
def security_settings
{
require_two_factor: require_two_factor,
session_timeout_minutes: session_timeout_minutes,
notify_on_new_login: notify_on_new_login,
max_sessions: max_sessions
}
end
def enforce_max_sessions
return unless max_sessions > 0
# Get all sessions except the current one, ordered by last activity
other_sessions = sessions.where.not(id: Current.session&.id).order(updated_at: :desc)
# If we have more sessions than allowed, destroy the oldest ones
if other_sessions.count >= max_sessions
sessions_to_remove = other_sessions.offset(max_sessions - 1)
sessions_to_remove.destroy_all
end
end
def notify_on_new_login?
notify_on_new_login
end
end
<!DOCTYPE html>
<html>
<head>
<title inertia><%= content_for(:title) || "Img Manager" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag "application" %>
<%= vite_react_refresh_tag %>
<%= vite_client_tag %>
<%= inertia_ssr_head %>
<%= vite_javascript_tag 'inertia' %>
<!--
If using a TypeScript entrypoint file:
vite_typescript_tag 'application'
If using a .jsx or .tsx entrypoint, add the extension:
vite_javascript_tag 'application.jsx'
Visit the guide for more information: https://vite-ruby.netlify.app/guide/rails
-->
</head>
<body>
<%= yield %>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html>
<h1>Update your password</h1>
<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
<%= form_with url: password_path(params[:token]), method: :put do |form| %>
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %><br>
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %><br>
<%= form.submit "Save" %>
<% end %>
<h1>Forgot your password?</h1>
<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
<%= form_with url: passwords_path do |form| %>
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
<%= form.submit "Email reset instructions" %>
<% end %>
<p>
You can reset your password within the next 15 minutes on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
</p>
You can reset your password within the next 15 minutes on this password reset page:
<%= edit_password_url(@user.password_reset_token) %>
{
"name": "ImgManager",
"icons": [
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "ImgManager.",
"theme_color": "red",
"background_color": "red"
}
// Add a service worker for processing Web Push notifications:
//
// self.addEventListener("push", async (event) => {
// const { title, options } = await event.data.json()
// event.waitUntil(self.registration.showNotification(title, options))
// })
//
// self.addEventListener("notificationclick", function(event) {
// event.notification.close()
// event.waitUntil(
// clients.matchAll({ type: "window" }).then((clientList) => {
// for (let i = 0; i < clientList.length; i++) {
// let client = clientList[i]
// let clientPath = (new URL(client.url)).pathname
//
// if (clientPath == event.notification.data.path && "focus" in client) {
// return client.focus()
// }
// }
//
// if (clients.openWindow) {
// return clients.openWindow(event.notification.data.path)
// }
// })
// )
// })
<h1>Tags#create</h1>
<p>Find me in app/views/tags/create.html.erb</p>
<h1>Tags#destroy</h1>
<p>Find me in app/views/tags/destroy.html.erb</p>
<h1>Tags#edit</h1>
<p>Find me in app/views/tags/edit.html.erb</p>
<h1>Tags#index</h1>
<p>Find me in app/views/tags/index.html.erb</p>
<h1>Tags#new</h1>
<p>Find me in app/views/tags/new.html.erb</p>
<h1>Tags#update</h1>
<p>Find me in app/views/tags/update.html.erb</p>
#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"
ARGV.unshift("--ensure-latest")
load Gem.bin_path("brakeman", "brakeman")
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'bundle' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require "rubygems"
m = Module.new do
module_function
def invoked_as_script?
File.expand_path($0) == File.expand_path(__FILE__)
end
def env_var_version
ENV["BUNDLER_VERSION"]
end
def cli_arg_version
return unless invoked_as_script? # don't want to hijack other binstubs
return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
bundler_version = nil
update_index = nil
ARGV.each_with_index do |a, i|
if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN)
bundler_version = a
end
next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
bundler_version = $1
update_index = i
end
bundler_version
end
def gemfile
gemfile = ENV["BUNDLE_GEMFILE"]
return gemfile if gemfile && !gemfile.empty?
File.expand_path("../Gemfile", __dir__)
end
def lockfile
lockfile =
case File.basename(gemfile)
when "gems.rb" then gemfile.sub(/\.rb$/, ".locked")
else "#{gemfile}.lock"
end
File.expand_path(lockfile)
end
def lockfile_version
return unless File.file?(lockfile)
lockfile_contents = File.read(lockfile)
return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
Regexp.last_match(1)
end
def bundler_requirement
@bundler_requirement ||=
env_var_version ||
cli_arg_version ||
bundler_requirement_for(lockfile_version)
end
def bundler_requirement_for(version)
return "#{Gem::Requirement.default}.a" unless version
bundler_gem_version = Gem::Version.new(version)
bundler_gem_version.approximate_recommendation
end
def load_bundler!
ENV["BUNDLE_GEMFILE"] ||= gemfile
activate_bundler
end
def activate_bundler
gem_error = activation_error_handling do
gem "bundler", bundler_requirement
end
return if gem_error.nil?
require_error = activation_error_handling do
require "bundler/version"
end
return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
exit 42
end
def activation_error_handling
yield
nil
rescue StandardError, LoadError => e
e
end
end
m.load_bundler!
if m.invoked_as_script?
load Gem.bin_path("bundler", "bundle")
end
#!/usr/bin/env sh
export PORT="${PORT:-3000}"
if command -v overmind 1> /dev/null 2>&1
then
overmind start -f Procfile.dev "$@"
exit $?
fi
if command -v hivemind 1> /dev/null 2>&1
then
echo "Hivemind is installed. Running the application with Hivemind..."
exec hivemind Procfile.dev "$@"
exit $?
fi
if gem list --no-installed --exact --silent foreman; then
echo "Installing foreman..."
gem install foreman
fi
foreman start -f Procfile.dev "$@"
#!/bin/bash -e
# Enable jemalloc for reduced memory usage and latency.
if [ -z "${LD_PRELOAD+x}" ]; then
LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit)
export LD_PRELOAD
fi
# If running the rails server then create or migrate existing database
if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
./bin/rails db:prepare
fi
exec "${@}"
#!/usr/bin/env ruby
require_relative "../config/environment"
require "solid_queue/cli"
SolidQueue::Cli.start(ARGV)
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'kamal' is installed as part of a gem, and
# this file is here to facilitate running it.
#
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
bundle_binstub = File.expand_path("bundle", __dir__)
if File.file?(bundle_binstub)
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
load(bundle_binstub)
else
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
end
end
require "rubygems"
require "bundler/setup"
load Gem.bin_path("kamal", "kamal")
#!/usr/bin/env ruby
APP_PATH = File.expand_path("../config/application", __dir__)
require_relative "../config/boot"
require "rails/commands"
#!/usr/bin/env ruby
require_relative "../config/boot"
require "rake"
Rake.application.run
#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"
# explicit rubocop config increases performance slightly while avoiding config confusion.
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
load Gem.bin_path("rubocop", "rubocop")
#!/usr/bin/env ruby
require "fileutils"
APP_ROOT = File.expand_path("..", __dir__)
def system!(*args)
system(*args, exception: true)
end
FileUtils.chdir APP_ROOT do
# This script is a way to set up or update your development environment automatically.
# This script is idempotent, so that you can run it at any time and get an expectable outcome.
# Add necessary setup steps to this file.
puts "== Installing dependencies =="
system("bundle check") || system!("bundle install")
# puts "\n== Copying sample files =="
# unless File.exist?("config/database.yml")
# FileUtils.cp "config/database.yml.sample", "config/database.yml"
# end
puts "\n== Preparing database =="
system! "bin/rails db:prepare"
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
unless ARGV.include?("--skip-server")
puts "\n== Starting development server =="
STDOUT.flush # flush the output before exec(2) so that it displays
exec "bin/dev"
end
end
#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"
load Gem.bin_path("thruster", "thrust")
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# This file was generated by Bundler.
#
# The application 'vite' is installed as part of a gem, and
# this file is here to facilitate running it.
#
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
bundle_binstub = File.expand_path("bundle", __dir__)
if File.file?(bundle_binstub)
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
load(bundle_binstub)
else
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
end
end
require "rubygems"
require "bundler/setup"
load Gem.bin_path("vite_ruby", "vite")
# This file is used by Rack-based servers to start the application.
require_relative "config/environment"
run Rails.application
Rails.application.load_server
require_relative "boot"
require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module ImgManager
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.0
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
end
end
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" # Set up gems listed in the Gemfile.
require "bootsnap/setup" # Speed up boot time by caching expensive operations.
# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear.
development:
adapter: async
test:
adapter: test
production:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day
default: &default
store_options:
# Cap age of oldest cache entry to fulfill retention policies
# max_age: <%= 60.days.to_i %>
max_size: <%= 256.megabytes %>
namespace: <%= Rails.env %>
development:
<<: *default
test:
<<: *default
production:
database: cache
<<: *default
SKSLDbkDzWEEJ6JjE5Nw68w14y9XqDX7pVy3WeDIIUbwtwFZdEnqo9nvEeGbBznKH5Gqgh9b4oUK7rg/4zyDHJhwAWBGnjxcmeBmozBkma6Gp1Qk+sS1CuACEOZrTXa/NzAv3bB+0N95BL5gyMW2+UrrZTncxgOkjuEgHfN7xNUKGGfuSjwKcEUFegYd8Y5Y5YN7H83mcgaqKpTYbMe/m+2zn6lVfPD6rq/0fABhF9plClrFhL3yqpdqa/1z6YZ1hO7V7PjYDHvGchHv9pPKp2V1wuI4v40SjOcr+OtKYJYbf4dwa94GWAh5Ol6V04YK0WB7Q2tG5WBH5Khf40JiUY3zjzXdj1daYngxP97Eq9E4QqXqEZuI3MWJFBdGeqx50Os7Gxa49tqHrVWYgItvOQsBS2aMhZriKn0TqbWy+AD0ZNRFCEsvBTq5stQ3S3/olplOOZcDGS+cHgN9pxTGuIAlcv5Mj18eBEJziBblIVoU2fuW+lCAVRoc--VXNyvk/JXXMp90rI--lsmPyGMiurDnl6mA+zuixw==
\ No newline at end of file
# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: storage/test.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate
# Name of your application. Used to uniquely configure containers.
service: img_manager
# Name of the container image.
image: your-user/img_manager
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer.
#
# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
proxy:
ssl: true
host: app.example.com
# Credentials for your image host.
registry:
# Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ...
username: your-user
# Always use an access token rather than real password when possible.
password:
- KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use img_manager-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "img_manager_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: 3.2.2
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: redis:7.0
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data
# Load the Rails application.
require_relative "application"
# Initialize the Rails application.
Rails.application.initialize!
require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Make code changes take effect immediately without server restart.
config.enable_reloading = true
# Do not eager load code on boot.
config.eager_load = false
# Show full error reports.
config.consider_all_requests_local = true
# Enable server timing.
config.server_timing = true
# Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching.
if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
else
config.action_controller.perform_caching = false
end
# Change to :null_store to avoid any caching.
config.cache_store = :memory_store
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false
# Make template changes take effect immediately.
config.action_mailer.perform_caching = false
# Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
# Raise an error on page load if there are pending migrations.
config.active_record.migration_error = :page_load
# Highlight code that triggered database queries in logs.
config.active_record.verbose_query_logs = true
# Append comments with runtime information tags to SQL queries in logs.
config.active_record.query_log_tags_enabled = true
# Highlight code that enqueued background job in logs.
config.active_job.verbose_enqueue_logs = true
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
config.action_view.annotate_rendered_view_with_filenames = true
# Uncomment if you wish to allow Action Cable access from any origin.
# config.action_cable.disable_request_forgery_protection = true
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
# config.generators.apply_rubocop_autocorrect_after_generate!
end
require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.enable_reloading = false
# Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
config.eager_load = true
# Full error reports are disabled.
config.consider_all_requests_local = false
# Turn on fragment caching in view templates.
config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = true
# Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Log to STDOUT with the current request id as a default log tag.
config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
# Change to "debug" to log everything (including potentially personally-identifiable information!)
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Prevent health checks from clogging up the logs.
config.silence_healthcheck_path = "/up"
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Replace the default in-process memory cache store with a durable alternative.
config.cache_store = :solid_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" }
# Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
# password: Rails.application.credentials.dig(:smtp, :password),
# address: "smtp.example.com",
# port: 587,
# authentication: :plain
# }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
# Enable DNS rebinding protection and other `Host` header attacks.
# config.hosts = [
# "example.com", # Allow requests from example.com
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
# ]
#
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end
# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped
# and recreated between test runs. Don't rely on the data there!
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# While tests run files are not watched, reloading is not necessary.
config.enable_reloading = false
# Eager loading loads your entire application. When running a single test locally,
# this is usually not necessary, and can slow down your test suite. However, it's
# recommended that you enable it in continuous integration systems to ensure eager
# loading is working properly before deploying your code.
config.eager_load = ENV["CI"].present?
# Configure public file server for tests with cache-control for performance.
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
# Show full error reports.
config.consider_all_requests_local = true
config.cache_store = :null_store
# Render exception templates for rescuable exceptions and raise for other exceptions.
config.action_dispatch.show_exceptions = :rescuable
# Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false
# Store uploaded files on the local file system in a temporary directory.
config.active_storage.service = :test
# Tell Action Mailer not to deliver emails to the real world.
# The :test delivery method accumulates sent emails in the
# ActionMailer::Base.deliveries array.
config.action_mailer.delivery_method = :test
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" }
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
end
# Be sure to restart your server when you modify this file.
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# Allow @vite/client to hot reload javascript changes in development
# policy.script_src *policy.script_src, :unsafe_eval, "http://#{ ViteRuby.config.host_with_port }" if Rails.env.development?
# You may need to enable this in production as well depending on your setup.
# policy.script_src *policy.script_src, :blob if Rails.env.test?
# policy.style_src :self, :https
# Allow @vite/client to hot reload style changes in development
# policy.style_src *policy.style_src, :unsafe_inline if Rails.env.development?
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
# Be sure to restart your server when you modify this file.
# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
# Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
]
# frozen_string_literal: true
InertiaRails.configure do |config|
config.ssr_enabled = ViteRuby.config.ssr_build_enabled
config.version = ViteRuby.digest
end
# Be sure to restart your server when you modify this file.
# Add new inflection rules using the following format. Inflections
# are locale specific, and you may define rules for as many different
# locales as you wish. All of these examples are active by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.plural /^(ox)$/i, "\\1en"
# inflect.singular /^(ox)en/i, "\\1"
# inflect.irregular "person", "people"
# inflect.uncountable %w( fish sheep )
# end
# These inflection rules are supported but not enabled by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym "RESTful"
# end
# Files in the config/locales directory are used for internationalization and
# are automatically loaded by Rails. If you want to use locales other than
# English, add the necessary files in this directory.
#
# To use the locales, use `I18n.t`:
#
# I18n.t "hello"
#
# In views, this is aliased to just `t`:
#
# <%= t("hello") %>
#
# To use a different locale, set it with `I18n.locale`:
#
# I18n.locale = :es
#
# This would use the information in config/locales/es.yml.
#
# To learn more about the API, please read the Rails Internationalization guide
# at https://guides.rubyonrails.org/i18n.html.
#
# Be aware that YAML interprets the following case-insensitive strings as
# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
# must be quoted to be interpreted as strings. For example:
#
# en:
# "yes": yup
# enabled: "ON"
en:
hello: "Hello world"
# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
# Run the Solid Queue supervisor inside of Puma for single-server deployments
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
polling_interval: 0.1
development:
<<: *default
test:
<<: *default
production:
<<: *default
# production:
# periodic_cleanup:
# class: CleanSoftDeletedRecordsJob
# queue: background
# args: [ 1000, { batch_size: 500 } ]
# schedule: every hour
# periodic_command:
# command: "SoftDeletedRecord.due.delete_all"
# priority: 2
# schedule: at 5am every day
Rails.application.routes.draw do
get "tags/index", to: "tags#index"
get "tags/new", to: "tags#new"
get "tags/create", to: "tags#create"
get "tags/edit", to: "tags#edit"
get "tags/update", to: "tags#update"
get "tags/destroy", to: "tags#destroy"
get "images/index", to: "images#index"
get "images/show", to: "images#show"
get "images/new", to: "images#new"
get "images/create", to: "images#create"
get "images/edit", to: "images#edit"
get "images/update", to: "images#update"
get "images/destroy", to: "images#destroy"
get "images/search", to: "images#search"
get "sessions/index", to: "sessions#index"
get "sessions/show", to: "sessions#show"
get "session/new", to: "sessions#new"
get "sessions/security", to: "sessions#security"
# Authentication routes
resource :session
resources :sessions, only: [ :index, :show ] do
member do
delete :destroy_session
end
collection do
get :security
patch :update_security
post :terminate_all
end
end
resources :passwords, param: :token
resources :users, only: [ :new, :create ]
# Image management routes
resources :images do
collection do
get :search
end
member do
patch :approve
patch :reject
end
end
# Tag management routes
resources :tags, except: [ :show ]
# Admin namespace for admin-only actions
namespace :admin do
get "users/index", to: "users#index"
get "users/show", to: "users#show"
get "users/edit", to: "users#edit"
get "users/update", to: "users#update"
get "users/destroy", to: "users#destroy"
get "images/index", to: "images#index"
get "images/show", to: "images#show"
get "images/update", to: "images#update"
get "images/destroy", to: "images#destroy"
get "images/pending", to: "images#pending"
get "images/approved", to: "images#approved"
get "images/rejected", to: "images#rejected"
get "images/approve", to: "images#approve"
get "images/reject", to: "images#reject"
get "images/add_tags", to: "images#add_tags"
get "images/remove_tag", to: "images#remove_tag"
get "dashboard/index", to: "dashboard#index"
resources :images, only: [ :index, :show, :update, :destroy ] do
member do
patch :approve
patch :reject
post :add_tags
delete :remove_tag
end
collection do
get :pending
get :approved
get :rejected
end
end
resources :users, only: [ :index, :show, :edit, :update, :destroy ]
resources :tags, only: [ :index, :create, :destroy ]
root to: "dashboard#index"
end
# Example route (can be removed later)
get "inertia-example", to: "inertia_example#index"
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
# Root path route
root "images#index"
end
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %>
# Remember not to checkin your GCS keyfile to a repository
# google:
# service: GCS
# project: your_project
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
# bucket: your_own_bucket-<%= Rails.env %>
# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
# microsoft:
# service: AzureStorage
# storage_account_name: your_account_name
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
# container: your_container_name-<%= Rails.env %>
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]
{
"all": {
"sourceCodeDir": "app/frontend",
"watchAdditionalPaths": []
},
"development": {
"autoBuild": true,
"publicOutputDir": "vite-dev",
"port": 3036
},
"test": {
"autoBuild": true,
"publicOutputDir": "vite-test",
"port": 3037
}
}
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_cable_messages", force: :cascade do |t|
t.binary "channel", limit: 1024, null: false
t.binary "payload", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "channel_hash", limit: 8, null: false
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
end
end
# frozen_string_literal: true
ActiveRecord::Schema[7.2].define(version: 1) do
create_table "solid_cache_entries", force: :cascade do |t|
t.binary "key", limit: 1024, null: false
t.binary "value", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "key_hash", limit: 8, null: false
t.integer "byte_size", limit: 4, null: false
t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
end
end
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
end
class CreateSessions < ActiveRecord::Migration[8.0]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
end
class CreateTags < ActiveRecord::Migration[8.0]
def change
create_table :tags do |t|
t.string :name
t.timestamps
end
end
end
class CreateImages < ActiveRecord::Migration[8.0]
def change
create_table :images do |t|
t.string :title
t.integer :status
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
class CreateImageTags < ActiveRecord::Migration[8.0]
def change
create_table :image_tags do |t|
t.references :image, null: false, foreign_key: true
t.references :tag, null: false, foreign_key: true
t.timestamps
end
end
end
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_queue_blocked_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.string "concurrency_key", null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
end
create_table "solid_queue_claimed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.bigint "process_id"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
end
create_table "solid_queue_failed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.text "error"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
end
create_table "solid_queue_jobs", force: :cascade do |t|
t.string "queue_name", null: false
t.string "class_name", null: false
t.text "arguments"
t.integer "priority", default: 0, null: false
t.string "active_job_id"
t.datetime "scheduled_at"
t.datetime "finished_at"
t.string "concurrency_key"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
end
create_table "solid_queue_pauses", force: :cascade do |t|
t.string "queue_name", null: false
t.datetime "created_at", null: false
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
end
create_table "solid_queue_processes", force: :cascade do |t|
t.string "kind", null: false
t.datetime "last_heartbeat_at", null: false
t.bigint "supervisor_id"
t.integer "pid", null: false
t.string "hostname"
t.text "metadata"
t.datetime "created_at", null: false
t.string "name", null: false
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
end
create_table "solid_queue_ready_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
end
create_table "solid_queue_recurring_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "task_key", null: false
t.datetime "run_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
end
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
t.string "key", null: false
t.string "schedule", null: false
t.string "command", limit: 2048
t.string "class_name"
t.text "arguments"
t.string "queue_name"
t.integer "priority", default: 0
t.boolean "static", default: true, null: false
t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
end
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "scheduled_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
end
create_table "solid_queue_semaphores", force: :cascade do |t|
t.string "key", null: false
t.integer "value", default: 1, null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
end
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
end
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_03_08_054000) do
create_table "image_tags", force: :cascade do |t|
t.integer "image_id", null: false
t.integer "tag_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["image_id"], name: "index_image_tags_on_image_id"
t.index ["tag_id"], name: "index_image_tags_on_tag_id"
end
create_table "images", force: :cascade do |t|
t.string "title"
t.integer "status"
t.integer "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_images_on_user_id"
end
create_table "sessions", force: :cascade do |t|
t.integer "user_id", null: false
t.string "ip_address"
t.string "user_agent"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "tags", force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "users", force: :cascade do |t|
t.string "email_address", null: false
t.string "password_digest", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
add_foreign_key "image_tags", "images"
add_foreign_key "image_tags", "tags"
add_foreign_key "images", "users"
add_foreign_key "sessions", "users"
end
# This file should ensure the existence of records required to run the application in every environment (production,
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
#
# Example:
#
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name)
# end
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"private": true,
"type": "module",
"devDependencies": {
"vite": "^5.4.14",
"vite-plugin-ruby": "^5.1.1"
},
"dependencies": {
"@inertiajs/react": "^2.0.5",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-form": "^0.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.0.12",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.12"
}
}
<!doctype html>
<html lang="en">
<head>
<title>The server cannot process the request due to a client error (400 Bad Request)</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<meta name="robots" content="noindex, nofollow">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-size: 16px;
}
body {
background: #FFF;
color: #261B23;
display: grid;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: clamp(1rem, 2.5vw, 2rem);
-webkit-font-smoothing: antialiased;
font-style: normal;
font-weight: 400;
letter-spacing: -0.0025em;
line-height: 1.4;
min-height: 100vh;
place-items: center;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
}
a {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.0925em;
}
b, strong {
font-weight: 700;
}
i, em {
font-style: italic;
}
main {
display: grid;
gap: 1em;
padding: 2em;
place-items: center;
text-align: center;
}
main header {
width: min(100%, 12em);
}
main header svg {
height: auto;
max-width: 100%;
width: 100%;
}
main article {
width: min(100%, 30em);
}
main article p {
font-size: 75%;
}
main article br {
display: none;
@media(min-width: 48em) {
display: inline;
}
}
</style>
</head>
<body>
<!-- This file lives in public/400.html -->
<main>
<header>
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm140.456 133.2831c-40.823 0-64.884-35.146-64.884-85.7015 0-50.5554 24.061-85.700907 64.884-85.700907 40.822 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.062 85.7015-64.884 85.7015zm0-133.2831c-17.573 0-22.71 21.8984-22.71 47.5816 0 25.6835 5.137 47.5815 22.71 47.5815 17.302 0 22.709-21.898 22.709-47.5815 0-25.6832-5.407-47.5816-22.709-47.5816z" fill="#f0eff0"/><path d="m123.606 85.4445c3.212 1.0523 5.538 4.2089 5.538 8.0301 0 6.1472-4.209 9.5254-11.298 9.5254h-15.617v-34.0033h14.565c7.089 0 11.353 3.1566 11.353 9.2484 0 3.6551-2.049 6.3134-4.541 7.1994zm-12.904-2.9905h5.095c2.603 0 3.988-.9968 3.988-3.1013 0-2.1044-1.385-3.0459-3.988-3.0459h-5.095zm0 6.6456v6.5902h5.981c2.492 0 3.877-1.3291 3.877-3.2674 0-2.049-1.385-3.3228-3.877-3.3228zm43.786 13.9004h-8.362v-1.274c-.831.831-3.323 1.717-5.981 1.717-4.929 0-9.083-2.769-9.083-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.483 1.3845v-1.606c0-1.606-.942-2.9905-3.046-2.9905-1.606 0-2.548.7199-2.935 1.8275h-8.197c.72-4.8181 4.985-8.6393 11.409-8.6393 7.088 0 11.131 3.7659 11.131 10.2453zm-8.362-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.434.7199-3.434 2.3813 0 1.7168 1.717 2.4367 3.434 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm27.996 6.9779v-1.994c-1.163 1.329-3.599 2.548-6.147 2.548-7.199 0-11.131-5.8151-11.131-13.0145s3.932-13.0143 11.131-13.0143c2.548 0 4.984 1.2184 6.147 2.5475v-13.0697h8.695v35.997zm0-9.1931v-6.5902c-.664-1.3291-2.159-2.326-3.821-2.326-2.99 0-4.763 2.4368-4.763 5.6488s1.773 5.5934 4.763 5.5934c1.717 0 3.157-.9415 3.821-2.326zm35.471-2.049h-3.101v11.2421h-8.806v-34.0033h15.285c7.31 0 12.35 4.1535 12.35 11.5744 0 5.1503-2.603 8.6947-6.757 10.2453l7.975 12.1836h-9.858zm-3.101-15.2849v8.1962h5.538c3.156 0 4.596-1.606 4.596-4.0981s-1.44-4.0981-4.596-4.0981zm36.957 17.8323h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm30.98 27.5234v-10.799c-1.163 1.329-3.6 2.548-6.147 2.548-7.2 0-11.132-5.9259-11.132-13.0145 0-7.144 3.932-13.0143 11.132-13.0143 2.547 0 4.984 1.2184 6.147 2.5475v-1.9937h8.695v33.726zm0-17.9981v-6.5902c-.665-1.3291-2.105-2.326-3.821-2.326-2.991 0-4.763 2.4368-4.763 5.6488s1.772 5.5934 4.763 5.5934c1.661 0 3.156-.9415 3.821-2.326zm36.789-15.7279v24.921h-8.695v-2.16c-1.329 1.551-3.821 2.714-6.646 2.714-5.482 0-8.75-3.5999-8.75-9.1379v-16.3371h8.64v14.288c0 2.1045.996 3.5997 3.212 3.5997 1.606 0 3.101-1.0522 3.544-2.769v-15.1187zm19.084 16.2263h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.963 5.095 11.963 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm13.428 11.0206h8.474c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.939-1.5506l-4.873-.9969c-4.154-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.762-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.741-8.2518zm27.538-.8861v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.993-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.871 0-9.193-2.769-9.193-9.0819z" fill="#d30001"/></svg>
</header>
<article>
<p><strong>The server cannot process the request due to a client error.</strong> Please check the request and try again. If you’re the application owner check the logs for more information.</p>
</article>
</main>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<title>The page you were looking for doesn’t exist (404 Not found)</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<meta name="robots" content="noindex, nofollow">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-size: 16px;
}
body {
background: #FFF;
color: #261B23;
display: grid;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: clamp(1rem, 2.5vw, 2rem);
-webkit-font-smoothing: antialiased;
font-style: normal;
font-weight: 400;
letter-spacing: -0.0025em;
line-height: 1.4;
min-height: 100vh;
place-items: center;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
}
a {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.0925em;
}
b, strong {
font-weight: 700;
}
i, em {
font-style: italic;
}
main {
display: grid;
gap: 1em;
padding: 2em;
place-items: center;
text-align: center;
}
main header {
width: min(100%, 12em);
}
main header svg {
height: auto;
max-width: 100%;
width: 100%;
}
main article {
width: min(100%, 30em);
}
main article p {
font-size: 75%;
}
main article br {
display: none;
@media(min-width: 48em) {
display: inline;
}
}
</style>
</head>
<body>
<!-- This file lives in public/404.html -->
<main>
<header>
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm165.328-35.41581-45.689 100.02991h26.224v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.184v-31.901l50.285-103.27391z" fill="#f0eff0"/><path d="m157.758 68.9967v34.0033h-7.199l-14.233-19.8814v19.8814h-8.584v-34.0033h8.307l13.125 18.7184v-18.7184zm28.454 21.5428c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.528 0c0-3.4336-1.496-5.8703-4.209-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.209-2.3813 4.209-5.8149zm13.184 3.8766v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm37.027 8.5839h-8.806v-34.0033h23.924v7.6978h-15.118v6.7564h13.9v7.5316h-13.9zm41.876-12.4605c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm35.337-12.4605v24.921h-8.695v-2.16c-1.329 1.551-3.821 2.714-6.646 2.714-5.482 0-8.75-3.5999-8.75-9.1379v-16.3371h8.64v14.288c0 2.1045.997 3.5997 3.212 3.5997 1.606 0 3.101-1.0522 3.544-2.769v-15.1187zm4.076 24.921v-24.921h8.694v2.1598c1.385-1.5506 3.822-2.7136 6.701-2.7136 5.538 0 8.806 3.5997 8.806 9.1377v16.3371h-8.639v-14.2327c0-2.049-1.053-3.5443-3.268-3.5443-1.717 0-3.156.9969-3.6 2.7136v15.0634zm44.113 0v-1.994c-1.163 1.329-3.6 2.548-6.147 2.548-7.2 0-11.132-5.8151-11.132-13.0145s3.932-13.0143 11.132-13.0143c2.547 0 4.984 1.2184 6.147 2.5475v-13.0697h8.695v35.997zm0-9.1931v-6.5902c-.665-1.3291-2.16-2.326-3.821-2.326-2.991 0-4.763 2.4368-4.763 5.6488s1.772 5.5934 4.763 5.5934c1.717 0 3.156-.9415 3.821-2.326z" fill="#d30001"/></svg>
</header>
<article>
<p><strong>The page you were looking for doesn’t exist.</strong> You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.</p>
</article>
</main>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<title>Your browser is not supported (406 Not Acceptable)</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<meta name="robots" content="noindex, nofollow">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-size: 16px;
}
body {
background: #FFF;
color: #261B23;
display: grid;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: clamp(1rem, 2.5vw, 2rem);
-webkit-font-smoothing: antialiased;
font-style: normal;
font-weight: 400;
letter-spacing: -0.0025em;
line-height: 1.4;
min-height: 100vh;
place-items: center;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
}
a {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.0925em;
}
b, strong {
font-weight: 700;
}
i, em {
font-style: italic;
}
main {
display: grid;
gap: 1em;
padding: 2em;
place-items: center;
text-align: center;
}
main header {
width: min(100%, 12em);
}
main header svg {
height: auto;
max-width: 100%;
width: 100%;
}
main article {
width: min(100%, 30em);
}
main article p {
font-size: 75%;
}
main article br {
display: none;
@media(min-width: 48em) {
display: inline;
}
}
</style>
</head>
<body>
<!-- This file lives in public/406-unsupported-browser.html -->
<main>
<header>
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm202.906 9.7326h-41.093c-2.433-7.2994-7.84-12.4361-17.302-12.4361-16.221 0-25.413 17.5728-25.954 34.8752v1.3517c5.137-7.0291 16.221-12.4361 30.82-12.4361 33.524 0 54.881 24.0612 54.881 53.7998 0 33.253-23.791 58.396-61.64 58.396-21.628 0-39.741-10.003-50.825-27.576-9.733-14.599-13.788-32.442-13.788-54.3406 0-51.9072 24.331-89.485807 66.236-89.485807 32.712 0 53.258 18.654107 58.665 47.851907zm-82.727 66.2355c0 13.247 9.463 22.439 22.71 22.439 12.977 0 22.439-9.192 22.439-22.439 0-13.517-9.462-22.7091-22.439-22.7091-13.247 0-22.71 9.1921-22.71 22.7091z" fill="#f0eff0"/><path d="m100.761 68.9967v34.0033h-7.1991l-14.2326-19.8814v19.8814h-8.5839v-34.0033h8.307l13.125 18.7184v-18.7184zm28.454 21.5428c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm13.185 3.8766v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.02-25.4194h9.083l12.958 34.0033h-9.027l-2.436-6.5902h-12.35l-2.381 6.5902h-8.806zm4.431 10.5222-3.489 9.5807h6.978zm17.44 11.0206c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm25.676 0c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm42.013 3.7658h8.031c-.887 5.7597-5.206 9.2487-11.686 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.317-13.0143 12.516-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.319 4.5965 1.773 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm23.4 16.7244v10.799h-8.694v-33.726h8.694v1.9937c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8703 11.131 13.0143 0 7.0886-3.932 13.0145-11.131 13.0145-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.16 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.717 0-3.157.9969-3.822 2.326zm21.892 7.1994v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.206v6.7564h-5.206v8.307c0 1.9383.941 2.769 2.658 2.769.942 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.458 8.5839h-8.363v-1.274c-.83.831-3.322 1.717-5.981 1.717-4.928 0-9.082-2.769-9.082-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.045-2.9905-1.606 0-2.548.7199-2.936 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.553-1.0522-2.049-1.7167-3.655-1.7167-1.716 0-3.433.7199-3.433 2.3813 0 1.7168 1.717 2.4367 3.433 2.4367 1.606 0 3.102-.6645 3.655-1.6614zm20.742 4.9839v1.994h-8.694v-35.997h8.694v13.0697c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8149 11.131 13.0143s-3.932 13.0145-11.131 13.0145c-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.105 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.662 0-3.157.9969-3.822 2.326zm28.759-20.2137v35.997h-8.695v-35.997zm19.172 27.3023h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.384-3.5997-3.544-3.5997z" fill="#d30001"/></svg>
</header>
<article>
<p><strong>Your browser is not supported.</strong><br> Please upgrade your browser to continue.</p>
</article>
</main>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<title>The change you wanted was rejected (422 Unprocessable Entity)</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<meta name="robots" content="noindex, nofollow">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-size: 16px;
}
body {
background: #FFF;
color: #261B23;
display: grid;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: clamp(1rem, 2.5vw, 2rem);
-webkit-font-smoothing: antialiased;
font-style: normal;
font-weight: 400;
letter-spacing: -0.0025em;
line-height: 1.4;
min-height: 100vh;
place-items: center;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
}
a {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.0925em;
}
b, strong {
font-weight: 700;
}
i, em {
font-style: italic;
}
main {
display: grid;
gap: 1em;
padding: 2em;
place-items: center;
text-align: center;
}
main header {
width: min(100%, 12em);
}
main header svg {
height: auto;
max-width: 100%;
width: 100%;
}
main article {
width: min(100%, 30em);
}
main article p {
font-size: 75%;
}
main article br {
display: none;
@media(min-width: 48em) {
display: inline;
}
}
</style>
</head>
<body>
<!-- This file lives in public/422.html -->
<main>
<header>
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm130.453 51.63681c0-8.9215-6.218-15.4099-15.681-15.4099-10.273 0-15.95 7.5698-16.491 16.4913h-44.608c3.244-30.8199 25.683-55.421707 61.099-55.421707 36.498 0 59.477 20.816907 59.477 51.636807 0 21.3577-14.869 36.7676-31.901 52.7186l-27.305 27.035h59.747v37.308h-120.306v-27.846l57.044-56.7736c11.084-11.8954 18.925-20.0059 18.925-29.7385zm140.455 0c0-8.9215-6.218-15.4099-15.68-15.4099-10.274 0-15.951 7.5698-16.492 16.4913h-44.608c3.245-30.8199 25.684-55.421707 61.1-55.421707 36.497 0 59.477 20.816907 59.477 51.636807 0 21.3577-14.87 36.7676-31.902 52.7186l-27.305 27.035h59.747v37.308h-120.305v-27.846l57.043-56.7736c11.085-11.8954 18.925-20.0059 18.925-29.7385z" fill="#f0eff0"/><path d="m19.3936 103.554c-8.9715 0-14.84183-5.0952-14.84183-14.4544v-20.1029h8.86083v19.3276c0 4.8181 2.2706 7.3102 5.981 7.3102 3.6551 0 5.9257-2.4921 5.9257-7.3102v-19.3276h8.8608v20.1583c0 9.3038-5.8149 14.399-14.7865 14.399zm18.734-.554v-24.921h8.6947v2.1598c1.3845-1.5506 3.8212-2.7136 6.701-2.7136 5.538 0 8.8054 3.5997 8.8054 9.1377v16.3371h-8.6393v-14.2327c0-2.049-1.0522-3.5443-3.2674-3.5443-1.7168 0-3.1567.9969-3.5997 2.7136v15.0634zm36.8584-1.994v10.799h-8.6946v-33.726h8.6946v1.9937c1.163-1.3291 3.5997-2.5475 6.1472-2.5475 7.1994 0 11.1314 5.8703 11.1314 13.0143 0 7.0886-3.932 13.0145-11.1314 13.0145-2.5475 0-4.9842-1.219-6.1472-2.548zm0-13.7893v6.5902c.6646 1.3845 2.1599 2.326 3.8213 2.326 2.9905 0 4.7626-2.3814 4.7626-5.5934s-1.7721-5.6488-4.7626-5.6488c-1.7168 0-3.1567.9969-3.8213 2.326zm36.789-9.2485v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.6949v-24.921h8.6949v2.2152c1.218-1.6614 3.156-2.769 5.648-2.769 1.108 0 1.994.2215 2.382.443zm26.769 12.5713c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.528 0c0-3.4336-1.496-5.8703-4.209-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.209-2.3813 4.209-5.8149zm10.352 0c0-7.6978 5.095-13.0143 12.571-13.0143 6.701 0 10.855 3.9874 11.574 9.8023h-8.417c-.222-1.4953-1.385-2.6029-3.157-2.6029-2.437 0-3.987 2.2706-3.987 5.8149s1.55 5.7595 3.987 5.7595c1.772 0 2.935-1.0522 3.157-2.5475h8.417c-.719 5.7596-4.873 9.8025-11.574 9.8025-7.476 0-12.571-5.3167-12.571-13.0145zm42.013 3.7658h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.544-3.5997zm13.428 11.0206h8.473c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.938-1.5506l-4.874-.9969c-4.153-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.763-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.74-8.2518zm24.269 0h8.474c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.939-1.5506l-4.873-.9969c-4.154-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.763-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.741-8.2518zm47.918 7.6978h-8.363v-1.274c-.831.831-3.323 1.717-5.981 1.717-4.929 0-9.082-2.769-9.082-8.0301 0-4.818 4.153-7.9193 9.581-7.9193 2.049 0 4.485.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.046-2.9905-1.606 0-2.547.7199-2.935 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.434.7199-3.434 2.3813 0 1.7168 1.717 2.4367 3.434 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm20.742 4.9839v1.994h-8.695v-35.997h8.695v13.0697c1.163-1.3291 3.6-2.5475 6.147-2.5475 7.2 0 11.132 5.8149 11.132 13.0143s-3.932 13.0145-11.132 13.0145c-2.547 0-4.984-1.219-6.147-2.548zm0-13.7893v6.5902c.665 1.3845 2.105 2.326 3.821 2.326 2.991 0 4.763-2.3814 4.763-5.5934s-1.772-5.6488-4.763-5.6488c-1.661 0-3.156.9969-3.821 2.326zm28.759-20.2137v35.997h-8.695v-35.997zm19.172 27.3023h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm25.461-15.2849h24.311v7.6424h-15.561v5.3165h14.232v7.4763h-14.232v5.8703h15.561v7.6978h-24.311zm27.942 34.0033v-24.921h8.694v2.1598c1.385-1.5506 3.822-2.7136 6.701-2.7136 5.538 0 8.806 3.5997 8.806 9.1377v16.3371h-8.639v-14.2327c0-2.049-1.053-3.5443-3.268-3.5443-1.717 0-3.157.9969-3.6 2.7136v15.0634zm29.991-8.5839v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.206v6.7564h-5.206v8.307c0 1.9383.941 2.769 2.658 2.769.942 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm26.161-16.3371v24.921h-8.694v-24.921zm.61-6.7564c0 2.8244-2.271 4.652-4.929 4.652s-4.929-1.8276-4.929-4.652c0-2.8797 2.271-4.7073 4.929-4.7073s4.929 1.8276 4.929 4.7073zm5.382 23.0935v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.206v6.7564h-5.206v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm29.22 17.3889h-8.584l3.655-9.414-9.303-24.312h9.026l4.763 14.1773 4.652-14.1773h8.639z" fill="#d30001"/></svg>
</header>
<article>
<p><strong>The change you wanted was rejected.</strong> Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.</p>
</article>
</main>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<title>We’re sorry, but something went wrong (500 Internal Server Error)</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<meta name="robots" content="noindex, nofollow">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-size: 16px;
}
body {
background: #FFF;
color: #261B23;
display: grid;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: clamp(1rem, 2.5vw, 2rem);
-webkit-font-smoothing: antialiased;
font-style: normal;
font-weight: 400;
letter-spacing: -0.0025em;
line-height: 1.4;
min-height: 100vh;
place-items: center;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
}
a {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.0925em;
}
b, strong {
font-weight: 700;
}
i, em {
font-style: italic;
}
main {
display: grid;
gap: 1em;
padding: 2em;
place-items: center;
text-align: center;
}
main header {
width: min(100%, 12em);
}
main header svg {
height: auto;
max-width: 100%;
width: 100%;
}
main article {
width: min(100%, 30em);
}
main article p {
font-size: 75%;
}
main article br {
display: none;
@media(min-width: 48em) {
display: inline;
}
}
</style>
</head>
<body>
<!-- This file lives in public/500.html -->
<main>
<header>
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m101.23 93.8427c-8.1103 0-15.4098 3.7849-19.7354 8.3813h-36.2269v-99.21891h103.8143v37.03791h-68.3984v24.8722c5.1366-2.7035 15.1396-5.9477 24.6014-5.9477 35.146 0 56.233 22.7094 56.233 55.4215 0 34.605-23.791 57.315-60.558 57.315-37.8492 0-61.64-22.169-63.8028-55.963h42.9857c1.0814 10.814 9.1919 19.195 21.6281 19.195 11.355 0 19.465-8.381 19.465-20.547 0-11.625-7.299-20.5463-20.006-20.5463zm138.833 77.8613c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm140.456 133.2831c-40.823 0-64.884-35.146-64.884-85.7015 0-50.5554 24.061-85.700907 64.884-85.700907 40.822 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.062 85.7015-64.884 85.7015zm0-133.2831c-17.573 0-22.71 21.8984-22.71 47.5816 0 25.6835 5.137 47.5815 22.71 47.5815 17.302 0 22.709-21.898 22.709-47.5815 0-25.6832-5.407-47.5816-22.709-47.5816z" fill="#f0eff0"/><path d="m23.1377 68.9967v34.0033h-8.9162v-34.0033zm4.3157 34.0033v-24.921h8.6947v2.1598c1.3845-1.5506 3.8212-2.7136 6.701-2.7136 5.538 0 8.8054 3.5997 8.8054 9.1377v16.3371h-8.6393v-14.2327c0-2.049-1.0522-3.5443-3.2674-3.5443-1.7168 0-3.1567.9969-3.5997 2.7136v15.0634zm29.9913-8.5839v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.5839v6.8671h5.2058v6.7564h-5.2058v8.307c0 1.9383.9415 2.769 2.6583 2.769.9414 0 1.9937-.2216 2.769-.5538v7.3654c-.9969.443-2.8798.775-4.8181.775-5.8703 0-9.1931-2.769-9.1931-9.0819zm32.3666-.1108h8.0301c-.8861 5.7597-5.2057 9.2487-11.6852 9.2487-7.6424 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.3165-13.0143 12.5159-13.0143 7.6424 0 11.9621 5.095 11.9621 12.5159v2.1598h-16.1156c.2769 2.9905 1.8275 4.5965 4.3196 4.5965 1.7722 0 3.1567-.7753 3.6551-2.4921zm-3.8212-10.0237c-2.0491 0-3.4336 1.2737-3.9874 3.5997h7.5317c-.1107-2.0491-1.3845-3.5997-3.5443-3.5997zm31.4299-6.3134v8.3624c-1.052-.5538-2.215-.7753-3.599-.7753-2.382 0-3.988 1.0522-4.431 2.8244v14.6203h-8.694v-24.921h8.694v2.2152c1.219-1.6614 3.157-2.769 5.649-2.769 1.108 0 1.994.2215 2.381.443zm2.949 25.0318v-24.921h8.694v2.1598c1.385-1.5506 3.821-2.7136 6.701-2.7136 5.538 0 8.806 3.5997 8.806 9.1377v16.3371h-8.64v-14.2327c0-2.049-1.052-3.5443-3.267-3.5443-1.717 0-3.157.9969-3.6 2.7136v15.0634zm50.371 0h-8.363v-1.274c-.83.831-3.323 1.717-5.981 1.717-4.929 0-9.082-2.769-9.082-8.0301 0-4.818 4.153-7.9193 9.581-7.9193 2.049 0 4.485.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.046-2.9905-1.606 0-2.547.7199-2.935 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.433.7199-3.433 2.3813 0 1.7168 1.716 2.4367 3.433 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm20.742-29.0191v35.997h-8.694v-35.997zm13.036 25.9178h9.248c.72 2.326 2.714 3.489 5.483 3.489 2.713 0 4.596-1.163 4.596-3.2674 0-1.6061-1.052-2.326-3.212-2.8244l-6.534-1.3845c-4.985-1.1076-8.751-3.7105-8.751-9.47 0-6.6456 5.538-11.0206 13.07-11.0206 8.307 0 13.014 4.5411 13.956 10.4114h-8.695c-.72-1.8829-2.27-3.3228-5.205-3.3228-2.548 0-4.265 1.1076-4.265 2.9905 0 1.4953 1.052 2.326 2.825 2.7137l6.645 1.5506c5.815 1.3845 9.027 4.5412 9.027 9.8023 0 6.9778-5.87 10.9654-13.291 10.9654-8.141 0-13.679-3.9322-14.897-10.6332zm46.509 1.3845h8.031c-.887 5.7597-5.206 9.2487-11.686 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.317-13.0143 12.516-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.319 4.5965 1.773 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm31.431-6.3134v8.3624c-1.053-.5538-2.216-.7753-3.6-.7753-2.381 0-3.988 1.0522-4.431 2.8244v14.6203h-8.694v-24.921h8.694v2.2152c1.219-1.6614 3.157-2.769 5.649-2.769 1.108 0 1.994.2215 2.382.443zm18.288 25.0318h-7.809l-9.47-24.921h8.861l4.763 14.288 4.652-14.288h8.528zm25.614-8.6947h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.384-3.5997-3.544-3.5997zm31.43-6.3134v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.157-2.769 5.649-2.769 1.107 0 1.993.2215 2.381.443zm13.703-8.9715h24.312v7.6424h-15.562v5.3165h14.232v7.4763h-14.232v5.8703h15.562v7.6978h-24.312zm44.667 8.9715v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.156-2.769 5.648-2.769 1.108 0 1.994.2215 2.382.443zm19.673 0v8.3624c-1.053-.5538-2.216-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.156-2.769 5.648-2.769 1.108 0 1.994.2215 2.382.443zm26.769 12.5713c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm28.082-12.5713v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.157-2.769 5.649-2.769 1.107 0 1.993.2215 2.381.443z" fill="#d30001"/></svg>
</header>
<article>
<p><strong>We’re sorry, but something went wrong.</strong><br> If you’re the application owner check the logs for more information.</p>
</article>
</main>
</body>
</html>
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="256" fill="red"/>
</svg>
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./app/views/**/*.html.erb',
'./app/frontend/**/*.{js,jsx,ts,tsx}',
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
}
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
end
require "test_helper"
class Admin::DashboardControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get admin_dashboard_index_url
assert_response :success
end
end
require "test_helper"
class Admin::ImagesControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get admin_images_index_url
assert_response :success
end
test "should get show" do
get admin_images_show_url
assert_response :success
end
test "should get update" do
get admin_images_update_url
assert_response :success
end
test "should get destroy" do
get admin_images_destroy_url
assert_response :success
end
test "should get pending" do
get admin_images_pending_url
assert_response :success
end
test "should get approved" do
get admin_images_approved_url
assert_response :success
end
test "should get rejected" do
get admin_images_rejected_url
assert_response :success
end
test "should get approve" do
get admin_images_approve_url
assert_response :success
end
test "should get reject" do
get admin_images_reject_url
assert_response :success
end
test "should get add_tags" do
get admin_images_add_tags_url
assert_response :success
end
test "should get remove_tag" do
get admin_images_remove_tag_url
assert_response :success
end
end
require "test_helper"
class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get admin_users_index_url
assert_response :success
end
test "should get show" do
get admin_users_show_url
assert_response :success
end
test "should get edit" do
get admin_users_edit_url
assert_response :success
end
test "should get update" do
get admin_users_update_url
assert_response :success
end
test "should get destroy" do
get admin_users_destroy_url
assert_response :success
end
end
require "test_helper"
class ImagesControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get images_index_url
assert_response :success
end
test "should get show" do
get images_show_url
assert_response :success
end
test "should get new" do
get images_new_url
assert_response :success
end
test "should get create" do
get images_create_url
assert_response :success
end
test "should get edit" do
get images_edit_url
assert_response :success
end
test "should get update" do
get images_update_url
assert_response :success
end
test "should get destroy" do
get images_destroy_url
assert_response :success
end
test "should get search" do
get images_search_url
assert_response :success
end
end
require "test_helper"
class TagsControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
get tags_index_url
assert_response :success
end
test "should get new" do
get tags_new_url
assert_response :success
end
test "should get create" do
get tags_create_url
assert_response :success
end
test "should get edit" do
get tags_edit_url
assert_response :success
end
test "should get update" do
get tags_update_url
assert_response :success
end
test "should get destroy" do
get tags_destroy_url
assert_response :success
end
end
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
image: one
tag: one
two:
image: two
tag: two
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
title: MyString
status: 1
user: one
two:
title: MyString
status: 1
user: two
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: MyString
two:
name: MyString
<% password_digest = BCrypt::Password.create("password") %>
one:
email_address: one@example.com
password_digest: <%= password_digest %>
two:
email_address: two@example.com
password_digest: <%= password_digest %>
# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer
class PasswordsMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset
def reset
PasswordsMailer.reset(User.take)
end
end
require "test_helper"
class ImageTagTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
require "test_helper"
class ImageTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
require "test_helper"
class TagTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
require "test_helper"
class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
module ActiveSupport
class TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
# Add more helper methods to be used by all tests here...
end
end
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import RubyPlugin from "vite-plugin-ruby";
export default defineConfig({
plugins: [react(), tailwindcss(), RubyPlugin()],
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment