aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <msabate@suse.com>2024-03-13 18:03:43 +0100
committerMiquel Sabaté Solà <msabate@suse.com>2024-03-13 18:03:43 +0100
commita7791635aa98f012d35f97c3c5288494bbb28e38 (patch)
treebad987e6b4d10ad2f7cd02e40658d464907595ee
downloadoperum-a7791635aa98f012d35f97c3c5288494bbb28e38.tar.gz
operum-a7791635aa98f012d35f97c3c5288494bbb28e38.zip
Initial commit
Signed-off-by: Miquel Sabaté Solà <msabate@suse.com>
-rw-r--r--.dockerignore37
-rw-r--r--.gitattributes9
-rw-r--r--.github/ISSUE_TEMPLATE/custom.md27
-rw-r--r--.github/PULL_REQUEST_TEMPLATE.md10
-rw-r--r--.github/workflows/ci.yml56
-rw-r--r--.gitignore35
-rw-r--r--.rubocop.yml106
-rw-r--r--.ruby-version1
-rw-r--r--CHANGELOG.md3
-rw-r--r--CONTRIBUTING.md53
-rw-r--r--Dockerfile62
-rw-r--r--Gemfile64
-rw-r--r--Gemfile.lock337
-rw-r--r--README.md54
-rw-r--r--Rakefile8
-rw-r--r--app/assets/config/manifest.js4
-rw-r--r--app/assets/images/.keep0
-rw-r--r--app/assets/stylesheets/actiontext.css31
-rw-r--r--app/assets/stylesheets/application.css40
-rw-r--r--app/assets/stylesheets/comment.css27
-rw-r--r--app/assets/stylesheets/icons.css51
-rw-r--r--app/assets/stylesheets/search.css75
-rw-r--r--app/assets/stylesheets/tags.css27
-rw-r--r--app/assets/stylesheets/things.css94
-rw-r--r--app/assets/stylesheets/utils.css24
-rw-r--r--app/controllers/application_controller.rb22
-rw-r--r--app/controllers/comments_controller.rb68
-rw-r--r--app/controllers/concerns/.keep0
-rw-r--r--app/controllers/concerns/taggable.rb17
-rw-r--r--app/controllers/exports_controller.rb38
-rw-r--r--app/controllers/licenses_controller.rb7
-rw-r--r--app/controllers/searches_controller.rb60
-rw-r--r--app/controllers/sessions_controller.rb27
-rw-r--r--app/controllers/shared_searches_controller.rb13
-rw-r--r--app/controllers/tags_controller.rb39
-rw-r--r--app/controllers/things_controller.rb69
-rw-r--r--app/helpers/shared_searches_helper.rb17
-rw-r--r--app/javascript/application.js6
-rw-r--r--app/javascript/controllers/application.js9
-rw-r--r--app/javascript/controllers/comment_controller.js36
-rw-r--r--app/javascript/controllers/export_controller.js19
-rw-r--r--app/javascript/controllers/header_controller.js21
-rw-r--r--app/javascript/controllers/index.js11
-rw-r--r--app/javascript/controllers/search_controller.js36
-rw-r--r--app/models/application_record.rb5
-rw-r--r--app/models/comment.rb18
-rw-r--r--app/models/concerns/.keep0
-rw-r--r--app/models/search.rb99
-rw-r--r--app/models/tag.rb7
-rw-r--r--app/models/tag_reference.rb6
-rw-r--r--app/models/thing.rb32
-rw-r--r--app/models/user.rb9
-rw-r--r--app/views/active_storage/blobs/_blob.html.erb14
-rw-r--r--app/views/comments/_comment.html.erb42
-rw-r--r--app/views/comments/_list.html.erb30
-rw-r--r--app/views/exports/new.html.erb14
-rw-r--r--app/views/exports/show.csv.erb1
-rw-r--r--app/views/exports/show.uoc.erb7
-rw-r--r--app/views/layouts/_errors.html.erb13
-rw-r--r--app/views/layouts/action_text/contents/_content.html.erb3
-rw-r--r--app/views/layouts/application.html.erb78
-rw-r--r--app/views/licenses/show.html.erb23
-rw-r--r--app/views/searches/_form.html.erb29
-rw-r--r--app/views/searches/_search.html.erb5
-rw-r--r--app/views/searches/edit.html.erb1
-rw-r--r--app/views/searches/index.html.erb1
-rw-r--r--app/views/searches/new.html.erb1
-rw-r--r--app/views/searches/show.html.erb1
-rw-r--r--app/views/sessions/new.html.erb21
-rw-r--r--app/views/shared_searches/index.html.erb13
-rw-r--r--app/views/shared_searches/show.html.erb28
-rw-r--r--app/views/tags/_form.html.erb12
-rw-r--r--app/views/tags/_tag.html.erb8
-rw-r--r--app/views/tags/_taggable.html.erb17
-rw-r--r--app/views/tags/index.html.erb13
-rw-r--r--app/views/tags/new.html.erb9
-rw-r--r--app/views/things/_form.html.erb102
-rw-r--r--app/views/things/_thing.html.erb7
-rw-r--r--app/views/things/edit.html.erb3
-rw-r--r--app/views/things/new.html.erb1
-rw-r--r--app/views/things/show.html.erb7
-rwxr-xr-xbin/bundle109
-rwxr-xr-xbin/docker-entrypoint8
-rwxr-xr-xbin/importmap5
-rwxr-xr-xbin/rails6
-rwxr-xr-xbin/rake6
-rwxr-xr-xbin/setup35
-rw-r--r--config.ru8
-rw-r--r--config/application.rb29
-rw-r--r--config/boot.rb6
-rw-r--r--config/cable.yml10
-rw-r--r--config/credentials.yml.enc1
-rw-r--r--config/database.yml25
-rw-r--r--config/environment.rb7
-rw-r--r--config/environments/development.rb78
-rw-r--r--config/environments/production.rb103
-rw-r--r--config/environments/test.rb66
-rw-r--r--config/importmap.rb11
-rw-r--r--config/initializers/assets.rb14
-rw-r--r--config/initializers/content_security_policy.rb26
-rw-r--r--config/initializers/filter_parameter_logging.rb11
-rw-r--r--config/initializers/inflections.rb17
-rw-r--r--config/initializers/locale.rb6
-rw-r--r--config/initializers/mime_types.rb3
-rw-r--r--config/initializers/permissions_policy.rb14
-rw-r--r--config/locales/ca.yml129
-rw-r--r--config/locales/en.yml131
-rw-r--r--config/puma.rb37
-rw-r--r--config/routes.rb27
-rw-r--r--config/storage.yml34
-rw-r--r--db/migrate/20240224162412_create_things.rb23
-rw-r--r--db/migrate/20240224164039_create_users.rb10
-rw-r--r--db/migrate/20240224165311_create_comments.rb10
-rw-r--r--db/migrate/20240224165703_create_active_storage_tables.active_storage.rb57
-rw-r--r--db/migrate/20240224165704_create_action_text_tables.action_text.rb26
-rw-r--r--db/migrate/20240224220515_create_tags.rb9
-rw-r--r--db/migrate/20240225070152_create_tag_references.rb10
-rw-r--r--db/migrate/20240225071927_create_searches.rb11
-rw-r--r--db/migrate/20240225100002_add_note_to_things.rb5
-rw-r--r--db/migrate/20240226063134_add_uniqueness_index_search.rb8
-rw-r--r--db/migrate/20240227085442_add_last_search_id_to_users.rb5
-rw-r--r--db/migrate/20240227145317_add_authors_to_thing.rb6
-rw-r--r--db/migrate/20240301154452_add_shared_to_searches.rb5
-rw-r--r--db/migrate/20240307220309_add_where_is_it_to_things.rb5
-rw-r--r--db/migrate/20240313133513_change_access_and_bought_at_to_date.rb6
-rw-r--r--db/schema.rb131
-rw-r--r--db/seeds.rb118
-rw-r--r--lib/assets/.keep0
-rw-r--r--lib/base_exporter.rb29
-rw-r--r--lib/csv_exporter.rb21
-rw-r--r--lib/tasks/.keep0
-rw-r--r--lib/uoc_exporter.rb81
-rw-r--r--log/.keep0
-rw-r--r--public/404.html67
-rw-r--r--public/422.html67
-rw-r--r--public/500.html66
-rw-r--r--public/apple-touch-icon-precomposed.png0
-rw-r--r--public/apple-touch-icon.png0
-rw-r--r--public/favicon.ico0
-rw-r--r--public/robots.txt1
-rw-r--r--storage/.keep0
-rw-r--r--test/application_system_test_case.rb26
-rw-r--r--test/controllers/exports_controller_test.rb24
-rw-r--r--test/fixtures/action_text/rich_texts.yml4
-rw-r--r--test/fixtures/comments.yml2
-rw-r--r--test/fixtures/files/all.uoc.tex9
-rw-r--r--test/fixtures/searches.yml4
-rw-r--r--test/fixtures/tag_references.yml15
-rw-r--r--test/fixtures/tags.yml5
-rw-r--r--test/fixtures/things.yml39
-rw-r--r--test/fixtures/users.yml4
-rw-r--r--test/models/comment_test.rb18
-rw-r--r--test/models/search_test.rb90
-rw-r--r--test/models/tag_test.rb16
-rw-r--r--test/models/thing_test.rb95
-rw-r--r--test/models/user_test.rb28
-rw-r--r--test/system/comments_test.rb103
-rw-r--r--test/system/exports_test.rb27
-rw-r--r--test/system/licenses_test.rb11
-rw-r--r--test/system/searches_test.rb123
-rw-r--r--test/system/sessions_test.rb27
-rw-r--r--test/system/shared_searches_test.rb34
-rw-r--r--test/system/tags_test.rb40
-rw-r--r--test/system/things_test.rb102
-rw-r--r--test/test_helper.rb18
-rw-r--r--tmp/.keep0
-rw-r--r--tmp/pids/.keep0
-rw-r--r--tmp/storage/.keep0
-rw-r--r--vendor/.keep0
-rw-r--r--vendor/assets/stylesheets/simple.css698
-rw-r--r--vendor/javascript/.keep0
171 files changed, 5731 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..9612375
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,37 @@
+# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.
+
+# Ignore git directory.
+/.git/
+
+# Ignore bundler config.
+/.bundle
+
+# Ignore all environment files (except templates).
+/.env*
+!/.env*.erb
+
+# 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
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..8dc4323
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+# See https://git-scm.com/docs/gitattributes for more about git attribute files.
+
+# Mark the database schema as having been generated.
+db/schema.rb linguist-generated
+
+# Mark any vendored files as having been vendored.
+vendor/* linguist-vendored
+config/credentials/*.yml.enc diff=rails_credentials
+config/credentials.yml.enc diff=rails_credentials
diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md
new file mode 100644
index 0000000..fd66e04
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/custom.md
@@ -0,0 +1,27 @@
+---
+name: Custom issue template
+about: General issues, both bugs and features.
+title: ''
+labels: ''
+assignees: mssola
+---
+
+### Description
+
+Check out the [contribution guidelines](../CONTRIBUTING.md) file for some considerations before submitting a new issue.
+
+### Steps to reproduce
+
+1. First I did this...
+2. Then that...
+3. And this happened!
+
+- **Expected behavior**: I expected this to happen!
+- **Actual behavior**: But this happened...
+
+Providing logs would also be useful.
+
+### Deployment information
+
+- **Deployment method**: how have you deployed this application? If possible, could you paste your configuration? (don't forget to strip passwords or other sensitive data!)
+- Commit SHA.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..7e5026e
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,10 @@
+Provide a general description of the changes in your pull request. If this pull request fixes a known issue, please tag it as well (e.g.: `Fixes #1`).
+
+Before submitting a PR make sure the following things have been done (and denote this by checking the relevant checkboxes):
+
+- [ ] The commits are consistent with the [contribution guidelines](../CONTRIBUTING.md).
+- [ ] `bundle exec rails test`, `bundle exec rails test:system` and `bundle exec rubocop` are passing.
+- [ ] You've updated the [changelog](../CHANGELOG.md) (if adding/changing user-visible functionality).
+- [ ] You've updated the [readme](../README.md) (if adding/changing user-visible functionality).
+
+Thanks for contributing to this project!
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..54baa0e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,56 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Run linting
+ run: |
+ bundle exec rubocop --parallel
+
+ - name: Run security checks
+ run: |
+ bundle exec bundler-audit --update
+ bundle exec brakeman -q -w2
+
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ bundler-cache: true
+
+ - name: Setup database
+ env:
+ RAILS_ENV: "test"
+ run: |
+ bin/rails db:setup
+
+ - name: Check autoloading (zeitwerk)
+ env:
+ RAILS_ENV: "test"
+ run: |
+ bin/rails zeitwerk:check
+
+ - name: Run tests
+ env:
+ RAILS_ENV: "test"
+ run: |
+ bin/rails test
+ bin/rails test:system
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5fb66c9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,35 @@
+# See https://help.github.com/articles/ignoring-files for more about ignoring files.
+#
+# If you find yourself ignoring temporary files generated by your text editor
+# or operating system, you probably want to add a global ignore instead:
+# git config --global core.excludesfile '~/.gitignore_global'
+
+# Ignore bundler config.
+/.bundle
+
+# Ignore all environment files (except templates).
+/.env*
+!/.env*.erb
+
+# 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
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 0000000..555e7e8
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,106 @@
+AllCops:
+ TargetRubyVersion: 3.2
+ TargetRailsVersion: 7.1
+
+ DisplayCopNames: true
+ DisplayStyleGuide: false
+ SuggestExtensions: false
+
+ NewCops: enable
+
+ Exclude:
+ # Files that are out of our control and that are not excluded in the
+ # default config of rubocop.
+ - bin/bundle
+ - db/schema.rb
+ - db/migrate/*
+ - db/seeds.rb
+ - vendor/**/*
+
+##
+# Plugins
+
+require:
+ - rubocop-rails
+ - rubocop-performance
+
+#
+# Layout
+#
+
+# The table format is more human readable.
+Layout/HashAlignment:
+ EnforcedHashRocketStyle: table
+ EnforcedColonStyle: table
+
+# The default is just too small. A limit of 100 looks reasonable.
+Layout/LineLength:
+ Max: 100
+
+#
+# Metrics
+#
+
+# The default is just too small.
+Metrics/AbcSize:
+ Max: 30
+
+# We will skip it for tests.
+Metrics/ClassLength:
+ Max: 200
+ Exclude:
+ - test/**/*
+
+# Default is just too low.
+Metrics/CyclomaticComplexity:
+ Max: 10
+
+# Default is just too low.
+Metrics/PerceivedComplexity:
+ Max: 10
+
+# We will skip it for Rake tasks.
+Metrics/BlockLength:
+ Exclude:
+ - config/**/*
+ - lib/tasks/**/*
+ - test/**/*
+
+# The default is just too small.
+Metrics/MethodLength:
+ Max: 20
+
+#
+# Style
+#
+
+# It's not needed to add documentation for obvious modules or classes. The main
+# idea is that documentation will be asked during the review process if needed.
+Style/Documentation:
+ Enabled: false
+
+# This forces us to create a new object for no real reason.
+Style/MultipleComparison:
+ Enabled: false
+
+# There are some false positives (e.g. "module ::Module", in which we want to
+# make sure there are no clashes or misunderstandings). Therefore, we just
+# disable this cop.
+Style/ClassAndModuleChildren:
+ Enabled: false
+
+# I do need to write non-ASCII words from time to time since, you know, this is
+# a language application and latin words can have macrons.
+Style/AsciiComments:
+ Enabled: false
+
+#
+# Naming
+#
+
+# The default minimum length is 3, which is too long for good names like
+# "js". Variables with only one letter are usually disallowed, but there are
+# some names which are easy to understand (e.g. convention).
+Naming/MethodParameterName:
+ MinNameLength: 2
+ AllowedNames: [_, n]
diff --git a/.ruby-version b/.ruby-version
new file mode 100644
index 0000000..9e79f6c
--- /dev/null
+++ b/.ruby-version
@@ -0,0 +1 @@
+ruby-3.2.2
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d2fe77d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Changelog
+
+Under development
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..838ecda
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,53 @@
+## Why?
+
+Do you want to fix an error you have found? Do you have a suggestion to improve
+this application? I am open for discussion and welcome any help!
+
+## How?
+
+There are many ways to help me out. One way might be to open an issue on
+[Github's tracker](https://github.com/mssola/operum/issues) and start a
+discussion. For this, mind the following:
+
+- Check that the issue has not already been reported or fixed in `main`.
+- Try to be concise and precise in your description.
+- If you have found a problem, provide a step by step guide on how to reproduce it.
+- Provide the version you are using (git commit SHA).
+
+Another way is to simply submit a pull request. For this, also mind these:
+
+- Write a [good commit message](https://chris.beams.io/posts/git-commit/).
+- Tests continue to work (see `Testing` below).
+- The application continues to work.
+- The pull request has *only* one subject and a clear title. You are not
+ submitting a pull request with tons of different unrelated commits.
+
+## Testing
+
+Before doing anything at all make sure that your ruby version is as specified in
+the `.ruby-version` file, and that you have also installed `bundler`. After
+that, just run `bundle` to get all the gems as needed.
+
+Then prepare the database:
+
+```sh
+$ bundle exec rake db:setup
+```
+
+And finally, in order to run unit tests, simply run:
+
+```sh
+$ bundle exec rails test
+```
+
+System tests can also be run quite simply:
+
+```sh
+$ bundle exec rails test:system
+```
+
+And finally, make sure that the code follows the proper style:
+
+```sh
+$ bundle exec rubocop
+```
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5cb8402
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,62 @@
+# syntax = docker/dockerfile:1
+
+# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
+ARG RUBY_VERSION=3.2.2
+FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
+
+# Rails app lives here
+WORKDIR /rails
+
+# 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 libvips pkg-config
+
+# 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/
+
+# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
+RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
+
+
+# Final stage for app image
+FROM base
+
+# Install packages needed for deployment
+RUN apt-get update -qq && \
+ apt-get install --no-install-recommends -y curl libsqlite3-0 libvips && \
+ rm -rf /var/lib/apt/lists /var/cache/apt/archives
+
+# Copy built artifacts: gems, application
+COPY --from=build /usr/local/bundle /usr/local/bundle
+COPY --from=build /rails /rails
+
+# Run and own only the runtime files as a non-root user for security
+RUN useradd rails --create-home --shell /bin/bash && \
+ chown -R rails:rails db log storage tmp
+USER rails:rails
+
+# Entrypoint prepares the database.
+ENTRYPOINT ["/rails/bin/docker-entrypoint"]
+
+# Start the server by default, this can be overwritten at runtime
+EXPOSE 3000
+CMD ["thrust", "./bin/rails", "server"]
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..d125a8d
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
+
+ruby '3.2.2'
+
+# Rails itself :)
+gem 'rails', '~> 7.1.3'
+gem 'rails-i18n', '~> 7.0.0'
+
+# The original asset pipeline for Rails.
+gem 'sprockets-rails'
+
+# sqlite3 should be fine for this project, and we keep things simpler.
+gem 'sqlite3', '~> 1.4'
+
+# Web server.
+gem 'puma', '>= 5.0'
+gem 'thruster'
+
+# Use JavaScript with ESM import maps.
+gem 'importmap-rails'
+
+# Hotwire's SPA-like page accelerator.
+gem 'turbo-rails'
+
+# Hotwire's modest JavaScript framework.
+gem 'stimulus-rails'
+
+# Use Active Model has_secure_password.
+gem 'bcrypt', '~> 3.1.7'
+
+# Reduces boot times through caching; required in config/boot.rb
+gem 'bootsnap', require: false
+
+# Use Active Storage variants.
+gem 'image_processing', '~> 1.2'
+
+group :development, :test do
+ gem 'debug', platforms: %i[mri windows]
+
+ # Security
+ gem 'brakeman', require: false
+ gem 'bundle-audit', require: false
+end
+
+group :development do
+ # Use console on exceptions pages.
+ gem 'web-console'
+
+ # Convenient & prettier printer.
+ gem 'awesome_print'
+
+ # Style
+ gem 'rubocop', require: false
+ gem 'rubocop-performance', require: false
+ gem 'rubocop-rails', require: false
+end
+
+group :test do
+ # System testing.
+ gem 'capybara'
+ gem 'selenium-webdriver'
+end
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..803d4e7
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,337 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ actioncable (7.1.3.2)
+ actionpack (= 7.1.3.2)
+ activesupport (= 7.1.3.2)
+ nio4r (~> 2.0)
+ websocket-driver (>= 0.6.1)
+ zeitwerk (~> 2.6)
+ actionmailbox (7.1.3.2)
+ actionpack (= 7.1.3.2)
+ activejob (= 7.1.3.2)
+ activerecord (= 7.1.3.2)
+ activestorage (= 7.1.3.2)
+ activesupport (= 7.1.3.2)
+ mail (>= 2.7.1)
+ net-imap
+ net-pop
+ net-smtp
+ actionmailer (7.1.3.2)
+ actionpack (= 7.1.3.2)
+ actionview (= 7.1.3.2)
+ activejob (= 7.1.3.2)
+ activesupport (= 7.1.3.2)
+ mail (~> 2.5, >= 2.5.4)
+ net-imap
+ net-pop
+ net-smtp
+ rails-dom-testing (~> 2.2)
+ actionpack (7.1.3.2)
+ actionview (= 7.1.3.2)
+ activesupport (= 7.1.3.2)
+ nokogiri (>= 1.8.5)
+ racc
+ rack (>= 2.2.4)
+ rack-session (>= 1.0.1)
+ rack-test (>= 0.6.3)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ actiontext (7.1.3.2)
+ actionpack (= 7.1.3.2)
+ activerecord (= 7.1.3.2)
+ activestorage (= 7.1.3.2)
+ activesupport (= 7.1.3.2)
+ globalid (>= 0.6.0)
+ nokogiri (>= 1.8.5)
+ actionview (7.1.3.2)
+ activesupport (= 7.1.3.2)
+ builder (~> 3.1)
+ erubi (~> 1.11)
+ rails-dom-testing (~> 2.2)
+ rails-html-sanitizer (~> 1.6)
+ activejob (7.1.3.2)
+ activesupport (= 7.1.3.2)
+ globalid (>= 0.3.6)
+ activemodel (7.1.3.2)
+ activesupport (= 7.1.3.2)
+ activerecord (7.1.3.2)
+ activemodel (= 7.1.3.2)
+ activesupport (= 7.1.3.2)
+ timeout (>= 0.4.0)
+ activestorage (7.1.3.2)
+ actionpack (= 7.1.3.2)
+ activejob (= 7.1.3.2)
+ activerecord (= 7.1.3.2)
+ activesupport (= 7.1.3.2)
+ marcel (~> 1.0)
+ activesupport (7.1.3.2)
+ base64
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.0.2)
+ connection_pool (>= 2.2.5)
+ drb
+ i18n (>= 1.6, < 2)
+ minitest (>= 5.1)
+ mutex_m
+ tzinfo (~> 2.0)
+ addressable (2.8.6)
+ public_suffix (>= 2.0.2, < 6.0)
+ ast (2.4.2)
+ awesome_print (1.9.2)
+ base64 (0.2.0)
+ bcrypt (3.1.20)
+ bigdecimal (3.1.6)
+ bindex (0.8.1)
+ bootsnap (1.18.3)
+ msgpack (~> 1.2)
+ brakeman (6.0.1)
+ builder (3.2.4)
+ bundle-audit (0.1.0)
+ bundler-audit
+ bundler-audit (0.9.1)
+ bundler (>= 1.2.0, < 3)
+ thor (~> 1.0)
+ capybara (3.40.0)
+ addressable
+ matrix
+ mini_mime (>= 0.1.3)
+ nokogiri (~> 1.11)
+ rack (>= 1.6.0)
+ rack-test (>= 0.6.3)
+ regexp_parser (>= 1.5, < 3.0)
+ xpath (~> 3.2)
+ concurrent-ruby (1.2.3)
+ connection_pool (2.4.1)
+ crass (1.0.6)
+ date (3.3.4)
+ debug (1.9.1)
+ irb (~> 1.10)
+ reline (>= 0.3.8)
+ drb (2.2.0)
+ ruby2_keywords
+ erubi (1.12.0)
+ ffi (1.16.3)
+ globalid (1.2.1)
+ activesupport (>= 6.1)
+ i18n (1.14.1)
+ concurrent-ruby (~> 1.0)
+ image_processing (1.12.2)
+ mini_magick (>= 4.9.5, < 5)
+ ruby-vips (>= 2.0.17, < 3)
+ importmap-rails (2.0.1)
+ actionpack (>= 6.0.0)
+ activesupport (>= 6.0.0)
+ railties (>= 6.0.0)
+ io-console (0.7.2)
+ irb (1.11.2)
+ rdoc
+ reline (>= 0.4.2)
+ json (2.6.3)
+ language_server-protocol (3.17.0.3)
+ loofah (2.22.0)
+ crass (~> 1.0.2)
+ nokogiri (>= 1.12.0)
+ mail (2.8.1)
+ mini_mime (>= 0.1.1)
+ net-imap
+ net-pop
+ net-smtp
+ marcel (1.0.2)
+ matrix (0.4.2)
+ mini_magick (4.12.0)
+ mini_mime (1.1.5)
+ minitest (5.22.2)
+ msgpack (1.7.2)
+ mutex_m (0.2.0)
+ net-imap (0.4.10)
+ date
+ net-protocol
+ net-pop (0.1.2)
+ net-protocol
+ net-protocol (0.2.2)
+ timeout
+ net-smtp (0.4.0.1)
+ net-protocol
+ nio4r (2.7.0)
+ nokogiri (1.16.2-aarch64-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.2-arm-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.2-arm64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.16.2-x86-linux)
+ racc (~> 1.4)
+ nokogiri (1.16.2-x86_64-darwin)
+ racc (~> 1.4)
+ nokogiri (1.16.2-x86_64-linux)
+ racc (~> 1.4)
+ parallel (1.23.0)
+ parser (3.2.2.4)
+ ast (~> 2.4.1)
+ racc
+ psych (5.1.2)
+ stringio
+ public_suffix (5.0.4)
+ puma (6.4.2)
+ nio4r (~> 2.0)
+ racc (1.7.3)
+ rack (3.0.9.1)
+ rack-session (2.0.0)
+ rack (>= 3.0.0)
+ rack-test (2.1.0)
+ rack (>= 1.3)
+ rackup (2.1.0)
+ rack (>= 3)
+ webrick (~> 1.8)
+ rails (7.1.3.2)
+ actioncable (= 7.1.3.2)
+ actionmailbox (= 7.1.3.2)
+ actionmailer (= 7.1.3.2)
+ actionpack (= 7.1.3.2)
+ actiontext (= 7.1.3.2)
+ actionview (= 7.1.3.2)
+ activejob (= 7.1.3.2)
+ activemodel (= 7.1.3.2)
+ activerecord (= 7.1.3.2)
+ activestorage (= 7.1.3.2)
+ activesupport (= 7.1.3.2)
+ bundler (>= 1.15.0)
+ railties (= 7.1.3.2)
+ rails-dom-testing (2.2.0)
+ activesupport (>= 5.0.0)
+ minitest
+ nokogiri (>= 1.6)
+ rails-html-sanitizer (1.6.0)
+ loofah (~> 2.21)
+ nokogiri (~> 1.14)
+ rails-i18n (7.0.8)
+ i18n (>= 0.7, < 2)
+ railties (>= 6.0.0, < 8)
+ railties (7.1.3.2)
+ actionpack (= 7.1.3.2)
+ activesupport (= 7.1.3.2)
+ irb
+ rackup (>= 1.0.0)
+ rake (>= 12.2)
+ thor (~> 1.0, >= 1.2.2)
+ zeitwerk (~> 2.6)
+ rainbow (3.1.1)
+ rake (13.1.0)
+ rdoc (6.6.2)
+ psych (>= 4.0.0)
+ regexp_parser (2.9.0)
+ reline (0.4.2)
+ io-console (~> 0.5)
+ rexml (3.2.6)
+ rubocop (1.57.2)
+ json (~> 2.3)
+ language_server-protocol (>= 3.17.0)
+ parallel (~> 1.10)
+ parser (>= 3.2.2.4)
+ rainbow (>= 2.2.2, < 4.0)
+ regexp_parser (>= 1.8, < 3.0)
+ rexml (>= 3.2.5, < 4.0)
+ rubocop-ast (>= 1.28.1, < 2.0)
+ ruby-progressbar (~> 1.7)
+ unicode-display_width (>= 2.4.0, < 3.0)
+ rubocop-ast (1.30.0)
+ parser (>= 3.2.1.0)
+ rubocop-performance (1.19.1)
+ rubocop (>= 1.7.0, < 2.0)
+ rubocop-ast (>= 0.4.0)
+ rubocop-rails (2.21.1)
+ activesupport (>= 4.2.0)
+ rack (>= 1.1)
+ rubocop (>= 1.33.0, < 2.0)
+ ruby-progressbar (1.13.0)
+ ruby-vips (2.2.1)
+ ffi (~> 1.12)
+ ruby2_keywords (0.0.5)
+ rubyzip (2.3.2)
+ selenium-webdriver (4.18.1)
+ base64 (~> 0.2)
+ rexml (~> 3.2, >= 3.2.5)
+ rubyzip (>= 1.2.2, < 3.0)
+ websocket (~> 1.0)
+ sprockets (4.2.1)
+ concurrent-ruby (~> 1.0)
+ rack (>= 2.2.4, < 4)
+ sprockets-rails (3.4.2)
+ actionpack (>= 5.2)
+ activesupport (>= 5.2)
+ sprockets (>= 3.0.0)
+ sqlite3 (1.7.2-aarch64-linux)
+ sqlite3 (1.7.2-arm-linux)
+ sqlite3 (1.7.2-arm64-darwin)
+ sqlite3 (1.7.2-x86-linux)
+ sqlite3 (1.7.2-x86_64-darwin)
+ sqlite3 (1.7.2-x86_64-linux)
+ stimulus-rails (1.3.3)
+ railties (>= 6.0.0)
+ stringio (3.1.0)
+ thor (1.3.0)
+ thruster (0.1.0)
+ thruster (0.1.0-aarch64-linux)
+ thruster (0.1.0-arm64-darwin)
+ thruster (0.1.0-x86_64-darwin)
+ thruster (0.1.0-x86_64-linux)
+ timeout (0.4.1)
+ turbo-rails (2.0.4)
+ actionpack (>= 6.0.0)
+ activejob (>= 6.0.0)
+ railties (>= 6.0.0)
+ tzinfo (2.0.6)
+ concurrent-ruby (~> 1.0)
+ unicode-display_width (2.5.0)
+ web-console (4.2.1)
+ actionview (>= 6.0.0)
+ activemodel (>= 6.0.0)
+ bindex (>= 0.4.0)
+ railties (>= 6.0.0)
+ webrick (1.8.1)
+ websocket (1.2.10)
+ websocket-driver (0.7.6)
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.5)
+ xpath (3.2.0)
+ nokogiri (~> 1.8)
+ zeitwerk (2.6.13)
+
+PLATFORMS
+ aarch64-linux
+ arm-linux
+ arm64-darwin
+ x86-linux
+ x86_64-darwin
+ x86_64-linux
+
+DEPENDENCIES
+ awesome_print
+ bcrypt (~> 3.1.7)
+ bootsnap
+ brakeman
+ bundle-audit
+ capybara
+ debug
+ image_processing (~> 1.2)
+ importmap-rails
+ puma (>= 5.0)
+ rails (~> 7.1.3)
+ rails-i18n (~> 7.0.0)
+ rubocop
+ rubocop-performance
+ rubocop-rails
+ selenium-webdriver
+ sprockets-rails
+ sqlite3 (~> 1.4)
+ stimulus-rails
+ thruster
+ turbo-rails
+ web-console
+
+RUBY VERSION
+ ruby 3.2.2p53
+
+BUNDLED WITH
+ 2.5.1
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ef40c7c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,54 @@
+<p align="center">
+ <a href="https://github.com/mssola/operum/actions?query=workflow%3ACI" title="CI status for the main branch"><img src="https://github.com/mssola/operum/workflows/CI/badge.svg" alt="Build Status for main branch" /></a>
+</p>
+
+---
+
+> mille dea est operum: certe dea carminis illa est; <br />
+> &emsp; si mereor, studiis adsit amica meis.
+>
+> &mdash; P. Ovidi Nasonis - Fasti 3.833-834
+
+This is a small application in order to keep track of books, articles, papers,
+etc.; that you might have and need to know where they are, reference it on a
+paper, etc. To sum things up, it's a way to keep your bibliography under
+control.
+
+## Basic usage
+
+To do.
+
+## Deployment
+
+To do.
+
+OPERUM_BASE_TITLE
+OPERUM_DEFAULT_LOCALE
+
+## Contributing
+
+Do you want to contribute with code, or to report an issue you are facing? Read
+the [CONTRIBUTING.md](./CONTRIBUTING.md) file.
+
+## [Changelog](https://pbs.twimg.com/media/DJDYCcLXcAA_eIo?format=jpg&name=small)
+
+Read the [CHANGELOG.md](./CHANGELOG.md) file.
+
+## License
+
+```txt
+Copyright (C) 2023-Ω Miquel Sabaté Solà <mikisabate@gmail.com>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+```
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..488c551
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# 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
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
new file mode 100644
index 0000000..ddd546a
--- /dev/null
+++ b/app/assets/config/manifest.js
@@ -0,0 +1,4 @@
+//= link_tree ../images
+//= link_directory ../stylesheets .css
+//= link_tree ../../javascript .js
+//= link_tree ../../../vendor/javascript .js
diff --git a/app/assets/images/.keep b/app/assets/images/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/assets/images/.keep
diff --git a/app/assets/stylesheets/actiontext.css b/app/assets/stylesheets/actiontext.css
new file mode 100644
index 0000000..3cfcb2b
--- /dev/null
+++ b/app/assets/stylesheets/actiontext.css
@@ -0,0 +1,31 @@
+/*
+ * Provides a drop-in pointer for the default Trix stylesheet that will format the toolbar and
+ * the trix-editor content (whether displayed or under editing). Feel free to incorporate this
+ * inclusion directly in any other asset bundle and remove this file.
+ *
+ *= require trix
+*/
+
+/*
+ * We need to override trix.css’s image gallery styles to accommodate the
+ * <action-text-attachment> element we wrap around attachments. Otherwise,
+ * images in galleries will be squished by the max-width: 33%; rule.
+*/
+.trix-content .attachment-gallery > action-text-attachment,
+.trix-content .attachment-gallery > .attachment {
+ flex: 1 0 33%;
+ padding: 0 0.5em;
+ max-width: 33%;
+}
+
+.trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment,
+.trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment,
+.trix-content .attachment-gallery.attachment-gallery--4 > .attachment {
+ flex-basis: 50%;
+ max-width: 50%;
+}
+
+.trix-content action-text-attachment .attachment {
+ padding: 0 !important;
+ max-width: 100% !important;
+}
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
new file mode 100644
index 0000000..042a686
--- /dev/null
+++ b/app/assets/stylesheets/application.css
@@ -0,0 +1,40 @@
+/*
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
+ * listed below.
+ *
+ * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's
+ * vendor/assets/stylesheets directory can be referenced here using a relative path.
+ *
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS
+ * files in this directory. Styles in this file should be added after the last require_* statement.
+ * It is generally better to create a new file per style scope.
+ *
+ *= require simple
+ *= require_tree .
+ *= require_self
+ */
+
+body {
+ /* Make the central block wider as it is with the default simple.css layout. */
+ grid-template-columns: 1fr min(65rem,90%) 1fr !important;
+}
+
+header {
+ padding: 0px !important;
+}
+
+/* Tweaks on dark mode */
+@media (prefers-color-scheme: dark) {
+ /* Change the accent from simple.css */
+ :root,
+ ::backdrop {
+ --accent: #d7af87 !important;
+ --accent-hover: #ffe099 !important;
+ }
+
+ /* Make the buttons from Trix a bit more visible on dark mode */
+ trix-toolbar .trix-button {
+ background: var(--text) !important;
+ }
+}
diff --git a/app/assets/stylesheets/comment.css b/app/assets/stylesheets/comment.css
new file mode 100644
index 0000000..4472c62
--- /dev/null
+++ b/app/assets/stylesheets/comment.css
@@ -0,0 +1,27 @@
+/*
+ * Single comment.
+ */
+
+.comment {
+ margin: 1rem .5rem 1rem .5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--standard-border-radius);
+ padding: .5rem 1rem;
+
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+}
+
+.comment .comment-header {
+ display: flex;
+ gap: 10px;
+}
+
+.comment .comment-header .comment-header-title {
+ flex-grow: 1;
+}
+
+.comment .comment-header .comment-header-actions {
+ flex-grow: 0;
+}
diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css
new file mode 100644
index 0000000..484265e
--- /dev/null
+++ b/app/assets/stylesheets/icons.css
@@ -0,0 +1,51 @@
+/*
+ * Icons taken and adapted from: https://github.com/astrit/css.gg.
+ */
+
+/*
+ * Arrow down.
+ */
+
+.gg-chevron-down {
+ box-sizing: border-box;
+ position: relative;
+ transform: scale(1);
+ border: 2px solid transparent;
+}
+.gg-chevron-down::after {
+ content: "";
+ display: block;
+ box-sizing: border-box;
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ border-bottom: 2px solid;
+ border-right: 2px solid;
+ transform: rotate(45deg);
+ left: -5px;
+ top: 4px
+}
+
+/*
+ * Arrow up.
+ */
+
+.gg-chevron-up {
+ box-sizing: border-box;
+ position: relative;
+ transform: scale(1);
+ border: 2px solid transparent;
+}
+.gg-chevron-up::after {
+ content: "";
+ display: block;
+ box-sizing: border-box;
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ border-top: 2px solid;
+ border-right: 2px solid;
+ transform: rotate(-45deg);
+ left: -5px;
+ bottom: 4px
+}
diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css
new file mode 100644
index 0000000..eb72638
--- /dev/null
+++ b/app/assets/stylesheets/search.css
@@ -0,0 +1,75 @@
+/*
+ * Root
+ */
+
+#hidden-nav {
+ padding: 1rem .5rem 2rem .5rem;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ justify-content: center;
+}
+
+#hidden-nav div:not(:last-child) {
+ padding-right: 1rem;
+ border-right: 1px solid var(--border);
+}
+
+#hidden-nav a {
+ margin-left: .5rem;
+}
+
+/*
+ * New search.
+ */
+
+#search_form {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+ padding-bottom: 1rem;
+ margin-bottom: 2rem;
+ border-bottom: 1px solid var(--border);
+}
+
+#search_form input[type=text] {
+ width: 100%;
+}
+
+/*
+ * Export.
+ */
+
+@media only screen and (max-width: 720px) {
+ #export-format-button {
+ width: 100%;
+ text-align: center;
+ }
+}
+
+/*
+ * Shared searches.
+ */
+
+#public-search-table {
+ margin: 0px;
+ width: 100%;
+}
+
+#public-search-table td {
+ padding: 0px;
+ border: 0px;
+}
+
+#public-search-table details {
+ background: 0;
+ margin: 0px;
+ border-radius: 0px;
+}
+
+#public-search-table .details-flex {
+ display: flex;
+ align-items: flex-start;
+ gap: 20px;
+ flex-wrap: wrap;
+}
diff --git a/app/assets/stylesheets/tags.css b/app/assets/stylesheets/tags.css
new file mode 100644
index 0000000..141cbca
--- /dev/null
+++ b/app/assets/stylesheets/tags.css
@@ -0,0 +1,27 @@
+#tag-index h2 span {
+ font-size: 1rem;
+ vertical-align: middle;
+}
+
+#tag-new h2 span {
+ font-size: 1rem;
+ vertical-align: middle;
+}
+
+#tags-list {
+ display: grid;
+ grid-template-columns: repeat(1, 1fr);
+}
+
+.tag-container {
+ display: flex;
+ align-items: flex-start;
+}
+
+.tag-container .name {
+ flex-grow: 1;
+}
+
+.tag-container .action {
+ flex-grow: 0;
+}
diff --git a/app/assets/stylesheets/things.css b/app/assets/stylesheets/things.css
new file mode 100644
index 0000000..af8487f
--- /dev/null
+++ b/app/assets/stylesheets/things.css
@@ -0,0 +1,94 @@
+/*
+ * Thing form.
+ */
+
+#thing_form {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+}
+
+@media only screen and (max-width: 720px) {
+ #thing_form {
+ grid-template-columns: 1fr;
+ }
+
+ #thing-delete-button {
+ width: 100%;
+ }
+}
+
+#thing_form input[type=text], input[type=number], input[type=date], select {
+ width: 100%;
+}
+
+#thing_form .thing_large {
+ grid-column: 1 / -1;
+}
+
+h5 a {
+ font-size: 1rem;
+}
+
+/*
+ * Listing things.
+ */
+
+.thing_row {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+}
+
+.thing_head {
+ flex-grow: 0;
+}
+
+.thing_body {
+ flex-grow: 1;
+}
+
+.thing_body a {
+ text-decoration: none;
+ color: var(--text) !important;
+}
+
+.thing_body a:hover {
+ color: var(--accent) !important;
+}
+
+.thing_tail {
+ flex-grow: 0;
+}
+
+.dot {
+ height: 8px;
+ width: 8px;
+ border-radius: 50%;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.dot_other {
+ background-color: black;
+}
+
+.dot_poetry {
+ background-color: #facc15;
+}
+
+.dot_theater {
+ background-color: #c084fc;
+}
+
+.dot_essay {
+ background-color: #ef4444;
+}
+
+.dot_shorts {
+ background-color: #a3e635;
+}
+
+.dot_novel {
+ background-color: #60a5fa;
+}
diff --git a/app/assets/stylesheets/utils.css b/app/assets/stylesheets/utils.css
new file mode 100644
index 0000000..335e595
--- /dev/null
+++ b/app/assets/stylesheets/utils.css
@@ -0,0 +1,24 @@
+.width-100 {
+ width: 100%;
+}
+
+.center-contents {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+}
+
+.flex-list {
+ display: flex;
+ align-items: start;
+ gap: 10px 20px;
+}
+
+.flex-wrap {
+ display: flex;
+ align-items: start;
+ flex-wrap: wrap;
+}
+
+.hidden {
+ display: none !important;
+}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
new file mode 100644
index 0000000..2a63b66
--- /dev/null
+++ b/app/controllers/application_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class ApplicationController < ActionController::Base
+ before_action :user_authenticated!
+ before_action :set_searches
+
+ helper_method :current_user
+
+ def current_user
+ @current_user ||= User.find(session[:user_id]) if session[:user_id]
+ end
+
+ def user_authenticated!
+ return if current_user
+
+ redirect_to new_sessions_path
+ end
+
+ def set_searches
+ @searches = Search.all
+ end
+end
diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb
new file mode 100644
index 0000000..d362460
--- /dev/null
+++ b/app/controllers/comments_controller.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+class CommentsController < ApplicationController
+ include Taggable
+
+ before_action :set_thing
+ before_action :set_comment, only: %i[update destroy]
+
+ def create
+ ps = comment_params
+ from = ps.delete(:from)
+ tag_ids = ps.delete(:tag_ids)
+
+ ok = ActiveRecord::Base.transaction do
+ comment = @thing.comments.create(ps)
+ next unless comment
+
+ update_tags!(object: comment, ids: tag_ids)
+ end
+
+ flash[:alert] = t('comments.bad-create') unless ok
+ handle_from_param!(from:)
+ end
+
+ def update
+ ps = comment_params
+ from = ps.delete(:from)
+ tag_ids = ps.delete(:tag_ids)
+
+ ok = ActiveRecord::Base.transaction do
+ next unless @comment.update(ps)
+
+ update_tags!(object: @comment, ids: tag_ids)
+ end
+
+ flash[:alert] = t('comments.bad-update') unless ok
+ handle_from_param!(from:)
+ end
+
+ def destroy
+ comment = Comment.find(params[:id])
+ comment.destroy
+
+ handle_from_param!(from: params[:from])
+ end
+
+ protected
+
+ def comment_params
+ params.required(:comment).permit(:content, :from, tag_ids: [])
+ end
+
+ def set_thing
+ @thing = Thing.find(params[:thing_id])
+ end
+
+ def set_comment
+ @comment = Comment.find(params[:id])
+ end
+
+ def handle_from_param!(from:)
+ if from == 'things/edit'
+ redirect_to edit_thing_path(@thing)
+ else
+ redirect_to @thing
+ end
+ end
+end
diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/controllers/concerns/.keep
diff --git a/app/controllers/concerns/taggable.rb b/app/controllers/concerns/taggable.rb
new file mode 100644
index 0000000..a9bda12
--- /dev/null
+++ b/app/controllers/concerns/taggable.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+# This module provides methods for controllers that have to deal with tag
+# references (i.e. comments and things). This is closely tied to `TagReference`
+# and its polymorphic `:taggable` column.
+module Taggable
+ extend ActiveSupport::Concern
+
+ # Update tag references for the given `object` with tags identified by the
+ # `ids` array.
+ def update_tags!(object:, ids:)
+ object.tag_references.destroy_all
+ return true unless ids
+
+ ids.each { |id| TagReference.create!(tag_id: id, taggable: object) }
+ end
+end
diff --git a/app/controllers/exports_controller.rb b/app/controllers/exports_controller.rb
new file mode 100644
index 0000000..4303ea6
--- /dev/null
+++ b/app/controllers/exports_controller.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+class ExportsController < ApplicationController
+ before_action :set_search
+ before_action :set_results, only: %i[show]
+
+ def show
+ respond_to do |format|
+ format.csv { set_file_name!(ext: 'csv') }
+ format.uoc { set_file_name!(ext: 'tex') }
+ end
+ end
+
+ def new; end
+
+ protected
+
+ # Sets the file name for the attachment according to the @search's name, and
+ # it adds the given string `ext` as the extension.
+ def set_file_name!(ext:)
+ response.headers['Content-Disposition'] = "attachment; filename=\"#{@search.name}.#{ext}\""
+ end
+
+ # Set @search. If there was no `:search_id` parameter, then we assume that the
+ # ID = 0, which is the fake search we use for the base 'Home' (i.e. get
+ # everything).
+ def set_search
+ @search = if params[:search_id].to_i.zero?
+ Search.new(id: 0, name: 'Home')
+ else
+ Search.find(params[:search_id])
+ end
+ end
+
+ def set_results
+ @results = @search.results.fetch(:things, [])
+ end
+end
diff --git a/app/controllers/licenses_controller.rb b/app/controllers/licenses_controller.rb
new file mode 100644
index 0000000..03b6087
--- /dev/null
+++ b/app/controllers/licenses_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class LicensesController < ApplicationController
+ skip_before_action :user_authenticated!, only: %i[show]
+
+ def show; end
+end
diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb
new file mode 100644
index 0000000..752ccec
--- /dev/null
+++ b/app/controllers/searches_controller.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class SearchesController < ApplicationController
+ before_action :set_search, only: %i[show edit update destroy]
+
+ def index
+ current_user.update!(last_search_id: nil)
+ @search = Search.new
+ end
+
+ def show
+ current_user.update!(last_search_id: @search.id)
+ end
+
+ def new
+ @search = Search.new
+ end
+
+ def edit; end
+
+ def create
+ @search = Search.new(search_params)
+ @search.user_id = @current_user.id
+
+ if @search.save
+ redirect_to @search
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ if @search.update(search_params)
+ redirect_to @search, status: :see_other
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @search.destroy!
+ redirect_to searches_url, status: :see_other
+ end
+
+ def search
+ @search = Search.new
+ @search.body = params.permit(:body).fetch(:body, '')
+ render 'search', partial: true, locals: { items: @search.results }
+ end
+
+ protected
+
+ def set_search
+ @search = Search.find(params[:id])
+ end
+
+ def search_params
+ params.require(:search).permit(:name, :body, :shared)
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 0000000..b79e3dd
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# SessionsController holds the logic for logging in and out users.
+class SessionsController < ApplicationController
+ skip_before_action :user_authenticated!, only: %i[new create]
+ skip_before_action :set_searches, only: %i[new create]
+
+ # We only need to render its template.
+ def new; end
+
+ # Create a session for the current user.
+ def create
+ user = User.find_by(username: params[:username])
+ if user&.authenticate(params[:password])
+ session[:user_id] = user.id
+ redirect_to root_url
+ else
+ redirect_to root_url, alert: t('sessions.wrong-credentials')
+ end
+ end
+
+ # Destroy the current session in order to log out.
+ def destroy
+ session[:user_id] = nil
+ redirect_to root_url
+ end
+end
diff --git a/app/controllers/shared_searches_controller.rb b/app/controllers/shared_searches_controller.rb
new file mode 100644
index 0000000..3208e7c
--- /dev/null
+++ b/app/controllers/shared_searches_controller.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class SharedSearchesController < ApplicationController
+ skip_before_action :user_authenticated!, only: %i[show]
+
+ def index
+ @searches = Search.where(shared: true)
+ end
+
+ def show
+ @search = Search.find(params[:search_id])
+ end
+end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
new file mode 100644
index 0000000..d967185
--- /dev/null
+++ b/app/controllers/tags_controller.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class TagsController < ApplicationController
+ before_action :set_tag, only: %i[destroy]
+
+ def index
+ @tags = Tag.all
+ end
+
+ def new
+ @tag = Tag.new
+ end
+
+ def create
+ @tag = Tag.new(tag_params)
+
+ if @tag.save
+ redirect_to tags_url, notice: t('tags.create-success')
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @tag.destroy!
+
+ redirect_to tags_url, notice: t('tags.destroy-success')
+ end
+
+ private
+
+ def set_tag
+ @tag = Tag.find(params[:id])
+ end
+
+ def tag_params
+ params.require(:tag).permit(:name)
+ end
+end
diff --git a/app/controllers/things_controller.rb b/app/controllers/things_controller.rb
new file mode 100644
index 0000000..450b409
--- /dev/null
+++ b/app/controllers/things_controller.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class ThingsController < ApplicationController
+ include Taggable
+
+ before_action :set_thing, only: %i[edit show update destroy]
+
+ def show; end
+
+ def new
+ @thing = Thing.new
+ end
+
+ def edit; end
+
+ def create
+ ps = thing_params
+ tag_ids = ps.delete(:tag_ids)
+ @thing = Thing.new(ps)
+ @thing.user_id = @current_user.id
+
+ updated = ActiveRecord::Base.transaction do
+ next unless @thing.save
+
+ update_tags!(object: @thing, ids: tag_ids)
+ end
+
+ if updated
+ redirect_to thing_url(@thing.reload), notice: t('things.create-success')
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ ps = thing_params
+ tag_ids = ps.delete(:tag_ids)
+
+ updated = ActiveRecord::Base.transaction do
+ next unless @thing.update(ps)
+
+ update_tags!(object: @thing, ids: tag_ids)
+ end
+
+ if updated
+ redirect_to edit_thing_url(@thing), notice: t('things.update-success')
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @thing.destroy!
+
+ redirect_to root_url, notice: t('things.destroy-success')
+ end
+
+ private
+
+ def set_thing
+ @thing = Thing.find(params[:id])
+ end
+
+ def thing_params
+ params.require(:thing).permit(:target, :title, :publisher, :address, :year, :url, :access,
+ :authors, :location, :insideof, :pages, :rate, :status, :kind,
+ :bought_at, :editors, :note, :where_is_it, tag_ids: [])
+ end
+end
diff --git a/app/helpers/shared_searches_helper.rb b/app/helpers/shared_searches_helper.rb
new file mode 100644
index 0000000..dbbc67d
--- /dev/null
+++ b/app/helpers/shared_searches_helper.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module SharedSearchesHelper
+ # Returns `arg` as is if not blank, otherwise it returns '-'.
+ def string_maybe(arg)
+ arg.presence || '-'
+ end
+
+ # Format `ae.authors` depending on whether they are editors or not.
+ def authors_or_editors(ae)
+ if ae.editors
+ "#{ae.authors} (eds.)"
+ else
+ ae.authors
+ end
+ end
+end
diff --git a/app/javascript/application.js b/app/javascript/application.js
new file mode 100644
index 0000000..9ae56c5
--- /dev/null
+++ b/app/javascript/application.js
@@ -0,0 +1,6 @@
+// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
+import "@hotwired/turbo-rails"
+import "controllers"
+
+import "trix"
+import "@rails/actiontext"
diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js
new file mode 100644
index 0000000..1213e85
--- /dev/null
+++ b/app/javascript/controllers/application.js
@@ -0,0 +1,9 @@
+import { Application } from "@hotwired/stimulus"
+
+const application = Application.start()
+
+// Configure Stimulus development experience
+application.debug = false
+window.Stimulus = application
+
+export { application }
diff --git a/app/javascript/controllers/comment_controller.js b/app/javascript/controllers/comment_controller.js
new file mode 100644
index 0000000..22167c8
--- /dev/null
+++ b/app/javascript/controllers/comment_controller.js
@@ -0,0 +1,36 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Controller that handles actions for adding some dynamism like showing/hiding
+// comments.
+export default class extends Controller {
+ // Cancel the current operation for comments.
+ toggle(event) {
+ event.preventDefault();
+
+ const parent = event.target.closest('.comment');
+ const regl_body = parent.getElementsByClassName('comment-body');
+ const edit_body = parent.getElementsByClassName('comment-edit-body');
+ if (regl_body.length !== 1 || edit_body.length !== 1) {
+ return;
+ }
+
+ regl_body[0].classList.toggle('hidden');
+ edit_body[0].classList.toggle('hidden');
+ }
+
+ // Hide/show the "New comment" button & form properly, while also focusing on
+ // the text area when shown.
+ toggleNewComment(event) {
+ event.preventDefault();
+
+ document.getElementById("comment-new-button").classList.toggle('hidden');
+
+ let body = document.getElementById("comment-new-body");
+ body.classList.toggle('hidden');
+
+ // BUG (minor): this is not working! (bug in Trix?)
+ if (!body.classList.contains("hidden")) {
+ document.getElementById("comment_content").focus();
+ }
+ }
+}
diff --git a/app/javascript/controllers/export_controller.js b/app/javascript/controllers/export_controller.js
new file mode 100644
index 0000000..1807799
--- /dev/null
+++ b/app/javascript/controllers/export_controller.js
@@ -0,0 +1,19 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Controller used in the exports#new page. That is, it allows us to change the
+// format to be picked up by the exporter.
+export default class extends Controller {
+ static targets = ["id"]
+
+ connect() {
+ const url = `/searches/${this.idTarget.value}/exports.csv`;
+ document.getElementById("export-format-button").setAttribute('href', url);
+ }
+
+ // Change the format to be used.
+ change() {
+ const format = document.getElementById("export-select-format").value;
+ const url = `/searches/${this.idTarget.value}/exports.${format}`;
+ document.getElementById("export-format-button").setAttribute('href', url);
+ }
+}
diff --git a/app/javascript/controllers/header_controller.js b/app/javascript/controllers/header_controller.js
new file mode 100644
index 0000000..ceb53a6
--- /dev/null
+++ b/app/javascript/controllers/header_controller.js
@@ -0,0 +1,21 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Controller being used by the top application header. It allows the user to
+// show/hide the hidden menu with the different options.
+export default class extends Controller {
+ // Hide/show the hidden navigation menu from the header.
+ toggle(event) {
+ event.preventDefault();
+
+ // Toggle the up/down arrow.
+ const el = document.getElementById("header-chevron");
+ if (el.classList[0] === "gg-chevron-down") {
+ el.classList.replace("gg-chevron-down", "gg-chevron-up")
+ } else {
+ el.classList.replace("gg-chevron-up", "gg-chevron-down")
+ }
+
+ // Hide/show the hidden navigation menu.
+ document.getElementById("hidden-nav").classList.toggle('hidden');
+ }
+}
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
new file mode 100644
index 0000000..54ad4ca
--- /dev/null
+++ b/app/javascript/controllers/index.js
@@ -0,0 +1,11 @@
+// Import and register all your controllers from the importmap under controllers/*
+
+import { application } from "controllers/application"
+
+// Eager load all controllers defined in the import map under controllers/**/*_controller
+import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
+eagerLoadControllersFrom("controllers", application)
+
+// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
+// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
+// lazyLoadControllersFrom("controllers", application)
diff --git a/app/javascript/controllers/search_controller.js b/app/javascript/controllers/search_controller.js
new file mode 100644
index 0000000..b35593a
--- /dev/null
+++ b/app/javascript/controllers/search_controller.js
@@ -0,0 +1,36 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Controller that dynamically fetches search results from the body as
+// introduced on an input. Then it fills the `results` elements with them.
+export default class extends Controller {
+ static targets = ["body"]
+
+ connect() {
+ document.getElementById("search_body").focus();
+ }
+
+ load(event) {
+ event.preventDefault();
+
+ try {
+ fetch("/searches/search?" + new URLSearchParams({body: this.bodyTarget.value}))
+ .then(response => response.text())
+ .then(html => {
+ document.getElementById("results").innerHTML = html;
+ document.getElementById("save-search").classList.toggle("hidden");
+ })
+ } catch (error) {
+ document.getElementById("results").innerHTML = '<div class="notice">Something went wrong!</div>'
+ }
+ }
+
+ save(event) {
+ event.preventDefault();
+
+ document.getElementById("search-form-submit").classList.toggle("hidden");
+ document.getElementById("save-search").classList.toggle("hidden");
+ document.getElementById("search-form-name").classList.toggle("hidden");
+ document.getElementById("search-form-shared").classList.toggle("hidden");
+ document.getElementById("search_name").focus();
+ }
+}
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
new file mode 100644
index 0000000..08dc537
--- /dev/null
+++ b/app/models/application_record.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ApplicationRecord < ActiveRecord::Base
+ primary_abstract_class
+end
diff --git a/app/models/comment.rb b/app/models/comment.rb
new file mode 100644
index 0000000..deb6ad7
--- /dev/null
+++ b/app/models/comment.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Comment < ApplicationRecord
+ validates :content, presence: true
+
+ belongs_to :thing
+
+ has_many :tag_references, as: :taggable, dependent: :destroy
+ has_many :tags, through: :tag_references
+
+ has_rich_text :content
+
+ # Returns all comments which have a content matching the given text.
+ def self.like(text:)
+ Comment.joins(:rich_text_content)
+ .where('action_text_rich_texts.body LIKE ?', "%#{text}%")
+ end
+end
diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/app/models/concerns/.keep
diff --git a/app/models/search.rb b/app/models/search.rb
new file mode 100644
index 0000000..81a1c34
--- /dev/null
+++ b/app/models/search.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+class Search < ApplicationRecord
+ validates :name, presence: true, uniqueness: true
+ validates :body, presence: true, uniqueness: true
+
+ belongs_to :user
+
+ # Returns all the results that can be fetched with the current `body`. It's
+ # returned into a hash which groups the taggable types.
+ def results
+ # This can happen as part of an empty Search.new instance. That is, it's
+ # still invalid, but it's the object being initialized for the default
+ # search (i.e. just show me everything).
+ return { things: Thing.order('rate DESC, created_at'), comments: [] } if body.blank?
+
+ # Parse the body of the search, and return early if nothing was able to be
+ # parsed (e.g. user wrote unknown identifiers).
+ parsed = parse_body
+ return { things: [], comments: [] } if parsed.all? { |_, v| v.blank? }
+
+ # And add everything into the `res` hash with the results.
+ res = find_by_text(plain: parsed[:plain])
+ find_by_tags(res:, tags: parsed[:tag])
+ end
+
+ protected
+
+ # Returns a hash which contains the tags that has been specified (`tag`) and
+ # the plain text (`plain`) to check on the different fields.
+ def parse_body
+ res = { tag: [], plain: [] }
+
+ body.split.each do |part|
+ if part.include?(':')
+ parts = part.split(':', 2)
+ parts[1] = clean_clause(part: parts[1])
+
+ next if parts[0] != 'tag'
+
+ res[parts[0].to_sym] << parts[1]
+ else
+ res[:plain] << part
+ end
+ end
+
+ res
+ end
+
+ # Returns the argument without any leading/trailing quotes.
+ def clean_clause(part:)
+ part.gsub(/^("|')+/, '').gsub(/("|')+$/, '')
+ end
+
+ # Returns a hash which groups into taggable types the results by looking the
+ # `plain` text into different fields.
+ def find_by_text(plain:)
+ res = { things: [], comments: [] }
+
+ plain.each do |text|
+ res[:things] = if res[:things].any?
+ res[:things].and(Thing.like(text:)).order('rate DESC, created_at')
+ else
+ Thing.like(text:).order('rate DESC, created_at')
+ end
+ res[:comments] = if res[:comments].any?
+ res[:comments].and(Comment.like(text:)).order(:created_at)
+ else
+ Comment.like(text:).order(:created_at)
+ end
+ end
+
+ res
+ end
+
+ # Returns a hash which groups into taggable types the results by matching the
+ # given `tags` which their references. It expects `res` to be already
+ # initialized by `find_by_text` (yeah, great design, I know), which is also
+ # the structure that will be returned.
+ def find_by_tags(res:, tags:)
+ return res if tags.blank?
+
+ tags = tags.map { |name| Tag.find_by(name:) }
+
+ query = TagReference.where(tag: tags, taggable_type: 'Thing')
+ query = query.where(taggable_id: res[:things].pluck(:id)) if res[:things].present?
+ res[:things] = query.group(:taggable_id)
+ .having('count(taggable_id) = ?', tags.size)
+ .map(&:taggable)
+
+ query = TagReference.where(tag: tags, taggable_type: 'Comment')
+ query = query.where(taggable_id: res[:comments].pluck(:id)) if res[:comments].present?
+ res[:comments] = query.group(:taggable_id)
+ .having('count(taggable_id) = ?', tags.size)
+ .map(&:taggable)
+
+ res
+ end
+end
diff --git a/app/models/tag.rb b/app/models/tag.rb
new file mode 100644
index 0000000..1f359d6
--- /dev/null
+++ b/app/models/tag.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Tag < ApplicationRecord
+ validates :name, presence: true, uniqueness: true
+
+ has_many :tag_references, dependent: :destroy
+end
diff --git a/app/models/tag_reference.rb b/app/models/tag_reference.rb
new file mode 100644
index 0000000..bd72267
--- /dev/null
+++ b/app/models/tag_reference.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class TagReference < ApplicationRecord
+ belongs_to :tag
+ belongs_to :taggable, polymorphic: true
+end
diff --git a/app/models/thing.rb b/app/models/thing.rb
new file mode 100644
index 0000000..5c758fb
--- /dev/null
+++ b/app/models/thing.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class Thing < ApplicationRecord
+ validates :title, presence: true, uniqueness: true
+ validates :target, presence: true, uniqueness: true
+ validates :authors, presence: true
+ validates :rate, numericality: { in: 0..10 }
+
+ belongs_to :user
+
+ has_many :comments, dependent: :destroy
+ has_many :tag_references, as: :taggable, dependent: :destroy
+ has_many :tags, through: :tag_references
+
+ enum :status, %i[read notread tobepublished], validate: true
+ enum :kind, %i[other poetry theater essay shorts novel paper], validate: true
+
+ # Returns all things which match the given text for any of the string columns
+ # from the table.
+ def self.like(text:)
+ Thing.where('target LIKE ?', "%#{text}%")
+ .or(where('title LIKE ?', "%#{text}%"))
+ .or(where('publisher LIKE ?', "%#{text}%"))
+ .or(where('address LIKE ?', "%#{text}%"))
+ .or(where('url LIKE ?', "%#{text}%"))
+ .or(where('location LIKE ?', "%#{text}%"))
+ .or(where('insideof LIKE ?', "%#{text}%"))
+ .or(where('pages LIKE ?', "%#{text}%"))
+ .or(where('note LIKE ?', "%#{text}%"))
+ .or(where('authors LIKE ?', "%#{text}%"))
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..7eaba55
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class User < ApplicationRecord
+ has_secure_password
+
+ validates :username, presence: true, uniqueness: true
+ validates :password, presence: true, on: :create
+ validates :password_confirmation, presence: true, on: :create
+end
diff --git a/app/views/active_storage/blobs/_blob.html.erb b/app/views/active_storage/blobs/_blob.html.erb
new file mode 100644
index 0000000..49ba357
--- /dev/null
+++ b/app/views/active_storage/blobs/_blob.html.erb
@@ -0,0 +1,14 @@
+<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
+ <% if blob.representable? %>
+ <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
+ <% end %>
+
+ <figcaption class="attachment__caption">
+ <% if caption = blob.try(:caption) %>
+ <%= caption %>
+ <% else %>
+ <span class="attachment__name"><%= blob.filename %></span>
+ <span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
+ <% end %>
+ </figcaption>
+</figure>
diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb
new file mode 100644
index 0000000..8c0975e
--- /dev/null
+++ b/app/views/comments/_comment.html.erb
@@ -0,0 +1,42 @@
+<div id="<%= dom_id(comment) %>" class="comment">
+ <div class="comment-header">
+ <div class="comment-header-title">
+ <% if defined?(idx) %>
+ <span title="Commented on <%= comment.created_at %>"><b><%= I18n.t('comments.title') %></b> #<%= idx + 1 %></span>
+ <% else %>
+ <span title="Commented on <%= comment.created_at %>"><b><%= I18n.t('comments.title') %></b> <%= I18n.t('general.in') %> <%= link_to comment.thing.title, thing_path(comment.thing) %></span>
+ <% end %>
+ </div>
+
+ <div class="comment-header-actions">
+ <% if defined?(from) %>
+ <a title="<%= I18n.t('comments.edit-title') %>" href="#" class="edit-comment" data-action="comment#toggle"><%= I18n.t('general.edit').capitalize %></a>
+ <%= link_to I18n.t('general.delete').capitalize, thing_comment_path(comment.thing, comment, from: from), data: { "turbo-method": :delete }, title: I18n.t('comments.delete-title'), class: 'delete-comment' %>
+ <% end %>
+ </div>
+ </div>
+
+ <div class="comment-body">
+ <%= comment.content %>
+ </div>
+
+ <% if comment.tag_references.any? %>
+ <div class="comment-footer">
+ <small><b><%= I18n.t('tags.title') %></b>: <%= comment.tags.order(:name).pluck(:name).join(", ") %></small>
+ </div>
+ <% end %>
+
+ <% if defined?(from) %>
+ <div class="comment-edit-body hidden">
+ <%= form_with model: [comment.thing, comment] do |form| %>
+ <%= form.hidden_field :from, value: from %>
+ <%= form.rich_text_area :content, autofocus: true %>
+
+ <%= render "tags/taggable", tag_references: comment.tag_references.pluck(:tag_id), form: form %>
+
+ <%= form.submit %>
+ <span><%= I18n.t('general.or') %> <a title="Cancel editing of comment" href="#" data-action="comment#toggle"><%= I18n.t('general.cancel') %></a></span>
+ <% end %>
+ </div>
+ <% end %>
+</div>
diff --git a/app/views/comments/_list.html.erb b/app/views/comments/_list.html.erb
new file mode 100644
index 0000000..2396140
--- /dev/null
+++ b/app/views/comments/_list.html.erb
@@ -0,0 +1,30 @@
+<div id="comment-list" data-controller="comment">
+ <h5><%= I18n.t('comments.title-plural') %></h5>
+
+ <div>
+ <% if thing.comments.any? %>
+ <% thing.comments.each_with_index do |comment, idx| %>
+ <%= render comment, from: from, idx: idx %>
+ <% end %>
+ <% else %>
+ <p><%= I18n.t('comments.none') %>.</p>
+ <% end %>
+ </div>
+
+ <div id="comment-new-section">
+ <a id="comment-new-button" class="button" href="#" data-action="comment#toggleNewComment"><%= I18n.t('comments.new') %></a>
+
+ <div id="comment-new-body" class="hidden">
+ <%= form_with model: [thing, Comment.new] do |form| %>
+ <%= form.rich_text_area :content, size: "20x5", autofocus: true %>
+
+ <%= form.hidden_field :from, value: from %>
+
+ <%= render "tags/taggable", tag_references: [], form: form %>
+
+ <%= form.submit %>
+ <span><%= I18n.t('general.or') %> <a title="<%= I18n.t('general.cancel') %>" href="#" data-action="comment#toggleNewComment"><%= I18n.t('general.cancel') %></a></span>
+ <% end %>
+ </div>
+ </div>
+</div>
diff --git a/app/views/exports/new.html.erb b/app/views/exports/new.html.erb
new file mode 100644
index 0000000..73481a7
--- /dev/null
+++ b/app/views/exports/new.html.erb
@@ -0,0 +1,14 @@
+<div class="notice"><b><%= I18n.t('searches.export.note') %></b>: <%= I18n.t('searches.export.note-msg') %>.</div>
+
+<div data-controller="export">
+ <div id="errors"></div>
+
+ <input type="hidden" data-export-target="id" value="<%= @search.id %>" />
+
+ <select id="export-select-format" data-action="export#change">
+ <option selected="selected" value="csv">CSV</option>
+ <option value="uoc">UOC</option>
+ </select>
+
+ <a id="export-format-button" class="button" href="#">Export</a>
+</div>
diff --git a/app/views/exports/show.csv.erb b/app/views/exports/show.csv.erb
new file mode 100644
index 0000000..43c7b82
--- /dev/null
+++ b/app/views/exports/show.csv.erb
@@ -0,0 +1 @@
+<%= CsvExporter.new(things: @results).export.html_safe %>
diff --git a/app/views/exports/show.uoc.erb b/app/views/exports/show.uoc.erb
new file mode 100644
index 0000000..b8f73b1
--- /dev/null
+++ b/app/views/exports/show.uoc.erb
@@ -0,0 +1,7 @@
+\chapter{<%= I18n.t('searches.export.chapter') %>}
+
+{\setlength{\parskip}{-0.3cm}
+
+<%= UocExporter.new(things: @results.order(:authors)).export %>
+
+}
diff --git a/app/views/layouts/_errors.html.erb b/app/views/layouts/_errors.html.erb
new file mode 100644
index 0000000..9e5ce38
--- /dev/null
+++ b/app/views/layouts/_errors.html.erb
@@ -0,0 +1,13 @@
+<% if model.errors.size.positive? %>
+ <div class="notice">
+ <% if model.errors.size == 1 %>
+ <span><%= model.errors.first.full_message %>.</span>
+ <% else %>
+ <ul>
+ <% model.errors.each do |error| %>
+ <li><%= error.full_message %>.</li>
+ <% end %>
+ </ul>
+ <% end %>
+ </div>
+<% end %>
diff --git a/app/views/layouts/action_text/contents/_content.html.erb b/app/views/layouts/action_text/contents/_content.html.erb
new file mode 100644
index 0000000..9e3c0d0
--- /dev/null
+++ b/app/views/layouts/action_text/contents/_content.html.erb
@@ -0,0 +1,3 @@
+<div class="trix-content">
+ <%= yield -%>
+</div>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
new file mode 100644
index 0000000..6042f43
--- /dev/null
+++ b/app/views/layouts/application.html.erb
@@ -0,0 +1,78 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title><%= ENV.fetch('OPERUM_BASE_TITLE', 'Operum') %></title>
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
+ <%= javascript_importmap_tags %>
+ </head>
+
+ <body>
+ <% if current_user && @searches %>
+ <header data-controller="header">
+ <nav>
+ <a class="<%= 'current' if current_user.last_search_id.nil? %>" href="/"><%= I18n.t('searches.home') %></a>
+ <% Search.find_each do |s| %>
+ <a class="<%= 'current' if current_user.last_search_id == s.id %>" href="<%= search_path(s) %>"><%= s.name %></a>
+ <% end %>
+ <a id="toggle-hidden-global-menu" href="#" data-action="header#toggle"><i id="header-chevron" class="gg-chevron-down"></i></a>
+ </nav>
+
+ <div id="hidden-nav" class="hidden">
+ <div class="flex-wrap">
+ <span><%= I18n.t('general.create').capitalize %>: </span>
+ <%= link_to I18n.t('searches.object'), new_search_path %>
+ <%= link_to I18n.t('things.object'), new_thing_path %>
+ </div>
+
+ <div class="flex-wrap">
+ <span><%= I18n.t('searches.title') %>: </span>
+ <% if @search&.id %>
+ <%= link_to I18n.t('general.edit'), edit_search_path(@search) %>
+ <%= link_to I18n.t('general.delete'), search_path(@search), data: { turbo_confirm: I18n.t('general.sure'), turbo_method: :delete } %>
+ <% end %>
+
+ <%= link_to I18n.t('searches.export.action'), new_search_exports_path(current_user.last_search_id ? current_user.last_search_id : '0') %>
+ </div>
+
+ <div class="flex-wrap">
+ <span><%= I18n.t('general.list').capitalize %>: </span>
+ <%= link_to I18n.t('tags.title').downcase, tags_path %>
+ <%= link_to I18n.t('searches.shared.title').downcase, shared_searches_path %>
+ </div>
+ </div>
+ </header>
+ <% end %>
+
+ <main>
+ <% if flash[:notice] %>
+ <div class="notice">
+ <span><%= flash[:notice] %>.</span>
+ </div>
+ <% end %>
+
+ <% if flash[:alert] %>
+ <div class="notice">
+ <span><%= flash[:alert] %>.</span>
+ </div>
+ <% end %>
+
+ <%= yield %>
+ </main>
+
+ <footer>
+ <p>
+ <%= I18n.t('layout.created-by') %> <a href="http://jo.mssola.com/">Miquel Sabaté Solà</a>
+ <br>
+ <%= I18n.t('layout.this-page-license') %> <a href="/license">AGPLv3</a> (<a href="https://github.com/mssola/operum"><%= I18n.t('layout.source-code') %></a>)
+ <% if current_user %>
+ <br>
+ <%= link_to I18n.t('sessions.sign-out'), sessions_path, data: { turbo_method: :delete } %>
+ <% end %>
+ </p>
+ </footer>
+ </body>
+</html>
diff --git a/app/views/licenses/show.html.erb b/app/views/licenses/show.html.erb
new file mode 100644
index 0000000..7687d54
--- /dev/null
+++ b/app/views/licenses/show.html.erb
@@ -0,0 +1,23 @@
+<h3><%= t('license.title') %> <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPLv3</a></h3>
+
+<p><%= t('license.at') %> <a href="https://github.com/mssola/operum"><%= t('license.here') %></a>.</p>
+
+<blockquote>
+<pre>
+Copyright (C) 2023-Ω Miquel Sabaté Solà &lt;mikisabate@gmail.com&gt;
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;.
+</pre>
+
+</blockquote>
diff --git a/app/views/searches/_form.html.erb b/app/views/searches/_form.html.erb
new file mode 100644
index 0000000..c940fe2
--- /dev/null
+++ b/app/views/searches/_form.html.erb
@@ -0,0 +1,29 @@
+<div data-controller="search">
+ <%= form_with(model: search) do |form| %>
+ <%= render "layouts/errors", model: search %>
+
+ <div id="search_form">
+ <div id="search-form-name" class="hidden">
+ <%= form.label :name %>
+ <%= form.text_field :name, required: true, autocomplete: 'off' %>
+ </div>
+
+ <div>
+ <%= form.text_field :body, "data-action": "keydown.enter->search#load", "data-search-target": "body", placeholder: 'Search body' %>
+ <a id="save-search" href="#" data-action="search#save" class="hidden"><%= I18n.t('searches.save') %></a>
+ </div>
+
+ <div id="search-form-shared" class="hidden">
+ <%= form.check_box :shared %>
+ <%= form.label :shared, I18n.t('searches.public') %>
+ </div>
+
+ <div id="search-form-submit" class="hidden">
+ <%= form.submit %>
+ </div>
+ </div>
+ <% end %>
+
+ <div id="results">
+ </div>
+</div>
diff --git a/app/views/searches/_search.html.erb b/app/views/searches/_search.html.erb
new file mode 100644
index 0000000..a717a22
--- /dev/null
+++ b/app/views/searches/_search.html.erb
@@ -0,0 +1,5 @@
+<div id="items">
+ <% items.values.flatten.each do |item| %>
+ <%= render item %>
+ <% end %>
+</div>
diff --git a/app/views/searches/edit.html.erb b/app/views/searches/edit.html.erb
new file mode 100644
index 0000000..2702224
--- /dev/null
+++ b/app/views/searches/edit.html.erb
@@ -0,0 +1 @@
+<%= render "form", search: @search %>
diff --git a/app/views/searches/index.html.erb b/app/views/searches/index.html.erb
new file mode 100644
index 0000000..b7d3854
--- /dev/null
+++ b/app/views/searches/index.html.erb
@@ -0,0 +1 @@
+<%= render "search", items: @search.results %>
diff --git a/app/views/searches/new.html.erb b/app/views/searches/new.html.erb
new file mode 100644
index 0000000..2702224
--- /dev/null
+++ b/app/views/searches/new.html.erb
@@ -0,0 +1 @@
+<%= render "form", search: @search %>
diff --git a/app/views/searches/show.html.erb b/app/views/searches/show.html.erb
new file mode 100644
index 0000000..b7d3854
--- /dev/null
+++ b/app/views/searches/show.html.erb
@@ -0,0 +1 @@
+<%= render "search", items: @search.results %>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
new file mode 100644
index 0000000..c6d7f4f
--- /dev/null
+++ b/app/views/sessions/new.html.erb
@@ -0,0 +1,21 @@
+<div class="center-contents">
+ <div></div>
+ <div>
+ <h2><%= I18n.t('sessions.title') %></h2>
+
+ <%= form_tag sessions_path do |form| %>
+ <div class='field'>
+ <%= label_tag :username, I18n.t('activerecord.attributes.user.username') %>
+ <%= text_field_tag :username, '', autofocus: true, class: 'width-100' %>
+ </div>
+ <div class='field'>
+ <%= label_tag :password, I18n.t('activerecord.attributes.user.password') %>
+ <%= password_field_tag :password, '', class: 'width-100' %>
+ </div>
+ <div class='actions'>
+ <%= submit_tag I18n.t('sessions.sign-in') %>
+ </div>
+ <% end %>
+ </div>
+ <div></div>
+</div>
diff --git a/app/views/shared_searches/index.html.erb b/app/views/shared_searches/index.html.erb
new file mode 100644
index 0000000..9a145f6
--- /dev/null
+++ b/app/views/shared_searches/index.html.erb
@@ -0,0 +1,13 @@
+<h3><%= I18n.t('searches.shared.title') %></h3>
+
+<% if @searches.empty? %>
+ <div><span><%= I18n.t('searches.shared.none') %>.</span></div>
+<% else %>
+ <div id="shared-searches-list">
+ <% @searches.each do |s| %>
+ <div>
+ <%= link_to s.name, search_shared_path(s) %>
+ </div>
+ <% end %>
+ </div>
+<% end %>
diff --git a/app/views/shared_searches/show.html.erb b/app/views/shared_searches/show.html.erb
new file mode 100644
index 0000000..1430529
--- /dev/null
+++ b/app/views/shared_searches/show.html.erb
@@ -0,0 +1,28 @@
+<table id="public-search-table">
+ <% @search.results.values.flatten.each do |res| %>
+ <tr>
+ <td>
+ <details>
+ <% if res.is_a? Thing %>
+ <summary><%= res.title %></summary>
+ <div class="details-flex">
+ <div><span><b>Authors</b>: <%= authors_or_editors(res) %></span></div>
+ <div><span><b>Publisher</b>: <%= string_maybe(res.publisher) %></span></div>
+ <div><span><b>Year</b>: <%= string_maybe(res.year) %></span></div>
+ <div><span><b>Kind</b>: <%= res.kind %></span></div>
+ <div><span><b>Note</b>: <%= string_maybe(res.note) %></span></div>
+ <% unless res.url.blank? %>
+ <div><span><b>URL</b>: <a href="<%= res.url %>" target="_blank"><%= res.url %></a></span></div>
+ <% end %>
+ </div>
+ <% else %>
+ <summary><b><%= I18n.t('comments.title') %></b> <%= I18n.t('general.in') %> «<i><%= res.thing.title %></i>»</summary>
+ <div class="details-flex">
+ <%= res.content %>
+ </div>
+ <% end %>
+ </details>
+ </td>
+ </tr>
+ <% end %>
+</table>
diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb
new file mode 100644
index 0000000..75fc003
--- /dev/null
+++ b/app/views/tags/_form.html.erb
@@ -0,0 +1,12 @@
+<%= form_with(model: tag) do |form| %>
+ <%= render "layouts/errors", model: tag %>
+
+ <div>
+ <%= form.label :name, style: "display: block" %>
+ <%= form.text_field :name, autofocus: true, autocomplete: 'off', class: 'width-100' %>
+ </div>
+
+ <div>
+ <%= form.submit %>
+ </div>
+<% end %>
diff --git a/app/views/tags/_tag.html.erb b/app/views/tags/_tag.html.erb
new file mode 100644
index 0000000..65c6d4e
--- /dev/null
+++ b/app/views/tags/_tag.html.erb
@@ -0,0 +1,8 @@
+<div class="tag-container" id="<%= dom_id tag %>">
+ <div class="name">
+ <%= tag.name %>
+ </div>
+ <div class="action">
+ <%= link_to I18n.t('general.delete'), tag_path(tag), data: { "turbo-method": :delete } %>
+ </div>
+</div>
diff --git a/app/views/tags/_taggable.html.erb b/app/views/tags/_taggable.html.erb
new file mode 100644
index 0000000..84e236b
--- /dev/null
+++ b/app/views/tags/_taggable.html.erb
@@ -0,0 +1,17 @@
+<% if Tag.any? %>
+ <div>
+ <h5><%= I18n.t('tags.title') %> &#8212; <%= link_to I18n.t('tags.new-action'), new_tag_path %></h5>
+
+ <div class="flex-list">
+ <% Tag.find_each do |tag| %>
+ <div class="flex-element">
+ <label>
+ <%= form.check_box :tag_ids, { multiple: true, checked: tag_references&.include?(tag.id) }, tag.id, nil %>
+ <%= tag.name %>
+ </label>
+ </div>
+ <% end %>
+ </div>
+ </div>
+ <p></p>
+<% end %>
diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb
new file mode 100644
index 0000000..329e069
--- /dev/null
+++ b/app/views/tags/index.html.erb
@@ -0,0 +1,13 @@
+<div class="center-contents">
+ <div></div>
+ <div id="tag-index">
+ <h2><%= I18n.t('tags.title') %> <span>&#8212; <%= link_to I18n.t('tags.new-action'), new_tag_path %></span></h2>
+
+ <div id="tags-list">
+ <% @tags.each do |tag| %>
+ <%= render tag %>
+ <% end %>
+ </div>
+ </div>
+ <div></div>
+</div>
diff --git a/app/views/tags/new.html.erb b/app/views/tags/new.html.erb
new file mode 100644
index 0000000..bd6c586
--- /dev/null
+++ b/app/views/tags/new.html.erb
@@ -0,0 +1,9 @@
+<div class="center-contents">
+ <div></div>
+ <div id="tag-new">
+ <h2><%= I18n.t('tags.new') %> <span>&#8212; <%= link_to I18n.t('general.back-to-list'), tags_path %></span></h2>
+
+ <%= render "form", tag: @tag %>
+ </div>
+ <div></div>
+</div>
diff --git a/app/views/things/_form.html.erb b/app/views/things/_form.html.erb
new file mode 100644
index 0000000..da90302
--- /dev/null
+++ b/app/views/things/_form.html.erb
@@ -0,0 +1,102 @@
+<%= form_with(model: thing) do |form| %>
+ <%= render "layouts/errors", model: thing %>
+
+ <div id="thing_form">
+ <div class="thing_large">
+ <%= form.label :title %>
+ <%= form.text_field :title, autofocus: true, required: true %>
+ </div>
+
+ <div class="thing_large">
+ <%= form.label :authors %>
+ <%= form.text_field :authors, required: true %>
+ <%= form.check_box :editors %>
+ <%= form.label :editors %>
+ </div>
+
+ <div class="thing_large">
+ <%= form.label :note %>
+ <%= form.text_field :note %>
+ </div>
+
+ <div>
+ <%= form.label :target %>
+ <%= form.text_field :target, required: true %>
+ </div>
+
+ <div>
+ <%= form.label :publisher %>
+ <%= form.text_field :publisher %>
+ </div>
+
+ <div>
+ <%= form.label :year %>
+ <%= form.number_field :year %>
+ </div>
+
+ <div>
+ <%= form.label :address %>
+ <%= form.text_field :address %>
+ </div>
+
+ <div>
+ <%= form.label :url %>
+ <%= form.text_field :url %>
+ </div>
+
+ <div>
+ <%= form.label :access %>
+ <%= form.date_field :access %>
+ </div>
+
+ <div>
+ <%= form.label :location %>
+ <%= form.text_field :location %>
+ </div>
+
+ <div>
+ <%= form.label :insideof %>
+ <%= form.text_field :insideof %>
+ </div>
+
+ <div>
+ <%= form.label :pages %>
+ <%= form.text_field :pages %>
+ </div>
+
+ <div>
+ <%= form.label :rate %>
+ <%= form.number_field :rate, min: 0, max: 10, required: true %>
+ </div>
+
+ <div>
+ <%= form.label :status %>
+ <%= form.select :status, options_for_select(Thing.statuses.map { |k, _| [I18n.t("things.status.#{k}"), k] }.sort, thing.status) %>
+ </div>
+
+ <div>
+ <%= form.label :kind %>
+ <%= form.select :kind, options_for_select(Thing.kinds.map { |k, _| [I18n.t("things.kind.#{k}"), k] }.sort, thing.kind) %>
+ </div>
+
+ <div>
+ <%= form.label :bought_at %>
+ <%= form.date_field :bought_at %>
+ </div>
+
+ <div>
+ <%= form.label :where_is_it %>
+ <%= form.text_field :where_is_it %>
+ </div>
+ </div>
+
+ <%= render "tags/taggable", tag_references: thing.tag_references.pluck(:tag_id), form: form %>
+
+ <div>
+ <%= form.submit %>
+ </div>
+<% end %>
+
+<% if thing.id %>
+ <%= button_to I18n.t('general.delete').capitalize, thing, id: 'thing-delete-button', form: { data: { turbo_confirm: I18n.t('general.sure') } }, method: :delete %>
+<% end %>
diff --git a/app/views/things/_thing.html.erb b/app/views/things/_thing.html.erb
new file mode 100644
index 0000000..5a31ccc
--- /dev/null
+++ b/app/views/things/_thing.html.erb
@@ -0,0 +1,7 @@
+<div id="<%= dom_id thing %>" class="thing_row">
+ <div class="thing_head">
+ <div class="dot dot_<%= thing.kind %>" title="<%= I18n.t("things.kind.#{thing.kind}") %>"></div>
+ </div>
+ <div class="thing_body"><%= link_to thing.title, edit_thing_path(thing) %></div>
+ <div class="thing_tail"><%= thing.rate %></div>
+</div>
diff --git a/app/views/things/edit.html.erb b/app/views/things/edit.html.erb
new file mode 100644
index 0000000..f30ec5c
--- /dev/null
+++ b/app/views/things/edit.html.erb
@@ -0,0 +1,3 @@
+<%= render "form", thing: @thing %>
+
+<%= render "comments/list", thing: @thing, from: 'things/edit' %>
diff --git a/app/views/things/new.html.erb b/app/views/things/new.html.erb
new file mode 100644
index 0000000..d28df14
--- /dev/null
+++ b/app/views/things/new.html.erb
@@ -0,0 +1 @@
+<%= render "form", thing: @thing %>
diff --git a/app/views/things/show.html.erb b/app/views/things/show.html.erb
new file mode 100644
index 0000000..c534e49
--- /dev/null
+++ b/app/views/things/show.html.erb
@@ -0,0 +1,7 @@
+<%= render "things/thing", thing: @thing %>
+
+<p>
+ <%= link_to I18n.t('things.create-another'), new_thing_path %>
+</p>
+
+<%= render "comments/list", thing: @thing, from: 'things/show' %>
diff --git a/bin/bundle b/bin/bundle
new file mode 100755
index 0000000..50da5fd
--- /dev/null
+++ b/bin/bundle
@@ -0,0 +1,109 @@
+#!/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
diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint
new file mode 100755
index 0000000..67ef493
--- /dev/null
+++ b/bin/docker-entrypoint
@@ -0,0 +1,8 @@
+#!/bin/bash -e
+
+# If running the rails server then create or migrate existing database
+if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then
+ ./bin/rails db:prepare
+fi
+
+exec "${@}"
diff --git a/bin/importmap b/bin/importmap
new file mode 100755
index 0000000..d423864
--- /dev/null
+++ b/bin/importmap
@@ -0,0 +1,5 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require_relative '../config/application'
+require 'importmap/commands'
diff --git a/bin/rails b/bin/rails
new file mode 100755
index 0000000..a31728a
--- /dev/null
+++ b/bin/rails
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+APP_PATH = File.expand_path('../config/application', __dir__)
+require_relative '../config/boot'
+require 'rails/commands'
diff --git a/bin/rake b/bin/rake
new file mode 100755
index 0000000..c199955
--- /dev/null
+++ b/bin/rake
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require_relative '../config/boot'
+require 'rake'
+Rake.application.run
diff --git a/bin/setup b/bin/setup
new file mode 100755
index 0000000..016b7e2
--- /dev/null
+++ b/bin/setup
@@ -0,0 +1,35 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require 'fileutils'
+
+# path to your application root.
+APP_ROOT = File.expand_path('..', __dir__)
+
+def system!(*)
+ system(*, exception: true)
+end
+
+FileUtils.chdir APP_ROOT do
+ # This script is a way to set up or update your development environment automatically.
+ # This script is idempotent, so that you can run it at any time and get an expectable outcome.
+ # Add necessary setup steps to this file.
+
+ puts '== Installing dependencies =='
+ system! 'gem install bundler --conservative'
+ system('bundle check') || system!('bundle install')
+
+ # puts "\n== Copying sample files =="
+ # unless File.exist?("config/database.yml")
+ # FileUtils.cp "config/database.yml.sample", "config/database.yml"
+ # end
+
+ puts "\n== Preparing database =="
+ system! 'bin/rails db:prepare'
+
+ puts "\n== Removing old logs and tempfiles =="
+ system! 'bin/rails log:clear tmp:clear'
+
+ puts "\n== Restarting application server =="
+ system! 'bin/rails restart'
+end
diff --git a/config.ru b/config.ru
new file mode 100644
index 0000000..6dc8321
--- /dev/null
+++ b/config.ru
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# This file is used by Rack-based servers to start the application.
+
+require_relative 'config/environment'
+
+run Rails.application
+Rails.application.load_server
diff --git a/config/application.rb b/config/application.rb
new file mode 100644
index 0000000..1c5d017
--- /dev/null
+++ b/config/application.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require_relative 'boot'
+
+require 'rails/all'
+
+# Require the gems listed in Gemfile, including any gems
+# you've limited to :test, :development, or :production.
+Bundler.require(*Rails.groups)
+
+module Operum
+ class Application < Rails::Application
+ # Initialize configuration defaults for originally generated Rails version.
+ config.load_defaults 7.1
+
+ # Please, add to the `ignore` list any other `lib` subdirectories that do
+ # not contain `.rb` files, or that should not be reloaded or eager loaded.
+ # Common ones are `templates`, `generators`, or `middleware`, for example.
+ config.autoload_lib(ignore: %w[assets tasks])
+
+ # Configuration for the application, engines, and railties goes here.
+ #
+ # These settings can be overridden in specific environments using the files
+ # in config/environments, which are processed later.
+ #
+ # config.time_zone = "Central Time (US & Canada)"
+ # config.eager_load_paths << Rails.root.join("extras")
+ end
+end
diff --git a/config/boot.rb b/config/boot.rb
new file mode 100644
index 0000000..c04863f
--- /dev/null
+++ b/config/boot.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
+
+require 'bundler/setup' # Set up gems listed in the Gemfile.
+require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
diff --git a/config/cable.yml b/config/cable.yml
new file mode 100644
index 0000000..8747fc1
--- /dev/null
+++ b/config/cable.yml
@@ -0,0 +1,10 @@
+development:
+ adapter: async
+
+test:
+ adapter: test
+
+production:
+ adapter: redis
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
+ channel_prefix: operum_production
diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc
new file mode 100644
index 0000000..9c7c0cb
--- /dev/null
+++ b/config/credentials.yml.enc
@@ -0,0 +1 @@
+pIht0KY2mizAwrCdTEzPaWNu6vu/NQpI564SwxPwhLNexvZyWSrZ7L0YcGGENLlIW5xBH9gwVZJHpupRi0G1DcdP0NgHqVUHYqxnFXf6ngp7L15t/rWd4691K3HWF/9rebrjRwB33joitCvQQ/fud8SwIZ5nEQEq1edJzZywAX/9L+eMuI3hkxyq0+APQMnuOXN2yK38qTTdG46hI3lkcWm+ZcfOXKDg0oscjExdGT6zVpNoIqgtIdYbT+s1DXPzsxLt+Z4T0eSByQjSgfEHmNK0zmPpFR6LSpe97MCkqDM00rotJBQQpNeXqPOP6omdzJKsgiCst/PCJMYk74NtX+iv1SY4WvCzu8ReqnwDZyJ4MAzlAGLMO5ENwcoidugPoo72daoIcGVCX7iUEF6DUb4CZYtf--1M9Bc6cWdSUi6Y8k--dp3GZYMgAU4gvlnD+FlEhQ== \ No newline at end of file
diff --git a/config/database.yml b/config/database.yml
new file mode 100644
index 0000000..796466b
--- /dev/null
+++ b/config/database.yml
@@ -0,0 +1,25 @@
+# SQLite. Versions 3.8.0 and up are supported.
+# gem install sqlite3
+#
+# Ensure the SQLite 3 gem is defined in your Gemfile
+# gem "sqlite3"
+#
+default: &default
+ adapter: sqlite3
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
+ timeout: 5000
+
+development:
+ <<: *default
+ database: storage/development.sqlite3
+
+# Warning: The database defined as "test" will be erased and
+# re-generated from your development database when you run "rake".
+# Do not set this db to the same as development or production.
+test:
+ <<: *default
+ database: storage/test.sqlite3
+
+production:
+ <<: *default
+ database: storage/production.sqlite3
diff --git a/config/environment.rb b/config/environment.rb
new file mode 100644
index 0000000..d5abe55
--- /dev/null
+++ b/config/environment.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Load the Rails application.
+require_relative 'application'
+
+# Initialize the Rails application.
+Rails.application.initialize!
diff --git a/config/environments/development.rb b/config/environments/development.rb
new file mode 100644
index 0000000..80e0587
--- /dev/null
+++ b/config/environments/development.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'active_support/core_ext/integer/time'
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # In the development environment your application's code is reloaded any time
+ # it changes. This slows down response time but is perfect for development
+ # since you don't have to restart the web server when you make code changes.
+ config.enable_reloading = true
+
+ # Do not eager load code on boot.
+ config.eager_load = false
+
+ # Show full error reports.
+ config.consider_all_requests_local = true
+
+ # Enable server timing
+ config.server_timing = true
+
+ # Enable/disable caching. By default caching is disabled.
+ # Run rails dev:cache to toggle caching.
+ if Rails.root.join('tmp/caching-dev.txt').exist?
+ config.action_controller.perform_caching = true
+ config.action_controller.enable_fragment_cache_logging = true
+
+ config.cache_store = :memory_store
+ config.public_file_server.headers = {
+ 'Cache-Control' => "public, max-age=#{2.days.to_i}"
+ }
+ else
+ config.action_controller.perform_caching = false
+
+ config.cache_store = :null_store
+ end
+
+ # Store uploaded files on the local file system (see config/storage.yml for options).
+ config.active_storage.service = :local
+
+ # Don't care if the mailer can't send.
+ config.action_mailer.raise_delivery_errors = false
+
+ config.action_mailer.perform_caching = false
+
+ # Print deprecation notices to the Rails logger.
+ config.active_support.deprecation = :log
+
+ # Raise exceptions for disallowed deprecations.
+ config.active_support.disallowed_deprecation = :raise
+
+ # Tell Active Support which deprecation messages to disallow.
+ config.active_support.disallowed_deprecation_warnings = []
+
+ # Raise an error on page load if there are pending migrations.
+ config.active_record.migration_error = :page_load
+
+ # Highlight code that triggered database queries in logs.
+ config.active_record.verbose_query_logs = true
+
+ # Highlight code that enqueued background job in logs.
+ config.active_job.verbose_enqueue_logs = true
+
+ # Suppress logger output for asset requests.
+ config.assets.quiet = true
+
+ # Raises error for missing translations.
+ # config.i18n.raise_on_missing_translations = true
+
+ # Annotate rendered view with file names.
+ # config.action_view.annotate_rendered_view_with_filenames = true
+
+ # Uncomment if you wish to allow Action Cable access from any origin.
+ # config.action_cable.disable_request_forgery_protection = true
+
+ # Raise error when a before_action's only/except options reference missing actions
+ config.action_controller.raise_on_missing_callback_actions = true
+end
diff --git a/config/environments/production.rb b/config/environments/production.rb
new file mode 100644
index 0000000..a9e4d6d
--- /dev/null
+++ b/config/environments/production.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require 'active_support/core_ext/integer/time'
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # Code is not reloaded between requests.
+ config.enable_reloading = false
+
+ # Eager load code on boot. This eager loads most of Rails and
+ # your application in memory, allowing both threaded web servers
+ # and those relying on copy on write to perform better.
+ # Rake tasks automatically ignore this option for performance.
+ config.eager_load = true
+
+ # Full error reports are disabled and caching is turned on.
+ config.consider_all_requests_local = false
+ config.action_controller.perform_caching = true
+
+ # Ensures that a master key has been made available in
+ # ENV["RAILS_MASTER_KEY"], config/master.key, or an environment key such as
+ # config/credentials/production.key. This key is used to decrypt credentials
+ # (and other encrypted files).
+ # config.require_master_key = true
+
+ # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead.
+ # config.public_file_server.enabled = false
+
+ # Compress CSS using a preprocessor.
+ # config.assets.css_compressor = :sass
+
+ # Do not fall back to assets pipeline if a precompiled asset is missed.
+ config.assets.compile = false
+
+ # Enable serving of images, stylesheets, and JavaScripts from an asset server.
+ # config.asset_host = "http://assets.example.com"
+
+ # Specifies the header that your server uses for sending files.
+ # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache
+ # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
+
+ # Store uploaded files on the local file system (see config/storage.yml for options).
+ config.active_storage.service = :local
+
+ # Mount Action Cable outside main process or domain.
+ # config.action_cable.mount_path = nil
+ # config.action_cable.url = "wss://example.com/cable"
+ # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ]
+
+ # Assume all access to the app is happening through a SSL-terminating reverse proxy.
+ # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.
+ # config.assume_ssl = true
+
+ # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
+ config.force_ssl = true
+
+ # Log to STDOUT by default
+ config.logger = ActiveSupport::Logger.new($stdout)
+ .tap { |logger| logger.formatter = Logger::Formatter.new }
+ .then { |logger| ActiveSupport::TaggedLogging.new(logger) }
+
+ # Prepend all log lines with the following tags.
+ config.log_tags = [:request_id]
+
+ # "info" includes generic and useful information about system operation, but
+ # avoids logging too much information to avoid inadvertent exposure of
+ # personally identifiable information (PII). If you want to log everything,
+ # set the level to "debug".
+ config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
+
+ # Use a different cache store in production.
+ # config.cache_store = :mem_cache_store
+
+ # Use a real queuing backend for Active Job (and separate queues per environment).
+ # config.active_job.queue_adapter = :resque
+ # config.active_job.queue_name_prefix = "operum_production"
+
+ config.action_mailer.perform_caching = false
+
+ # Ignore bad email addresses and do not raise email delivery errors. Set this
+ # to true and configure the email server for immediate delivery to raise
+ # delivery errors.
+ # config.action_mailer.raise_delivery_errors = false
+
+ # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+ # the I18n.default_locale when a translation cannot be found).
+ config.i18n.fallbacks = true
+
+ # Don't log any deprecations.
+ config.active_support.report_deprecations = false
+
+ # Do not dump schema after migrations.
+ config.active_record.dump_schema_after_migration = false
+
+ # Enable DNS rebinding protection and other `Host` header attacks.
+ # config.hosts = [
+ # "example.com", # Allow requests from example.com
+ # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
+ # ]
+ # Skip DNS rebinding protection for the default health check endpoint.
+ # config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
+end
diff --git a/config/environments/test.rb b/config/environments/test.rb
new file mode 100644
index 0000000..f1d2fb5
--- /dev/null
+++ b/config/environments/test.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'active_support/core_ext/integer/time'
+
+# The test environment is used exclusively to run your application's
+# test suite. You never need to work with it otherwise. Remember that
+# your test database is "scratch space" for the test suite and is wiped
+# and recreated between test runs. Don't rely on the data there!
+
+Rails.application.configure do
+ # Settings specified here will take precedence over those in config/application.rb.
+
+ # While tests run files are not watched, reloading is not necessary.
+ config.enable_reloading = false
+
+ # Eager loading loads your entire application. When running a single test locally,
+ # this is usually not necessary, and can slow down your test suite. However, it's
+ # recommended that you enable it in continuous integration systems to ensure eager
+ # loading is working properly before deploying your code.
+ config.eager_load = ENV['CI'].present?
+
+ # Configure public file server for tests with Cache-Control for performance.
+ config.public_file_server.enabled = true
+ config.public_file_server.headers = {
+ 'Cache-Control' => "public, max-age=#{1.hour.to_i}"
+ }
+
+ # Show full error reports and disable caching.
+ config.consider_all_requests_local = true
+ config.action_controller.perform_caching = false
+ config.cache_store = :null_store
+
+ # Render exception templates for rescuable exceptions and raise for other exceptions.
+ config.action_dispatch.show_exceptions = :rescuable
+
+ # Disable request forgery protection in test environment.
+ config.action_controller.allow_forgery_protection = false
+
+ # Store uploaded files on the local file system in a temporary directory.
+ config.active_storage.service = :test
+
+ config.action_mailer.perform_caching = false
+
+ # Tell Action Mailer not to deliver emails to the real world.
+ # The :test delivery method accumulates sent emails in the
+ # ActionMailer::Base.deliveries array.
+ config.action_mailer.delivery_method = :test
+
+ # Print deprecation notices to the stderr.
+ config.active_support.deprecation = :stderr
+
+ # Raise exceptions for disallowed deprecations.
+ config.active_support.disallowed_deprecation = :raise
+
+ # Tell Active Support which deprecation messages to disallow.
+ config.active_support.disallowed_deprecation_warnings = []
+
+ # Raises error for missing translations.
+ # config.i18n.raise_on_missing_translations = true
+
+ # Annotate rendered view with file names.
+ # config.action_view.annotate_rendered_view_with_filenames = true
+
+ # Raise error when a before_action's only/except options reference missing actions
+ config.action_controller.raise_on_missing_callback_actions = true
+end
diff --git a/config/importmap.rb b/config/importmap.rb
new file mode 100644
index 0000000..bca5021
--- /dev/null
+++ b/config/importmap.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# Pin npm packages by running ./bin/importmap
+
+pin 'application'
+pin '@hotwired/turbo-rails', to: 'turbo.min.js'
+pin '@hotwired/stimulus', to: 'stimulus.min.js'
+pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js'
+pin_all_from 'app/javascript/controllers', under: 'controllers'
+pin 'trix'
+pin '@rails/actiontext', to: 'actiontext.esm.js'
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
new file mode 100644
index 0000000..bcafccd
--- /dev/null
+++ b/config/initializers/assets.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Version of your assets, change this if you want to expire all your assets.
+Rails.application.config.assets.version = '1.0'
+
+# Add additional assets to the asset load path.
+# Rails.application.config.assets.paths << Emoji.images_path
+
+# Precompile additional assets.
+# application.js, application.css, and all non-JS/CSS in the app/assets
+# folder are already added.
+# Rails.application.config.assets.precompile += %w( admin.js admin.css )
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
new file mode 100644
index 0000000..af395e4
--- /dev/null
+++ b/config/initializers/content_security_policy.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+# Be sure to restart your server when you modify this file.
+
+# Define an application-wide content security policy.
+# See the Securing Rails Applications Guide for more information:
+# https://guides.rubyonrails.org/security.html#content-security-policy-header
+
+# Rails.application.configure do
+# config.content_security_policy do |policy|
+# policy.default_src :self, :https
+# policy.font_src :self, :https, :data
+# policy.img_src :self, :https, :data
+# policy.object_src :none
+# policy.script_src :self, :https
+# policy.style_src :self, :https
+# # Specify URI for violation reports
+# # policy.report_uri "/csp-violation-report-endpoint"
+# end
+#
+# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
+# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
+# config.content_security_policy_nonce_directives = %w(script-src style-src)
+#
+# # Report violations without enforcing the policy.
+# # config.content_security_policy_report_only = true
+# end
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
new file mode 100644
index 0000000..7231487
--- /dev/null
+++ b/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Configure parameters to be partially matched (e.g. passw matches password) and
+# filtered from the log file. Use this to limit dissemination of sensitive
+# information. See the ActiveSupport::ParameterFilter documentation for
+# supported notations and behaviors.
+Rails.application.config.filter_parameters += %i[
+ passw secret token _key crypt salt certificate otp ssn
+]
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
new file mode 100644
index 0000000..6c78420
--- /dev/null
+++ b/config/initializers/inflections.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+# Be sure to restart your server when you modify this file.
+
+# Add new inflection rules using the following format. Inflections
+# are locale specific, and you may define rules for as many different
+# locales as you wish. All of these examples are active by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.plural /^(ox)$/i, "\\1en"
+# inflect.singular /^(ox)en/i, "\\1"
+# inflect.irregular "person", "people"
+# inflect.uncountable %w( fish sheep )
+# end
+
+# These inflection rules are supported but not enabled by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+# inflect.acronym "RESTful"
+# end
diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb
new file mode 100644
index 0000000..f3d7af7
--- /dev/null
+++ b/config/initializers/locale.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+I18n.available_locales = %i[en ca]
+I18n.default_locale = ENV.fetch('OPERUM_DEFAULT_LOCALE', :ca).to_sym
+
+Rails.logger.info "Locale: #{I18n.default_locale}"
diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb
new file mode 100644
index 0000000..6043616
--- /dev/null
+++ b/config/initializers/mime_types.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+Mime::Type.register 'application/x-tex', :uoc
diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb
new file mode 100644
index 0000000..b635b52
--- /dev/null
+++ b/config/initializers/permissions_policy.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+# Be sure to restart your server when you modify this file.
+
+# Define an application-wide HTTP permissions policy. For further
+# information see: https://developers.google.com/web/updates/2018/06/feature-policy
+
+# Rails.application.config.permissions_policy do |policy|
+# policy.camera :none
+# policy.gyroscope :none
+# policy.microphone :none
+# policy.usb :none
+# policy.fullscreen :self
+# policy.payment :self, "https://secure.example.com"
+# end
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
new file mode 100644
index 0000000..ae9983c
--- /dev/null
+++ b/config/locales/ca.yml
@@ -0,0 +1,129 @@
+ca:
+ ##
+ # Rails thingies.
+
+ activerecord:
+ models:
+ comment: Comentari
+ tag: Etiqueta
+ thing: Font
+ attributes:
+ comment:
+ content: Contingut
+ tag:
+ name: Nom
+ thing:
+ target: Identificador
+ title: Títol
+ publisher: Editorial
+ address: Adreça
+ year: Any
+ url: Enllaç
+ access: Última data de consulta
+ location: Lloc
+ insideof: Dins de
+ pages: Pàgines
+ rate: Qualificació
+ status: Estat
+ kind: Tipus
+ note: Notes
+ authors: Autors
+ editors: Editors d'una col·lecció
+ bought_at: Data de compra
+ where_is_it: Per on para?
+ user:
+ username: Nom d'usuari
+ password: Contrasenya
+
+ helpers:
+ submit:
+ create: Afegeix
+ update: Actualitza
+
+ ##
+ # App-specific strings.
+
+ general:
+ sure: N'estàs segur?
+ edit: edita
+ delete: elimina
+ new: nou
+ create: Afegeix
+ list: llista
+ in: a
+ back-to-list: torna a la llista
+ or: o
+ cancel: cancel·la
+ insideof: Dins de
+ insideof-vowel: Dins d'
+
+ comments:
+ title: Comentari
+ title-plural: Comentaris
+ new: Comenta
+ none: De moment no sha fet cap comentari
+ edit-title: Edita comnetari
+ delete-title: Esborra comentari
+ bad-create: "No s'ha pogut afegir el comentari per motius desconeguts"
+ bad-update: "No s'ha pogut actualitzar el comentari per motius desconeguts"
+
+ layout:
+ created-by: Creat per
+ this-page-license: Aquesta pàgina és programari lliure i està sota la llicència
+ source-code: codi font
+
+ license:
+ title: Llicència
+ at: El codi font es pot trobar
+ here: aquí
+
+ sessions:
+ title: Benvingut!
+ wrong-credentials: Usuari o contrasenya incorrectes
+ sign-in: Entra
+ sign-out: Tanca la sessió
+
+ searches:
+ title: Cerca
+ object: cerca
+ home: Tot
+ save: guarda cerca
+ public: Públic
+
+ shared:
+ title: Cerques públiques
+ none: De moment no s'ha compartit cap cerca
+
+ export:
+ action: exporta
+ note: ATENCIÓ
+ note-msg: només s'inclourà informació que pugui ser utilitzada per a escriure una secció de bibliografia
+ chapter: Bibliografia
+
+ tags:
+ title: Etiquetes
+ new: Etiqueta nova
+ new-action: nova
+ create-success: Etiqueta creada correctament
+ destroy-success: Etiqueta esborrada correctament
+
+ things:
+ object: font
+ create-another: Afegeix-ne una altra
+ create-success: Nova font creada correctament
+ update-success: Font actualitzada correctament
+ destroy-success: Font esborrada correctament
+
+ status:
+ read: Llegit
+ notread: Per llegir
+ tobepublished: Per publicar
+
+ kind:
+ other: Altre
+ poetry: Poesia
+ theater: Teatre
+ essay: Assaig
+ shorts: Narració breu
+ novel: Novel·la
+ paper: Article
diff --git a/config/locales/en.yml b/config/locales/en.yml
new file mode 100644
index 0000000..f00d7e5
--- /dev/null
+++ b/config/locales/en.yml
@@ -0,0 +1,131 @@
+en:
+ ##
+ # Rails thingies.
+
+ activerecord:
+ models:
+ comment: Comment
+ tag: Tag
+ thing: Source
+ attributes:
+ comment:
+ content: Content
+ tag:
+ name: Name
+ thing:
+ target: Identifier
+ title: Title
+ publisher: Publisher
+ address: Address
+ year: Year
+ url: URL
+ access: Last consulted at
+ location: Place
+ insideof: Inside of
+ pages: Pages
+ rate: Rate
+ status: Status
+ kind: Kind
+ note: Notes
+ authors: Authors
+ editors: Editors of a collection
+ bought_at: Bought at
+ where_is_it: Where is it?
+ user:
+ username: Username
+ password: Password
+
+ helpers:
+ submit:
+ create: Create
+ update: Update
+
+ ##
+ # App-specific strings.
+
+ general:
+ sure: Are you sure?
+ edit: edit
+ delete: delete
+ new: new
+ create: create
+ list: list
+ in: in
+ back-to-list: back to list
+ or: or
+ cancel: cancel
+ insideof: Inside of
+ insideof-vowel: Inside of
+
+ comments:
+ title: Comment
+ title-plural: Comments
+ new: New comment
+ none: 'No comments have been made so far'
+ edit-title: Edit comment
+ delete-title: Delete comment
+ bad-create: Could not add comment for unknown reasons
+ bad-update: Could not update comment for unknown reasons
+
+ layout:
+ created-by: Created by
+ this-page-license: This site is free software and is under the
+ source-code: source code
+
+ license:
+ title: License
+ at: You can find the source code
+ here: here
+
+ sessions:
+ title: Welcome!
+ wrong-credentials: Wrong credentials
+ sign-in: Sign in
+ sign-out: Sign out
+
+ searches:
+ title: Search
+ object: search
+ home: Home
+ save: save search
+ public: Public
+
+ shared:
+ title: Public searches
+ none: 'No searches have been shared for now'
+
+ export:
+ action: export
+ note: 'NOTE'
+ note-msg: only information that can be used for building up a bibliography section will be included
+ chapter: Bibliography
+
+ tags:
+ title: Tags
+ new: New tag
+ new-action: new
+
+ create-success: Tag was successfully created
+ destroy-success: Tag was successfully deleted
+
+ things:
+ object: source
+ create-another: Create another one
+
+ create-success: New source was successfully created
+ update-success: Source was successfully updated
+ destroy-success: Source was successfully deleted
+
+ status:
+ read: Read
+ notread: Not read
+ tobepublished: To be published
+
+ kind:
+ other: Other
+ poetry: Poetry
+ theater: Theater
+ essay: Essay
+ shorts: Shorts
+ novel: Novel
+ paper: Paper
diff --git a/config/puma.rb b/config/puma.rb
new file mode 100644
index 0000000..7ed4157
--- /dev/null
+++ b/config/puma.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# This configuration file will be evaluated by Puma. The top-level methods that
+# are invoked here are part of Puma's configuration DSL. For more information
+# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
+
+# Puma can serve each request in a thread from an internal thread pool.
+# The `threads` method setting takes two numbers: a minimum and maximum.
+# Any libraries that use thread pools should be configured to match
+# the maximum value specified for Puma. Default is set to 5 threads for minimum
+# and maximum; this matches the default thread size of Active Record.
+max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
+min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count }
+threads min_threads_count, max_threads_count
+
+# Specifies that the worker count should equal the number of processors in production.
+if ENV['RAILS_ENV'] == 'production'
+ require 'concurrent-ruby'
+ worker_count = Integer(ENV.fetch('WEB_CONCURRENCY') { Concurrent.physical_processor_count })
+ workers worker_count if worker_count > 1
+end
+
+# Specifies the `worker_timeout` threshold that Puma will use to wait before
+# terminating a worker in development environments.
+worker_timeout 3600 if ENV.fetch('RAILS_ENV', 'development') == 'development'
+
+# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
+port ENV.fetch('PORT', 3000)
+
+# Specifies the `environment` that Puma will run in.
+environment ENV.fetch('RAILS_ENV', 'development')
+
+# Specifies the `pidfile` that Puma will use.
+pidfile ENV.fetch('PIDFILE', 'tmp/pids/server.pid')
+
+# Allow puma to be restarted by `bin/rails restart` command.
+plugin :tmp_restart
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644
index 0000000..1e5eedb
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+Rails.application.routes.draw do
+ resources :searches do
+ collection do
+ get 'search'
+ get 'shared' => 'shared_searches#index'
+ end
+
+ get 'shared' => 'shared_searches#show'
+
+ resource :exports, only: %i[new show]
+ end
+
+ resources :tags, only: %i[index new create destroy]
+ resources :things, except: %i[index] do
+ resources :comments, only: %i[create update destroy]
+ end
+
+ resource :license, only: %i[show]
+ resource :sessions, only: %i[new create destroy]
+
+ # Provided by Rails for health checking :)
+ get 'up' => 'rails/health#show', as: :rails_health_check
+
+ root 'searches#index'
+end
diff --git a/config/storage.yml b/config/storage.yml
new file mode 100644
index 0000000..4942ab6
--- /dev/null
+++ b/config/storage.yml
@@ -0,0 +1,34 @@
+test:
+ service: Disk
+ root: <%= Rails.root.join("tmp/storage") %>
+
+local:
+ service: Disk
+ root: <%= Rails.root.join("storage") %>
+
+# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
+# amazon:
+# service: S3
+# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
+# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
+# region: us-east-1
+# bucket: your_own_bucket-<%= Rails.env %>
+
+# Remember not to checkin your GCS keyfile to a repository
+# google:
+# service: GCS
+# project: your_project
+# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
+# bucket: your_own_bucket-<%= Rails.env %>
+
+# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
+# microsoft:
+# service: AzureStorage
+# storage_account_name: your_account_name
+# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
+# container: your_container_name-<%= Rails.env %>
+
+# mirror:
+# service: Mirror
+# primary: local
+# mirrors: [ amazon, google, microsoft ]
diff --git a/db/migrate/20240224162412_create_things.rb b/db/migrate/20240224162412_create_things.rb
new file mode 100644
index 0000000..5fca815
--- /dev/null
+++ b/db/migrate/20240224162412_create_things.rb
@@ -0,0 +1,23 @@
+class CreateThings < ActiveRecord::Migration[7.1]
+ def change
+ create_table :things do |t|
+ t.string :target
+ t.string :title, null: false, index: { unique: true }
+ t.string :publisher
+ t.string :address
+ t.integer :year
+ t.string :url
+ t.datetime :access
+ t.string :location
+ t.string :insideof
+ t.string :pages
+ t.references :user, null: false, foreign_key: true
+ t.integer :rate
+ t.integer :status
+ t.integer :kind
+ t.datetime :bought_at
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240224164039_create_users.rb b/db/migrate/20240224164039_create_users.rb
new file mode 100644
index 0000000..cfd4944
--- /dev/null
+++ b/db/migrate/20240224164039_create_users.rb
@@ -0,0 +1,10 @@
+class CreateUsers < ActiveRecord::Migration[7.1]
+ def change
+ create_table :users do |t|
+ t.string :username, null: false, index: { unique: true }
+ t.string :password_digest
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240224165311_create_comments.rb b/db/migrate/20240224165311_create_comments.rb
new file mode 100644
index 0000000..49cb989
--- /dev/null
+++ b/db/migrate/20240224165311_create_comments.rb
@@ -0,0 +1,10 @@
+class CreateComments < ActiveRecord::Migration[7.1]
+ def change
+ create_table :comments do |t|
+ t.references :thing, null: false, foreign_key: true
+ t.text :content
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240224165703_create_active_storage_tables.active_storage.rb b/db/migrate/20240224165703_create_active_storage_tables.active_storage.rb
new file mode 100644
index 0000000..e4706aa
--- /dev/null
+++ b/db/migrate/20240224165703_create_active_storage_tables.active_storage.rb
@@ -0,0 +1,57 @@
+# This migration comes from active_storage (originally 20170806125915)
+class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
+ def change
+ # Use Active Record's configured type for primary and foreign keys
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
+
+ create_table :active_storage_blobs, id: primary_key_type do |t|
+ t.string :key, null: false
+ t.string :filename, null: false
+ t.string :content_type
+ t.text :metadata
+ t.string :service_name, null: false
+ t.bigint :byte_size, null: false
+ t.string :checksum
+
+ if connection.supports_datetime_with_precision?
+ t.datetime :created_at, precision: 6, null: false
+ else
+ t.datetime :created_at, null: false
+ end
+
+ t.index [ :key ], unique: true
+ end
+
+ create_table :active_storage_attachments, id: primary_key_type do |t|
+ t.string :name, null: false
+ t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
+ t.references :blob, null: false, type: foreign_key_type
+
+ if connection.supports_datetime_with_precision?
+ t.datetime :created_at, precision: 6, null: false
+ else
+ t.datetime :created_at, null: false
+ end
+
+ t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
+ t.foreign_key :active_storage_blobs, column: :blob_id
+ end
+
+ create_table :active_storage_variant_records, id: primary_key_type do |t|
+ t.belongs_to :blob, null: false, index: false, type: foreign_key_type
+ t.string :variation_digest, null: false
+
+ t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
+ t.foreign_key :active_storage_blobs, column: :blob_id
+ end
+ end
+
+ private
+ def primary_and_foreign_key_types
+ config = Rails.configuration.generators
+ setting = config.options[config.orm][:primary_key_type]
+ primary_key_type = setting || :primary_key
+ foreign_key_type = setting || :bigint
+ [primary_key_type, foreign_key_type]
+ end
+end
diff --git a/db/migrate/20240224165704_create_action_text_tables.action_text.rb b/db/migrate/20240224165704_create_action_text_tables.action_text.rb
new file mode 100644
index 0000000..1be48d7
--- /dev/null
+++ b/db/migrate/20240224165704_create_action_text_tables.action_text.rb
@@ -0,0 +1,26 @@
+# This migration comes from action_text (originally 20180528164100)
+class CreateActionTextTables < ActiveRecord::Migration[6.0]
+ def change
+ # Use Active Record's configured type for primary and foreign keys
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
+
+ create_table :action_text_rich_texts, id: primary_key_type do |t|
+ t.string :name, null: false
+ t.text :body, size: :long
+ t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
+
+ t.timestamps
+
+ t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
+ end
+ end
+
+ private
+ def primary_and_foreign_key_types
+ config = Rails.configuration.generators
+ setting = config.options[config.orm][:primary_key_type]
+ primary_key_type = setting || :primary_key
+ foreign_key_type = setting || :bigint
+ [primary_key_type, foreign_key_type]
+ end
+end
diff --git a/db/migrate/20240224220515_create_tags.rb b/db/migrate/20240224220515_create_tags.rb
new file mode 100644
index 0000000..7db0657
--- /dev/null
+++ b/db/migrate/20240224220515_create_tags.rb
@@ -0,0 +1,9 @@
+class CreateTags < ActiveRecord::Migration[7.1]
+ def change
+ create_table :tags do |t|
+ t.string :name, null: false, index: { unique: true }
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240225070152_create_tag_references.rb b/db/migrate/20240225070152_create_tag_references.rb
new file mode 100644
index 0000000..ba945a8
--- /dev/null
+++ b/db/migrate/20240225070152_create_tag_references.rb
@@ -0,0 +1,10 @@
+class CreateTagReferences < ActiveRecord::Migration[7.1]
+ def change
+ create_table :tag_references do |t|
+ t.references :tag, null: false, foreign_key: true
+ t.references :taggable, null: false, polymorphic: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240225071927_create_searches.rb b/db/migrate/20240225071927_create_searches.rb
new file mode 100644
index 0000000..a2a42e7
--- /dev/null
+++ b/db/migrate/20240225071927_create_searches.rb
@@ -0,0 +1,11 @@
+class CreateSearches < ActiveRecord::Migration[7.1]
+ def change
+ create_table :searches do |t|
+ t.string :name
+ t.string :body
+ t.references :user, null: false, foreign_key: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240225100002_add_note_to_things.rb b/db/migrate/20240225100002_add_note_to_things.rb
new file mode 100644
index 0000000..b0c0f40
--- /dev/null
+++ b/db/migrate/20240225100002_add_note_to_things.rb
@@ -0,0 +1,5 @@
+class AddNoteToThings < ActiveRecord::Migration[7.1]
+ def change
+ add_column :things, :note, :string
+ end
+end
diff --git a/db/migrate/20240226063134_add_uniqueness_index_search.rb b/db/migrate/20240226063134_add_uniqueness_index_search.rb
new file mode 100644
index 0000000..77247df
--- /dev/null
+++ b/db/migrate/20240226063134_add_uniqueness_index_search.rb
@@ -0,0 +1,8 @@
+class AddUniquenessIndexSearch < ActiveRecord::Migration[7.1]
+ def change
+ add_index :searches, :name, unique: true
+ add_index :searches, :body, unique: true
+
+ add_index :things, :target, unique: true
+ end
+end
diff --git a/db/migrate/20240227085442_add_last_search_id_to_users.rb b/db/migrate/20240227085442_add_last_search_id_to_users.rb
new file mode 100644
index 0000000..be58999
--- /dev/null
+++ b/db/migrate/20240227085442_add_last_search_id_to_users.rb
@@ -0,0 +1,5 @@
+class AddLastSearchIdToUsers < ActiveRecord::Migration[7.1]
+ def change
+ add_column :users, :last_search_id, :integer
+ end
+end
diff --git a/db/migrate/20240227145317_add_authors_to_thing.rb b/db/migrate/20240227145317_add_authors_to_thing.rb
new file mode 100644
index 0000000..0c26b81
--- /dev/null
+++ b/db/migrate/20240227145317_add_authors_to_thing.rb
@@ -0,0 +1,6 @@
+class AddAuthorsToThing < ActiveRecord::Migration[7.1]
+ def change
+ add_column :things, :authors, :string, null: false
+ add_column :things, :editors, :boolean, default: false
+ end
+end
diff --git a/db/migrate/20240301154452_add_shared_to_searches.rb b/db/migrate/20240301154452_add_shared_to_searches.rb
new file mode 100644
index 0000000..4950c23
--- /dev/null
+++ b/db/migrate/20240301154452_add_shared_to_searches.rb
@@ -0,0 +1,5 @@
+class AddSharedToSearches < ActiveRecord::Migration[7.1]
+ def change
+ add_column :searches, :shared, :boolean, default: false
+ end
+end
diff --git a/db/migrate/20240307220309_add_where_is_it_to_things.rb b/db/migrate/20240307220309_add_where_is_it_to_things.rb
new file mode 100644
index 0000000..6d18307
--- /dev/null
+++ b/db/migrate/20240307220309_add_where_is_it_to_things.rb
@@ -0,0 +1,5 @@
+class AddWhereIsItToThings < ActiveRecord::Migration[7.1]
+ def change
+ add_column :things, :where_is_it, :string
+ end
+end
diff --git a/db/migrate/20240313133513_change_access_and_bought_at_to_date.rb b/db/migrate/20240313133513_change_access_and_bought_at_to_date.rb
new file mode 100644
index 0000000..ea2f5bd
--- /dev/null
+++ b/db/migrate/20240313133513_change_access_and_bought_at_to_date.rb
@@ -0,0 +1,6 @@
+class ChangeAccessAndBoughtAtToDate < ActiveRecord::Migration[7.1]
+ def change
+ change_column :things, :access, :date
+ change_column :things, :bought_at, :date
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 0000000..967b074
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,131 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[7.1].define(version: 2024_03_13_133513) do
+ create_table "action_text_rich_texts", force: :cascade do |t|
+ t.string "name", null: false
+ t.text "body"
+ t.string "record_type", null: false
+ t.bigint "record_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
+ end
+
+ create_table "active_storage_attachments", force: :cascade do |t|
+ t.string "name", null: false
+ t.string "record_type", null: false
+ t.bigint "record_id", null: false
+ t.bigint "blob_id", null: false
+ t.datetime "created_at", null: false
+ t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
+ t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
+ end
+
+ create_table "active_storage_blobs", force: :cascade do |t|
+ t.string "key", null: false
+ t.string "filename", null: false
+ t.string "content_type"
+ t.text "metadata"
+ t.string "service_name", null: false
+ t.bigint "byte_size", null: false
+ t.string "checksum"
+ t.datetime "created_at", null: false
+ t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
+ end
+
+ create_table "active_storage_variant_records", force: :cascade do |t|
+ t.bigint "blob_id", null: false
+ t.string "variation_digest", null: false
+ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
+ end
+
+ create_table "comments", force: :cascade do |t|
+ t.integer "thing_id", null: false
+ t.text "content"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["thing_id"], name: "index_comments_on_thing_id"
+ end
+
+ create_table "searches", force: :cascade do |t|
+ t.string "name"
+ t.string "body"
+ t.integer "user_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.boolean "shared", default: false
+ t.index ["body"], name: "index_searches_on_body", unique: true
+ t.index ["name"], name: "index_searches_on_name", unique: true
+ t.index ["user_id"], name: "index_searches_on_user_id"
+ end
+
+ create_table "tag_references", force: :cascade do |t|
+ t.integer "tag_id", null: false
+ t.string "taggable_type", null: false
+ t.integer "taggable_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["tag_id"], name: "index_tag_references_on_tag_id"
+ t.index ["taggable_type", "taggable_id"], name: "index_tag_references_on_taggable"
+ end
+
+ create_table "tags", force: :cascade do |t|
+ t.string "name", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["name"], name: "index_tags_on_name", unique: true
+ end
+
+ create_table "things", force: :cascade do |t|
+ t.string "target"
+ t.string "title", null: false
+ t.string "publisher"
+ t.string "address"
+ t.integer "year"
+ t.string "url"
+ t.date "access"
+ t.string "location"
+ t.string "insideof"
+ t.string "pages"
+ t.integer "user_id", null: false
+ t.integer "rate"
+ t.integer "status"
+ t.integer "kind"
+ t.date "bought_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "note"
+ t.string "authors", null: false
+ t.boolean "editors", default: false
+ t.string "where_is_it"
+ t.index ["target"], name: "index_things_on_target", unique: true
+ t.index ["title"], name: "index_things_on_title", unique: true
+ t.index ["user_id"], name: "index_things_on_user_id"
+ end
+
+ create_table "users", force: :cascade do |t|
+ t.string "username", null: false
+ t.string "password_digest"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.integer "last_search_id"
+ t.index ["username"], name: "index_users_on_username", unique: true
+ end
+
+ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "comments", "things"
+ add_foreign_key "searches", "users"
+ add_foreign_key "tag_references", "tags"
+ add_foreign_key "things", "users"
+end
diff --git a/db/seeds.rb b/db/seeds.rb
new file mode 100644
index 0000000..6e07c8b
--- /dev/null
+++ b/db/seeds.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+return unless Rails.env == 'development'
+
+u = User.find_by(username: 'user')
+unless u
+ u = User.new(username: 'user', password: '12341234', password_confirmation: '12341234')
+ u.save!
+end
+
+##
+# Books, articles, whatever.
+
+[
+ {
+ target: 'Rossich2006',
+ title: 'Poesia catalana del barroc. Antologia',
+ authors: 'Albert Rossich, Pep Valsalobre',
+ publisher: 'Edicions Vitel·la',
+ address: "Bellcaire d'Empordà",
+ year: 2006,
+ kind: Thing.kinds[:poetry],
+ status: Thing.statuses[:read],
+ rate: 10,
+ user: u
+ },
+ {
+ target: 'OvidiMetamorfosisParramon',
+ title: 'Metamorfosis',
+ authors: 'Ovidi',
+ publisher: 'Quaderns Crema',
+ address: 'Barcelona',
+ note: 'Traducció en versos a càrrec de Jordi Parramon',
+ year: 2000,
+ kind: Thing.kinds[:poetry],
+ status: Thing.statuses[:read],
+ rate: 10,
+ user: u
+ },
+ {
+ target: 'Rossich2023',
+ title: "Sou lo que podeu mostrar que haveu begut d'aquesta font",
+ authors: 'Eulàlia Miralles, Marc Sogues, Pep Valsalobre',
+ editors: true,
+ publisher: 'Editorial Afers',
+ address: 'Catarroja - Barcelona',
+ note: 'Homenatge al professor Albert Rossich',
+ year: 2023,
+ kind: Thing.kinds[:essay],
+ status: Thing.statuses[:notread],
+ rate: 10,
+ user: u
+ },
+ {
+ target: 'EuripidesIX2',
+ title: "Tragèdies. IX, 2",
+ authors: 'Eurípides',
+ publisher: 'Fundació Bernat Metge',
+ address: 'Barcelona',
+ note: 'Ifigènia a Àulida',
+ year: 2023,
+ kind: Thing.kinds[:theater],
+ status: Thing.statuses[:notread],
+ rate: 10,
+ user: u
+ },
+ {
+ target: 'RodoredaContes',
+ title: "Tots els contes",
+ authors: 'Mercè Rodoreda',
+ publisher: 'Edicions 62 - labutxaca',
+ address: 'Barcelona',
+ note: 'Segona edició',
+ year: 2017,
+ kind: Thing.kinds[:shorts],
+ status: Thing.statuses[:read],
+ rate: 8,
+ user: u
+ },
+ {
+ target: 'CussaEmperador',
+ title: 'El primer emperador i la reina Lluna',
+ authors: 'Jordi Cussà',
+ publisher: 'Comanegra',
+ address: 'Barcelona',
+ note: '',
+ year: 2020,
+ kind: Thing.kinds[:novel],
+ status: Thing.statuses[:read],
+ rate: 9,
+ user: u
+ }
+].each { |params| Thing.find_or_create_by!(params) }
+
+##
+# Comments
+
+Thing.find_by(target: 'Rossich2006').comments.find_or_create_by!(content: 'Primer comentari a Rossich')
+Thing.find_by(target: 'Rossich2006').comments.find_or_create_by!(content: 'Segon comentari a Rossich')
+
+##
+# Tags
+
+['Per llegir', 'Moderna', 'Clàssiques', 'TFG'].each { |t| Tag.find_or_create_by!(name: t) }
+
+##
+# Tag references
+
+Thing.find_by(target: 'Rossich2006').tag_references.find_or_create_by!(tag: Tag.find_by(name: 'Moderna'))
+Thing.find_by(target: 'Rossich2006').tag_references.find_or_create_by!(tag: Tag.find_by(name: 'TFG'))
+Thing.find_by(target: 'OvidiMetamorfosisParramon').tag_references.find_or_create_by!(tag: Tag.find_by(name: 'Clàssiques'))
+Thing.find_by(target: 'OvidiMetamorfosisParramon').tag_references.find_or_create_by!(tag: Tag.find_by(name: 'TFG'))
+Comment.find(1).tag_references.find_or_create_by!(tag: Tag.find_by(name: 'TFG'))
+
+##
+# Searches
+
+Search.find_or_create_by!(name: 'tfg', body: 'tag:"TFG"', user: u)
diff --git a/lib/assets/.keep b/lib/assets/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/assets/.keep
diff --git a/lib/base_exporter.rb b/lib/base_exporter.rb
new file mode 100644
index 0000000..b040592
--- /dev/null
+++ b/lib/base_exporter.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class BaseExporter
+ def initialize(things:)
+ @things = things
+ end
+
+ # Returns a string which forces a new TeX line.
+ def tex_new_line
+ '\\\\~\\\\'
+ end
+
+ # Returns a two-sized array containing the first name and the last name as
+ # parsed from the `author` string. This method assumes the following format is
+ # followed:
+ # - 'Name' -> ['Name', nil]
+ # - 'Name Surname' -> ['Name', 'Surname']
+ # - 'Compound Name Surname' -> ['Compound Name', 'Surname']
+ # - 'Name _Surname1 Surname2_' -> ['Name', 'Surname1 Surname2']
+ def parse_author(author:)
+ matches = /(.+)?\s_(.+)_/.match(author)
+ return matches[1].strip, matches[2].strip if matches&.size == 3
+
+ a = author.split
+ return [author, nil] if a.size == 1
+
+ [a[0..-2].join(' '), a.last]
+ end
+end
diff --git a/lib/csv_exporter.rb b/lib/csv_exporter.rb
new file mode 100644
index 0000000..a44815d
--- /dev/null
+++ b/lib/csv_exporter.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'csv'
+
+class CsvExporter < BaseExporter
+ def export
+ str = ''
+ @things.each do |thing|
+ str += thing_to_csv(thing:)
+ end
+
+ str
+ end
+
+ def thing_to_csv(thing:)
+ CSV.generate_line([thing.target, thing.authors, thing.editors, thing.year, thing.title,
+ thing.note, thing.insideof, thing.url, thing.publisher, thing.kind,
+ thing.access, thing.bought_at],
+ quote_empty: false)
+ end
+end
diff --git a/lib/tasks/.keep b/lib/tasks/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/tasks/.keep
diff --git a/lib/uoc_exporter.rb b/lib/uoc_exporter.rb
new file mode 100644
index 0000000..9a83d7c
--- /dev/null
+++ b/lib/uoc_exporter.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+class UocExporter < BaseExporter
+ def export
+ @things.map.with_index do |thing, idx|
+ if idx.zero?
+ "\\hypertarget{#{thing.target}}{#{head(thing:)} #{body(thing:)}}"
+ else
+ "#{tex_new_line}\\hypertarget{#{thing.target}}{#{head(thing:)} #{body(thing:)}}"
+ end
+ end.join("\n\n")
+ end
+
+ protected
+
+ # Returns the head of a line, containing authors, year, title and, if
+ # available, the "inside of" information.
+ def head(thing:)
+ str = "#{parse_authors(thing:)} (#{thing.year}). \\emph{#{thing.title}}."
+
+ if thing.insideof.present?
+ str += " #{inside_of(thing.insideof)}"
+ str += thing.pages.present? ? ", #{thing.pages}." : '.'
+ end
+
+ str
+ end
+
+ # Parse the authors as delivered by `thing.authors` and render them in the
+ # proper format.
+ def parse_authors(thing:)
+ str = thing.authors.split(',').map do |author|
+ first, last = parse_author(author: author.strip)
+ last.blank? ? "\\textsc{#{first}}" : "\\textsc{#{last}}, #{first}"
+ end.join('; ')
+
+ str += ' (eds.)' if thing.editors
+ str
+ end
+
+ # Returns the proper 'inside of' translated text.
+ def inside_of(sub)
+ str = if sub.downcase.start_with?(*%w[a e i o u])
+ I18n.t('general.insideof-vowel')
+ else
+ I18n.t('general.insideof')
+ end
+
+ str + " \\emph{#{sub}}"
+ end
+
+ # Returns the 'body' of the line, which is the rest of it (i.e. note,
+ # publisher, year).
+ def body(thing:)
+ union, tail = thing_union_tail(thing:)
+
+ if thing.publisher.present?
+ pub = thing.note.present? ? "#{thing.note}. #{thing.publisher}" : thing.publisher
+ pub + union + tail
+ else
+ tail
+ end
+ end
+
+ # Returns a two-sized array containing the last bits of info from a line,
+ # being either the address, the url + access date, or just a final dot. The
+ # first item contains the 'union' of the last item of the returned array and
+ # the expected string that is going to be prefixed to it.
+ def thing_union_tail(thing:)
+ if thing.address.present?
+ [', ', "#{thing.address}: #{thing.year}."]
+ elsif thing.url.present?
+ ['. ',
+ "[\\url{#{thing.url}}]. [#{I18n.t('activerecord.attributes.thing.access')}: #{I18n.l(
+ thing.access, format: :long
+ ).downcase}]."]
+ else
+ ['', '.']
+ end
+ end
+end
diff --git a/log/.keep b/log/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/log/.keep
diff --git a/public/404.html b/public/404.html
new file mode 100644
index 0000000..2be3af2
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>The page you were looking for doesn't exist (404)</title>
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <style>
+ .rails-default-error-page {
+ background-color: #EFEFEF;
+ color: #2E2F30;
+ text-align: center;
+ font-family: arial, sans-serif;
+ margin: 0;
+ }
+
+ .rails-default-error-page div.dialog {
+ width: 95%;
+ max-width: 33em;
+ margin: 4em auto 0;
+ }
+
+ .rails-default-error-page div.dialog > div {
+ border: 1px solid #CCC;
+ border-right-color: #999;
+ border-left-color: #999;
+ border-bottom-color: #BBB;
+ border-top: #B00100 solid 4px;
+ border-top-left-radius: 9px;
+ border-top-right-radius: 9px;
+ background-color: white;
+ padding: 7px 12% 0;
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
+ }
+
+ .rails-default-error-page h1 {
+ font-size: 100%;
+ color: #730E15;
+ line-height: 1.5em;
+ }
+
+ .rails-default-error-page div.dialog > p {
+ margin: 0 0 1em;
+ padding: 1em;
+ background-color: #F7F7F7;
+ border: 1px solid #CCC;
+ border-right-color: #999;
+ border-left-color: #999;
+ border-bottom-color: #999;
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-top-color: #DADADA;
+ color: #666;
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
+ }
+ </style>
+</head>
+
+<body class="rails-default-error-page">
+ <!-- This file lives in public/404.html -->
+ <div class="dialog">
+ <div>
+ <h1>The page you were looking for doesn't exist.</h1>
+ <p>You may have mistyped the address or the page may have moved.</p>
+ </div>
+ <p>If you are the application owner check the logs for more information.</p>
+ </div>
+</body>
+</html>
diff --git a/public/422.html b/public/422.html
new file mode 100644
index 0000000..c08eac0
--- /dev/null
+++ b/public/422.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>The change you wanted was rejected (422)</title>
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <style>
+ .rails-default-error-page {
+ background-color: #EFEFEF;
+ color: #2E2F30;
+ text-align: center;
+ font-family: arial, sans-serif;
+ margin: 0;
+ }
+
+ .rails-default-error-page div.dialog {
+ width: 95%;
+ max-width: 33em;
+ margin: 4em auto 0;
+ }
+
+ .rails-default-error-page div.dialog > div {
+ border: 1px solid #CCC;
+ border-right-color: #999;
+ border-left-color: #999;
+ border-bottom-color: #BBB;
+ border-top: #B00100 solid 4px;
+ border-top-left-radius: 9px;
+ border-top-right-radius: 9px;
+ background-color: white;
+ padding: 7px 12% 0;
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
+ }
+
+ .rails-default-error-page h1 {
+ font-size: 100%;
+ color: #730E15;
+ line-height: 1.5em;
+ }
+
+ .rails-default-error-page div.dialog > p {
+ margin: 0 0 1em;
+ padding: 1em;
+ background-color: #F7F7F7;
+ border: 1px solid #CCC;
+ border-right-color: #999;
+ border-left-color: #999;
+ border-bottom-color: #999;
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-top-color: #DADADA;
+ color: #666;
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
+ }
+ </style>
+</head>
+
+<body class="rails-default-error-page">
+ <!-- This file lives in public/422.html -->
+ <div class="dialog">
+ <div>
+ <h1>The change you wanted was rejected.</h1>
+ <p>Maybe you tried to change something you didn't have access to.</p>
+ </div>
+ <p>If you are the application owner check the logs for more information.</p>
+ </div>
+</body>
+</html>
diff --git a/public/500.html b/public/500.html
new file mode 100644
index 0000000..78a030a
--- /dev/null
+++ b/public/500.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>We're sorry, but something went wrong (500)</title>
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <style>
+ .rails-default-error-page {
+ background-color: #EFEFEF;
+ color: #2E2F30;
+ text-align: center;
+ font-family: arial, sans-serif;
+ margin: 0;
+ }
+
+ .rails-default-error-page div.dialog {
+ width: 95%;
+ max-width: 33em;
+ margin: 4em auto 0;
+ }
+
+ .rails-default-error-page div.dialog > div {
+ border: 1px solid #CCC;
+ border-right-color: #999;
+ border-left-color: #999;
+ border-bottom-color: #BBB;
+ border-top: #B00100 solid 4px;
+ border-top-left-radius: 9px;
+ border-top-right-radius: 9px;
+ background-color: white;
+ padding: 7px 12% 0;
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
+ }
+
+ .rails-default-error-page h1 {
+ font-size: 100%;
+ color: #730E15;
+ line-height: 1.5em;
+ }
+
+ .rails-default-error-page div.dialog > p {
+ margin: 0 0 1em;
+ padding: 1em;
+ background-color: #F7F7F7;
+ border: 1px solid #CCC;
+ border-right-color: #999;
+ border-left-color: #999;
+ border-bottom-color: #999;
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-top-color: #DADADA;
+ color: #666;
+ box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17);
+ }
+ </style>
+</head>
+
+<body class="rails-default-error-page">
+ <!-- This file lives in public/500.html -->
+ <div class="dialog">
+ <div>
+ <h1>We're sorry, but something went wrong.</h1>
+ </div>
+ <p>If you are the application owner check the logs for more information.</p>
+ </div>
+</body>
+</html>
diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/apple-touch-icon-precomposed.png
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/apple-touch-icon.png
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/public/favicon.ico
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..c19f78a
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1 @@
+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
diff --git a/storage/.keep b/storage/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/storage/.keep
diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb
new file mode 100644
index 0000000..eaa28b0
--- /dev/null
+++ b/test/application_system_test_case.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
+ driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
+
+ # Sign in with the default user.
+ def sign_in!
+ visit new_sessions_url
+
+ fill_in I18n.t('activerecord.attributes.user.username'), with: users(:user).username
+ fill_in I18n.t('activerecord.attributes.user.password'), with: '12341234'
+ click_on I18n.t('sessions.sign-in')
+
+ assert_text I18n.t('searches.home')
+ end
+
+ # Sign out the current user if it's already signed in, otherwise do nothing.
+ def sign_out_maybe!
+ click_on I18n.t('sessions.sign-out')
+ assert_text I18n.t('sessions.title')
+ rescue Capybara::ElementNotFound
+ # We were not logged in, do nothing.
+ end
+end
diff --git a/test/controllers/exports_controller_test.rb b/test/controllers/exports_controller_test.rb
new file mode 100644
index 0000000..42ed1a2
--- /dev/null
+++ b/test/controllers/exports_controller_test.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class ExportsControllerTest < ActionDispatch::IntegrationTest
+ # We need the session to be initialized as a signed in user.
+ setup { post sessions_url, params: { username: users(:user).username, password: '12341234' } }
+
+ test 'csv: works' do
+ get search_exports_url(0, format: 'csv')
+
+ body = @response.body.split("\n")
+ assert body.size == 2
+ assert body.first.split(',')[0] == things(:thing2).target
+ assert body.last.split(',')[0] == things(:thing1).target
+
+ assert @response.media_type == 'text/csv'
+ end
+
+ test 'uoc: works' do
+ get search_exports_url(0, format: 'uoc')
+
+ assert @response.body == file_fixture('all.uoc.tex').read
+ assert @response.media_type == 'application/x-tex'
+ end
+end
diff --git a/test/fixtures/action_text/rich_texts.yml b/test/fixtures/action_text/rich_texts.yml
new file mode 100644
index 0000000..8c95217
--- /dev/null
+++ b/test/fixtures/action_text/rich_texts.yml
@@ -0,0 +1,4 @@
+comment1:
+ record: comment1 (Comment)
+ name: content
+ body: <p>Such awesome content from Miquel</p>
diff --git a/test/fixtures/comments.yml b/test/fixtures/comments.yml
new file mode 100644
index 0000000..f61437d
--- /dev/null
+++ b/test/fixtures/comments.yml
@@ -0,0 +1,2 @@
+comment1:
+ thing_id: <%= ActiveRecord::FixtureSet.identify(:thing1) %>
diff --git a/test/fixtures/files/all.uoc.tex b/test/fixtures/files/all.uoc.tex
new file mode 100644
index 0000000..227a2bb
--- /dev/null
+++ b/test/fixtures/files/all.uoc.tex
@@ -0,0 +1,9 @@
+\chapter{Bibliografia}
+
+{\setlength{\parskip}{-0.3cm}
+
+\hypertarget{target2}{\textsc{Sabaté}, Miquel; \textsc{Nom} (eds.) (2024). \emph{This is a 2}. Dins de \emph{Some other thing}, 22-24. This is a note 2. Publisher 2, Address 2: 2024.}
+
+\\~\\\hypertarget{target1}{\textsc{Sabaté}, Miquel; \textsc{Smith}, John; \textsc{cognom2}, Nom cognom1; \textsc{cognom3 cognom4}, Nom (2024). \emph{This is a title 1}. Dins de \emph{Some other thing}, 22-24. This is a note 1. Publisher 1. [\url{http://example.com}]. [Última data de consulta: 24 de febrer de 2024].}
+
+}
diff --git a/test/fixtures/searches.yml b/test/fixtures/searches.yml
new file mode 100644
index 0000000..ffc7a28
--- /dev/null
+++ b/test/fixtures/searches.yml
@@ -0,0 +1,4 @@
+search1:
+ name: 'search1'
+ body: "tag:\"tag1\""
+ user_id: <%= ActiveRecord::FixtureSet.identify(:user) %>
diff --git a/test/fixtures/tag_references.yml b/test/fixtures/tag_references.yml
new file mode 100644
index 0000000..d6267d5
--- /dev/null
+++ b/test/fixtures/tag_references.yml
@@ -0,0 +1,15 @@
+thing1tag1:
+ tag_id: <%= ActiveRecord::FixtureSet.identify(:tag1) %>
+ taggable: thing1 (Thing)
+
+thing1tag2:
+ tag_id: <%= ActiveRecord::FixtureSet.identify(:tag2) %>
+ taggable: thing1 (Thing)
+
+thing2tag1:
+ tag_id: <%= ActiveRecord::FixtureSet.identify(:tag1) %>
+ taggable: thing2 (Thing)
+
+comment1tag1:
+ tag_id: <%= ActiveRecord::FixtureSet.identify(:tag1) %>
+ taggable: comment1 (Comment)
diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml
new file mode 100644
index 0000000..1e0ce52
--- /dev/null
+++ b/test/fixtures/tags.yml
@@ -0,0 +1,5 @@
+tag1:
+ name: 'tag1'
+
+tag2:
+ name: 'tag2'
diff --git a/test/fixtures/things.yml b/test/fixtures/things.yml
new file mode 100644
index 0000000..069cf85
--- /dev/null
+++ b/test/fixtures/things.yml
@@ -0,0 +1,39 @@
+thing1:
+ target: 'target1'
+ title: 'This is a title 1'
+ publisher: 'Publisher 1'
+ authors: 'Miquel Sabaté, John Smith, Nom cognom1 cognom2, Nom _cognom3 cognom4_'
+ editors: false
+ address:
+ year: 2024
+ url: 'http://example.com'
+ location: 'Somewhere'
+ insideof: 'Some other thing'
+ pages: '22-24'
+ note: 'This is a note 1'
+ rate: 5
+ status: <%= Thing.statuses[:read] %>
+ kind: <%= Thing.kinds[:novel] %>
+ access: 2024-02-24 17:24:12
+ bought_at: 2024-02-24 17:24:12
+ user_id: <%= ActiveRecord::FixtureSet.identify(:user) %>
+
+thing2:
+ target: 'target2'
+ title: 'This is a 2'
+ publisher: 'Publisher 2'
+ authors: 'Miquel Sabaté, Nom'
+ editors: true
+ address: 'Address 2'
+ year: 2024
+ url: 'http://example.com'
+ location: 'Somewhere'
+ insideof: 'Some other thing'
+ pages: '22-24'
+ note: 'This is a note 2'
+ rate: 8
+ status: <%= Thing.statuses[:read] %>
+ kind: <%= Thing.kinds[:poetry] %>
+ access: 2024-02-24 17:24:12
+ bought_at: 2024-02-24 17:24:12
+ user_id: <%= ActiveRecord::FixtureSet.identify(:user) %>
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
new file mode 100644
index 0000000..66baf92
--- /dev/null
+++ b/test/fixtures/users.yml
@@ -0,0 +1,4 @@
+user:
+ username: user
+ password_digest: <%= BCrypt::Password.create("12341234", cost: 4) %>
+ last_search_id: null
diff --git a/test/models/comment_test.rb b/test/models/comment_test.rb
new file mode 100644
index 0000000..087963a
--- /dev/null
+++ b/test/models/comment_test.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class CommentTest < ActiveSupport::TestCase
+ test 'content is present' do
+ comment = Comment.new(thing_id: things(:thing1).id)
+ assert_raise(ActiveRecord::RecordInvalid) { comment.save! }
+
+ comment.content = 'whatever'
+ assert_difference('Comment.count') { comment.save! }
+ end
+
+ test 'has many tags through tag_references' do
+ comment = comments(:comment1)
+ assert comment.tags.size == 1
+ end
+end
diff --git a/test/models/search_test.rb b/test/models/search_test.rb
new file mode 100644
index 0000000..9614bc0
--- /dev/null
+++ b/test/models/search_test.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class SearchTest < ActiveSupport::TestCase
+ test 'name and body have to be present and unique' do
+ search = Search.new(user_id: users(:user).id)
+ assert_raise(ActiveRecord::RecordInvalid) { search.save! }
+
+ search.name = 'new search'
+ assert_raise(ActiveRecord::RecordInvalid) { search.save! }
+
+ search.name = nil
+ search.body = 'body'
+ assert_raise(ActiveRecord::RecordInvalid) { search.save! }
+
+ search.name = searches(:search1).name
+ assert_raise(ActiveRecord::RecordInvalid) { search.save! }
+
+ search.name = 'another'
+ search.body = searches(:search1).body
+ assert_raise(ActiveRecord::RecordInvalid) { search.save! }
+
+ search.body = 'another body'
+ assert_difference('Search.count') { search.save! }
+ end
+
+ ##
+ # Results.
+
+ test 'returns all things when an empty body is given' do
+ res = Search.new.results
+
+ assert res[:things].size == 2
+ assert res[:things][0][:target] == things(:thing2).target
+ assert res[:things][1][:target] == things(:thing1).target
+ end
+
+ test 'returns everything matching a specific tag' do
+ res = Search.new(body: "tag:'#{tags(:tag1).name}'").results
+
+ assert res[:things].size == 2
+ assert res[:things][0][:target] == things(:thing2).target
+ assert res[:things][1][:target] == things(:thing1).target
+ assert res[:comments].size == 1
+ assert res[:comments][0][:id] == comments(:comment1).id
+ end
+
+ test 'returns everything matching two tags' do
+ res = Search.new(body: "tag:'#{tags(:tag1).name}' tag:'#{tags(:tag2).name}'").results
+
+ assert res[:things].size == 1
+ assert res[:things][0][:target] == things(:thing1).target
+ assert res[:comments].empty?
+ end
+
+ test 'returns everything matching a given text' do
+ res = Search.new(body: 'Miquel').results
+
+ assert res[:things].size == 2
+ assert res[:things][0][:target] == things(:thing2).target
+ assert res[:things][1][:target] == things(:thing1).target
+ assert res[:comments].size == 1
+ assert res[:comments][0][:id] == comments(:comment1).id
+ end
+
+ test 'returns everything matching two tags and a given text' do
+ res = Search.new(body: "Miquel tag:'#{tags(:tag1).name}' tag:'#{tags(:tag2).name}'").results
+
+ assert res[:things].size == 1
+ assert res[:things][0][:target] == things(:thing1).target
+ assert res[:comments].empty?
+ end
+
+ test 'returns everything matching a given compound text' do
+ res = Search.new(body: 'some other').results
+
+ assert res[:things].size == 2
+ assert res[:things][0][:target] == things(:thing2).target
+ assert res[:things][1][:target] == things(:thing1).target
+ assert res[:comments].empty?
+ end
+
+ test 'returns an empty result with unknown fields' do
+ res = Search.new(body: 'whatever:"unknown"').results
+
+ assert res[:things].empty?
+ assert res[:comments].empty?
+ end
+end
diff --git a/test/models/tag_test.rb b/test/models/tag_test.rb
new file mode 100644
index 0000000..dafb8e4
--- /dev/null
+++ b/test/models/tag_test.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class TagTest < ActiveSupport::TestCase
+ test 'name is present and unique' do
+ tag = Tag.new
+ assert_raise(ActiveRecord::RecordInvalid) { tag.save! }
+
+ tag = Tag.new(name: tags(:tag1).name)
+ assert_raise(ActiveRecord::RecordInvalid) { tag.save! }
+
+ tag.name = 'another'
+ assert_difference('Tag.count') { tag.save! }
+ end
+end
diff --git a/test/models/thing_test.rb b/test/models/thing_test.rb
new file mode 100644
index 0000000..0b3961e
--- /dev/null
+++ b/test/models/thing_test.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class ThingTest < ActiveSupport::TestCase
+ test 'validates presence' do
+ assert_raise(ActiveRecord::RecordInvalid) { Thing.new.save! }
+
+ # Missing 'title'
+ assert_raise(ActiveRecord::RecordInvalid) do
+ Thing.new(target: 'target', authors: 'Author',
+ user_id: users(:user).id, rate: 5,
+ status: Thing.statuses[:read], kind: Thing.kinds[:novel]).save!
+ end
+
+ # Missing 'target'
+ assert_raise(ActiveRecord::RecordInvalid) do
+ Thing.new(title: 'title', authors: 'Author',
+ user_id: users(:user).id, rate: 5,
+ status: Thing.statuses[:read], kind: Thing.kinds[:novel]).save!
+ end
+
+ # Missing 'authors'
+ assert_raise(ActiveRecord::RecordInvalid) do
+ Thing.new(title: 'title', target: 'target',
+ user_id: users(:user).id, rate: 5,
+ status: Thing.statuses[:read], kind: Thing.kinds[:novel]).save!
+ end
+
+ # Valid!
+ assert_difference('Thing.count') do
+ Thing.new(title: 'title', target: 'target', authors: 'Author',
+ user_id: users(:user).id, rate: 5,
+ status: Thing.statuses[:read], kind: Thing.kinds[:novel]).save!
+ end
+ end
+
+ test "'rate' has to be between 0 and 10" do
+ thing = things(:thing1)
+
+ thing.rate = -1
+ assert_raise(ActiveRecord::RecordInvalid) { thing.save! }
+
+ thing.rate = 11
+ assert_raise(ActiveRecord::RecordInvalid) { thing.save! }
+
+ thing.rate = 5
+ thing.save!
+ end
+
+ test "'status' has to have a value as defined by its enum" do
+ thing = things(:thing1)
+
+ thing.status = 'whatever'
+ assert_raise(ActiveRecord::RecordInvalid) { thing.save! }
+
+ thing.status = Thing.statuses[:tobepublished]
+ thing.save!
+ end
+
+ test "'kind' has to have a value as defined by its enum" do
+ thing = things(:thing1)
+
+ thing.kind = 'whatever'
+ assert_raise(ActiveRecord::RecordInvalid) { thing.save! }
+
+ thing.kind = Thing.kinds[:other]
+ thing.save!
+ end
+
+ test 'title and target must be unique' do
+ thing = things(:thing1).dup
+
+ thing.title = 'another'
+ assert_raise(ActiveRecord::RecordInvalid) { thing.save! }
+
+ thing.target = 'also another'
+ thing.title = things(:thing1).title
+ assert_raise(ActiveRecord::RecordInvalid) { thing.save! }
+
+ thing.target = 'also another'
+ thing.title = 'now for real'
+ assert_difference('Thing.count') { thing.save! }
+ end
+
+ test 'has many comments' do
+ thing = things(:thing1)
+ assert thing.comments.size == 1
+ end
+
+ test 'has many tags through tag_references' do
+ thing = things(:thing1)
+ assert thing.tags.size == 2
+ end
+end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
new file mode 100644
index 0000000..6ff5e19
--- /dev/null
+++ b/test/models/user_test.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+class UserTest < ActiveSupport::TestCase
+ test 'has to provide username and password' do
+ user = User.new
+ assert_raise(ActiveRecord::RecordInvalid) { user.save! }
+
+ user.username = 'whatever'
+ assert_raise(ActiveRecord::RecordInvalid) { user.save! }
+
+ user.password = '1234'
+ user.password_confirmation = '12345'
+ assert_raise(ActiveRecord::RecordInvalid) { user.save! }
+
+ user.password_confirmation = '1234'
+ assert_difference('User.count') do
+ user.save!
+ end
+ end
+
+ test 'username has to be unique' do
+ user = User.new(username: users(:user).username,
+ password: '1234', password_confirmation: '1234')
+ assert_raise(ActiveRecord::RecordInvalid) { user.save! }
+ end
+end
diff --git a/test/system/comments_test.rb b/test/system/comments_test.rb
new file mode 100644
index 0000000..b2490b4
--- /dev/null
+++ b/test/system/comments_test.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require 'application_system_test_case'
+
+class CommentsTest < ApplicationSystemTestCase
+ setup { sign_in! }
+
+ ##
+ # In things#show
+
+ test 'things#show: the hidden form is shown when clicking the proper button' do
+ visit thing_url(things(:thing1))
+
+ click_on I18n.t('comments.new')
+ assert_text I18n.t('tags.new-action'), count: 1
+
+ click_on I18n.t('general.cancel')
+ assert_text I18n.t('tags.new-action'), count: 0
+ end
+
+ test 'things#show: you can add a new comment' do
+ visit thing_url(things(:thing1))
+
+ click_on I18n.t('comments.new')
+ find(:css, '#comment_content').set('a new comment')
+ find("#comment_tag_ids_#{tags(:tag1).id}").click
+ find("#comment_tag_ids_#{tags(:tag2).id}").click
+
+ click_on I18n.t('helpers.submit.create')
+
+ assert_text "#{I18n.t('comments.title')} #2"
+ assert_text 'a new comment'
+ assert_text "#{I18n.t('tags.title')}: #{tags(:tag1).name}, #{tags(:tag2).name}"
+ assert page.current_path == thing_path(things(:thing1))
+ end
+
+ test 'things#show: you can update a comment' do
+ visit thing_url(things(:thing1))
+
+ click_on I18n.t('general.edit').capitalize
+ find(:css, '#comment_content').set('updated comment')
+
+ find("#comment_#{comments(:comment1).id} input[type=submit]").click
+ assert_text 'updated comment'
+ assert page.current_path == thing_path(things(:thing1))
+ end
+
+ test 'things#show: you can delete a comment' do
+ visit thing_url(things(:thing1))
+
+ all('.delete-comment').last.click
+ assert_text I18n.t('comments.none')
+ assert page.current_path == thing_path(things(:thing1))
+ end
+
+ ##
+ # In things#edit
+
+ test 'things#edit: the hidden form is shown when clicking the proper button' do
+ visit edit_thing_url(things(:thing1))
+
+ click_on I18n.t('comments.new')
+ assert_text I18n.t('tags.new-action'), count: 2
+
+ click_on I18n.t('general.cancel')
+ assert_text I18n.t('tags.new-action'), count: 1
+ end
+
+ test 'things#edit: you can add a new comment' do
+ visit edit_thing_url(things(:thing1))
+
+ click_on I18n.t('comments.new')
+ find(:css, '#comment_content').set('a new comment')
+ find("#comment_tag_ids_#{tags(:tag1).id}").click
+ find("#comment_tag_ids_#{tags(:tag2).id}").click
+
+ click_on I18n.t('helpers.submit.create')
+
+ assert_text "#{I18n.t('comments.title')} #2"
+ assert_text 'a new comment'
+ assert_text "#{I18n.t('tags.title')}: #{tags(:tag1).name}, #{tags(:tag2).name}"
+ assert page.current_path == edit_thing_path(things(:thing1))
+ end
+
+ test 'things#edit: you can update a comment' do
+ visit edit_thing_url(things(:thing1))
+
+ click_on I18n.t('general.edit').capitalize
+ find(:css, '#comment_content').set('updated comment')
+
+ find("#comment_#{comments(:comment1).id} input[type=submit]").click
+ assert_text 'updated comment'
+ assert page.current_path == edit_thing_path(things(:thing1))
+ end
+
+ test 'things#edit: you can delete a comment' do
+ visit edit_thing_url(things(:thing1))
+
+ all('.delete-comment').last.click
+ assert_text I18n.t('comments.none')
+ assert page.current_path == edit_thing_path(things(:thing1))
+ end
+end
diff --git a/test/system/exports_test.rb b/test/system/exports_test.rb
new file mode 100644
index 0000000..b324321
--- /dev/null
+++ b/test/system/exports_test.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'application_system_test_case'
+
+class ExportsTest < ApplicationSystemTestCase
+ setup do
+ sign_in!
+ end
+
+ test 'can access exports from the hidden menu' do
+ find('#toggle-hidden-global-menu').click
+ assert_text I18n.t('searches.export.action')
+
+ click_on I18n.t('searches.export.action')
+ assert_text I18n.t('searches.export.note-msg')
+ assert page.current_path == new_search_exports_path(0)
+ end
+
+ test 'can export using multiple formats' do
+ visit new_search_exports_path(0)
+
+ assert find('#export-format-button')[:href].ends_with?('/searches/0/exports.csv')
+
+ find('#export-select-format').select('UOC')
+ assert find('#export-format-button')[:href].ends_with?('/searches/0/exports.uoc')
+ end
+end
diff --git a/test/system/licenses_test.rb b/test/system/licenses_test.rb
new file mode 100644
index 0000000..1e26664
--- /dev/null
+++ b/test/system/licenses_test.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'application_system_test_case'
+
+class LicensesTest < ApplicationSystemTestCase
+ test 'can reach #show without login' do
+ visit license_url
+
+ assert_text 'AGPLv3'
+ end
+end
diff --git a/test/system/searches_test.rb b/test/system/searches_test.rb
new file mode 100644
index 0000000..a400e71
--- /dev/null
+++ b/test/system/searches_test.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require 'application_system_test_case'
+
+class SearchesTest < ApplicationSystemTestCase
+ setup do
+ # All actions will require the user to be logged in.
+ sign_in!
+ end
+
+ test 'root url is searches#index, which contains all things plus the tabs we expect' do
+ visit root_url
+
+ # All searches are listed.
+ assert_text I18n.t('searches.home')
+ searches.each { |s| assert_text s.name }
+
+ # All things are listed
+ things.each { |t| assert_text t.title }
+ end
+
+ test 'the hidden menu should allow us to go into the searches#new url' do
+ find('#toggle-hidden-global-menu').click
+ assert_text I18n.t('searches.title')
+
+ click_on I18n.t('searches.object')
+ assert find('#search_body')
+ assert page.current_path == new_search_path
+ end
+
+ test 'can perform a new search' do
+ visit new_search_url
+
+ # Fill in the body input and press enter, otherwise the javascript
+ # controller won't fire up.
+ fill_in 'search_body', with: 'Miquel'
+ find('#search_body').native.send_keys(:return)
+
+ # It's actually a pretty generous search: it should give us all thing and a
+ # comment.
+ assert_text I18n.t('searches.save')
+ assert_text things(:thing1).title
+ assert_text things(:thing2).title
+ assert_text I18n.t('comments.title')
+ assert_text tags(:tag1).name
+ end
+
+ test 'can save a new search' do
+ visit new_search_url
+
+ # Fill in the body input and press enter, otherwise the javascript
+ # controller won't fire up.
+ fill_in 'search_body', with: 'Autor'
+ find('#search_body').native.send_keys(:return)
+
+ # Hit the save button so the hidden form appears.
+ assert_text I18n.t('searches.save')
+ find('#save-search').click
+ assert_text I18n.t('searches.public')
+
+ fill_in 'Name', with: 'Nova cerca'
+ click_on I18n.t('helpers.submit.create')
+
+ assert_text 'Nova cerca'
+ assert page.current_path == search_path(Search.find_by(name: 'Nova cerca'))
+ end
+
+ test 'can edit a search that is not the Home' do
+ # Home cannot be edited.
+ find('#toggle-hidden-global-menu').click
+ assert_selector 'a', text: I18n.t('general.edit'), count: 0
+
+ # Select search1
+ click_on searches(:search1).name
+ assert_selector 'a', text: things(:thing1).title, count: 2
+ find('#toggle-hidden-global-menu').click
+
+ # It can be edited!
+ assert_text I18n.t('general.edit')
+ click_on I18n.t('general.edit')
+
+ # If you click on it, you will be on the edit page for it.
+ assert find('#search_body')
+ assert page.current_path == edit_search_path(searches(:search1))
+ end
+
+ test 'edit an existing search works' do
+ visit edit_search_url(searches(:search1))
+
+ find('#search_body').native.send_keys(:return)
+
+ # Hit the save button so the hidden form appears.
+ assert_text I18n.t('searches.save')
+ find('#save-search').click
+ assert_text I18n.t('searches.public')
+
+ # Update the name.
+ fill_in 'Name', with: 'Nova cerca'
+ click_on I18n.t('helpers.submit.update')
+
+ # We have been redirected to search#show, and the name has been refreshed.
+ assert_selector 'a', text: searches(:search1).name, count: 0
+ assert_text 'Nova cerca'
+ assert page.current_path == search_path(searches(:search1))
+ end
+
+ test 'can delete a search that is not the Home' do
+ # Home cannot be deleted.
+ find('#toggle-hidden-global-menu').click
+ assert_selector 'a', text: I18n.t('general.delete'), count: 0
+
+ # Select search1
+ click_on searches(:search1).name
+ assert_selector 'a', text: things(:thing1).title, count: 2
+ find('#toggle-hidden-global-menu').click
+
+ # It can be deleted!
+ assert_text I18n.t('general.delete')
+ accept_alert { click_on I18n.t('general.delete') }
+ assert_selector 'a', text: searches(:search1).name, count: 0
+ assert Search.none?
+ end
+end
diff --git a/test/system/sessions_test.rb b/test/system/sessions_test.rb
new file mode 100644
index 0000000..2d01eef
--- /dev/null
+++ b/test/system/sessions_test.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'application_system_test_case'
+
+class SessionsTest < ApplicationSystemTestCase
+ test 'logs in successfully' do
+ sign_in!
+ end
+
+ test 'fails to log in with wrong password' do
+ visit new_sessions_url
+
+ fill_in I18n.t('activerecord.attributes.user.username'), with: users(:user).username
+ fill_in I18n.t('activerecord.attributes.user.password'), with: 'I AM ERROR'
+ click_on I18n.t('sessions.sign-in')
+
+ assert_text I18n.t('sessions.wrong-credentials')
+ end
+
+ test 'logs out successfully' do
+ sign_in!
+
+ click_on I18n.t('sessions.sign-out')
+
+ assert_text I18n.t('sessions.title')
+ end
+end
diff --git a/test/system/shared_searches_test.rb b/test/system/shared_searches_test.rb
new file mode 100644
index 0000000..0cfc4bd
--- /dev/null
+++ b/test/system/shared_searches_test.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'application_system_test_case'
+
+class SharedSearchesTest < ApplicationSystemTestCase
+ test 'can access shared_searches#index from the hidden menu' do
+ sign_in!
+
+ find('#toggle-hidden-global-menu').click
+ assert_text I18n.t('searches.shared.title').downcase
+
+ click_on I18n.t('searches.shared.title').downcase
+ assert_text I18n.t('searches.shared.none')
+ end
+
+ test 'cannot access shared_searches#index if not logged in' do
+ sign_out_maybe!
+
+ visit shared_searches_path
+ assert_text I18n.t('sessions.title')
+ assert page.current_path == new_sessions_path
+ end
+
+ test 'shared_searches#show gives us the expected results' do
+ # NOTE: not logged in!
+
+ searches(:search1).update!(shared: true)
+
+ visit search_shared_path(searches(:search1))
+ assert_text things(:thing1).title
+ assert_text things(:thing2).title
+ assert_text I18n.t('comments.title')
+ end
+end
diff --git a/test/system/tags_test.rb b/test/system/tags_test.rb
new file mode 100644
index 0000000..1ee83a9
--- /dev/null
+++ b/test/system/tags_test.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'application_system_test_case'
+
+class SharedSearchesTest < ApplicationSystemTestCase
+ setup { sign_in! }
+
+ test 'lists all the tags' do
+ visit tags_url
+
+ tags.each { |t| assert_text t.name }
+ end
+
+ test 'can create a new tag' do
+ visit new_tag_url
+
+ fill_in I18n.t('activerecord.attributes.tag.name'), with: 'whatever'
+ assert_difference 'Tag.count' do
+ click_on I18n.t('helpers.submit.create')
+ assert_text 'whatever'
+ end
+ end
+
+ test 'gives feedback on errors' do
+ visit new_tag_url
+
+ fill_in I18n.t('activerecord.attributes.tag.name'), with: tags(:tag1).name
+ click_on I18n.t('helpers.submit.create')
+ assert_text "#{I18n.t('activerecord.attributes.tag.name')} #{I18n.t('errors.messages.taken')}"
+ end
+
+ test 'can delete an existing tag' do
+ visit tags_url
+
+ assert_difference 'Tag.count', -1 do
+ click_link(I18n.t('general.delete'), match: :first)
+ assert_text I18n.t('tags.title')
+ end
+ end
+end
diff --git a/test/system/things_test.rb b/test/system/things_test.rb
new file mode 100644
index 0000000..9dd482d
--- /dev/null
+++ b/test/system/things_test.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require 'application_system_test_case'
+
+class ThingsTest < ApplicationSystemTestCase
+ setup { sign_in! }
+
+ test 'the hidden menu has a things#new link' do
+ find('#toggle-hidden-global-menu').click
+ assert_text I18n.t('things.object').downcase
+
+ click_on I18n.t('things.object').downcase
+ assert_text I18n.t('activerecord.attributes.thing.title')
+
+ assert page.current_path == new_thing_path
+ end
+
+ test 'you can click on a thing listed on the search to go to the its #show page' do
+ click_on things(:thing1).title
+
+ assert_text I18n.t('activerecord.attributes.thing.title')
+
+ assert page.current_path == edit_thing_path(things(:thing1))
+ end
+
+ test 'you can create a new thing' do
+ visit new_thing_url
+
+ fill_in I18n.t('activerecord.attributes.thing.title'), with: 'new title'
+ fill_in I18n.t('activerecord.attributes.thing.authors'), with: 'author'
+ fill_in I18n.t('activerecord.attributes.thing.target'), with: 'identifier'
+ fill_in I18n.t('activerecord.attributes.thing.rate'), with: 5
+ find("#thing_tag_ids_#{tags(:tag1).id}").click
+
+ click_on I18n.t('helpers.submit.create')
+
+ assert_text I18n.t('things.create-success')
+
+ # Ensure that tag references are properly created.
+ tags = Thing.find_by(title: 'new title').tags
+ assert tags.size == 1
+ assert tags.first.name == tags(:tag1).name
+
+ # You can go back to create another thing with a convenient link.
+ click_on I18n.t('things.create-another')
+ assert_text I18n.t('activerecord.attributes.thing.title')
+ assert page.current_path == new_thing_path
+ end
+
+ test 'you get feedback from wrong values for a new thing' do
+ visit new_thing_url
+
+ fill_in I18n.t('activerecord.attributes.thing.title'), with: things(:thing1).title
+ fill_in I18n.t('activerecord.attributes.thing.authors'), with: 'author'
+ fill_in I18n.t('activerecord.attributes.thing.target'), with: 'identifier'
+ fill_in I18n.t('activerecord.attributes.thing.rate'), with: 5
+ find("#thing_tag_ids_#{tags(:tag1).id}").click
+
+ click_on I18n.t('helpers.submit.create')
+
+ assert_text "#{I18n.t('activerecord.attributes.thing.title')} " \
+ "#{I18n.t('errors.messages.taken')}"
+ end
+
+ test 'you can update a value from a thing' do
+ visit edit_thing_url(things(:thing1))
+
+ # Originally :thing1 has references to :tag1 and :tag2. This test will
+ # remove the reference to :tag1.
+ assert things(:thing1).tag_references.size == 2
+
+ fill_in I18n.t('activerecord.attributes.thing.title'), with: 'another thing entirely'
+ find("#thing_tag_ids_#{tags(:tag1).id}").click
+ click_on I18n.t('helpers.submit.update')
+
+ assert_text I18n.t('things.update-success')
+
+ # Tag references updated: only one reference (:tag2).
+ tags = things(:thing1).reload.tags
+ assert tags.size == 1
+ assert tags.first.id = tags(:tag2).id
+ end
+
+ test 'you get feedback from wrong values for a thing' do
+ visit edit_thing_url(things(:thing1))
+
+ fill_in I18n.t('activerecord.attributes.thing.title'), with: things(:thing2).title
+ click_on I18n.t('helpers.submit.update')
+
+ assert_text "#{I18n.t('activerecord.attributes.thing.title')} " \
+ "#{I18n.t('errors.messages.taken')}"
+ end
+
+ test 'you can delete a thing' do
+ visit edit_thing_url(things(:thing1))
+
+ assert_difference 'Thing.count', -1 do
+ accept_alert { click_on I18n.t('general.delete').capitalize, match: :first }
+ assert_text I18n.t('things.destroy-success')
+ end
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644
index 0000000..b13faef
--- /dev/null
+++ b/test/test_helper.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+ENV['RAILS_ENV'] ||= 'test'
+require_relative '../config/environment'
+require 'rails/test_help'
+
+# It helps when writing tests.
+require 'ap'
+
+module ActiveSupport
+ class TestCase
+ # Run tests in parallel with specified workers
+ parallelize(workers: :number_of_processors)
+
+ # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
+ fixtures :all
+ end
+end
diff --git a/tmp/.keep b/tmp/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tmp/.keep
diff --git a/tmp/pids/.keep b/tmp/pids/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tmp/pids/.keep
diff --git a/tmp/storage/.keep b/tmp/storage/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tmp/storage/.keep
diff --git a/vendor/.keep b/vendor/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/vendor/.keep
diff --git a/vendor/assets/stylesheets/simple.css b/vendor/assets/stylesheets/simple.css
new file mode 100644
index 0000000..788d069
--- /dev/null
+++ b/vendor/assets/stylesheets/simple.css
@@ -0,0 +1,698 @@
+/* Global variables. */
+:root,
+::backdrop {
+ /* Set sans-serif & mono fonts */
+ --sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir,
+ "Nimbus Sans L", Roboto, "Noto Sans", "Segoe UI", Arial, Helvetica,
+ "Helvetica Neue", sans-serif;
+ --mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
+ --standard-border-radius: 5px;
+
+ /* Default (light) theme */
+ --bg: #fff;
+ --accent-bg: #f5f7ff;
+ --text: #212121;
+ --text-light: #585858;
+ --border: #898EA4;
+ --accent: #0d47a1;
+ --accent-hover: #1266e2;
+ --accent-text: var(--bg);
+ --code: #d81b60;
+ --preformatted: #444;
+ --marked: #ffdd33;
+ --disabled: #efefef;
+}
+
+/* Dark theme */
+@media (prefers-color-scheme: dark) {
+ :root,
+ ::backdrop {
+ color-scheme: dark;
+ --bg: #212121;
+ --accent-bg: #2b2b2b;
+ --text: #dcdcdc;
+ --text-light: #ababab;
+ --accent: #ffb300;
+ --accent-hover: #ffe099;
+ --accent-text: var(--bg);
+ --code: #f06292;
+ --preformatted: #ccc;
+ --disabled: #111;
+ }
+ /* Add a bit of transparency so light media isn't so glaring in dark mode */
+ img,
+ video {
+ opacity: 0.8;
+ }
+}
+
+/* Reset box-sizing */
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+/* Reset default appearance */
+textarea,
+select,
+input,
+progress {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+}
+
+html {
+ /* Set the font globally */
+ font-family: var(--sans-font);
+ scroll-behavior: smooth;
+}
+
+/* Make the body a nice central block */
+body {
+ color: var(--text);
+ background-color: var(--bg);
+ font-size: 1.15rem;
+ line-height: 1.5;
+ display: grid;
+ grid-template-columns: 1fr min(45rem, 90%) 1fr;
+ margin: 0;
+}
+body > * {
+ grid-column: 2;
+}
+
+/* Make the header bg full width, but the content inline with body */
+body > header {
+ background-color: var(--accent-bg);
+ border-bottom: 1px solid var(--border);
+ text-align: center;
+ padding: 0 0.5rem 2rem 0.5rem;
+ grid-column: 1 / -1;
+}
+
+body > header > *:only-child {
+ margin-block-start: 2rem;
+}
+
+body > header h1 {
+ max-width: 1200px;
+ margin: 1rem auto;
+}
+
+body > header p {
+ max-width: 40rem;
+ margin: 1rem auto;
+}
+
+/* Add a little padding to ensure spacing is correct between content and header > nav */
+main {
+ padding-top: 1.5rem;
+}
+
+body > footer {
+ margin-top: 4rem;
+ padding: 2rem 1rem 1.5rem 1rem;
+ color: var(--text-light);
+ font-size: 0.9rem;
+ text-align: center;
+ border-top: 1px solid var(--border);
+}
+
+/* Format headers */
+h1 {
+ font-size: 3rem;
+}
+
+h2 {
+ font-size: 2.6rem;
+ margin-top: 3rem;
+}
+
+h3 {
+ font-size: 2rem;
+ margin-top: 3rem;
+}
+
+h4 {
+ font-size: 1.44rem;
+}
+
+h5 {
+ font-size: 1.15rem;
+}
+
+h6 {
+ font-size: 0.96rem;
+}
+
+p {
+ margin: 1.5rem 0;
+}
+
+/* Prevent long strings from overflowing container */
+p, h1, h2, h3, h4, h5, h6 {
+ overflow-wrap: break-word;
+}
+
+/* Fix line height when title wraps */
+h1,
+h2,
+h3 {
+ line-height: 1.1;
+}
+
+/* Reduce header size on mobile */
+@media only screen and (max-width: 720px) {
+ h1 {
+ font-size: 2.5rem;
+ }
+
+ h2 {
+ font-size: 2.1rem;
+ }
+
+ h3 {
+ font-size: 1.75rem;
+ }
+
+ h4 {
+ font-size: 1.25rem;
+ }
+}
+
+/* Format links & buttons */
+a,
+a:visited {
+ color: var(--accent);
+}
+
+a:hover {
+ text-decoration: none;
+}
+
+button,
+.button,
+a.button, /* extra specificity to override a */
+input[type="submit"],
+input[type="reset"],
+input[type="button"],
+label[type="button"] {
+ border: 1px solid var(--accent);
+ background-color: var(--accent);
+ color: var(--accent-text);
+ padding: 0.5rem 0.9rem;
+ text-decoration: none;
+ line-height: normal;
+}
+
+.button[aria-disabled="true"],
+input:disabled,
+textarea:disabled,
+select:disabled,
+button[disabled] {
+ cursor: not-allowed;
+ background-color: var(--disabled);
+ border-color: var(--disabled);
+ color: var(--text-light);
+}
+
+input[type="range"] {
+ padding: 0;
+}
+
+/* Set the cursor to '?' on an abbreviation and style the abbreviation to show that there is more information underneath */
+abbr[title] {
+ cursor: help;
+ text-decoration-line: underline;
+ text-decoration-style: dotted;
+}
+
+button:enabled:hover,
+.button:not([aria-disabled="true"]):hover,
+input[type="submit"]:enabled:hover,
+input[type="reset"]:enabled:hover,
+input[type="button"]:enabled:hover,
+label[type="button"]:hover {
+ background-color: var(--accent-hover);
+ border-color: var(--accent-hover);
+ cursor: pointer;
+}
+
+.button:focus-visible,
+button:focus-visible:where(:enabled),
+input:enabled:focus-visible:where(
+ [type="submit"],
+ [type="reset"],
+ [type="button"]
+) {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* Format navigation */
+header > nav {
+ font-size: 1rem;
+ line-height: 2;
+ padding: 1rem 0 0 0;
+}
+
+/* Use flexbox to allow items to wrap, as needed */
+header > nav ul,
+header > nav ol {
+ align-content: space-around;
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+
+/* List items are inline elements, make them behave more like blocks */
+header > nav ul li,
+header > nav ol li {
+ display: inline-block;
+}
+
+header > nav a,
+header > nav a:visited {
+ margin: 0 0.5rem 1rem 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--standard-border-radius);
+ color: var(--text);
+ display: inline-block;
+ padding: 0.1rem 1rem;
+ text-decoration: none;
+}
+
+header > nav a:hover,
+header > nav a.current,
+header > nav a[aria-current="page"] {
+ border-color: var(--accent);
+ color: var(--accent);
+ cursor: pointer;
+}
+
+/* Reduce nav side on mobile */
+@media only screen and (max-width: 720px) {
+ header > nav a {
+ border: none;
+ padding: 0;
+ text-decoration: underline;
+ line-height: 1;
+ }
+}
+
+/* Consolidate box styling */
+aside, details, pre, progress {
+ background-color: var(--accent-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--standard-border-radius);
+ margin-bottom: 1rem;
+}
+
+aside {
+ font-size: 1rem;
+ width: 30%;
+ padding: 0 15px;
+ margin-inline-start: 15px;
+ float: right;
+}
+*[dir="rtl"] aside {
+ float: left;
+}
+
+/* Make aside full-width on mobile */
+@media only screen and (max-width: 720px) {
+ aside {
+ width: 100%;
+ float: none;
+ margin-inline-start: 0;
+ }
+}
+
+article, fieldset, dialog {
+ border: 1px solid var(--border);
+ padding: 1rem;
+ border-radius: var(--standard-border-radius);
+ margin-bottom: 1rem;
+}
+
+article h2:first-child,
+section h2:first-child {
+ margin-top: 1rem;
+}
+
+section {
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+ padding: 2rem 1rem;
+ margin: 3rem 0;
+}
+
+/* Don't double separators when chaining sections */
+section + section,
+section:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+section:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
+}
+
+details {
+ padding: 0.7rem 1rem;
+}
+
+summary {
+ cursor: pointer;
+ font-weight: bold;
+ padding: 0.7rem 1rem;
+ margin: -0.7rem -1rem;
+ word-break: break-all;
+}
+
+details[open] > summary + * {
+ margin-top: 0;
+}
+
+details[open] > summary {
+ margin-bottom: 0.5rem;
+}
+
+details[open] > :last-child {
+ margin-bottom: 0;
+}
+
+/* Format tables */
+table {
+ border-collapse: collapse;
+ margin: 1.5rem 0;
+}
+
+figure > table {
+ width: max-content;
+}
+
+td,
+th {
+ border: 1px solid var(--border);
+ text-align: start;
+ padding: 0.5rem;
+}
+
+th {
+ background-color: var(--accent-bg);
+ font-weight: bold;
+}
+
+tr:nth-child(even) {
+ /* Set every other cell slightly darker. Improves readability. */
+ background-color: var(--accent-bg);
+}
+
+table caption {
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
+
+/* Format forms */
+textarea,
+select,
+input,
+button,
+.button {
+ font-size: inherit;
+ font-family: inherit;
+ padding: 0.5rem;
+ margin-bottom: 0.5rem;
+ border-radius: var(--standard-border-radius);
+ box-shadow: none;
+ max-width: 100%;
+ display: inline-block;
+}
+textarea,
+select,
+input {
+ color: var(--text);
+ background-color: var(--bg);
+ border: 1px solid var(--border);
+}
+label {
+ display: block;
+}
+textarea:not([cols]) {
+ width: 100%;
+}
+
+/* Add arrow to drop-down */
+select:not([multiple]) {
+ background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%),
+ linear-gradient(135deg, var(--text) 51%, transparent 49%);
+ background-position: calc(100% - 15px), calc(100% - 10px);
+ background-size: 5px 5px, 5px 5px;
+ background-repeat: no-repeat;
+ padding-inline-end: 25px;
+}
+*[dir="rtl"] select:not([multiple]) {
+ background-position: 10px, 15px;
+}
+
+/* checkbox and radio button style */
+input[type="checkbox"],
+input[type="radio"] {
+ vertical-align: middle;
+ position: relative;
+ width: min-content;
+}
+
+input[type="checkbox"] + label,
+input[type="radio"] + label {
+ display: inline-block;
+}
+
+input[type="radio"] {
+ border-radius: 100%;
+}
+
+input[type="checkbox"]:checked,
+input[type="radio"]:checked {
+ background-color: var(--accent);
+}
+
+input[type="checkbox"]:checked::after {
+ /* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */
+ content: " ";
+ width: 0.18em;
+ height: 0.32em;
+ border-radius: 0;
+ position: absolute;
+ top: 0.05em;
+ left: 0.17em;
+ background-color: transparent;
+ border-right: solid var(--bg) 0.08em;
+ border-bottom: solid var(--bg) 0.08em;
+ font-size: 1.8em;
+ transform: rotate(45deg);
+}
+input[type="radio"]:checked::after {
+ /* creates a colored circle for the checked radio button */
+ content: " ";
+ width: 0.25em;
+ height: 0.25em;
+ border-radius: 100%;
+ position: absolute;
+ top: 0.125em;
+ background-color: var(--bg);
+ left: 0.125em;
+ font-size: 32px;
+}
+
+/* Makes input fields wider on smaller screens */
+@media only screen and (max-width: 720px) {
+ textarea,
+ select,
+ input {
+ width: 100%;
+ }
+}
+
+/* Set a height for color input */
+input[type="color"] {
+ height: 2.5rem;
+ padding: 0.2rem;
+}
+
+/* do not show border around file selector button */
+input[type="file"] {
+ border: 0;
+}
+
+/* Misc body elements */
+hr {
+ border: none;
+ height: 1px;
+ background: var(--border);
+ margin: 1rem auto;
+}
+
+mark {
+ padding: 2px 5px;
+ border-radius: var(--standard-border-radius);
+ background-color: var(--marked);
+ color: black;
+}
+
+mark a {
+ color: #0d47a1;
+}
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--standard-border-radius);
+}
+
+figure {
+ margin: 0;
+ display: block;
+ overflow-x: auto;
+}
+
+figcaption {
+ text-align: center;
+ font-size: 0.9rem;
+ color: var(--text-light);
+ margin-bottom: 1rem;
+}
+
+blockquote {
+ margin-inline-start: 2rem;
+ margin-inline-end: 0;
+ margin-block: 2rem;
+ padding: 0.4rem 0.8rem;
+ border-inline-start: 0.35rem solid var(--accent);
+ color: var(--text-light);
+ font-style: italic;
+}
+
+cite {
+ font-size: 0.9rem;
+ color: var(--text-light);
+ font-style: normal;
+}
+
+dt {
+ color: var(--text-light);
+}
+
+/* Use mono font for code elements */
+code,
+pre,
+pre span,
+kbd,
+samp {
+ font-family: var(--mono-font);
+ color: var(--code);
+}
+
+kbd {
+ color: var(--preformatted);
+ border: 1px solid var(--preformatted);
+ border-bottom: 3px solid var(--preformatted);
+ border-radius: var(--standard-border-radius);
+ padding: 0.1rem 0.4rem;
+}
+
+pre {
+ padding: 1rem 1.4rem;
+ max-width: 100%;
+ overflow: auto;
+ color: var(--preformatted);
+}
+
+/* Fix embedded code within pre */
+pre code {
+ color: var(--preformatted);
+ background: none;
+ margin: 0;
+ padding: 0;
+}
+
+/* Progress bars */
+/* Declarations are repeated because you */
+/* cannot combine vendor-specific selectors */
+progress {
+ width: 100%;
+}
+
+progress:indeterminate {
+ background-color: var(--accent-bg);
+}
+
+progress::-webkit-progress-bar {
+ border-radius: var(--standard-border-radius);
+ background-color: var(--accent-bg);
+}
+
+progress::-webkit-progress-value {
+ border-radius: var(--standard-border-radius);
+ background-color: var(--accent);
+}
+
+progress::-moz-progress-bar {
+ border-radius: var(--standard-border-radius);
+ background-color: var(--accent);
+ transition-property: width;
+ transition-duration: 0.3s;
+}
+
+progress:indeterminate::-moz-progress-bar {
+ background-color: var(--accent-bg);
+}
+
+dialog {
+ max-width: 40rem;
+ margin: auto;
+}
+
+dialog::backdrop {
+ background-color: var(--bg);
+ opacity: 0.8;
+}
+
+@media only screen and (max-width: 720px) {
+ dialog {
+ max-width: 100%;
+ margin: auto 1em;
+ }
+}
+
+/* Superscript & Subscript */
+/* Prevent scripts from affecting line-height. */
+sup, sub {
+ vertical-align: baseline;
+ position: relative;
+}
+
+sup {
+ top: -0.4em;
+}
+
+sub {
+ top: 0.3em;
+}
+
+/* Classes for notices */
+.notice {
+ background: var(--accent-bg);
+ border: 2px solid var(--border);
+ border-radius: var(--standard-border-radius);
+ padding: 1.5rem;
+ margin: 2rem 0;
+}
diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/vendor/javascript/.keep