lean_cms 0.2.12
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 +7 -0
- data/CHANGELOG.md +235 -0
- data/LICENSE +21 -0
- data/README.md +107 -0
- data/app/assets/images/lean_cms/sloth-404.png +0 -0
- data/app/assets/images/lean_cms/sloth-500.png +0 -0
- data/app/assets/images/lean_cms/sloth-favicon-16.png +0 -0
- data/app/assets/images/lean_cms/sloth-favicon-32.png +0 -0
- data/app/assets/images/lean_cms/sloth-favicon-64.png +0 -0
- data/app/assets/images/lean_cms/sloth-logo.png +0 -0
- data/app/assets/lean_cms/actiontext.css +440 -0
- data/app/assets/lean_cms/cms_edit_controls.css +548 -0
- data/app/assets/tailwind/lean_cms/engine.css +14 -0
- data/app/components/lean_cms/base_component.rb +61 -0
- data/app/components/lean_cms/bullets_section_component.html.erb +23 -0
- data/app/components/lean_cms/bullets_section_component.rb +54 -0
- data/app/components/lean_cms/cards_section_component.html.erb +237 -0
- data/app/components/lean_cms/cards_section_component.rb +71 -0
- data/app/components/lean_cms/editable_content_component.html.erb +15 -0
- data/app/components/lean_cms/editable_content_component.rb +53 -0
- data/app/components/lean_cms/section_component.html.erb +18 -0
- data/app/components/lean_cms/section_component.rb +35 -0
- data/app/controllers/concerns/lean_cms/authentication.rb +60 -0
- data/app/controllers/concerns/lean_cms/authorization.rb +60 -0
- data/app/controllers/lean_cms/activity_controller.rb +16 -0
- data/app/controllers/lean_cms/application_controller.rb +48 -0
- data/app/controllers/lean_cms/dashboard_controller.rb +13 -0
- data/app/controllers/lean_cms/form_submissions_controller.rb +37 -0
- data/app/controllers/lean_cms/notification_settings_controller.rb +145 -0
- data/app/controllers/lean_cms/notifications_controller.rb +26 -0
- data/app/controllers/lean_cms/page_contents_controller.rb +403 -0
- data/app/controllers/lean_cms/password_setup_controller.rb +65 -0
- data/app/controllers/lean_cms/passwords_controller.rb +42 -0
- data/app/controllers/lean_cms/posts_controller.rb +78 -0
- data/app/controllers/lean_cms/sessions_controller.rb +50 -0
- data/app/controllers/lean_cms/settings_controller.rb +124 -0
- data/app/controllers/lean_cms/users_controller.rb +113 -0
- data/app/helpers/lean_cms/activity_helper.rb +190 -0
- data/app/helpers/lean_cms/application_helper.rb +43 -0
- data/app/helpers/lean_cms/content_helper.rb +34 -0
- data/app/helpers/lean_cms/page_content_helper.rb +359 -0
- data/app/javascript/controllers/cards_editor_controller.js +317 -0
- data/app/javascript/controllers/cms_sticky_overlay_controller.js +59 -0
- data/app/javascript/controllers/field_editor_form_controller.js +68 -0
- data/app/javascript/controllers/field_editor_modal_controller.js +79 -0
- data/app/javascript/controllers/inline_edit_controller.js +414 -0
- data/app/javascript/controllers/inline_edit_toggle_controller.js +81 -0
- data/app/javascript/controllers/notifications_controller.js +19 -0
- data/app/javascript/controllers/settings_inline_edit_sync_controller.js +38 -0
- data/app/javascript/controllers/settings_override_controller.js +45 -0
- data/app/mailers/lean_cms/application_mailer.rb +6 -0
- data/app/mailers/lean_cms/passwords_mailer.rb +8 -0
- data/app/mailers/lean_cms/users_mailer.rb +39 -0
- data/app/models/lean_cms/current.rb +6 -0
- data/app/models/lean_cms/form_submission.rb +45 -0
- data/app/models/lean_cms/magic_link.rb +76 -0
- data/app/models/lean_cms/meta_tag.rb +30 -0
- data/app/models/lean_cms/notification_setting.rb +69 -0
- data/app/models/lean_cms/page.rb +23 -0
- data/app/models/lean_cms/page_content.rb +245 -0
- data/app/models/lean_cms/post.rb +65 -0
- data/app/models/lean_cms/session.rb +7 -0
- data/app/models/lean_cms/setting.rb +156 -0
- data/app/policies/lean_cms/application_policy.rb +35 -0
- data/app/policies/lean_cms/page_content_policy.rb +31 -0
- data/app/policies/lean_cms/post_policy.rb +37 -0
- data/app/policies/lean_cms/setting_policy.rb +17 -0
- data/app/views/layouts/lean_cms/application.html.erb +114 -0
- data/app/views/layouts/lean_cms/auth.html.erb +200 -0
- data/app/views/lean_cms/activity/index.html.erb +79 -0
- data/app/views/lean_cms/dashboard/index.html.erb +180 -0
- data/app/views/lean_cms/form_submissions/index.html.erb +104 -0
- data/app/views/lean_cms/form_submissions/show.html.erb +157 -0
- data/app/views/lean_cms/notification_settings/edit.html.erb +192 -0
- data/app/views/lean_cms/notifications/index.html.erb +72 -0
- data/app/views/lean_cms/notifications/show.html.erb +39 -0
- data/app/views/lean_cms/page_contents/_field_editor.html.erb +174 -0
- data/app/views/lean_cms/page_contents/edit.html.erb +428 -0
- data/app/views/lean_cms/page_contents/index.html.erb +113 -0
- data/app/views/lean_cms/password_setup/show.html.erb +35 -0
- data/app/views/lean_cms/passwords/edit.html.erb +26 -0
- data/app/views/lean_cms/passwords/new.html.erb +21 -0
- data/app/views/lean_cms/passwords_mailer/reset.html.erb +6 -0
- data/app/views/lean_cms/passwords_mailer/reset.text.erb +4 -0
- data/app/views/lean_cms/posts/_form.html.erb +118 -0
- data/app/views/lean_cms/posts/edit.html.erb +31 -0
- data/app/views/lean_cms/posts/index.html.erb +100 -0
- data/app/views/lean_cms/posts/new.html.erb +16 -0
- data/app/views/lean_cms/sessions/new.html.erb +28 -0
- data/app/views/lean_cms/settings/edit.html.erb +384 -0
- data/app/views/lean_cms/shared/_admin_bar.html.erb +85 -0
- data/app/views/lean_cms/shared/_header.html.erb +86 -0
- data/app/views/lean_cms/shared/_notifications_bell.html.erb +84 -0
- data/app/views/lean_cms/shared/_sidebar.html.erb +102 -0
- data/app/views/lean_cms/users/_form.html.erb +105 -0
- data/app/views/lean_cms/users/edit.html.erb +8 -0
- data/app/views/lean_cms/users/index.html.erb +99 -0
- data/app/views/lean_cms/users/new.html.erb +8 -0
- data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.html.erb +13 -0
- data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.text.erb +11 -0
- data/app/views/lean_cms/users_mailer/invitation.html.erb +13 -0
- data/app/views/lean_cms/users_mailer/invitation.text.erb +11 -0
- data/app/views/lean_cms/users_mailer/reactivation.html.erb +13 -0
- data/app/views/lean_cms/users_mailer/reactivation.text.erb +11 -0
- data/config/importmap.rb +8 -0
- data/config/routes.rb +78 -0
- data/db/migrate/20251112034030_create_lean_cms_tables.rb +131 -0
- data/db/migrate/20260513000001_create_lean_cms_auth_tables.rb +31 -0
- data/db/migrate/20260514000001_create_paper_trail_versions.rb +16 -0
- data/db/migrate/20260514000002_create_action_text_tables.rb +18 -0
- data/db/migrate/20260514000003_create_active_storage_tables.rb +45 -0
- data/db/migrate/20260514000004_create_noticed_tables.rb +27 -0
- data/lib/generators/lean_cms/demo/demo_generator.rb +54 -0
- data/lib/generators/lean_cms/demo/templates/lean_cms_structure.yml +129 -0
- data/lib/generators/lean_cms/demo/templates/pages_controller.rb +30 -0
- data/lib/generators/lean_cms/demo/templates/views/pages/about.html.erb +40 -0
- data/lib/generators/lean_cms/demo/templates/views/pages/contact.html.erb +55 -0
- data/lib/generators/lean_cms/demo/templates/views/pages/home.html.erb +31 -0
- data/lib/generators/lean_cms/install/install_generator.rb +317 -0
- data/lib/generators/lean_cms/install/templates/add_lean_cms_columns_to_users.rb.tt +7 -0
- data/lib/generators/lean_cms/install/templates/lean_cms.rb +11 -0
- data/lib/generators/lean_cms/install/templates/lean_cms_structure.yml +29 -0
- data/lib/lean_cms/configuration.rb +32 -0
- data/lib/lean_cms/engine.rb +93 -0
- data/lib/lean_cms/loader.rb +217 -0
- data/lib/lean_cms/sync_helper.rb +182 -0
- data/lib/lean_cms/version.rb +3 -0
- data/lib/lean_cms.rb +26 -0
- data/lib/tasks/lean_cms.rake +390 -0
- metadata +313 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module LeanCms
|
|
2
|
+
class Post < ApplicationRecord
|
|
3
|
+
self.table_name = 'lean_cms_posts'
|
|
4
|
+
|
|
5
|
+
has_paper_trail
|
|
6
|
+
|
|
7
|
+
belongs_to :author, class_name: 'User'
|
|
8
|
+
belongs_to :last_edited_by, class_name: 'User', optional: true
|
|
9
|
+
|
|
10
|
+
has_rich_text :body
|
|
11
|
+
has_one_attached :featured_image
|
|
12
|
+
has_one :meta_tag, as: :taggable, class_name: 'LeanCms::MetaTag', dependent: :destroy
|
|
13
|
+
|
|
14
|
+
enum :status, { draft: 0, published: 1 }
|
|
15
|
+
enum :content_type, { blog: 0, portfolio: 1 }, prefix: :content
|
|
16
|
+
|
|
17
|
+
validates :title, :slug, presence: true
|
|
18
|
+
validates :slug, uniqueness: true
|
|
19
|
+
validates :status, presence: true
|
|
20
|
+
|
|
21
|
+
scope :published, -> { where(status: :published).where('published_at <= ?', Time.current) }
|
|
22
|
+
scope :recent, -> { order(published_at: :desc) }
|
|
23
|
+
scope :blog_posts, -> { content_blog }
|
|
24
|
+
scope :portfolio_items, -> { content_portfolio }
|
|
25
|
+
|
|
26
|
+
before_validation :generate_slug, if: -> { slug.blank? }
|
|
27
|
+
before_validation :set_published_at, if: -> { status_changed? && published? && published_at.nil? }
|
|
28
|
+
|
|
29
|
+
# Class method to find by slug
|
|
30
|
+
def self.find_by_slug!(slug)
|
|
31
|
+
find_by!(slug: slug)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if post is published
|
|
35
|
+
def published?
|
|
36
|
+
status == 'published' && published_at.present? && published_at <= Time.current
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get excerpt or truncated body
|
|
40
|
+
def excerpt_or_body(length: 200)
|
|
41
|
+
excerpt.presence || body.to_plain_text.truncate(length)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def generate_slug
|
|
47
|
+
return if title.blank?
|
|
48
|
+
|
|
49
|
+
base_slug = title.parameterize
|
|
50
|
+
slug_candidate = base_slug
|
|
51
|
+
counter = 1
|
|
52
|
+
|
|
53
|
+
while LeanCms::Post.where(slug: slug_candidate).where.not(id: id).exists?
|
|
54
|
+
slug_candidate = "#{base_slug}-#{counter}"
|
|
55
|
+
counter += 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
self.slug = slug_candidate
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def set_published_at
|
|
62
|
+
self.published_at = Time.current
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
class LeanCms::Setting < ApplicationRecord
|
|
2
|
+
self.table_name = 'lean_cms_settings'
|
|
3
|
+
|
|
4
|
+
has_paper_trail
|
|
5
|
+
has_one_attached :file
|
|
6
|
+
|
|
7
|
+
validates :key, presence: true, uniqueness: true
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def get(key, default = nil)
|
|
11
|
+
Rails.cache.fetch("lean_cms_setting/#{key}", expires_in: 1.hour) do
|
|
12
|
+
setting = find_by(key: key)
|
|
13
|
+
setting&.value || default
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def set(key, value)
|
|
18
|
+
setting = find_or_initialize_by(key: key)
|
|
19
|
+
setting.value = value.to_s
|
|
20
|
+
PaperTrail.request(whodunnit: LeanCms::Current.user&.id&.to_s) do
|
|
21
|
+
setting.save!
|
|
22
|
+
end
|
|
23
|
+
Rails.cache.delete("lean_cms_setting/#{key}")
|
|
24
|
+
value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the ActiveStorage URL for the uploaded favicon (host-configured
|
|
28
|
+
# override), or nil if no favicon has been uploaded. Callers should fall
|
|
29
|
+
# back to the gem's default sloth favicon when this returns nil.
|
|
30
|
+
def site_favicon_url
|
|
31
|
+
setting = find_by(key: "site_favicon")
|
|
32
|
+
return nil unless setting&.file&.attached?
|
|
33
|
+
Rails.application.routes.url_helpers.rails_blob_path(setting.file, only_path: true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Attaches a new favicon file (e.g. from params[:site_favicon]).
|
|
37
|
+
def update_site_favicon!(file_param)
|
|
38
|
+
return if file_param.blank?
|
|
39
|
+
setting = find_or_create_by!(key: "site_favicon") { |s| s.value = "uploaded" }
|
|
40
|
+
setting.file.purge if setting.file.attached?
|
|
41
|
+
setting.file.attach(file_param)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def remove_site_favicon!
|
|
45
|
+
setting = find_by(key: "site_favicon")
|
|
46
|
+
setting&.file&.purge if setting&.file&.attached?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Bypass cache - use for settings that must take effect immediately (e.g. cookie consent)
|
|
50
|
+
def get_uncached(key, default = nil)
|
|
51
|
+
setting = find_by(key: key)
|
|
52
|
+
setting&.value || default
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def enabled?(key)
|
|
56
|
+
get(key, 'false') == 'true'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# JSON storage helpers
|
|
60
|
+
def get_json(key, default = {})
|
|
61
|
+
raw = get(key)
|
|
62
|
+
return default if raw.blank?
|
|
63
|
+
JSON.parse(raw)
|
|
64
|
+
rescue JSON::ParserError
|
|
65
|
+
default
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def set_json(key, value)
|
|
69
|
+
set(key, value.to_json)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Site information convenience methods
|
|
73
|
+
|
|
74
|
+
# Returns structured address data as hash
|
|
75
|
+
def site_address_data
|
|
76
|
+
get_json('site_address', {
|
|
77
|
+
'street1' => '',
|
|
78
|
+
'street2' => '',
|
|
79
|
+
'city' => '',
|
|
80
|
+
'state' => '',
|
|
81
|
+
'zip' => ''
|
|
82
|
+
})
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns formatted address string for display
|
|
86
|
+
def site_address
|
|
87
|
+
data = site_address_data
|
|
88
|
+
parts = []
|
|
89
|
+
parts << data['street1'] if data['street1'].present?
|
|
90
|
+
parts << data['street2'] if data['street2'].present?
|
|
91
|
+
|
|
92
|
+
city_state_zip = []
|
|
93
|
+
city_state_zip << data['city'] if data['city'].present?
|
|
94
|
+
city_state_zip << data['state'] if data['state'].present?
|
|
95
|
+
city_state_zip << data['zip'] if data['zip'].present?
|
|
96
|
+
|
|
97
|
+
parts << city_state_zip.join(', ') if city_state_zip.any?
|
|
98
|
+
parts.join("\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns single-line formatted address
|
|
102
|
+
def site_address_single_line
|
|
103
|
+
data = site_address_data
|
|
104
|
+
parts = []
|
|
105
|
+
parts << data['street1'] if data['street1'].present?
|
|
106
|
+
parts << data['street2'] if data['street2'].present?
|
|
107
|
+
|
|
108
|
+
city_state = []
|
|
109
|
+
city_state << data['city'] if data['city'].present?
|
|
110
|
+
city_state << data['state'] if data['state'].present?
|
|
111
|
+
|
|
112
|
+
location = city_state.join(', ')
|
|
113
|
+
location += " #{data['zip']}" if data['zip'].present?
|
|
114
|
+
|
|
115
|
+
parts << location if location.present?
|
|
116
|
+
parts.join(', ')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def site_phone
|
|
120
|
+
get('site_phone', '')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def site_email
|
|
124
|
+
get('site_email', '')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def business_hours
|
|
128
|
+
get_json('business_hours', { 'hours' => [], 'note' => '' })
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Content lock methods for sync workflow
|
|
132
|
+
def content_locked?
|
|
133
|
+
enabled?('content_locked')
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def lock_content!(reason = nil)
|
|
137
|
+
set('content_locked', 'true')
|
|
138
|
+
set('content_locked_at', Time.current.iso8601)
|
|
139
|
+
set('content_locked_reason', reason) if reason
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def unlock_content!
|
|
143
|
+
set('content_locked', 'false')
|
|
144
|
+
Rails.cache.delete("lean_cms_setting/content_locked_at")
|
|
145
|
+
Rails.cache.delete("lean_cms_setting/content_locked_reason")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def content_lock_info
|
|
149
|
+
return nil unless content_locked?
|
|
150
|
+
{
|
|
151
|
+
locked_at: get('content_locked_at'),
|
|
152
|
+
reason: get('content_locked_reason', 'Content sync in progress')
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LeanCms
|
|
4
|
+
class ApplicationPolicy
|
|
5
|
+
attr_reader :user, :record
|
|
6
|
+
|
|
7
|
+
def initialize(user, record)
|
|
8
|
+
@user = user
|
|
9
|
+
@record = record
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def index? = false
|
|
13
|
+
def show? = false
|
|
14
|
+
def create? = false
|
|
15
|
+
def new? = create?
|
|
16
|
+
def update? = false
|
|
17
|
+
def edit? = update?
|
|
18
|
+
def destroy? = false
|
|
19
|
+
|
|
20
|
+
class Scope
|
|
21
|
+
def initialize(user, scope)
|
|
22
|
+
@user = user
|
|
23
|
+
@scope = scope
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resolve
|
|
27
|
+
raise NoMethodError, "You must define #resolve in #{self.class}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :user, :scope
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LeanCms
|
|
4
|
+
class PageContentPolicy < ApplicationPolicy
|
|
5
|
+
def index?
|
|
6
|
+
user.can_edit_pages?
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show?
|
|
10
|
+
user.can_edit_pages?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update?
|
|
14
|
+
user.can_edit_pages?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def edit?
|
|
18
|
+
update?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Scope < ApplicationPolicy::Scope
|
|
22
|
+
def resolve
|
|
23
|
+
if user.can_edit_pages?
|
|
24
|
+
scope.all
|
|
25
|
+
else
|
|
26
|
+
scope.none
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LeanCms
|
|
4
|
+
class PostPolicy < ApplicationPolicy
|
|
5
|
+
def index?
|
|
6
|
+
user.can_edit_blog?
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show?
|
|
10
|
+
user.can_edit_blog?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def create?
|
|
14
|
+
user.can_edit_blog?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update?
|
|
18
|
+
return false unless user.can_edit_blog?
|
|
19
|
+
# Super admins can edit any post, others can only edit their own
|
|
20
|
+
user.is_super_admin? || record.author_id == user.id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def destroy?
|
|
24
|
+
update?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Scope < ApplicationPolicy::Scope
|
|
28
|
+
def resolve
|
|
29
|
+
if user.can_edit_blog?
|
|
30
|
+
scope.all
|
|
31
|
+
else
|
|
32
|
+
scope.none
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LeanCms
|
|
4
|
+
class SettingPolicy < ApplicationPolicy
|
|
5
|
+
def edit?
|
|
6
|
+
user.can_access_settings?
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def update?
|
|
10
|
+
user.can_access_settings?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update_override?
|
|
14
|
+
user.can_access_settings?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title><%= content_for?(:title) ? "#{yield(:title)} - " : "" %>Lean CMS - <%= LeanCms.site_name %></title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= csp_meta_tag %>
|
|
9
|
+
|
|
10
|
+
<link rel="icon" type="image/png" sizes="16x16" href="<%= asset_path("lean_cms/sloth-favicon-16.png") %>">
|
|
11
|
+
<link rel="icon" type="image/png" sizes="32x32" href="<%= asset_path("lean_cms/sloth-favicon-32.png") %>">
|
|
12
|
+
<link rel="icon" type="image/png" sizes="64x64" href="<%= asset_path("lean_cms/sloth-favicon-64.png") %>">
|
|
13
|
+
<link rel="apple-touch-icon" href="<%= asset_path("lean_cms/sloth-favicon-64.png") %>">
|
|
14
|
+
|
|
15
|
+
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
|
16
|
+
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
|
17
|
+
<%= stylesheet_link_tag "actiontext", "data-turbo-track": "reload" %>
|
|
18
|
+
<%= javascript_importmap_tags %>
|
|
19
|
+
|
|
20
|
+
<!-- Alpine.js for dropdown functionality -->
|
|
21
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
22
|
+
|
|
23
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
24
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
25
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
26
|
+
|
|
27
|
+
<style>
|
|
28
|
+
:root {
|
|
29
|
+
--cms-primary: <%= LeanCms.primary_color %>;
|
|
30
|
+
--cms-secondary: <%= LeanCms.secondary_color %>;
|
|
31
|
+
--cms-primary-dark: <%= darken_color(LeanCms.primary_color, 10) %>;
|
|
32
|
+
--cms-gray-50: #f8fafc;
|
|
33
|
+
--cms-gray-100: #f1f5f9;
|
|
34
|
+
--cms-gray-200: #e2e8f0;
|
|
35
|
+
--cms-gray-300: #cbd5e1;
|
|
36
|
+
--cms-gray-400: #94a3b8;
|
|
37
|
+
--cms-gray-500: #64748b;
|
|
38
|
+
--cms-gray-600: #475569;
|
|
39
|
+
--cms-gray-700: #334155;
|
|
40
|
+
--cms-gray-800: #1e293b;
|
|
41
|
+
--cms-gray-900: #0f172a;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
body {
|
|
45
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Ensure all interactive elements have pointer cursor */
|
|
49
|
+
a, button, [role="button"], input[type="submit"], input[type="button"] {
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
53
|
+
</head>
|
|
54
|
+
<body class="bg-gray-50 antialiased">
|
|
55
|
+
<div class="flex h-screen overflow-hidden">
|
|
56
|
+
<!-- Sidebar -->
|
|
57
|
+
<%= render 'lean_cms/shared/sidebar' %>
|
|
58
|
+
|
|
59
|
+
<!-- Main Content -->
|
|
60
|
+
<div class="flex-1 flex flex-col overflow-hidden">
|
|
61
|
+
<!-- Top Header -->
|
|
62
|
+
<%= render 'lean_cms/shared/header' %>
|
|
63
|
+
|
|
64
|
+
<!-- Content Lock Banner -->
|
|
65
|
+
<% if content_locked? %>
|
|
66
|
+
<% lock_info = content_lock_info %>
|
|
67
|
+
<div class="bg-amber-50 border-b border-amber-300 px-6 py-3 flex items-center justify-between">
|
|
68
|
+
<div class="flex items-center gap-3">
|
|
69
|
+
<svg class="w-5 h-5 text-amber-600 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
70
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
|
71
|
+
</svg>
|
|
72
|
+
<div>
|
|
73
|
+
<span class="font-semibold text-amber-800">Content editing is locked.</span>
|
|
74
|
+
<span class="text-amber-700 ml-1"><%= lock_info[:reason] %></span>
|
|
75
|
+
<% if lock_info[:locked_at].present? %>
|
|
76
|
+
<span class="text-amber-600 text-sm ml-2">Locked <%= time_ago_in_words(Time.parse(lock_info[:locked_at])) %> ago.</span>
|
|
77
|
+
<% end %>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<%= button_to 'Unlock', lean_cms_unlock_content_path, method: :post,
|
|
81
|
+
class: "text-sm font-medium px-3 py-1 rounded bg-amber-200 hover:bg-amber-300 text-amber-900 transition-colors" %>
|
|
82
|
+
</div>
|
|
83
|
+
<% end %>
|
|
84
|
+
|
|
85
|
+
<!-- Content Area -->
|
|
86
|
+
<main class="flex-1 overflow-y-auto bg-gray-50 p-6 pb-20">
|
|
87
|
+
<% if notice.present? %>
|
|
88
|
+
<div class="mb-4 bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg flex items-center justify-between" role="alert">
|
|
89
|
+
<span><%= notice %></span>
|
|
90
|
+
<button type="button" class="text-green-600 hover:text-green-800" onclick="this.parentElement.remove()">
|
|
91
|
+
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
92
|
+
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
|
93
|
+
</svg>
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<% end %>
|
|
97
|
+
|
|
98
|
+
<% if alert.present? %>
|
|
99
|
+
<div class="mb-4 bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg flex items-center justify-between" role="alert">
|
|
100
|
+
<span><%= alert %></span>
|
|
101
|
+
<button type="button" class="text-red-600 hover:text-red-800" onclick="this.parentElement.remove()">
|
|
102
|
+
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
103
|
+
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
|
104
|
+
</svg>
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
<% end %>
|
|
108
|
+
|
|
109
|
+
<%= yield %>
|
|
110
|
+
</main>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</body>
|
|
114
|
+
</html>
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title><%= content_for?(:title) ? "#{yield(:title)} - " : "" %><%= LeanCms.site_name %></title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= csp_meta_tag %>
|
|
9
|
+
|
|
10
|
+
<link rel="icon" type="image/png" sizes="16x16" href="<%= asset_path("lean_cms/sloth-favicon-16.png") %>">
|
|
11
|
+
<link rel="icon" type="image/png" sizes="32x32" href="<%= asset_path("lean_cms/sloth-favicon-32.png") %>">
|
|
12
|
+
<link rel="icon" type="image/png" sizes="64x64" href="<%= asset_path("lean_cms/sloth-favicon-64.png") %>">
|
|
13
|
+
<link rel="apple-touch-icon" href="<%= asset_path("lean_cms/sloth-favicon-64.png") %>">
|
|
14
|
+
|
|
15
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
16
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
17
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
/* Lean CMS auth pages — self-contained styling, no host CSS framework
|
|
21
|
+
required. The admin (post-login) layout assumes Tailwind. */
|
|
22
|
+
|
|
23
|
+
:root {
|
|
24
|
+
--lc-primary: <%= LeanCms.primary_color %>;
|
|
25
|
+
--lc-secondary: <%= LeanCms.secondary_color %>;
|
|
26
|
+
--lc-ink: #0f172a;
|
|
27
|
+
--lc-ink-soft: #334155;
|
|
28
|
+
--lc-muted: #64748b;
|
|
29
|
+
--lc-bg: #f8fafc;
|
|
30
|
+
--lc-surface: #ffffff;
|
|
31
|
+
--lc-border: #e2e8f0;
|
|
32
|
+
--lc-red-bg: #fef2f2;
|
|
33
|
+
--lc-red-fg: #b91c1c;
|
|
34
|
+
--lc-grn-bg: #f0fdf4;
|
|
35
|
+
--lc-grn-fg: #15803d;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
* { box-sizing: border-box; }
|
|
39
|
+
html, body { margin: 0; padding: 0; }
|
|
40
|
+
body {
|
|
41
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
42
|
+
background: var(--lc-bg);
|
|
43
|
+
color: var(--lc-ink);
|
|
44
|
+
min-height: 100vh;
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
-webkit-font-smoothing: antialiased;
|
|
48
|
+
-moz-osx-font-smoothing: grayscale;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.lc-auth-main {
|
|
52
|
+
flex: 1;
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
padding: 3rem 1rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.lc-auth-shell {
|
|
60
|
+
width: 100%;
|
|
61
|
+
max-width: 28rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.lc-auth-logo {
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: center;
|
|
67
|
+
margin-bottom: 2rem;
|
|
68
|
+
}
|
|
69
|
+
.lc-auth-logo img { height: 3rem; width: auto; }
|
|
70
|
+
|
|
71
|
+
.lc-auth-card {
|
|
72
|
+
background: var(--lc-surface);
|
|
73
|
+
border: 1px solid var(--lc-border);
|
|
74
|
+
border-radius: 16px;
|
|
75
|
+
padding: 2rem;
|
|
76
|
+
box-shadow: 0 10px 30px -10px rgba(15, 23, 42, 0.08);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.lc-auth-title {
|
|
80
|
+
font-size: 1.5rem;
|
|
81
|
+
font-weight: 700;
|
|
82
|
+
color: var(--lc-ink);
|
|
83
|
+
margin: 0 0 0.5rem 0;
|
|
84
|
+
}
|
|
85
|
+
.lc-auth-lede {
|
|
86
|
+
font-size: 0.9375rem;
|
|
87
|
+
color: var(--lc-ink-soft);
|
|
88
|
+
margin: 0 0 1.5rem 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.lc-auth-flash {
|
|
92
|
+
padding: 0.625rem 0.875rem;
|
|
93
|
+
border-radius: 8px;
|
|
94
|
+
font-size: 0.875rem;
|
|
95
|
+
font-weight: 500;
|
|
96
|
+
margin-bottom: 1.25rem;
|
|
97
|
+
}
|
|
98
|
+
.lc-auth-flash--alert { background: var(--lc-red-bg); color: var(--lc-red-fg); }
|
|
99
|
+
.lc-auth-flash--notice { background: var(--lc-grn-bg); color: var(--lc-grn-fg); }
|
|
100
|
+
|
|
101
|
+
.lc-auth-form { display: flex; flex-direction: column; gap: 1.25rem; }
|
|
102
|
+
|
|
103
|
+
.lc-auth-field { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
104
|
+
.lc-auth-field label {
|
|
105
|
+
font-size: 0.8125rem;
|
|
106
|
+
font-weight: 600;
|
|
107
|
+
color: var(--lc-ink-soft);
|
|
108
|
+
}
|
|
109
|
+
.lc-auth-input {
|
|
110
|
+
width: 100%;
|
|
111
|
+
padding: 0.75rem 0.875rem;
|
|
112
|
+
border: 2px solid var(--lc-border);
|
|
113
|
+
border-radius: 9px;
|
|
114
|
+
font-size: 0.9375rem;
|
|
115
|
+
font-family: inherit;
|
|
116
|
+
color: var(--lc-ink);
|
|
117
|
+
background: var(--lc-surface);
|
|
118
|
+
outline: none;
|
|
119
|
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
120
|
+
}
|
|
121
|
+
.lc-auth-input::placeholder { color: var(--lc-muted); }
|
|
122
|
+
.lc-auth-input:focus {
|
|
123
|
+
border-color: var(--lc-primary);
|
|
124
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lc-primary) 20%, transparent);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.lc-auth-btn {
|
|
128
|
+
display: inline-flex;
|
|
129
|
+
align-items: center;
|
|
130
|
+
justify-content: center;
|
|
131
|
+
padding: 0.75rem 1.5rem;
|
|
132
|
+
border: none;
|
|
133
|
+
border-radius: 9px;
|
|
134
|
+
font-size: 0.9375rem;
|
|
135
|
+
font-weight: 600;
|
|
136
|
+
font-family: inherit;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
background: var(--lc-primary);
|
|
139
|
+
color: white;
|
|
140
|
+
transition: filter 0.15s ease, transform 0.05s ease;
|
|
141
|
+
}
|
|
142
|
+
.lc-auth-btn:hover { filter: brightness(0.92); }
|
|
143
|
+
.lc-auth-btn:active { transform: translateY(1px); }
|
|
144
|
+
.lc-auth-btn--block { width: 100%; }
|
|
145
|
+
|
|
146
|
+
.lc-auth-actions {
|
|
147
|
+
display: flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: space-between;
|
|
150
|
+
gap: 1rem;
|
|
151
|
+
margin-top: 0.5rem;
|
|
152
|
+
}
|
|
153
|
+
@media (max-width: 420px) {
|
|
154
|
+
.lc-auth-actions { flex-direction: column; align-items: stretch; }
|
|
155
|
+
.lc-auth-actions .lc-auth-link { text-align: center; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.lc-auth-link {
|
|
159
|
+
color: var(--lc-primary);
|
|
160
|
+
font-size: 0.875rem;
|
|
161
|
+
font-weight: 500;
|
|
162
|
+
text-decoration: none;
|
|
163
|
+
}
|
|
164
|
+
.lc-auth-link:hover { filter: brightness(0.85); text-decoration: underline; }
|
|
165
|
+
|
|
166
|
+
.lc-auth-meta {
|
|
167
|
+
text-align: center;
|
|
168
|
+
font-size: 0.75rem;
|
|
169
|
+
color: var(--lc-muted);
|
|
170
|
+
margin-top: 1.5rem;
|
|
171
|
+
}
|
|
172
|
+
</style>
|
|
173
|
+
</head>
|
|
174
|
+
<body>
|
|
175
|
+
<main class="lc-auth-main">
|
|
176
|
+
<div class="lc-auth-shell">
|
|
177
|
+
<% if LeanCms.site_logo_path.present? %>
|
|
178
|
+
<div class="lc-auth-logo">
|
|
179
|
+
<%= link_to image_tag(LeanCms.site_logo_path, alt: LeanCms.site_name),
|
|
180
|
+
"/",
|
|
181
|
+
title: "Back to #{LeanCms.site_name}" %>
|
|
182
|
+
</div>
|
|
183
|
+
<% end %>
|
|
184
|
+
|
|
185
|
+
<div class="lc-auth-card">
|
|
186
|
+
<% if alert = flash[:alert] %>
|
|
187
|
+
<p class="lc-auth-flash lc-auth-flash--alert" role="alert"><%= alert %></p>
|
|
188
|
+
<% end %>
|
|
189
|
+
<% if notice = flash[:notice] %>
|
|
190
|
+
<p class="lc-auth-flash lc-auth-flash--notice" role="status"><%= notice %></p>
|
|
191
|
+
<% end %>
|
|
192
|
+
|
|
193
|
+
<%= yield %>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<p class="lc-auth-meta"><%= LeanCms.site_name %></p>
|
|
197
|
+
</div>
|
|
198
|
+
</main>
|
|
199
|
+
</body>
|
|
200
|
+
</html>
|