From 6deb511fe4f4232c3969eadce89872f860cfb267 Mon Sep 17 00:00:00 2001 From: jacky Date: Tue, 4 Mar 2025 16:32:22 +0700 Subject: [PATCH 1/6] chore: split files --- config/routes.rb | 18 ++---------------- config/routes/admin.rb | 11 +++++++++++ config/routes/employee.rb | 4 ++++ 3 files changed, 17 insertions(+), 16 deletions(-) create mode 100644 config/routes/admin.rb create mode 100644 config/routes/employee.rb diff --git a/config/routes.rb b/config/routes.rb index c0676f8..817afd2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,13 +5,6 @@ get '/erd', to: 'docs#erd' end - authenticate :user, lambda { |u| u.has_role?(:super_admin) } do - mount Sidekiq::Web => '/sidekiq' - unless Rails.env.production? - get 'admin/console', to: 'admin/console#index' - end - end - devise_for :users, controllers: { sessions: 'authentication/sessions', @@ -19,15 +12,8 @@ omniauth_callbacks: 'authentication/omniauth_callbacks' } - namespace :admin do - resources :users - - root to: 'users#index' - end - - namespace :employees do - # Add employee routes here - end + draw :admin + draw :employee root 'home#index' get 'up' => 'rails/health#show', as: :rails_health_check diff --git a/config/routes/admin.rb b/config/routes/admin.rb new file mode 100644 index 0000000..cef462f --- /dev/null +++ b/config/routes/admin.rb @@ -0,0 +1,11 @@ +authenticate :user, lambda { |u| u.has_role?(:super_admin) } do + mount Sidekiq::Web => '/sidekiq' + unless Rails.env.production? + get 'admin/console', to: 'admin/console#index' + end +end + +namespace :admin do + resources :users + root to: 'users#index' +end diff --git a/config/routes/employee.rb b/config/routes/employee.rb new file mode 100644 index 0000000..b77faed --- /dev/null +++ b/config/routes/employee.rb @@ -0,0 +1,4 @@ +namespace :employee do + # Add employee routes here + root to: 'home#index' +end From 24f7d5751b81f9170da18ff7beb08d0b80896cac Mon Sep 17 00:00:00 2001 From: jacky Date: Thu, 6 Mar 2025 16:07:33 +0700 Subject: [PATCH 2/6] chore: improve datetime helper --- app/helpers/date_time_helper.rb | 58 ++++++++++++++++----------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/app/helpers/date_time_helper.rb b/app/helpers/date_time_helper.rb index 2f9c190..6468450 100644 --- a/app/helpers/date_time_helper.rb +++ b/app/helpers/date_time_helper.rb @@ -1,45 +1,45 @@ module DateTimeHelper - def display_date(datetime = Date.current, format = nil) - strtime = format_date[format] || '%d/%m/%Y' - datetime.strftime(strtime) + def display_date(datetime = Date.current, format = :dmy_slash) + format_datetime_string(datetime, format, :date) end - def display_time(datetime = DateTime.current, format = nil) - strtime = format_time[format] || '%H:%M' - datetime.strftime(strtime) + def display_time(datetime = Time.zone.now, format = :hm24) + format_datetime_string(datetime, format, :time) end - def display_datetime(datetime = DateTime.current, format = nil) - strtime = format_datetime[format] || '%d/%m/%Y %H:%M' - datetime.strftime(strtime) + def display_datetime(datetime = Time.zone.now, format = :dmy_hm24) + format_datetime_string(datetime, format, :datetime) end private - def format_date - { - dmy_dashed: '%Y-%m-%d', - dmy_slash: '%d/%m/%Y', - dBy: '%d %B %Y', - dby: '%d %b %Y' - } - end + def format_datetime_string(datetime, format, type) + return '' if datetime.nil? - def format_time - { - hm24: '%H:%M', - hm12: '%I:%M', - hms24: '%H:%M:%S', - hms12: '%I:%M:%S' - } + format_string = datetime_formats[type][format] || datetime_formats[type].values.first + datetime.strftime(format_string) end - def format_datetime + def datetime_formats { - dmy_hm24: '%d/%m/%Y %H:%M', - dmy_hm12: '%d/%m/%Y %I:%M', - dmy_hms24: '%d/%m/%Y %H:%M:%S', - dmy_hms12: '%d/%m/%Y %I:%M:%S' + date: { + dmy_dashed: '%Y-%m-%d', + dmy_slash: '%d/%m/%Y', + dBy: '%d %B %Y', + dby: '%d %b %Y' + }, + time: { + hm24: '%H:%M', + hm12: '%I:%M %p', + hms24: '%H:%M:%S', + hms12: '%I:%M:%S %p' + }, + datetime: { + dmy_hm24: '%d/%m/%Y %H:%M', + dmy_hm12: '%d/%m/%Y %I:%M %p', + dmy_hms24: '%d/%m/%Y %H:%M:%S', + dmy_hms12: '%d/%m/%Y %I:%M:%S %p' + } } end end From 2886ed7f5c2737b01fd6f215554ed5d75e12317d Mon Sep 17 00:00:00 2001 From: jacky Date: Thu, 6 Mar 2025 16:08:47 +0700 Subject: [PATCH 3/6] chore: change role can access console web --- config/routes.rb | 8 ++++++++ config/routes/admin.rb | 7 ------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 817afd2..79f3bd4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,6 +5,14 @@ get '/erd', to: 'docs#erd' end + authenticate :user, lambda { |u| u.admin? } do # Consider using role based on application business + mount Sidekiq::Web => '/sidekiq' + + unless Rails.env.production? + get 'admin/console', to: 'admin/console#index' + end + end + devise_for :users, controllers: { sessions: 'authentication/sessions', diff --git a/config/routes/admin.rb b/config/routes/admin.rb index cef462f..38ab388 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -1,10 +1,3 @@ -authenticate :user, lambda { |u| u.has_role?(:super_admin) } do - mount Sidekiq::Web => '/sidekiq' - unless Rails.env.production? - get 'admin/console', to: 'admin/console#index' - end -end - namespace :admin do resources :users root to: 'users#index' From 3be8e330f35a6a0e3087a5553b7a824f2380fd56 Mon Sep 17 00:00:00 2001 From: jacky Date: Thu, 6 Mar 2025 16:10:03 +0700 Subject: [PATCH 4/6] fix: update policy user --- app/frontend/entrypoints/admin.js | 6 +++--- app/frontend/entrypoints/application.js | 4 ++-- app/models/concerns/roly.rb | 7 +++++++ app/models/user.rb | 4 +++- app/policies/application_policy.rb | 4 ++++ app/views/layouts/admin.html.slim | 1 - app/views/layouts/application.html.slim | 1 - vite.config.ts | 16 +++++++++++----- 8 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 app/models/concerns/roly.rb diff --git a/app/frontend/entrypoints/admin.js b/app/frontend/entrypoints/admin.js index 207199f..4714043 100644 --- a/app/frontend/entrypoints/admin.js +++ b/app/frontend/entrypoints/admin.js @@ -1,4 +1,4 @@ -import '../stylesheets/admin/index.scss'; -import '../controllers/admin'; -import '../controllers/shared'; +import '@/stylesheets/admin/index.scss'; +import '@/controllers/admin'; +import '@/controllers/shared'; import '@hotwired/turbo-rails'; diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index 72ef6bb..bd804b5 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -1,4 +1,4 @@ import '~/stylesheets/application/index.scss'; -import '../controllers/application'; -import '../controllers/shared'; +import '@/controllers/application'; +import '@/controllers/shared'; import '@hotwired/turbo-rails'; diff --git a/app/models/concerns/roly.rb b/app/models/concerns/roly.rb new file mode 100644 index 0000000..3504bcf --- /dev/null +++ b/app/models/concerns/roly.rb @@ -0,0 +1,7 @@ +module Roly + extend ActiveSupport::Concern + + def includes_role?(role_name, resource = nil) + roles.loaded? ? has_cached_role?(role_name, resource) : has_role?(role_name, resource) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index bf6bc18..7d82e06 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -35,6 +35,8 @@ class User < ApplicationRecord :omniauthable, omniauth_providers: [:google_oauth2] + include Roly + # associations has_one_attached :avatar do |attachable| attachable.variant :thumb, resize_to_limit: [200, 200] @@ -57,7 +59,7 @@ def self.from_google(google_params) end def admin? - has_role?(:admin) + includes_role?(:admin) end def employee? diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index be644fe..307a1c5 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -36,6 +36,10 @@ def destroy? false end + def admin? + user&.admin? + end + class Scope def initialize(user, scope) @user = user diff --git a/app/views/layouts/admin.html.slim b/app/views/layouts/admin.html.slim index 0d07ff8..3a5a3ed 100644 --- a/app/views/layouts/admin.html.slim +++ b/app/views/layouts/admin.html.slim @@ -12,7 +12,6 @@ html[lang="en"] link[rel="icon" href="/icon.png" type="image/png"] link[rel="icon" href="/icon.svg" type="image/svg+xml"] link[rel="apple-touch-icon" href="/icon.png"] - = javascript_include_tag "admin", "data-turbo-track": "reload", type: "module" = vite_client_tag = vite_javascript_tag "admin", "data-turbo-track": "reload" body data-controller="icons" diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index a77b93b..2eb92da 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -12,7 +12,6 @@ html[lang="en"] link[rel="icon" href="/icon.png" type="image/png"] link[rel="icon" href="/icon.svg" type="image/svg+xml"] link[rel="apple-touch-icon" href="/icon.png"] - = javascript_include_tag "application", "data-turbo-track": "reload", type: "module" = vite_client_tag = vite_javascript_tag "application", "data-turbo-track": "reload" body data-controller="icons" diff --git a/vite.config.ts b/vite.config.ts index a711864..f79ff8d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,14 +1,20 @@ -import { defineConfig } from 'vite' -import ViteRails from "vite-plugin-rails" +import { defineConfig } from 'vite'; +import ViteRails from 'vite-plugin-rails'; +import path from 'path'; export default defineConfig({ plugins: [ ViteRails({ - envVars: { RAILS_ENV: "development" }, - envOptions: { defineOn: "import.meta.env" }, + envVars: { RAILS_ENV: 'development' }, + envOptions: { defineOn: 'import.meta.env' }, fullReload: { additionalPaths: [], }, }), ], -}) + resolve: { + alias: { + '@': path.resolve(__dirname, 'app/frontend'), + }, + }, +}); From eaf48cca9192bccd8d6893a425f9272a3aa89080 Mon Sep 17 00:00:00 2001 From: jacky Date: Wed, 12 Mar 2025 10:07:42 +0700 Subject: [PATCH 5/6] feat: add crudable --- Gemfile | 3 + Gemfile.lock | 7 +- app/controllers/admin/users_controller.rb | 62 ++----- app/controllers/application_controller.rb | 11 ++ app/controllers/concerns/crudable.rb | 209 ++++++++++++++++++++++ package.json | 3 - 6 files changed, 244 insertions(+), 51 deletions(-) create mode 100644 app/controllers/concerns/crudable.rb diff --git a/Gemfile b/Gemfile index 3e0a2ae..1b3b49e 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,9 @@ gem 'sentry-rails' gem 'web-console' +# Search +gem 'ransack' + group :development, :test do gem 'brakeman', require: false gem 'debug', platforms: %i[mri windows], require: 'debug/prelude' diff --git a/Gemfile.lock b/Gemfile.lock index 4ed879b..197dd4c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -301,7 +301,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.10) + rack (3.1.12) rack-protection (4.1.1) base64 (>= 0.1.0) logger (>= 1.6.0) @@ -355,6 +355,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) + ransack (4.3.0) + activerecord (>= 6.1.5) + activesupport (>= 6.1.5) + i18n rbs (3.8.1) logger rdoc (6.12.0) @@ -568,6 +572,7 @@ DEPENDENCIES rails (~> 7.2.0) rails-controller-testing rails-mermaid_erd + ransack redis (>= 4.0.1) rolify rspec-rails diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index aa4d189..fbb4838 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,51 +1,19 @@ module Admin class UsersController < BaseController - before_action :set_user, only: %i[show edit update destroy] - - def index - @pagy, @users = pagy(User.order(created_at: :desc)) - end - - def show; end - - def new - @user = User.new - end - - def edit; end - - def create - @user = User.new(user_params) - - if @user.save - redirect_to admin_users_path(@user), notice: 'User was successfully created.' - else - render :new - end - end - - def update - if @user.update(user_params) - redirect_to admin_users_path(@user), notice: 'User was successfully updated.' - else - render :edit - end - end - - def destroy - @user.destroy! - redirect_to admin_users_url, notice: 'User was successfully destroyed.' - end - - private - - def set_user - @user = User.find(params[:id]) - authorize(@user) - end - - def user_params - params.require(:user).permit(policy(@user).permitted_attributes) - end + include Crudable + + crud_to class: User, + collection_variable: :@users, + object_variable: :@user, + collection_path: :admmin_users_path, + object_path: :admin_user_path, + searchable: true, + modal_form: false, + # collection_includes:, + flash_messages: { + created: I18n.t('common.create.success', model: :user), + updated: I18n.t('common.update.success', model: :user), + deleted: I18n.t('common.delete.success', model: :user) + } end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7f28d84..82eb552 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -28,6 +28,17 @@ def context_view_path(*strs) File.join([controller_path].push(*strs)) end + def current_user + @current_user ||= super.tap do |user| + if user.present? + ActiveRecord::Associations::Preloader.new( + records: [user], + associations: :roles + ).call + end + end + end + private def not_authorized diff --git a/app/controllers/concerns/crudable.rb b/app/controllers/concerns/crudable.rb new file mode 100644 index 0000000..229a351 --- /dev/null +++ b/app/controllers/concerns/crudable.rb @@ -0,0 +1,209 @@ +# Idea for this module from Mr Ben, https://github.com/tranquangvu +# Copyright (c) 2022 "Golden Owl Solutions", MIT License +# https://github.com/GoldenowlConsultingCompany + +module Crudable + extend ActiveSupport::Concern + + included do + before_action :prepare_collection, only: %i[index] + before_action :prepare_new_object, only: %i[new create] + before_action :prepare_object, only: %i[show edit update destroy] + + assign_resource_class_accessors + end + + module ClassMethods + attr_accessor :resource_class, + :resource_collection_variable, + :resource_object_variable, + :resource_query_variable, + :resource_pagy_variable, + :resource_route_namespaces, + :resource_collection_path, + :resource_object_path, + :resource_modal_form, + :resource_collection_includes, + :resource_object_includes, + :resource_flash_messages, + :resource_searchable + + RESOURCE_ACTIONS = %i[index show new create edit update destroy].freeze + + def crud_to(options = {}) + options.symbolize_keys! + options.assert_valid_keys( + :class, + :searchable, + :collection_variable, + :object_variable, + :route_namespaces, + :collection_path, + :object_path, + :modal_form, + :collection_includes, + :object_includes, + :flash_messages, + :only_actions, + :except_actions + ) + + only_actions = options.delete(:only_actions) + only_actions = only_actions.present? ? only_actions.map(&:to_sym) : RESOURCE_ACTIONS + except_actions = options.delete(:except_actions) + except_actions = except_actions.present? ? except_actions.map(&:to_sym) : [] + effect_actions = only_actions - except_actions + + assign_resource_class_accessors(options) + check_define_resource_actions(effect_actions) + format_request_resource_actions(effect_actions) + end + + private + + def assign_resource_class_accessors(options = {}) # rubocop:disable Metrics/AbcSize + self.resource_class = options.fetch(:class, (name.split('::').last.sub(/Controller$/, '').singularize.constantize rescue nil)) # rubocop:disable Style/RescueModifier + self.resource_collection_variable = options.fetch(:collection_variable, ("@#{resource_class.name.underscore.pluralize}" rescue :collection)).to_sym # rubocop:disable Style/RescueModifier + self.resource_object_variable = options.fetch(:object_variable, ("@#{resource_class.name.underscore}" rescue :object)).to_sym # rubocop:disable Style/RescueModifier + self.resource_searchable = options.fetch(:searchable, true) + self.resource_pagy_variable = :@pagy + self.resource_query_variable = :@q + + self.resource_route_namespaces = options.fetch(:route_namespaces, name.underscore.split('/')[0..-2]).map(&:to_sym) + self.resource_collection_path = options.fetch(:collection_path, nil) + self.resource_object_path = options.fetch(:object_path, nil) + + self.resource_modal_form = options.fetch(:modal_form, false) + self.resource_collection_includes = options.fetch(:collection_includes, []) + self.resource_object_includes = options.fetch(:object_includes, []) + self.resource_flash_messages = options.fetch(:flash_messages, {}) + end + + def check_define_resource_actions(actions) + (RESOURCE_ACTIONS - actions).each do |action| + undef_method(action) + end + end + + def format_request_resource_actions(actions) + effected_actions = actions & %i[new edit] + only_turbo_stream_for(*effected_actions) if resource_modal_form + end + end + + def index + pagy, collection = pagy(instance_variable_get(self.class.resource_collection_variable)) + instance_variable_set(self.class.resource_pagy_variable, pagy) + instance_variable_set(self.class.resource_collection_variable, collection) + block_given? ? yield : render(:index) + end + + def show + render(:show) + end + + def new + render(:new) + end + + def create + object = instance_variable_get(self.class.resource_object_variable) + object.assign_attributes(resource_permitted_params) + + created = object.save + + return yield(created) if block_given? + + if created + set_flash_message(:notice, :created) + redirect_to resource_after_create_or_update_path + else + template = self.class.resource_modal_form ? :reform : :new + render(template, status: :unprocessable_entity) + end + end + + def edit + render(:edit) + end + + def update + object = instance_variable_get(self.class.resource_object_variable) + updated = object.update(resource_permitted_params) + + return yield(updated) if block_given? + + if updated + set_flash_message(:notice, :updated) + redirect_to resource_after_create_or_update_path + else + template = self.class.resource_modal_form ? :reform : :edit + render(template, status: :unprocessable_entity) + end + end + + def destroy + object = instance_variable_get(self.class.resource_object_variable) + object.destroy! + + return yield(object) if block_given? + + set_flash_message(:notice, :deleted) + redirect_to(resource_after_destroy_path) + end + + private + + def set_flash_message(type, key, flash_now: false) + message = self.class.resource_flash_messages.fetch(key, nil) + (flash_now ? flash.now : flash)[type] = message if message + end + + def resource_object_path + object = instance_variable_get(self.class.resource_object_variable) + custom_path_method = self.class.resource_object_path + custom_path_method ? send(custom_path_method, object) : polymorphic_path([*self.class.resource_route_namespaces, object]) + end + + def resource_collection_path + custom_path_method = self.class.resource_collection_path + custom_path_method ? send(custom_path_method) : polymorphic_path([*self.class.resource_route_namespaces, self.class.resource_class]) + end + + def resource_after_create_or_update_path + self.class.resource_modal_form ? resource_collection_path : resource_object_path + end + + def resource_after_destroy_path + resource_collection_path + end + + def resource_permitted_params + raise NotImplementedError, "You must define `resource_permitted_params` as instance method in #{self.class.name} class" + end + + def resource_base_scope + policy_scope(self.class.resource_class) + end + + def prepare_collection + q = resource_base_scope.ransack(params[:q]) + collection = q.result(distinct: true).includes(self.class.resource_collection_includes) + + instance_variable_set(self.class.resource_query_variable, q) + instance_variable_set(self.class.resource_collection_variable, collection) + authorize(collection) + end + + def prepare_new_object + object = self.class.resource_class.new + instance_variable_set(self.class.resource_object_variable, object) + authorize(object) + end + + def prepare_object + object = resource_base_scope.includes(self.class.resource_object_includes).find(params[:id]) + instance_variable_set(self.class.resource_object_variable, object) + authorize(object) + end +end diff --git a/package.json b/package.json index 29cff32..d67f3e8 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,6 @@ { "private": true, "type": "module", - "engines": { - "node": "20.9.0" - }, "scripts": { "start": "concurrently -i -k --kill-others-on-fail -p none 'RUBY_DEBUG_OPEN=true bin/rails s' 'bin/vite dev'", "lint": "yarn run eslint app/frontend", From 29b7b915bdb7218028727c9e322e68823ad159d1 Mon Sep 17 00:00:00 2001 From: jacky Date: Wed, 12 Mar 2025 10:14:43 +0700 Subject: [PATCH 6/6] feat: sample layout for admin --- .../controllers/shared/sidebar_controller.js | 30 ++++++++ app/helpers/pagination_helper.rb | 45 ++++++++--- .../controllers/sidebar_controller.js | 26 +++++++ app/views/admin/users/index.html.slim | 27 +++++-- app/views/layouts/_admin_aside_menu.html.slim | 75 +++++++++++++++++++ app/views/layouts/_admin_header.html.slim | 61 ++++++++++++++- app/views/layouts/admin.html.slim | 23 +++++- app/views/layouts/application.html.slim | 2 + app/views/shared/_remote_modal.html.slim | 10 +++ 9 files changed, 278 insertions(+), 21 deletions(-) create mode 100644 app/frontend/controllers/shared/sidebar_controller.js create mode 100644 app/javascript/controllers/sidebar_controller.js create mode 100644 app/views/layouts/_admin_aside_menu.html.slim create mode 100644 app/views/shared/_remote_modal.html.slim diff --git a/app/frontend/controllers/shared/sidebar_controller.js b/app/frontend/controllers/shared/sidebar_controller.js new file mode 100644 index 0000000..6165330 --- /dev/null +++ b/app/frontend/controllers/shared/sidebar_controller.js @@ -0,0 +1,30 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['menu']; + + connect() { + // Check if we're on a large screen + if (window.innerWidth >= 1024) { + // Initialize from cookie + const isCollapsed = document.cookie.includes('sidebar_collapsed=true'); + if (isCollapsed) { + document.documentElement.classList.add('drawer-mini'); + } + } + } + + toggleCollapse(event) { + event.preventDefault(); + + const isCollapsed = document.documentElement.classList.contains('drawer-mini'); + + if (isCollapsed) { + document.cookie = 'sidebar_collapsed=false; path=/'; + document.documentElement.classList.remove('drawer-mini'); + } else { + document.cookie = 'sidebar_collapsed=true; path=/'; + document.documentElement.classList.add('drawer-mini'); + } + } +} diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb index a18b277..8259fe4 100644 --- a/app/helpers/pagination_helper.rb +++ b/app/helpers/pagination_helper.rb @@ -1,31 +1,56 @@ module PaginationHelper - def pagy_nav(pagy) - html = %(
) + def pagy_nav(pagy) # rubocop:disable Metrics/MethodLength,Metrics/PerceivedComplexity + html = %(
) + # Page info + html << %(
+ Showing #{pagy.from} to #{pagy.to} of #{pagy.count} entries +
) + + # Navigation + html << %(
) + + # Previous button + prev_btn_class = "join-item btn btn-sm #{pagy.prev ? '' : 'btn-disabled'}" html << if pagy.prev - %(«) + %( + + ) else - %() + %() end + # Page numbers and gaps pagy.series.each do |item| case item when Integer - html << %(#{item}) - when String - html << %() + html << %(#{item}) + when String # Current page + html << %() when :gap - html << %() + html << %() end end + # Next button + next_btn_class = "join-item btn btn-sm #{pagy.next ? '' : 'btn-disabled'}" html << if pagy.next - %(») + %( + + ) else - %() + %() end html << %(
) + html << %(
) html.html_safe # rubocop:disable Rails/OutputSafety end diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js new file mode 100644 index 0000000..6bc650b --- /dev/null +++ b/app/javascript/controllers/sidebar_controller.js @@ -0,0 +1,26 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['menu']; + + connect() { + // Initialize sidebar state from cookie + const isCollapsed = document.cookie.includes('sidebar_collapsed=true'); + if (isCollapsed) { + document.documentElement.classList.add('drawer-mini'); + } + } + + // Toggle sidebar collapse state + toggleCollapse() { + const isCollapsed = document.cookie.includes('sidebar_collapsed=true'); + + if (isCollapsed) { + document.cookie = 'sidebar_collapsed=false; path=/'; + document.documentElement.classList.remove('drawer-mini'); + } else { + document.cookie = 'sidebar_collapsed=true; path=/'; + document.documentElement.classList.add('drawer-mini'); + } + } +} diff --git a/app/views/admin/users/index.html.slim b/app/views/admin/users/index.html.slim index f66268e..2ee2e94 100644 --- a/app/views/admin/users/index.html.slim +++ b/app/views/admin/users/index.html.slim @@ -1,21 +1,23 @@ - provide(:title, "User list") -// Just example .overflow-x-auto table.table thead tr - th + th.w-16 + | # th | Email th | Created at th | Updated at + th.w-20 + | Actions tbody - - @users.each.with_index(1) do |user, index| + - @users.each.with_index(@pagy.from) do |user, index| tr.hover - th + th.text-center = index td = user.email @@ -23,5 +25,20 @@ = display_datetime(user.created_at) td = display_datetime(user.updated_at) - .pagy + td.relative + .dropdown.dropdown-end + button.btn.btn-sm.btn-ghost tabindex="0" + = lucide_icon 'more-vertical', class: 'w-5 h-5' + ul.dropdown-content.menu.p-2.shadow.bg-base-100.rounded-box.w-32.bg-white.z-50 + li + a href="#" + = lucide_icon 'pencil', class: 'w-4 h-4' + | Edit + li + a href="#" class="text-red-600" + = lucide_icon 'trash', class: 'w-4 h-4' + | Remove + + / Pagination + .mt-4 == pagy_nav(@pagy) diff --git a/app/views/layouts/_admin_aside_menu.html.slim b/app/views/layouts/_admin_aside_menu.html.slim new file mode 100644 index 0000000..4f97ff7 --- /dev/null +++ b/app/views/layouts/_admin_aside_menu.html.slim @@ -0,0 +1,75 @@ +aside.bg-base-200.h-screen.w-80.transition-all.duration-300 class="group-[.drawer-mini]:w-20" + .flex.flex-col.h-full + .p-4.pb-2.flex.justify-between.items-center + .flex.items-center.gap-3 class="group-[.drawer-mini]:justify-center" + = image_tag "logo.png", class: "w-8 h-8", alt: "Logo" + span.font-bold.text-xl class="group-[.drawer-mini]:hidden" Admin Panel + + / Collapse toggle button (only visible on large screens) + button.btn.btn-sm.btn-ghost.hidden.lg:flex.items-center.justify-center data-action="click->sidebar#toggleCollapse" + i.fa-solid.fa-chevron-left.transition-transform.duration-300 class="group-[.drawer-mini]:rotate-180" + + .divider.my-0 + + / Menu items + nav.flex-1.px-2.py-2 + ul.menu.menu-md.gap-2 + li + = link_to 'admin_dashboard_path', class: "flex items-center gap-3 #{current_page?('admin_dashboard_path') ? 'active' : ''}" do + i.fa-solid.fa-house + span class="group-[.drawer-mini]:hidden" Dashboard + + li + = link_to admin_users_path, class: "flex items-center gap-3 #{current_page?(admin_users_path) ? 'active' : ''}" do + i.fa-solid.fa-users + span class="group-[.drawer-mini]:hidden" Users + + li + .collapse + input type="checkbox" + .collapse-title.p-0 + .flex.items-center.gap-3.px-4.min-h-12 + i.fa-solid.fa-box + span class="group-[.drawer-mini]:hidden" Products + i.fa-solid.fa-chevron-down.ml-auto.transition-transform class="group-[.drawer-mini]:hidden" + .collapse-content.pt-0 + ul.menu.gap-1.pl-6 class="group-[.drawer-mini]:pl-2" + li + a.flex.items-center.gap-3 href="#" + i.fa-solid.fa-list.w-4 + span class="group-[.drawer-mini]:hidden" All Products + li + a.flex.items-center.gap-3 href="#" + i.fa-solid.fa-plus.w-4 + span class="group-[.drawer-mini]:hidden" Add Product + li + a.flex.items-center.gap-3 href="#" + i.fa-solid.fa-tags.w-4 + span class="group-[.drawer-mini]:hidden" Categories + + li + = link_to "#", class: "flex items-center gap-3" do + i.fa-solid.fa-chart-bar + span class="group-[.drawer-mini]:hidden" Reports + + li + = link_to "#", class: "flex items-center gap-3" do + i.fa-solid.fa-gear + span class="group-[.drawer-mini]:hidden" Settings + + .divider.my-0 + + / User profile section + .p-4.transition-all + .flex.items-center.gap-3.mb-4 class="group-[.drawer-mini]:justify-center" + .avatar.placeholder + .bg-neutral-focus.text-neutral-content.rounded-full.w-8 + span AM + .flex.flex-col class="group-[.drawer-mini]:hidden" + p.font-medium Admin User + p.text-sm.opacity-70 admin@example.com + + = button_to 'destroy_admin_session_path', method: :delete, + class: "btn btn-outline w-full transition-all group-[.drawer-mini]:w-12 group-[.drawer-mini]:h-12 group-[.drawer-mini]:p-0 group-[.drawer-mini]:aspect-square" do + i.fa-solid.fa-right-from-bracket + span.ml-2 class="group-[.drawer-mini]:hidden" Sign out diff --git a/app/views/layouts/_admin_header.html.slim b/app/views/layouts/_admin_header.html.slim index 756b042..fd64019 100644 --- a/app/views/layouts/_admin_header.html.slim +++ b/app/views/layouts/_admin_header.html.slim @@ -1,2 +1,59 @@ -nav class="bg-white border-gray-200 dark:bg-gray-900" - | Add Admin Header here... +div class="flex-1 flex flex-col md:ml-64" + nav class="bg-base-100 border-b border-base-200 px-4 py-3 sticky top-0 z-30" + .navbar.gap-2 + .flex-none + label.btn.btn-square.btn-ghost.lg:hidden for="drawer-toggle" + i.fas.fa-bars + + .flex-1.gap-2 + / Breadcrumb + .text-sm.breadcrumbs.hidden.sm:inline-flex + ul + li + a href="/admin" + i.fas.fa-home.mr-2 + | Admin + li + = yield(:page_title) || "Dashboard" + + .flex-none.gap-2 + / Search + .form-control.hidden.md:inline-flex + .input-group + input type="text" placeholder="Search..." class="input input-bordered w-48" + button.btn.btn-square + i.fas.fa-search + + / Notifications + .dropdown.dropdown-end + label.btn.btn-ghost.btn-circle tabindex="0" + .indicator + i.fas.fa-bell + span.badge.badge-sm.badge-primary.indicator-item 3 + + ul.mt-3.p-2.shadow.menu.menu-sm.dropdown-content.bg-base-200.rounded-box.w-52.z-50 tabindex="0" + li + a 1 new message + li + a 2 new orders + li + a View all notifications + + / Profile Dropdown + .dropdown.dropdown-end + label.btn.btn-ghost.btn-circle.avatar.placeholder tabindex="0" + .bg-neutral-focus.text-neutral-content.rounded-full.w-10 + span AM + + ul.mt-3.p-2.shadow.menu.menu-sm.dropdown-content.bg-base-200.rounded-box.w-52.z-50 tabindex="0" + li + a.justify-between + | Profile + span.badge.badge-primary Admin + li + a Settings + li.divider + li + = button_to 'destroy_admin_session_path', method: :delete, class: "w-full text-left" do + i.fas.fa-sign-out-alt.mr-2 + | Sign out diff --git a/app/views/layouts/admin.html.slim b/app/views/layouts/admin.html.slim index 3a5a3ed..363826b 100644 --- a/app/views/layouts/admin.html.slim +++ b/app/views/layouts/admin.html.slim @@ -1,5 +1,5 @@ doctype html -html[lang="en"] +html[lang="en" data-theme="light"] head title = content_for?(:title) ? strip_tags(yield(:title)) : "Rails Boilerplate" @@ -14,6 +14,21 @@ html[lang="en"] link[rel="apple-touch-icon" href="/icon.png"] = vite_client_tag = vite_javascript_tag "admin", "data-turbo-track": "reload" - body data-controller="icons" - = render "layouts/admin_header" - = yield + link[href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"] + + body.bg-base-100 class=("drawer-mini" if cookies[:sidebar_collapsed] == "true") data-controller="sidebar" + .drawer.lg:drawer-open + input#drawer-toggle.drawer-toggle type="checkbox" + + .drawer-content.flex.flex-col + / Header + = render "layouts/admin_header" + + / Main Content + main.flex-1.p-6 + = yield + + / Sidebar + .drawer-side.z-40 + label.drawer-overlay for="drawer-toggle" + = render "layouts/admin_aside_menu" diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index 2eb92da..ff14d28 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -18,3 +18,5 @@ html[lang="en"] = render "layouts/header" = yield = render "layouts/footer" + + = turbo_frame_tag 'remote_modal', target: '_top' diff --git a/app/views/shared/_remote_modal.html.slim b/app/views/shared/_remote_modal.html.slim new file mode 100644 index 0000000..9488d2c --- /dev/null +++ b/app/views/shared/_remote_modal.html.slim @@ -0,0 +1,10 @@ += turbo_frame_tag 'remote_modal' do + #remote-modal.modal.modal-blur data-controller='remote-modal' data-remote-modal-target='modal' + .modal-dialog.modal-dialog-centered + .modal-content + .modal-header + .modal-title + = title + button.btn-close type='button' data-action='remote-modal#hideModal' + .modal-body#remote_modal_body + = yield