diff options
| author | Miquel Sabaté Solà <msabate@suse.com> | 2024-03-13 18:03:43 +0100 |
|---|---|---|
| committer | Miquel Sabaté Solà <msabate@suse.com> | 2024-03-13 18:03:43 +0100 |
| commit | a7791635aa98f012d35f97c3c5288494bbb28e38 (patch) | |
| tree | bad987e6b4d10ad2f7cd02e40658d464907595ee /app | |
| download | operum-a7791635aa98f012d35f97c3c5288494bbb28e38.tar.gz operum-a7791635aa98f012d35f97c3c5288494bbb28e38.zip | |
Initial commit
Signed-off-by: Miquel Sabaté Solà <msabate@suse.com>
Diffstat (limited to 'app')
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à <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/>. +</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') %> — <%= 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>— <%= 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>— <%= 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' %> |
