diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b88b8bda..3a5bfec2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,9 @@ jobs: ruby-version: .ruby-version bundler-cache: true + - name: Build Tailwind CSS + run: bin/rails tailwindcss:build + - name: Run tests run: bin/rails db:test:prepare && bin/rspec diff --git a/.gitignore b/.gitignore index 2f127c9a..12846bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ db/structure.sql # Ignore IDE config files .idea/ .DS_Store + +/app/assets/builds/* +!/app/assets/builds/.keep diff --git a/Gemfile b/Gemfile index 7470d201..e921e372 100644 --- a/Gemfile +++ b/Gemfile @@ -31,6 +31,7 @@ gem "solid_cache" gem "solid_queue" gem "solid_queue_monitor", "~> 0.3.2" gem "stimulus-rails" +gem "tailwindcss-rails" gem "thruster", require: false gem "turbo-rails" diff --git a/Gemfile.lock b/Gemfile.lock index 1a7b82f1..2b3730e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -553,6 +553,16 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) + tailwindcss-rails (4.3.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.1.11) + tailwindcss-ruby (4.1.11-aarch64-linux-gnu) + tailwindcss-ruby (4.1.11-aarch64-linux-musl) + tailwindcss-ruby (4.1.11-arm64-darwin) + tailwindcss-ruby (4.1.11-x86_64-darwin) + tailwindcss-ruby (4.1.11-x86_64-linux-gnu) + tailwindcss-ruby (4.1.11-x86_64-linux-musl) thor (1.4.0) thruster (0.1.16) thruster (0.1.16-aarch64-linux) @@ -646,6 +656,7 @@ DEPENDENCIES solid_queue solid_queue_monitor (~> 0.3.2) stimulus-rails + tailwindcss-rails thruster turbo-rails tzinfo-data diff --git a/Procfile.dev b/Procfile.dev index 1abd8acc..e27fa6fb 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,3 @@ web: bin/rails server -p 3000 worker: bin/jobs -c config/queue.yml +css: bin/rails tailwindcss:watch diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/assets/images/skill.svg b/app/assets/images/skill.svg new file mode 100644 index 00000000..6a83a512 --- /dev/null +++ b/app/assets/images/skill.svg @@ -0,0 +1,49 @@ + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333c..f5c4d908 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -7,4 +7,6 @@ * depending on specificity. * * Consider organizing styles into separate files for maintainability. + * + * Note: Tailwind CSS is loaded separately via stylesheet_link_tag in the layout. */ diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 00000000..41e3d922 --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1,216 @@ +@import "tailwindcss"; + +/* Tom Select Tailwind Styles */ + +/* Hide the original select element */ +.ts-wrapper .ts-control + select, +.ts-wrapper + select { + @apply hidden; +} + +select[data-select-tags-target="tagList"] { + @apply hidden; +} + +.ts-wrapper { + @apply relative; +} + +.ts-wrapper.single .ts-control, +.ts-wrapper.multi .ts-control { + @apply w-full px-3.5 py-2.5 border border-gray-300 rounded-lg text-sm bg-white transition-all duration-200; + min-height: 42px; +} + +.ts-wrapper.multi .ts-control { + @apply flex flex-wrap items-center gap-1.5; + padding: 0.375rem 0.625rem; +} + +.ts-wrapper .ts-control:focus-within { + @apply border-blue-500 outline-none ring-4 ring-blue-500/10; +} + +.ts-wrapper.single .ts-control:hover:not(:focus-within), +.ts-wrapper.multi .ts-control:hover:not(:focus-within) { + @apply border-gray-400 bg-gray-50; +} + +.ts-wrapper .ts-control > input { + @apply flex-grow outline-none bg-transparent text-sm; + min-width: 60px; + padding: 0.25rem; +} + +.ts-wrapper .ts-control > input::placeholder { + @apply text-gray-400; +} + +/* Selected items (tags/badges) */ +.ts-wrapper.multi .ts-control > div { + @apply inline-flex items-center gap-1.5 px-2.5 py-1 bg-blue-500 text-white text-sm rounded-md; + max-width: 100%; +} + +.ts-wrapper.multi .ts-control > div.active { + @apply bg-blue-600; +} + +/* Remove button */ +.ts-wrapper .ts-control .remove { + @apply inline-flex items-center justify-center ml-1 text-white/80 hover:text-white cursor-pointer; + font-size: 1.125rem; + line-height: 1; + padding: 0; + border: none; + background: none; +} + +.ts-wrapper .ts-control .remove:hover { + @apply text-white; +} + +/* Dropdown */ +.ts-dropdown { + @apply absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden; + max-height: 280px; + overflow-y: auto; +} + +.ts-dropdown .ts-dropdown-content { + @apply py-1; +} + +/* Dropdown options */ +.ts-dropdown .option { + @apply px-3.5 py-2 text-sm text-gray-700 cursor-pointer transition-colors duration-150; +} + +.ts-dropdown .option:hover, +.ts-dropdown .option.active { + @apply bg-blue-500 text-white; +} + +.ts-dropdown .option.selected { + @apply hidden; +} + +/* No results message */ +.ts-dropdown .no-results { + @apply px-3.5 py-2 text-sm text-gray-500 italic; +} + +/* Loading state */ +.ts-wrapper.loading::after { + content: ''; + @apply absolute right-3 top-1/2 w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin; + margin-top: -0.5rem; +} + +/* Disabled state */ +.ts-wrapper.disabled .ts-control { + @apply bg-gray-100 text-gray-500 cursor-not-allowed border-gray-200; +} + +/* Single select caret */ +.ts-wrapper.single .ts-control::after { + content: ''; + @apply absolute right-3 top-1/2 w-0 h-0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid #6b7280; + margin-top: -2.5px; + pointer-events: none; +} + +.ts-wrapper.single.input-active .ts-control::after { + border-top-color: #3b82f6; +} + +/* Focus visible for accessibility */ +.ts-wrapper .ts-control:focus-visible { + @apply outline-none ring-4 ring-blue-500/10; +} + +/* Form Styles */ +.form-label { + @apply block text-sm font-semibold text-gray-700 mb-2; +} + +.form-input, +.form-select, +.form-textarea { + @apply w-full px-4 py-3 border-2 border-gray-200 rounded-lg text-sm transition-all duration-200 bg-gray-50; +} + +.form-select { + appearance: none; + padding-right: 2.5rem; +} + +.form-textarea { + @apply resize-y; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + @apply outline-none border-blue-500 ring-4 ring-blue-500/10 bg-white; +} + +.form-input:hover:not(:focus), +.form-select:hover:not(:focus), +.form-textarea:hover:not(:focus) { + @apply border-gray-300; +} + +.form-required { + @apply text-red-500; +} + +.form-help-text { + @apply mt-2 text-xs text-gray-500; +} + +.form-grid { + @apply grid gap-6; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +.form-grid-full { + grid-column: 1 / -1; +} + +/* Alternative Input Styles (lighter borders, less padding) */ +.input-label { + @apply block text-sm font-semibold text-gray-700 mb-2; +} + +.input-field { + @apply w-full border border-gray-300 rounded-lg text-sm transition-all duration-200 bg-white; + padding: 0.625rem 0.875rem; +} + +.input-field:focus { + @apply outline-none border-blue-500 bg-white; + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); +} + +.input-field:hover:not(:focus) { + @apply border-gray-400; +} + +.select-field { + @apply w-full border border-gray-300 rounded-lg text-sm bg-white transition-all duration-200 cursor-pointer; + padding: 0.625rem 2.5rem 0.625rem 0.875rem; + appearance: none; +} + +.select-field:focus { + @apply outline-none border-blue-500; + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); +} + +.select-field:hover:not(:focus) { + @apply border-gray-400; +} diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 8c5819dd..ede96c09 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -12,6 +12,21 @@ def index def show end + def new + @tag = Tag.new + end + + def create + @tag = Tag.new(tag_params) + + if @tag.save + SynchronizeCognatesOnTopicsJob.perform_later(@tag) if tag_params[:cognates_list].reject(&:empty?).any? + redirect_to tags_path, notice: "Tag was successfully created." + else + render :new, status: :unprocessable_entity + end + end + def edit end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4ab08a35..d1a90275 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -2,16 +2,29 @@ class UsersController < ApplicationController include Pagy::Backend before_action :redirect_contributors - before_action :set_user, only: %i[ edit update destroy ] + before_action :set_user, only: %i[ show edit update destroy ] def index - @pagy, @users = pagy(User.all.search_with_params(user_search_params)) + @pagy, @users = pagy(User.includes(:providers).search_with_params(user_search_params)) + + respond_to do |format| + format.html do + if turbo_frame_request? + render partial: "user_list" + else + render :index + end + end + end end def new @user = User.new end + def show + end + def create @user = User.new(user_params) @@ -48,7 +61,7 @@ def destroy private def set_user - @user = User.find(params.expect(:id)) + @user = User.includes(:providers).find(params.expect(:id)) end def user_params diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 716bfe3e..cfc8a987 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -8,4 +8,26 @@ def flash_class(level) else "alert-light-info" end end + + def tailwind_flash_class(level) + case level + when "notice" then "bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 text-green-800 shadow-sm" + when "alert" then "bg-gradient-to-r from-red-50 to-rose-50 border border-red-200 text-red-800 shadow-sm" + when "warning" then "bg-gradient-to-r from-yellow-50 to-amber-50 border border-yellow-200 text-yellow-800 shadow-sm" + when "error" then "bg-gradient-to-r from-red-50 to-rose-50 border border-red-200 text-red-800 shadow-sm" + when "success" then "bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 text-green-800 shadow-sm" + when "info" then "bg-gradient-to-r from-blue-50 to-cyan-50 border border-blue-200 text-blue-800 shadow-sm" + else "bg-gradient-to-r from-gray-50 to-slate-50 border border-gray-200 text-gray-800 shadow-sm" + end + end + + def nav_link_class(path) + base_style = "display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; text-decoration: none; border-radius: 0.5rem; transition: all 0.2s; font-weight: 500;" + + if current_page?(path) + base_style + " background-color: #dbeafe; color: #1d4ed8;" + else + base_style + " color: #374151;" + end + end end diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb new file mode 100644 index 00000000..afd3cff7 --- /dev/null +++ b/app/helpers/pagination_helper.rb @@ -0,0 +1,90 @@ +module PaginationHelper + def custom_pagy_nav(pagy, params: {}) + return "" unless pagy.pages > 1 + + content_tag :nav, 'aria-label': "Page navigation" do + content_tag :ul, class: "flex -space-x-px text-sm", style: "list-style: none; margin: 0; padding: 0;" do + safe_join([ + prev_link(pagy, params), + page_links(pagy, params), + next_link(pagy, params), + ].compact.flatten) + end + end + end + + private + + def prev_link(pagy, extra_params) + content_tag :li, style: "list-style: none;" do + if pagy.prev + link_to "Previous", pagy_url_for(pagy, pagy.prev, **extra_params), + class: "flex items-center justify-center text-gray-700 bg-gray-100 border border-gray-300 hover:bg-blue-600 hover:text-white font-medium rounded-l-md text-sm px-3 h-10", + style: "box-sizing: border-box;", + 'aria-label': "Go to previous page" + else + content_tag :span, "Previous", + class: "flex items-center justify-center text-gray-400 bg-gray-50 border border-gray-300 font-medium rounded-l-md text-sm px-3 h-10 cursor-not-allowed", + style: "box-sizing: border-box;" + end + end + end + + def next_link(pagy, extra_params) + content_tag :li, style: "list-style: none;" do + if pagy.next + link_to "Next", pagy_url_for(pagy, pagy.next, **extra_params), + class: "flex items-center justify-center text-gray-700 bg-gray-100 border border-gray-300 hover:bg-blue-600 hover:text-white font-medium rounded-r-md text-sm px-3 h-10", + style: "box-sizing: border-box;", + 'aria-label': "Go to next page" + else + content_tag :span, "Next", + class: "flex items-center justify-center text-gray-400 bg-gray-50 border border-gray-300 font-medium rounded-r-md text-sm px-3 h-10 cursor-not-allowed", + style: "box-sizing: border-box;" + end + end + end + + def page_links(pagy, extra_params) + pagy.series.map do |item| + case item + when Integer + page_link(pagy, item, extra_params) + when String + # Handle page numbers that come as strings (like "1") or gaps + if item == "gap" + gap_element + else + # Convert string page number to integer + page_link(pagy, item.to_i, extra_params) + end + when :gap + gap_element + end + end + end + + def page_link(pagy, page, extra_params) + content_tag :li, style: "list-style: none;" do + if page == pagy.page + content_tag :span, page, + class: "flex items-center justify-center text-blue-600 bg-blue-50 border border-gray-300 font-semibold text-sm w-10 h-10", + style: "box-sizing: border-box;", + 'aria-current': "page" + else + link_to page, pagy_url_for(pagy, page, **extra_params), + class: "flex items-center justify-center text-gray-700 bg-gray-100 border border-gray-300 hover:bg-blue-600 hover:text-white font-medium text-sm w-10 h-10", + style: "box-sizing: border-box;", + 'aria-label': "Go to page #{page}" + end + end + end + + def gap_element + content_tag :li, style: "list-style: none;" do + content_tag :span, "…", + class: "flex items-center justify-center text-gray-700 bg-gray-100 border border-gray-300 font-medium text-sm w-10 h-10", + style: "box-sizing: border-box;" + end + end +end diff --git a/app/javascript/controllers/select_tags_controller.js b/app/javascript/controllers/select_tags_controller.js index 8f219f67..84d44f70 100644 --- a/app/javascript/controllers/select_tags_controller.js +++ b/app/javascript/controllers/select_tags_controller.js @@ -1,6 +1,6 @@ import { Controller } from "@hotwired/stimulus"; import { get } from "@rails/request.js"; -import Tags from "bootstrap5-tags"; +import TomSelect from "tom-select"; export default class extends Controller { static targets = ["tagList"]; @@ -9,6 +9,12 @@ export default class extends Controller { this.initializeTags(); } + disconnect() { + if (this.tomSelect) { + this.tomSelect.destroy(); + } + } + notify() { this.dispatch("notify", { detail: { @@ -20,10 +26,39 @@ export default class extends Controller { } /** - * Initialize the tags input with given options - * @param {Object} options - Configuration options for bootstrap5-tags + * Initialize the tags input with Tom Select + * @param {Object} options - Configuration options for Tom Select */ - initializeTags(options = {}, reset = false) { - Tags.init(`select#${this.tagListTarget.id}`, options, reset); + initializeTags(options = {}) { + const allowClear = this.tagListTarget.dataset.allowClear === "true"; + const allowNew = this.tagListTarget.dataset.allowNew === "true"; + + const defaultOptions = { + plugins: { + remove_button: allowClear + ? { + title: "Remove this item", + } + : false, + }, + create: allowNew, + maxOptions: null, + closeAfterSelect: false, + hideSelected: true, + onItemAdd: function () { + // Clear the input after selecting a tag + this.setTextboxValue(""); + this.refreshOptions(false); + }, + render: { + no_results: function (data, escape) { + return '