aboutsummaryrefslogtreecommitdiff
path: root/app
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 /app
downloadoperum-a7791635aa98f012d35f97c3c5288494bbb28e38.tar.gz
operum-a7791635aa98f012d35f97c3c5288494bbb28e38.zip
Initial commit
Signed-off-by: Miquel Sabaté Solà <msabate@suse.com>
Diffstat (limited to 'app')
-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
66 files changed, 1568 insertions, 0 deletions
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' %>