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"
This diff is collapsed. Click to expand it.
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 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 { 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, 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 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>
)
}
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
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This source diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
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