panda-core 0.12.5 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +53 -0
- data/app/assets/builds/panda_core_admin.css +2 -0
- data/app/assets/tailwind/application.css +236 -36
- data/app/builders/panda/core/form_builder.rb +120 -18
- data/app/components/panda/core/UI/badge.html.erb +10 -0
- data/app/components/panda/core/UI/badge.rb +14 -30
- data/app/components/panda/core/UI/button.html.erb +5 -0
- data/app/components/panda/core/UI/button.rb +11 -15
- data/app/components/panda/core/UI/card.html.erb +3 -0
- data/app/components/panda/core/UI/card.rb +7 -6
- data/app/components/panda/core/admin/attribute_diff_component.html.erb +21 -0
- data/app/components/panda/core/admin/attribute_diff_component.rb +29 -0
- data/app/components/panda/core/admin/breadcrumb_component.html.erb +36 -0
- data/app/components/panda/core/admin/breadcrumb_component.rb +12 -64
- data/app/components/panda/core/admin/breadcrumbs_component.html.erb +16 -0
- data/app/components/panda/core/admin/breadcrumbs_component.rb +26 -0
- data/app/components/panda/core/admin/button_component.html.erb +9 -0
- data/app/components/panda/core/admin/button_component.rb +36 -31
- data/app/components/panda/core/admin/callout_component.html.erb +17 -0
- data/app/components/panda/core/admin/callout_component.rb +57 -0
- data/app/components/panda/core/admin/code_block_component.html.erb +9 -0
- data/app/components/panda/core/admin/code_block_component.rb +23 -0
- data/app/components/panda/core/admin/container_component.html.erb +27 -0
- data/app/components/panda/core/admin/container_component.rb +11 -77
- data/app/components/panda/core/admin/contributors_summary_component.html.erb +15 -0
- data/app/components/panda/core/admin/contributors_summary_component.rb +23 -0
- data/app/components/panda/core/admin/dashboard_default_content_component.html.erb +70 -0
- data/app/components/panda/core/admin/dashboard_default_content_component.rb +32 -0
- data/app/components/panda/core/admin/delete_button_component.html.erb +4 -0
- data/app/components/panda/core/admin/delete_button_component.rb +41 -0
- data/app/components/panda/core/admin/dropdown_button_component.html.erb +28 -0
- data/app/components/panda/core/admin/dropdown_button_component.rb +100 -0
- data/app/components/panda/core/admin/dropdown_component.html.erb +32 -0
- data/app/components/panda/core/admin/dropdown_component.rb +65 -0
- data/app/components/panda/core/admin/empty_state_component.html.erb +16 -0
- data/app/components/panda/core/admin/empty_state_component.rb +25 -0
- data/app/components/panda/core/admin/file_gallery_component.html.erb +58 -0
- data/app/components/panda/core/admin/file_gallery_component.rb +26 -98
- data/app/components/panda/core/admin/filter_pills_component.html.erb +6 -0
- data/app/components/panda/core/admin/filter_pills_component.rb +29 -0
- data/app/components/panda/core/admin/flash_message_component.html.erb +11 -0
- data/app/components/panda/core/admin/flash_message_component.rb +33 -60
- data/app/components/panda/core/admin/form_error_component.html.erb +7 -0
- data/app/components/panda/core/admin/form_error_component.rb +8 -16
- data/app/components/panda/core/admin/form_footer_component.html.erb +16 -0
- data/app/components/panda/core/admin/form_footer_component.rb +65 -0
- data/app/components/panda/core/admin/form_input_component.html.erb +1 -0
- data/app/components/panda/core/admin/form_input_component.rb +21 -13
- data/app/components/panda/core/admin/form_section_component.html.erb +16 -0
- data/app/components/panda/core/admin/form_section_component.rb +64 -0
- data/app/components/panda/core/admin/form_select_component.html.erb +5 -0
- data/app/components/panda/core/admin/form_select_component.rb +25 -26
- data/app/components/panda/core/admin/heading_component.html.erb +26 -0
- data/app/components/panda/core/admin/heading_component.rb +23 -60
- data/app/components/panda/core/admin/login_form_component.html.erb +38 -0
- data/app/components/panda/core/admin/login_form_component.rb +28 -0
- data/app/components/panda/core/admin/main_layout_component.html.erb +88 -0
- data/app/components/panda/core/admin/main_layout_component.rb +19 -0
- data/app/components/panda/core/admin/my_profile/connected_account_component.html.erb +41 -0
- data/app/components/panda/core/admin/my_profile/connected_account_component.rb +27 -0
- data/app/components/panda/core/admin/navigation/item_component.html.erb +34 -0
- data/app/components/panda/core/admin/navigation/item_component.rb +69 -0
- data/app/components/panda/core/admin/navigation/sub_item_component.html.erb +9 -0
- data/app/components/panda/core/admin/navigation/sub_item_component.rb +44 -0
- data/app/components/panda/core/admin/navigation/user_menu_component.html.erb +30 -0
- data/app/components/panda/core/admin/navigation/user_menu_component.rb +36 -0
- data/app/components/panda/core/admin/page_header_component.html.erb +28 -0
- data/app/components/panda/core/admin/page_header_component.rb +19 -35
- data/app/components/panda/core/admin/pagination_component.html.erb +33 -0
- data/app/components/panda/core/admin/pagination_component.rb +56 -0
- data/app/components/panda/core/admin/panel_component.html.erb +19 -0
- data/app/components/panda/core/admin/panel_component.rb +4 -29
- data/app/components/panda/core/admin/prose_panel_component.html.erb +6 -0
- data/app/components/panda/core/admin/prose_panel_component.rb +43 -0
- data/app/components/panda/core/admin/search_bar_component.html.erb +19 -0
- data/app/components/panda/core/admin/search_bar_component.rb +31 -0
- data/app/components/panda/core/admin/search_filter_bar_component.html.erb +22 -0
- data/app/components/panda/core/admin/search_filter_bar_component.rb +27 -0
- data/app/components/panda/core/admin/secret_field_component.html.erb +24 -0
- data/app/components/panda/core/admin/secret_field_component.rb +31 -0
- data/app/components/panda/core/admin/sidebar_component.html.erb +160 -0
- data/app/components/panda/core/admin/sidebar_component.rb +55 -0
- data/app/components/panda/core/admin/slideover_component.html.erb +67 -0
- data/app/components/panda/core/admin/slideover_component.rb +31 -70
- data/app/components/panda/core/admin/statistics_component.html.erb +4 -0
- data/app/components/panda/core/admin/statistics_component.rb +6 -8
- data/app/components/panda/core/admin/tab_bar_component.html.erb +48 -0
- data/app/components/panda/core/admin/tab_bar_component.rb +32 -67
- data/app/components/panda/core/admin/table_component.html.erb +82 -0
- data/app/components/panda/core/admin/table_component.rb +54 -87
- data/app/components/panda/core/admin/tag_badge_component.html.erb +7 -0
- data/app/components/panda/core/admin/tag_badge_component.rb +21 -0
- data/app/components/panda/core/admin/tag_component.html.erb +1 -0
- data/app/components/panda/core/admin/tag_component.rb +51 -20
- data/app/components/panda/core/admin/tag_input_component.html.erb +37 -0
- data/app/components/panda/core/admin/tag_input_component.rb +22 -0
- data/app/components/panda/core/admin/tags_panel_component.html.erb +8 -0
- data/app/components/panda/core/admin/tags_panel_component.rb +33 -0
- data/app/components/panda/core/admin/user_activity_component.html.erb +15 -0
- data/app/components/panda/core/admin/user_activity_component.rb +9 -22
- data/app/components/panda/core/admin/user_display_component.html.erb +8 -0
- data/app/components/panda/core/admin/user_display_component.rb +19 -22
- data/app/components/panda/core/base.rb +35 -42
- data/app/components/panda/core/shared/footer_component.html.erb +2 -0
- data/app/components/panda/core/shared/footer_component.rb +11 -0
- data/app/components/panda/core/shared/header_component.html.erb +32 -0
- data/app/components/panda/core/shared/header_component.rb +19 -0
- data/app/constraints/panda/core/admin_constraint.rb +2 -2
- data/app/controllers/concerns/panda/core/admin_authorization.rb +58 -0
- data/app/controllers/concerns/panda/core/authorizable.rb +107 -0
- data/app/controllers/panda/core/admin/base_controller.rb +62 -3
- data/app/controllers/panda/core/admin/editor/search_controller.rb +17 -0
- data/app/controllers/panda/core/admin/feature_flags_controller.rb +35 -0
- data/app/controllers/panda/core/admin/file_categories_controller.rb +84 -0
- data/app/controllers/panda/core/admin/files_controller.rb +230 -0
- data/app/controllers/panda/core/admin/import_sessions_controller.rb +120 -0
- data/app/controllers/panda/core/admin/my_profile/logins_controller.rb +24 -0
- data/app/controllers/panda/core/admin/my_profile_controller.rb +31 -0
- data/app/controllers/panda/core/admin/search_controller.rb +15 -0
- data/app/controllers/panda/core/admin/sessions_controller.rb +84 -10
- data/app/controllers/panda/core/admin/tags_controller.rb +77 -0
- data/app/controllers/panda/core/admin/test_sessions_controller.rb +5 -6
- data/app/controllers/panda/core/admin/users_controller.rb +184 -0
- data/app/controllers/panda/core/application_controller.rb +13 -1
- data/app/helpers/panda/core/active_storage_helper.rb +24 -0
- data/app/helpers/panda/core/feature_flag_helper.rb +11 -0
- data/app/helpers/panda/core/form_helper.rb +15 -0
- data/app/javascript/panda/core/controllers/avatar_upload_controller.js +73 -0
- data/app/javascript/panda/core/controllers/clipboard_controller.js +119 -0
- data/app/javascript/panda/core/controllers/collapsible_item_controller.js +45 -0
- data/app/javascript/panda/core/controllers/custom_select_controller.js +256 -0
- data/app/javascript/panda/core/controllers/datepicker_controller.js +121 -0
- data/app/javascript/panda/core/controllers/daterange_controller.js +193 -0
- data/app/javascript/panda/core/controllers/file_gallery_controller.js +134 -0
- data/app/javascript/panda/core/controllers/file_upload_controller.js +165 -0
- data/app/javascript/panda/core/controllers/global_search_controller.js +184 -0
- data/app/javascript/panda/core/controllers/index.js +36 -0
- data/app/javascript/panda/core/controllers/mobile_sidebar_controller.js +58 -0
- data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +49 -24
- data/app/javascript/panda/core/controllers/row_link_controller.js +27 -0
- data/app/javascript/panda/core/controllers/tag_input_controller.js +183 -0
- data/app/javascript/panda/core/tailwindcss-stimulus-components.js +2 -3
- data/app/javascript/panda/core/vendor/@fortawesome--fontawesome-free@7.2.0.js +8129 -0
- data/app/javascript/panda/core/vendor/@hotwired--turbo.js +7197 -100
- data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +511 -2
- data/app/javascript/panda/core/vendor/@tailwindplus--elements@1.0.22.js +65 -0
- data/app/javascript/panda/core/vendor/cropperjs@2.1.0.js +3 -0
- data/app/javascript/panda/core/vendor/vanilla-calendar-pro.js +2 -0
- data/app/models/concerns/panda/core/has_metadata.rb +164 -0
- data/app/models/concerns/panda/core/importable.rb +79 -0
- data/app/models/concerns/panda/core/searchable.rb +66 -0
- data/app/models/concerns/panda/core/taggable.rb +37 -0
- data/app/models/panda/core/feature_flag.rb +56 -0
- data/app/models/panda/core/file_categorization.rb +14 -0
- data/app/models/panda/core/file_category.rb +45 -0
- data/app/models/panda/core/import_session.rb +79 -0
- data/app/models/panda/core/presence.rb +24 -0
- data/app/models/panda/core/tag.rb +33 -0
- data/app/models/panda/core/tagging.rb +14 -0
- data/app/models/panda/core/user.rb +94 -16
- data/app/models/panda/core/user_activity.rb +42 -0
- data/app/models/panda/core/user_session.rb +35 -0
- data/app/services/panda/core/attach_avatar_service.rb +15 -3
- data/app/services/panda/core/csv_import_service.rb +65 -0
- data/app/services/panda/core/file_categorizer.rb +52 -0
- data/app/services/panda/core/file_parser.rb +76 -0
- data/app/services/panda/core/invite_user_service.rb +44 -0
- data/app/services/panda/core/presence_service.rb +49 -0
- data/app/views/layouts/panda/core/admin.html.erb +6 -76
- data/app/views/layouts/panda/core/admin_simple.html.erb +2 -2
- data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -73
- data/app/views/panda/core/admin/dashboard/show.html.erb +12 -15
- data/app/views/panda/core/admin/feature_flags/index.html.erb +56 -0
- data/app/views/panda/core/admin/file_categories/_form.html.erb +20 -0
- data/app/views/panda/core/admin/file_categories/edit.html.erb +5 -0
- data/app/views/panda/core/admin/file_categories/index.html.erb +55 -0
- data/app/views/panda/core/admin/file_categories/new.html.erb +5 -0
- data/app/views/panda/core/admin/files/_file_details.html.erb +156 -0
- data/app/views/panda/core/admin/files/index.html.erb +61 -0
- data/app/views/panda/core/admin/import_sessions/column_map.html.erb +43 -0
- data/app/views/panda/core/admin/import_sessions/index.html.erb +48 -0
- data/app/views/panda/core/admin/import_sessions/new.html.erb +37 -0
- data/app/views/panda/core/admin/import_sessions/preview.html.erb +51 -0
- data/app/views/panda/core/admin/import_sessions/show.html.erb +87 -0
- data/app/views/panda/core/admin/my_profile/edit.html.erb +51 -39
- data/app/views/panda/core/admin/my_profile/logins/show.html.erb +72 -0
- data/app/views/panda/core/admin/sessions/new.html.erb +1 -37
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +1 -18
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +1 -99
- data/app/views/panda/core/admin/shared/_slideover.html.erb +3 -3
- data/app/views/panda/core/admin/tags/_form.html.erb +21 -0
- data/app/views/panda/core/admin/tags/edit.html.erb +4 -0
- data/app/views/panda/core/admin/tags/index.html.erb +41 -0
- data/app/views/panda/core/admin/tags/new.html.erb +4 -0
- data/app/views/panda/core/admin/users/activity.html.erb +90 -0
- data/app/views/panda/core/admin/users/edit.html.erb +48 -0
- data/app/views/panda/core/admin/users/index.html.erb +161 -0
- data/app/views/panda/core/admin/users/sessions.html.erb +103 -0
- data/app/views/panda/core/admin/users/show.html.erb +224 -0
- data/app/views/panda/core/shared/_footer.html.erb +1 -2
- data/app/views/panda/core/shared/_header.html.erb +4 -22
- data/config/brakeman.ignore +68 -31
- data/config/importmap.rb +13 -11
- data/config/routes.rb +49 -2
- data/db/migrate/20260202171614_create_panda_core_file_categories.rb +30 -0
- data/db/migrate/20260203000001_add_user_management_fields_to_panda_core_users.rb +21 -0
- data/db/migrate/20260203000002_create_panda_core_user_activities.rb +24 -0
- data/db/migrate/20260203000003_create_panda_core_user_sessions.rb +25 -0
- data/db/migrate/20260207100000_create_panda_core_presences.rb +21 -0
- data/db/migrate/20260213000001_fix_active_storage_attachments_record_id_type.rb +44 -0
- data/db/migrate/20260217000001_create_panda_core_feature_flags.rb +14 -0
- data/db/migrate/20260303000001_add_metadata_to_panda_core_users.rb +11 -0
- data/db/migrate/20260310000001_create_panda_core_tags.rb +27 -0
- data/db/migrate/20260310000002_create_panda_core_import_sessions.rb +29 -0
- data/lib/generators/panda/core/install_generator.rb +32 -0
- data/lib/generators/panda/core/templates/panda.rb +63 -0
- data/lib/panda/core/asset_loader.rb +5 -2
- data/lib/panda/core/authentication.rb +3 -3
- data/lib/panda/core/configuration.rb +195 -1
- data/lib/panda/core/engine/admin_controller_config.rb +3 -6
- data/lib/panda/core/engine/autoload_config.rb +21 -9
- data/lib/panda/core/engine/omniauth_config.rb +108 -38
- data/lib/panda/core/engine/pending_migrations_check.rb +46 -0
- data/lib/panda/core/engine/sqlite_schema_compatibility.rb +36 -0
- data/lib/panda/core/engine/{phlex_config.rb → view_component_config.rb} +5 -7
- data/lib/panda/core/engine.rb +90 -25
- data/lib/panda/core/middleware.rb +35 -0
- data/lib/panda/core/navigation_registry.rb +455 -0
- data/lib/panda/core/oauth_providers.rb +27 -0
- data/lib/panda/core/permission_registry.rb +89 -0
- data/lib/panda/core/search_registry.rb +69 -0
- data/lib/panda/core/seeds/file_categories.rb +30 -0
- data/lib/panda/core/shared/inflections_config.rb +1 -5
- data/lib/panda/core/testing/rails_helper.rb +87 -0
- data/lib/panda/core/testing/support/authentication_test_helpers.rb +22 -18
- data/lib/panda/core/testing/support/system/cuprite_helpers.rb +4 -4
- data/lib/panda/core/testing/view_component_test_controller.rb +19 -0
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core/widget_registry.rb +50 -0
- data/lib/panda/core.rb +17 -8
- data/lib/tasks/panda/core/file_categories.rake +41 -0
- data/public/panda-core-assets/panda-core-0.12.5.css +2 -0
- data/public/panda-core-assets/panda-core-0.13.0.css +2 -0
- data/public/panda-core-assets/panda-core-0.14.0.css +2 -0
- data/public/panda-core-assets/panda-core-0.14.4.css +2 -0
- data/public/panda-core-assets/panda-core.css +2 -2
- data/public/panda-core-assets/vanilla-calendar.css +47 -0
- metadata +203 -26
- data/lib/panda/core/engine/inflections_config.rb +0 -23
|
@@ -4,9 +4,19 @@ module Panda
|
|
|
4
4
|
module Core
|
|
5
5
|
class User < ApplicationRecord
|
|
6
6
|
include HasUUID
|
|
7
|
+
include HasMetadata
|
|
8
|
+
|
|
9
|
+
metadata_field :internal, type: :boolean, filterable: true,
|
|
10
|
+
label: "Visibility", default_scope: :external,
|
|
11
|
+
filter_options: [["All Users", ""], ["Staff Users", "internal"], ["External Users", "external"]]
|
|
7
12
|
|
|
8
13
|
self.table_name = "panda_core_users"
|
|
9
14
|
|
|
15
|
+
# Associations
|
|
16
|
+
has_many :user_activities, class_name: "Panda::Core::UserActivity", dependent: :destroy
|
|
17
|
+
has_many :user_sessions, class_name: "Panda::Core::UserSession", dependent: :destroy
|
|
18
|
+
belongs_to :invited_by, class_name: "Panda::Core::User", optional: true
|
|
19
|
+
|
|
10
20
|
# Active Storage attachment for avatar with variants
|
|
11
21
|
has_one_attached :avatar do |attachable|
|
|
12
22
|
attachable.variant :thumb, resize_to_limit: [50, 50], preprocessed: true
|
|
@@ -29,30 +39,62 @@ module Panda
|
|
|
29
39
|
scope :admins, -> {
|
|
30
40
|
where(admin_column => true)
|
|
31
41
|
}
|
|
42
|
+
scope :enabled, -> { where(enabled: true) }
|
|
43
|
+
scope :disabled, -> { where(enabled: false) }
|
|
44
|
+
scope :invited, -> { where.not(invitation_token: nil).where(invitation_accepted_at: nil) }
|
|
45
|
+
scope :active_recently, -> { where(last_login_at: 30.days.ago..) }
|
|
46
|
+
scope :search, ->(query) {
|
|
47
|
+
return all if query.blank?
|
|
48
|
+
where("name ILIKE :q OR email ILIKE :q", q: "%#{sanitize_sql_like(query)}%")
|
|
49
|
+
}
|
|
32
50
|
|
|
33
51
|
def self.find_or_create_from_auth_hash(auth_hash)
|
|
34
52
|
user = find_by(email: auth_hash.info.email.downcase)
|
|
35
53
|
|
|
36
|
-
# Handle avatar for both new and existing users
|
|
37
54
|
avatar_url = auth_hash.info.image
|
|
38
55
|
if user
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
56
|
+
has_stored_avatar = begin
|
|
57
|
+
user.avatar.attached?
|
|
58
|
+
rescue
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if avatar_url.present?
|
|
63
|
+
# Update image_url with latest OAuth URL only if no local avatar is stored
|
|
64
|
+
user.update_column(:image_url, avatar_url) unless has_stored_avatar
|
|
65
|
+
|
|
66
|
+
# Skip OAuth avatar download when user has a manually uploaded avatar
|
|
67
|
+
# (indicated by oauth_avatar_url being nil while an avatar is attached)
|
|
68
|
+
manually_uploaded = has_stored_avatar && user.oauth_avatar_url.nil?
|
|
69
|
+
unless manually_uploaded
|
|
70
|
+
# Try to download and store avatar if URL changed or no avatar attached
|
|
71
|
+
if avatar_url != user.oauth_avatar_url || !has_stored_avatar
|
|
72
|
+
AttachAvatarService.call(user: user, avatar_url: avatar_url)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
42
75
|
end
|
|
43
76
|
return user
|
|
44
77
|
end
|
|
45
78
|
|
|
79
|
+
# Check if user creation is restricted (e.g. invite-only mode)
|
|
80
|
+
restrict = Panda::Core.config.restrict_user_creation
|
|
81
|
+
restricted = restrict.respond_to?(:call) ? restrict.call(auth_hash) : restrict
|
|
82
|
+
if restricted
|
|
83
|
+
user = new(email: auth_hash.info.email.downcase, name: auth_hash.info.name || "Unknown User")
|
|
84
|
+
user.errors.add(:base, "No account exists for this email address. Please contact your administrator.")
|
|
85
|
+
return user
|
|
86
|
+
end
|
|
87
|
+
|
|
46
88
|
attributes = {
|
|
47
89
|
:email => auth_hash.info.email.downcase,
|
|
48
90
|
:name => auth_hash.info.name || "Unknown User",
|
|
49
|
-
:image_url =>
|
|
91
|
+
:image_url => avatar_url,
|
|
50
92
|
admin_column => User.count.zero? # First user is admin
|
|
51
93
|
}
|
|
52
94
|
|
|
53
95
|
user = create!(attributes)
|
|
54
96
|
|
|
55
|
-
# Attach avatar for new user
|
|
97
|
+
# Attach avatar for new user (will clear image_url on success)
|
|
56
98
|
if avatar_url.present?
|
|
57
99
|
AttachAvatarService.call(user: user, avatar_url: avatar_url)
|
|
58
100
|
end
|
|
@@ -77,7 +119,42 @@ module Panda
|
|
|
77
119
|
alias_method :is_admin=, :admin=
|
|
78
120
|
|
|
79
121
|
def active_for_authentication?
|
|
80
|
-
|
|
122
|
+
enabled?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def enable!
|
|
126
|
+
update!(enabled: true)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def disable!
|
|
130
|
+
update!(enabled: false)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def enabled?
|
|
134
|
+
self[:enabled] != false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def invite!(invited_by:)
|
|
138
|
+
update!(
|
|
139
|
+
invitation_token: SecureRandom.urlsafe_base64(32),
|
|
140
|
+
invitation_sent_at: Time.current,
|
|
141
|
+
invited_by: invited_by
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def accept_invitation!
|
|
146
|
+
update!(
|
|
147
|
+
invitation_accepted_at: Time.current,
|
|
148
|
+
invitation_token: nil
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def track_login!(request)
|
|
153
|
+
update!(
|
|
154
|
+
last_login_at: Time.current,
|
|
155
|
+
last_login_ip: request.remote_ip,
|
|
156
|
+
login_count: (login_count || 0) + 1
|
|
157
|
+
)
|
|
81
158
|
end
|
|
82
159
|
|
|
83
160
|
# Returns the URL for the user's avatar
|
|
@@ -85,16 +162,17 @@ module Panda
|
|
|
85
162
|
# @param size [Symbol] The variant size (:thumb, :small, :medium, :large, or nil for original)
|
|
86
163
|
# @return [String, nil] The avatar URL or nil if no avatar available
|
|
87
164
|
def avatar_url(size: nil)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# Fallback to OAuth provider URL if no avatar is attached yet
|
|
96
|
-
self[:image_url]
|
|
165
|
+
return self[:image_url].presence unless avatar.attached?
|
|
166
|
+
|
|
167
|
+
helpers = Rails.application.routes.url_helpers
|
|
168
|
+
if size && [:thumb, :small, :medium, :large].include?(size)
|
|
169
|
+
helpers.rails_representation_path(avatar.variant(size), only_path: true)
|
|
170
|
+
else
|
|
171
|
+
helpers.rails_blob_path(avatar.blob, only_path: true)
|
|
97
172
|
end
|
|
173
|
+
rescue => e
|
|
174
|
+
Rails.logger.error("Error generating avatar URL for user #{id}: #{e.message}\n#{e.backtrace&.first(3)&.join("\n")}")
|
|
175
|
+
self[:image_url].presence
|
|
98
176
|
end
|
|
99
177
|
|
|
100
178
|
private
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
class UserActivity < ApplicationRecord
|
|
6
|
+
include HasUUID
|
|
7
|
+
|
|
8
|
+
self.table_name = "panda_core_user_activities"
|
|
9
|
+
|
|
10
|
+
belongs_to :user, class_name: "Panda::Core::User"
|
|
11
|
+
|
|
12
|
+
validates :action, presence: true
|
|
13
|
+
|
|
14
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
15
|
+
scope :for_user, ->(user) { where(user: user) }
|
|
16
|
+
scope :by_action, ->(action) { where(action: action) }
|
|
17
|
+
scope :today, -> { where(created_at: Time.current.beginning_of_day..) }
|
|
18
|
+
scope :this_week, -> { where(created_at: Time.current.beginning_of_week..) }
|
|
19
|
+
|
|
20
|
+
# Log a user activity with optional request context
|
|
21
|
+
def self.log!(user:, action:, resource: nil, metadata: {}, request: nil)
|
|
22
|
+
attrs = {
|
|
23
|
+
user: user,
|
|
24
|
+
action: action.to_s,
|
|
25
|
+
metadata: metadata
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if resource
|
|
29
|
+
attrs[:resource_type] = resource.class.name
|
|
30
|
+
attrs[:resource_id] = resource.id
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if request
|
|
34
|
+
attrs[:ip_address] = request.remote_ip
|
|
35
|
+
attrs[:user_agent] = request.user_agent
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
create!(attrs)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
class UserSession < ApplicationRecord
|
|
6
|
+
include HasUUID
|
|
7
|
+
|
|
8
|
+
self.table_name = "panda_core_user_sessions"
|
|
9
|
+
|
|
10
|
+
belongs_to :user, class_name: "Panda::Core::User"
|
|
11
|
+
belongs_to :revoked_by, class_name: "Panda::Core::User", optional: true
|
|
12
|
+
|
|
13
|
+
validates :session_id, presence: true, uniqueness: true
|
|
14
|
+
|
|
15
|
+
scope :active_sessions, -> { where(active: true, revoked_at: nil) }
|
|
16
|
+
scope :revoked_sessions, -> { where(active: false) }
|
|
17
|
+
scope :for_user, ->(user) { where(user: user) }
|
|
18
|
+
scope :recent, -> { order(last_active_at: :desc) }
|
|
19
|
+
|
|
20
|
+
# Revoke a session (e.g. admin force-logout)
|
|
21
|
+
def revoke!(admin:)
|
|
22
|
+
update!(
|
|
23
|
+
active: false,
|
|
24
|
+
revoked_at: Time.current,
|
|
25
|
+
revoked_by: admin
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Update last activity timestamp
|
|
30
|
+
def touch_activity!
|
|
31
|
+
update!(last_active_at: Time.current)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -15,11 +15,19 @@ module Panda
|
|
|
15
15
|
|
|
16
16
|
def call
|
|
17
17
|
return success if @avatar_url.blank?
|
|
18
|
-
|
|
18
|
+
|
|
19
|
+
avatar_attached = begin
|
|
20
|
+
@user.avatar.attached?
|
|
21
|
+
rescue
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
return success if @avatar_url == @user.oauth_avatar_url && avatar_attached
|
|
19
25
|
|
|
20
26
|
begin
|
|
21
27
|
download_and_attach_avatar
|
|
22
|
-
|
|
28
|
+
# Record which OAuth URL was downloaded and clear image_url since
|
|
29
|
+
# we now have a local ActiveStorage copy
|
|
30
|
+
@user.update_columns(oauth_avatar_url: @avatar_url, image_url: nil)
|
|
23
31
|
success(avatar_attached: true)
|
|
24
32
|
rescue => e
|
|
25
33
|
Rails.logger.error("Failed to attach avatar for user #{@user.id}: #{e.message}")
|
|
@@ -48,7 +56,11 @@ module Panda
|
|
|
48
56
|
|
|
49
57
|
filename = "avatar_#{@user.id}_#{Time.current.to_i}.webp"
|
|
50
58
|
|
|
51
|
-
#
|
|
59
|
+
# Purge any existing avatar first to prevent duplicate attachment records.
|
|
60
|
+
# has_one_attached should auto-purge, but explicit purge guards against
|
|
61
|
+
# race conditions and failed async purge jobs leaving orphaned records.
|
|
62
|
+
@user.avatar.purge if @user.avatar.attached?
|
|
63
|
+
|
|
52
64
|
@user.avatar.attach(
|
|
53
65
|
io: optimized_file,
|
|
54
66
|
filename: filename,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
class CSVImportService
|
|
6
|
+
attr_reader :import_session
|
|
7
|
+
|
|
8
|
+
def initialize(import_session)
|
|
9
|
+
@import_session = import_session
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
import_session.update!(status: "importing", started_at: Time.current)
|
|
14
|
+
rows = import_session.file_rows
|
|
15
|
+
import_session.update!(total_rows: rows.size)
|
|
16
|
+
|
|
17
|
+
klass = import_session.importable_class
|
|
18
|
+
mapping = import_session.column_mapping
|
|
19
|
+
tenant_attrs = build_tenant_attrs
|
|
20
|
+
|
|
21
|
+
rows.each_with_index do |row, index|
|
|
22
|
+
process_row(klass, row, mapping, tenant_attrs, index + 2) # +2 for header + 1-based
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
import_session.update!(status: "complete", completed_at: Time.current)
|
|
26
|
+
rescue => e
|
|
27
|
+
import_session.update!(status: "failed", completed_at: Time.current)
|
|
28
|
+
Rails.logger.error("[CSVImportService] Import #{import_session.id} failed: #{e.message}")
|
|
29
|
+
raise
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def process_row(klass, row, mapping, tenant_attrs, row_number)
|
|
35
|
+
attrs, parse_errors = klass.import_row(row, mapping)
|
|
36
|
+
|
|
37
|
+
if parse_errors.any?
|
|
38
|
+
log_error(row_number, parse_errors)
|
|
39
|
+
import_session.increment!(:error_count)
|
|
40
|
+
else
|
|
41
|
+
record = klass.new(attrs.merge(tenant_attrs))
|
|
42
|
+
if record.save
|
|
43
|
+
import_session.increment!(:imported_count)
|
|
44
|
+
else
|
|
45
|
+
log_error(row_number, record.errors.full_messages)
|
|
46
|
+
import_session.increment!(:error_count)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
import_session.increment!(:processed_rows)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def log_error(row_number, errors)
|
|
54
|
+
log = import_session.errors_log || []
|
|
55
|
+
log << {row: row_number, errors: errors}
|
|
56
|
+
import_session.update_column(:errors_log, log)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_tenant_attrs
|
|
60
|
+
return {} unless import_session.tenant
|
|
61
|
+
{tenant: import_session.tenant}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
class FileCategorizer
|
|
6
|
+
# Maps [record_type, attachment_name] to category slug
|
|
7
|
+
ATTACHMENT_CATEGORY_MAP = {
|
|
8
|
+
["Panda::Core::User", "avatar"] => "user-avatars",
|
|
9
|
+
["Panda::CMS::Page", "og_image"] => "page-images",
|
|
10
|
+
["Panda::CMS::Post", "og_image"] => "post-images",
|
|
11
|
+
["Panda::CMS::FormSubmission", "files"] => "form-uploads",
|
|
12
|
+
["Panda::Social::InstagramPost", "image"] => "social-media"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# Categorize a blob by looking up its attachment's record type and name
|
|
16
|
+
def categorize_attachment(attachment)
|
|
17
|
+
slug = slug_for_attachment(attachment)
|
|
18
|
+
return unless slug
|
|
19
|
+
|
|
20
|
+
categorize_blob(attachment.blob, category_slug: slug)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Directly assign a blob to a category by slug
|
|
24
|
+
def categorize_blob(blob, category_slug:)
|
|
25
|
+
category = Panda::Core::FileCategory.find_by(slug: category_slug)
|
|
26
|
+
return unless category
|
|
27
|
+
|
|
28
|
+
Panda::Core::FileCategorization.find_or_create_by!(
|
|
29
|
+
file_category: category,
|
|
30
|
+
blob_id: blob.id
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Register additional mappings from other engines (e.g. panda-cms-pro)
|
|
35
|
+
def self.register_mapping(record_type, attachment_name, category_slug)
|
|
36
|
+
@custom_mappings ||= {}
|
|
37
|
+
@custom_mappings[[record_type, attachment_name]] = category_slug
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.custom_mappings
|
|
41
|
+
@custom_mappings || {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def slug_for_attachment(attachment)
|
|
47
|
+
key = [attachment.record_type, attachment.name]
|
|
48
|
+
ATTACHMENT_CATEGORY_MAP[key] || self.class.custom_mappings[key]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module Panda
|
|
7
|
+
module Core
|
|
8
|
+
class FileParser
|
|
9
|
+
class UnsupportedFormatError < StandardError; end
|
|
10
|
+
|
|
11
|
+
SUPPORTED_EXTENSIONS = %w[.csv .tsv .xlsx].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(filename, content)
|
|
14
|
+
@filename = filename
|
|
15
|
+
@content = content
|
|
16
|
+
@extension = File.extname(filename).downcase
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def headers
|
|
20
|
+
@headers ||= parse.first
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def rows
|
|
24
|
+
@rows ||= parse.last
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.supported?(filename)
|
|
28
|
+
ext = File.extname(filename).downcase
|
|
29
|
+
SUPPORTED_EXTENSIONS.include?(ext)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.xls?(filename)
|
|
33
|
+
File.extname(filename).downcase == ".xls"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def parse
|
|
39
|
+
@parsed ||= case @extension
|
|
40
|
+
when ".csv"
|
|
41
|
+
parse_csv
|
|
42
|
+
when ".tsv"
|
|
43
|
+
parse_tsv
|
|
44
|
+
when ".xlsx"
|
|
45
|
+
parse_xlsx
|
|
46
|
+
else
|
|
47
|
+
raise UnsupportedFormatError, "Unsupported file format: #{@extension}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_csv
|
|
52
|
+
parsed = CSV.parse(@content, headers: true)
|
|
53
|
+
headers = parsed.headers
|
|
54
|
+
rows = parsed.map(&:to_h)
|
|
55
|
+
[headers, rows]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_tsv
|
|
59
|
+
parsed = CSV.parse(@content, headers: true, col_sep: "\t")
|
|
60
|
+
headers = parsed.headers
|
|
61
|
+
rows = parsed.map(&:to_h)
|
|
62
|
+
[headers, rows]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_xlsx
|
|
66
|
+
require "xsv"
|
|
67
|
+
|
|
68
|
+
workbook = Xsv.open(StringIO.new(@content))
|
|
69
|
+
sheet = workbook.sheets.first
|
|
70
|
+
sheet.parse_headers!
|
|
71
|
+
|
|
72
|
+
[sheet.headers, sheet.to_a]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
class InviteUserService < Services::BaseService
|
|
6
|
+
def initialize(email:, name:, invited_by:, admin: false)
|
|
7
|
+
@email = email.to_s.downcase.strip
|
|
8
|
+
@name = name.to_s.strip
|
|
9
|
+
@invited_by = invited_by
|
|
10
|
+
@admin = admin
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
return failure(["Email is required"]) if @email.blank?
|
|
15
|
+
return failure(["Name is required"]) if @name.blank?
|
|
16
|
+
|
|
17
|
+
existing_user = User.find_by(email: @email)
|
|
18
|
+
return failure(["A user with this email already exists"]) if existing_user
|
|
19
|
+
|
|
20
|
+
user = User.new(
|
|
21
|
+
email: @email,
|
|
22
|
+
name: @name,
|
|
23
|
+
admin: @admin,
|
|
24
|
+
enabled: true
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
unless user.save
|
|
28
|
+
return failure(user.errors.full_messages)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
user.invite!(invited_by: @invited_by)
|
|
32
|
+
|
|
33
|
+
UserActivity.log!(
|
|
34
|
+
user: @invited_by,
|
|
35
|
+
action: "invited_user",
|
|
36
|
+
resource: user,
|
|
37
|
+
metadata: {invited_email: @email, admin: @admin}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
success(user: user)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
class PresenceService
|
|
6
|
+
def self.record_presence(resource, user_id)
|
|
7
|
+
attrs = {
|
|
8
|
+
presenceable_type: resource.class.name,
|
|
9
|
+
presenceable_id: resource.id,
|
|
10
|
+
user_id: user_id
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
result = Presence.upsert(
|
|
14
|
+
attrs.merge(last_seen_at: Time.current),
|
|
15
|
+
unique_by: %i[presenceable_type presenceable_id user_id],
|
|
16
|
+
returning: %w[id]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
record_id = result.first&.fetch("id") { raise "Upsert did not return an ID" }
|
|
20
|
+
Presence.find(record_id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.remove_presence(resource, user_id)
|
|
24
|
+
Presence.where(
|
|
25
|
+
presenceable_type: resource.class.name,
|
|
26
|
+
presenceable_id: resource.id,
|
|
27
|
+
user_id: user_id
|
|
28
|
+
).delete_all
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.current_editors(resource)
|
|
32
|
+
Presence
|
|
33
|
+
.for_resource(resource)
|
|
34
|
+
.active(Presence::PRESENCE_TTL)
|
|
35
|
+
.includes(:user)
|
|
36
|
+
.map do |presence|
|
|
37
|
+
{
|
|
38
|
+
user_id: presence.user_id,
|
|
39
|
+
user_name: presence.user.name.presence || presence.user.email
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.cleanup_stale!
|
|
45
|
+
Presence.stale(Presence::PRESENCE_TTL).delete_all
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -1,76 +1,6 @@
|
|
|
1
|
-
<%= render
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
</div>
|
|
8
|
-
<div class="flex flex-col flex-1 mt-16 ml-0 lg:mt-0 lg:ml-72" id="panda-inner-container" <% if content_for :sidebar %> data-controller="toggle" data-action="keydown.esc@window->toggle#hide keydown.escape@window->toggle#hide" tabindex="-1"<% end %>>
|
|
9
|
-
<section id="panda-main" class="flex flex-row h-full">
|
|
10
|
-
<div class="flex-1 h-full" id="panda-primary-content">
|
|
11
|
-
<%= render "panda/core/admin/shared/breadcrumbs" %>
|
|
12
|
-
<%= render "panda/core/admin/shared/flash" %>
|
|
13
|
-
<%= yield %>
|
|
14
|
-
</div>
|
|
15
|
-
<% if content_for :sidebar %>
|
|
16
|
-
<!-- Backdrop overlay -->
|
|
17
|
-
<div data-toggle-target="toggleable" class="hidden fixed inset-0 bg-gray-500/75 transition-opacity duration-500 ease-in-out dark:bg-gray-900/50 z-40"
|
|
18
|
-
data-transition-enter="transition-opacity duration-500 ease-in-out"
|
|
19
|
-
data-transition-enter-from="opacity-0"
|
|
20
|
-
data-transition-enter-to="opacity-100"
|
|
21
|
-
data-transition-leave="transition-opacity duration-500 ease-in-out"
|
|
22
|
-
data-transition-leave-from="opacity-100"
|
|
23
|
-
data-transition-leave-to="opacity-0"></div>
|
|
24
|
-
|
|
25
|
-
<!-- Slideover panel -->
|
|
26
|
-
<div data-toggle-target="toggleable" class="hidden ml-auto block size-full max-w-md transform fixed right-0 top-0 h-full z-50"
|
|
27
|
-
data-transition-enter="transform transition ease-in-out duration-500 sm:duration-700"
|
|
28
|
-
data-transition-enter-from="translate-x-full"
|
|
29
|
-
data-transition-enter-to="translate-x-0"
|
|
30
|
-
data-transition-leave="transform transition ease-in-out duration-500 sm:duration-700"
|
|
31
|
-
data-transition-leave-from="translate-x-0"
|
|
32
|
-
data-transition-leave-to="translate-x-full"
|
|
33
|
-
id="slideover">
|
|
34
|
-
<div class="relative flex h-full flex-col bg-white shadow-xl dark:bg-gray-800 dark:after:absolute dark:after:inset-y-0 dark:after:left-0 dark:after:w-px dark:after:bg-white/10">
|
|
35
|
-
<!-- Header -->
|
|
36
|
-
<div class="bg-gradient-admin px-4 py-3 sm:px-6">
|
|
37
|
-
<div class="flex items-center justify-between">
|
|
38
|
-
<h2 class="text-base font-semibold text-white" id="slideover-title"><%= yield :sidebar_title %></h2>
|
|
39
|
-
<div class="ml-3 flex items-center">
|
|
40
|
-
<button type="button" data-action="click->toggle#toggle touch->toggle#toggle" class="flex items-center gap-x-2 rounded-md px-3 py-1.5 text-white bg-white/10 hover:bg-white/20 transition-colors border border-white/20">
|
|
41
|
-
<span class="text-sm font-semibold uppercase tracking-wide">Close</span>
|
|
42
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" class="size-5">
|
|
43
|
-
<path d="M6 18 18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
|
|
44
|
-
</svg>
|
|
45
|
-
</button>
|
|
46
|
-
</div>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
|
|
50
|
-
<!-- Content -->
|
|
51
|
-
<div class="flex-1 overflow-y-auto">
|
|
52
|
-
<%= yield :sidebar %>
|
|
53
|
-
</div>
|
|
54
|
-
|
|
55
|
-
<!-- Footer (if present) -->
|
|
56
|
-
<% if content_for?(:sidebar_footer) %>
|
|
57
|
-
<div class="flex shrink-0 justify-end gap-x-3 border-t border-gray-200 px-4 py-4 dark:border-white/10">
|
|
58
|
-
<%= yield :sidebar_footer %>
|
|
59
|
-
</div>
|
|
60
|
-
<% end %>
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
<script>
|
|
64
|
-
window.addEventListener('keydown', function(e) {
|
|
65
|
-
if (e.key === 'Escape') {
|
|
66
|
-
document.querySelectorAll('[data-toggle-target="toggleable"]').forEach(function(el) {
|
|
67
|
-
el.classList.add('hidden');
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
</script>
|
|
72
|
-
<% end %>
|
|
73
|
-
</section>
|
|
74
|
-
</div>
|
|
75
|
-
</div>
|
|
76
|
-
<%= render "panda/core/shared/footer" %>
|
|
1
|
+
<%= render Panda::Core::Admin::MainLayoutComponent.new(
|
|
2
|
+
user: current_user,
|
|
3
|
+
breadcrumbs: breadcrumbs
|
|
4
|
+
) do %>
|
|
5
|
+
<%= yield %>
|
|
6
|
+
<% end %>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
<%= render
|
|
1
|
+
<%= render Panda::Core::Shared::HeaderComponent.new(html_class: "", body_class: "bg-gradient-admin") %>
|
|
2
2
|
<%= render "panda/core/admin/shared/flash" %>
|
|
3
3
|
<div class="flex flex-col items-center justify-center min-h-screen px-4">
|
|
4
4
|
<%= yield %>
|
|
5
5
|
</div>
|
|
6
|
-
<%= render
|
|
6
|
+
<%= render Panda::Core::Shared::FooterComponent.new %>
|