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.
Files changed (130) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +235 -0
  3. data/LICENSE +21 -0
  4. data/README.md +107 -0
  5. data/app/assets/images/lean_cms/sloth-404.png +0 -0
  6. data/app/assets/images/lean_cms/sloth-500.png +0 -0
  7. data/app/assets/images/lean_cms/sloth-favicon-16.png +0 -0
  8. data/app/assets/images/lean_cms/sloth-favicon-32.png +0 -0
  9. data/app/assets/images/lean_cms/sloth-favicon-64.png +0 -0
  10. data/app/assets/images/lean_cms/sloth-logo.png +0 -0
  11. data/app/assets/lean_cms/actiontext.css +440 -0
  12. data/app/assets/lean_cms/cms_edit_controls.css +548 -0
  13. data/app/assets/tailwind/lean_cms/engine.css +14 -0
  14. data/app/components/lean_cms/base_component.rb +61 -0
  15. data/app/components/lean_cms/bullets_section_component.html.erb +23 -0
  16. data/app/components/lean_cms/bullets_section_component.rb +54 -0
  17. data/app/components/lean_cms/cards_section_component.html.erb +237 -0
  18. data/app/components/lean_cms/cards_section_component.rb +71 -0
  19. data/app/components/lean_cms/editable_content_component.html.erb +15 -0
  20. data/app/components/lean_cms/editable_content_component.rb +53 -0
  21. data/app/components/lean_cms/section_component.html.erb +18 -0
  22. data/app/components/lean_cms/section_component.rb +35 -0
  23. data/app/controllers/concerns/lean_cms/authentication.rb +60 -0
  24. data/app/controllers/concerns/lean_cms/authorization.rb +60 -0
  25. data/app/controllers/lean_cms/activity_controller.rb +16 -0
  26. data/app/controllers/lean_cms/application_controller.rb +48 -0
  27. data/app/controllers/lean_cms/dashboard_controller.rb +13 -0
  28. data/app/controllers/lean_cms/form_submissions_controller.rb +37 -0
  29. data/app/controllers/lean_cms/notification_settings_controller.rb +145 -0
  30. data/app/controllers/lean_cms/notifications_controller.rb +26 -0
  31. data/app/controllers/lean_cms/page_contents_controller.rb +403 -0
  32. data/app/controllers/lean_cms/password_setup_controller.rb +65 -0
  33. data/app/controllers/lean_cms/passwords_controller.rb +42 -0
  34. data/app/controllers/lean_cms/posts_controller.rb +78 -0
  35. data/app/controllers/lean_cms/sessions_controller.rb +50 -0
  36. data/app/controllers/lean_cms/settings_controller.rb +124 -0
  37. data/app/controllers/lean_cms/users_controller.rb +113 -0
  38. data/app/helpers/lean_cms/activity_helper.rb +190 -0
  39. data/app/helpers/lean_cms/application_helper.rb +43 -0
  40. data/app/helpers/lean_cms/content_helper.rb +34 -0
  41. data/app/helpers/lean_cms/page_content_helper.rb +359 -0
  42. data/app/javascript/controllers/cards_editor_controller.js +317 -0
  43. data/app/javascript/controllers/cms_sticky_overlay_controller.js +59 -0
  44. data/app/javascript/controllers/field_editor_form_controller.js +68 -0
  45. data/app/javascript/controllers/field_editor_modal_controller.js +79 -0
  46. data/app/javascript/controllers/inline_edit_controller.js +414 -0
  47. data/app/javascript/controllers/inline_edit_toggle_controller.js +81 -0
  48. data/app/javascript/controllers/notifications_controller.js +19 -0
  49. data/app/javascript/controllers/settings_inline_edit_sync_controller.js +38 -0
  50. data/app/javascript/controllers/settings_override_controller.js +45 -0
  51. data/app/mailers/lean_cms/application_mailer.rb +6 -0
  52. data/app/mailers/lean_cms/passwords_mailer.rb +8 -0
  53. data/app/mailers/lean_cms/users_mailer.rb +39 -0
  54. data/app/models/lean_cms/current.rb +6 -0
  55. data/app/models/lean_cms/form_submission.rb +45 -0
  56. data/app/models/lean_cms/magic_link.rb +76 -0
  57. data/app/models/lean_cms/meta_tag.rb +30 -0
  58. data/app/models/lean_cms/notification_setting.rb +69 -0
  59. data/app/models/lean_cms/page.rb +23 -0
  60. data/app/models/lean_cms/page_content.rb +245 -0
  61. data/app/models/lean_cms/post.rb +65 -0
  62. data/app/models/lean_cms/session.rb +7 -0
  63. data/app/models/lean_cms/setting.rb +156 -0
  64. data/app/policies/lean_cms/application_policy.rb +35 -0
  65. data/app/policies/lean_cms/page_content_policy.rb +31 -0
  66. data/app/policies/lean_cms/post_policy.rb +37 -0
  67. data/app/policies/lean_cms/setting_policy.rb +17 -0
  68. data/app/views/layouts/lean_cms/application.html.erb +114 -0
  69. data/app/views/layouts/lean_cms/auth.html.erb +200 -0
  70. data/app/views/lean_cms/activity/index.html.erb +79 -0
  71. data/app/views/lean_cms/dashboard/index.html.erb +180 -0
  72. data/app/views/lean_cms/form_submissions/index.html.erb +104 -0
  73. data/app/views/lean_cms/form_submissions/show.html.erb +157 -0
  74. data/app/views/lean_cms/notification_settings/edit.html.erb +192 -0
  75. data/app/views/lean_cms/notifications/index.html.erb +72 -0
  76. data/app/views/lean_cms/notifications/show.html.erb +39 -0
  77. data/app/views/lean_cms/page_contents/_field_editor.html.erb +174 -0
  78. data/app/views/lean_cms/page_contents/edit.html.erb +428 -0
  79. data/app/views/lean_cms/page_contents/index.html.erb +113 -0
  80. data/app/views/lean_cms/password_setup/show.html.erb +35 -0
  81. data/app/views/lean_cms/passwords/edit.html.erb +26 -0
  82. data/app/views/lean_cms/passwords/new.html.erb +21 -0
  83. data/app/views/lean_cms/passwords_mailer/reset.html.erb +6 -0
  84. data/app/views/lean_cms/passwords_mailer/reset.text.erb +4 -0
  85. data/app/views/lean_cms/posts/_form.html.erb +118 -0
  86. data/app/views/lean_cms/posts/edit.html.erb +31 -0
  87. data/app/views/lean_cms/posts/index.html.erb +100 -0
  88. data/app/views/lean_cms/posts/new.html.erb +16 -0
  89. data/app/views/lean_cms/sessions/new.html.erb +28 -0
  90. data/app/views/lean_cms/settings/edit.html.erb +384 -0
  91. data/app/views/lean_cms/shared/_admin_bar.html.erb +85 -0
  92. data/app/views/lean_cms/shared/_header.html.erb +86 -0
  93. data/app/views/lean_cms/shared/_notifications_bell.html.erb +84 -0
  94. data/app/views/lean_cms/shared/_sidebar.html.erb +102 -0
  95. data/app/views/lean_cms/users/_form.html.erb +105 -0
  96. data/app/views/lean_cms/users/edit.html.erb +8 -0
  97. data/app/views/lean_cms/users/index.html.erb +99 -0
  98. data/app/views/lean_cms/users/new.html.erb +8 -0
  99. data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.html.erb +13 -0
  100. data/app/views/lean_cms/users_mailer/admin_triggered_password_reset.text.erb +11 -0
  101. data/app/views/lean_cms/users_mailer/invitation.html.erb +13 -0
  102. data/app/views/lean_cms/users_mailer/invitation.text.erb +11 -0
  103. data/app/views/lean_cms/users_mailer/reactivation.html.erb +13 -0
  104. data/app/views/lean_cms/users_mailer/reactivation.text.erb +11 -0
  105. data/config/importmap.rb +8 -0
  106. data/config/routes.rb +78 -0
  107. data/db/migrate/20251112034030_create_lean_cms_tables.rb +131 -0
  108. data/db/migrate/20260513000001_create_lean_cms_auth_tables.rb +31 -0
  109. data/db/migrate/20260514000001_create_paper_trail_versions.rb +16 -0
  110. data/db/migrate/20260514000002_create_action_text_tables.rb +18 -0
  111. data/db/migrate/20260514000003_create_active_storage_tables.rb +45 -0
  112. data/db/migrate/20260514000004_create_noticed_tables.rb +27 -0
  113. data/lib/generators/lean_cms/demo/demo_generator.rb +54 -0
  114. data/lib/generators/lean_cms/demo/templates/lean_cms_structure.yml +129 -0
  115. data/lib/generators/lean_cms/demo/templates/pages_controller.rb +30 -0
  116. data/lib/generators/lean_cms/demo/templates/views/pages/about.html.erb +40 -0
  117. data/lib/generators/lean_cms/demo/templates/views/pages/contact.html.erb +55 -0
  118. data/lib/generators/lean_cms/demo/templates/views/pages/home.html.erb +31 -0
  119. data/lib/generators/lean_cms/install/install_generator.rb +317 -0
  120. data/lib/generators/lean_cms/install/templates/add_lean_cms_columns_to_users.rb.tt +7 -0
  121. data/lib/generators/lean_cms/install/templates/lean_cms.rb +11 -0
  122. data/lib/generators/lean_cms/install/templates/lean_cms_structure.yml +29 -0
  123. data/lib/lean_cms/configuration.rb +32 -0
  124. data/lib/lean_cms/engine.rb +93 -0
  125. data/lib/lean_cms/loader.rb +217 -0
  126. data/lib/lean_cms/sync_helper.rb +182 -0
  127. data/lib/lean_cms/version.rb +3 -0
  128. data/lib/lean_cms.rb +26 -0
  129. data/lib/tasks/lean_cms.rake +390 -0
  130. 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,7 @@
1
+ module LeanCms
2
+ class Session < ApplicationRecord
3
+ self.table_name = "lean_cms_sessions"
4
+
5
+ belongs_to :user, class_name: "User"
6
+ end
7
+ 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>