plum-cms 0.2.1 → 0.2.3

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +249 -0
  3. data/README.md +68 -0
  4. data/app/assets/builds/tailwind.css +1 -1
  5. data/app/controllers/plum/cp/entries_controller.rb +88 -5
  6. data/app/controllers/plum/cp/static_cache_controller.rb +12 -0
  7. data/app/controllers/plum/form_submissions_controller.rb +13 -0
  8. data/app/controllers/plum/pages_controller.rb +8 -0
  9. data/app/controllers/plum/theme_assets_controller.rb +2 -0
  10. data/app/javascript/controllers/plum/write_controller.js +174 -0
  11. data/app/models/plum/asset.rb +1 -0
  12. data/app/models/plum/content_type.rb +1 -0
  13. data/app/models/plum/entry.rb +57 -0
  14. data/app/models/plum/entry_term.rb +1 -0
  15. data/app/models/plum/form_definition.rb +1 -0
  16. data/app/models/plum/global.rb +1 -0
  17. data/app/models/plum/nav_item.rb +1 -0
  18. data/app/models/plum/nav_menu.rb +1 -0
  19. data/app/models/plum/site.rb +2 -0
  20. data/app/models/plum/site_setting.rb +1 -0
  21. data/app/models/plum/static_cache_invalidation.rb +30 -0
  22. data/app/models/plum/taxonomy.rb +1 -0
  23. data/app/models/plum/term.rb +1 -0
  24. data/app/services/plum/config_sync.rb +252 -0
  25. data/app/services/plum/draft_diff.rb +177 -0
  26. data/app/services/plum/form_renderer.rb +12 -1
  27. data/app/services/plum/liquid_context.rb +0 -2
  28. data/app/views/layouts/plum/cp.html.erb +1 -1
  29. data/app/views/layouts/plum/session.html.erb +1 -1
  30. data/app/views/layouts/plum/write.html.erb +140 -0
  31. data/app/views/plum/cp/dashboard/show.html.erb +10 -3
  32. data/app/views/plum/cp/entries/_form.html.erb +2 -2
  33. data/app/views/plum/cp/entries/diff.html.erb +40 -0
  34. data/app/views/plum/cp/entries/edit.html.erb +24 -0
  35. data/app/views/plum/cp/entries/index.html.erb +3 -0
  36. data/app/views/plum/cp/entries/write.html.erb +60 -0
  37. data/config/plum_routes.rb +5 -0
  38. data/db/engine_migrate/20260811090000_add_draft_data_to_plum_entries.rb +5 -0
  39. data/docs/config-as-code.md +103 -0
  40. data/docs/plum-cli.md +422 -0
  41. data/docs/static-caching.md +163 -0
  42. data/docs/zero-to-agency.md +172 -0
  43. data/lib/generators/plum/install/install_generator.rb +13 -1
  44. data/lib/generators/plum/install/templates/plum_initializer.rb +8 -0
  45. data/lib/plum/configuration.rb +12 -1
  46. data/lib/plum/engine.rb +18 -4
  47. data/lib/plum/static_cache/middleware.rb +61 -0
  48. data/lib/plum/static_cache.rb +103 -0
  49. data/lib/plum/version.rb +1 -1
  50. data/lib/plum.rb +7 -0
  51. data/lib/tasks/plum_config.rake +48 -0
  52. data/lib/tasks/plum_portability.rake +54 -29
  53. data/lib/tasks/plum_styles.rake +14 -9
  54. metadata +30 -5
@@ -0,0 +1,252 @@
1
+ require "yaml"
2
+
3
+ module Plum
4
+ # Config-as-code (phase 1): the content model lives as YAML files in the
5
+ # host repo and syncs into the database, like db:migrate for content types.
6
+ #
7
+ # plum/content_types/posts.yml -> Plum::ContentType (blueprint)
8
+ # plum/fieldsets/seo.yml -> Plum::Fieldset
9
+ #
10
+ # Files are the source of truth; `apply` upserts the DB from them, `export`
11
+ # writes the DB back out (bootstrap + CP write-back later), and `check`
12
+ # reports drift for CI. Content (entries, terms, assets) is never touched.
13
+ class ConfigSync
14
+ Result = Struct.new(:created, :updated, :unchanged, :deleted, keyword_init: true) do
15
+ def summary
16
+ "#{created.length} created, #{updated.length} updated, #{unchanged.length} unchanged, #{deleted.length} deleted"
17
+ end
18
+ end
19
+
20
+ class DriftError < StandardError; end
21
+ class UnsafePruneError < StandardError; end
22
+
23
+ CONTENT_TYPE_DIR = "content_types".freeze
24
+ FIELDSET_DIR = "fieldsets".freeze
25
+
26
+ def self.export(site:, dir:)
27
+ new(site: site, dir: dir).export
28
+ end
29
+
30
+ def self.apply(site:, dir:, prune: false, force: false)
31
+ new(site: site, dir: dir).apply(prune: prune, force: force)
32
+ end
33
+
34
+ def self.check(site:, dir:)
35
+ new(site: site, dir: dir).check
36
+ end
37
+
38
+ def initialize(site:, dir:)
39
+ @site = site
40
+ @dir = Pathname(dir)
41
+ end
42
+
43
+ # DB -> files. Mirrors the database exactly: stale files for handles that
44
+ # no longer exist are removed.
45
+ def export
46
+ written = []
47
+ written += export_kind(CONTENT_TYPE_DIR, content_types.order(:handle)) { |record| content_type_config(record) }
48
+ written += export_kind(FIELDSET_DIR, fieldsets.order(:handle)) { |record| fieldset_config(record) }
49
+ written
50
+ end
51
+
52
+ # Files -> DB. Upserts by handle inside a transaction. Deleting requires
53
+ # prune: true, and deleting a content type that still has entries
54
+ # additionally requires force: true.
55
+ def apply(prune: false, force: false)
56
+ result = Result.new(created: [], updated: [], unchanged: [], deleted: [])
57
+
58
+ site.transaction do
59
+ apply_content_types(result)
60
+ apply_fieldsets(result)
61
+ prune_missing(result, force: force) if prune
62
+ end
63
+
64
+ result
65
+ end
66
+
67
+ # Returns human-readable drift lines; empty means files and DB agree.
68
+ def check
69
+ drift = []
70
+ drift += check_kind(CONTENT_TYPE_DIR, content_types)
71
+ drift += check_kind(FIELDSET_DIR, fieldsets)
72
+ drift
73
+ end
74
+
75
+ private
76
+
77
+ attr_reader :site, :dir
78
+
79
+ # Fresh relations every time — going through site.content_types would
80
+ # cache the association on the site instance and hide later DB changes
81
+ # from repeated check/apply calls.
82
+ def content_types
83
+ ContentType.for_site(site)
84
+ end
85
+
86
+ def fieldsets
87
+ Fieldset.for_site(site)
88
+ end
89
+
90
+ def export_kind(subdir, records)
91
+ path = dir.join(subdir)
92
+ path.mkpath
93
+ keep = []
94
+
95
+ records.each do |record|
96
+ file = path.join("#{record.handle}.yml")
97
+ file.write(yaml_for(yield(record)))
98
+ keep << file
99
+ end
100
+
101
+ path.glob("*.yml").each { |file| file.delete unless keep.include?(file) }
102
+ keep
103
+ end
104
+
105
+ def apply_content_types(result)
106
+ each_config(CONTENT_TYPE_DIR) do |config, file|
107
+ record = content_types.find_or_initialize_by(handle: config.fetch("handle"))
108
+ record.site = site if record.new_record?
109
+ record.name = config["name"]
110
+ record.icon = config["icon"]
111
+ record.singleton = config["singleton"] unless config["singleton"].nil?
112
+ record.blueprint = merged_blueprint(record, config)
113
+ track(result, record, file)
114
+ end
115
+ end
116
+
117
+ def apply_fieldsets(result)
118
+ each_config(FIELDSET_DIR) do |config, file|
119
+ record = fieldsets.find_or_initialize_by(handle: config.fetch("handle"))
120
+ record.site = site if record.new_record?
121
+ record.name = config["name"]
122
+ record.fields = config["fields"] || []
123
+ track(result, record, file)
124
+ end
125
+ end
126
+
127
+ # Blueprint keys the files don't know about are preserved so config
128
+ # written by newer Plum versions survives a sync from older files.
129
+ def merged_blueprint(record, config)
130
+ blueprint = (record.blueprint || {}).deep_dup
131
+ blueprint["fields"] = config["fields"] || []
132
+ if config["route_prefix"].present?
133
+ blueprint["route_prefix"] = config["route_prefix"]
134
+ else
135
+ blueprint.delete("route_prefix")
136
+ end
137
+ blueprint
138
+ end
139
+
140
+ def track(result, record, file)
141
+ if record.new_record?
142
+ record.save!
143
+ result.created << record.handle
144
+ elsif record.changed?
145
+ record.save!
146
+ result.updated << record.handle
147
+ else
148
+ result.unchanged << record.handle
149
+ end
150
+ rescue ActiveRecord::RecordInvalid => e
151
+ raise ActiveRecord::RecordInvalid.new(e.record), "#{file.basename}: #{e.message}"
152
+ end
153
+
154
+ def prune_missing(result, force:)
155
+ file_handles = handles_in(CONTENT_TYPE_DIR)
156
+ content_types.where.not(handle: file_handles).find_each do |record|
157
+ if record.entries.exists? && !force
158
+ raise UnsafePruneError,
159
+ "Content type '#{record.handle}' has #{record.entries.count} entries; " \
160
+ "re-run with FORCE=1 to delete them"
161
+ end
162
+ record.destroy!
163
+ result.deleted << record.handle
164
+ end
165
+
166
+ fieldsets.where.not(handle: handles_in(FIELDSET_DIR)).find_each do |record|
167
+ record.destroy!
168
+ result.deleted << record.handle
169
+ end
170
+ end
171
+
172
+ def handles_in(subdir)
173
+ handles = []
174
+ each_config(subdir) { |config, _file| handles << config.fetch("handle") }
175
+ handles
176
+ end
177
+
178
+ def check_kind(subdir, records)
179
+ drift = []
180
+ configs = {}
181
+ each_config(subdir) { |config, _file| configs[config.fetch("handle")] = config }
182
+
183
+ db = records.index_by(&:handle)
184
+ configs.each do |handle, config|
185
+ record = db[handle]
186
+ if record.nil?
187
+ drift << "#{subdir}/#{handle}: in files but not in the database"
188
+ elsif normalize(subdir, config_for(subdir, record)) != normalize(subdir, config)
189
+ drift << "#{subdir}/#{handle}: files and database differ"
190
+ end
191
+ end
192
+ (db.keys - configs.keys).each do |handle|
193
+ drift << "#{subdir}/#{handle}: in the database but not in files"
194
+ end
195
+ drift
196
+ end
197
+
198
+ def config_for(subdir, record)
199
+ subdir == CONTENT_TYPE_DIR ? content_type_config(record) : fieldset_config(record)
200
+ end
201
+
202
+ # Key order and empty-vs-absent values must not register as drift.
203
+ def normalize(subdir, config)
204
+ if subdir == CONTENT_TYPE_DIR
205
+ {
206
+ "name" => config["name"].to_s,
207
+ "handle" => config["handle"].to_s,
208
+ "icon" => config["icon"].presence,
209
+ "singleton" => !!config["singleton"],
210
+ "route_prefix" => config["route_prefix"].presence,
211
+ "fields" => config["fields"] || []
212
+ }
213
+ else
214
+ {
215
+ "name" => config["name"].to_s,
216
+ "handle" => config["handle"].to_s,
217
+ "fields" => config["fields"] || []
218
+ }
219
+ end
220
+ end
221
+
222
+ def content_type_config(record)
223
+ config = {
224
+ "name" => record.name,
225
+ "handle" => record.handle,
226
+ "icon" => record.icon,
227
+ "singleton" => record.singleton,
228
+ "route_prefix" => record.route_prefix,
229
+ "fields" => record.fields
230
+ }
231
+ config.reject { |_key, value| value.nil? }
232
+ end
233
+
234
+ def fieldset_config(record)
235
+ { "name" => record.name, "handle" => record.handle, "fields" => record.fields || [] }
236
+ end
237
+
238
+ def each_config(subdir)
239
+ dir.join(subdir).glob("*.yml").sort.each do |file|
240
+ config = YAML.safe_load(file.read, aliases: true)
241
+ raise DriftError, "#{file} is not a YAML mapping" unless config.is_a?(Hash)
242
+
243
+ config["handle"] ||= file.basename(".yml").to_s
244
+ yield config, file
245
+ end
246
+ end
247
+
248
+ def yaml_for(config)
249
+ config.to_yaml
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,177 @@
1
+ module Plum
2
+ # Field-by-field comparison between an entry's live content and its working
3
+ # draft, tokenized for git-style rendering (equal / deleted / inserted
4
+ # segments). Rich text is compared as readable text, structured fields as
5
+ # pretty-printed JSON.
6
+ class DraftDiff
7
+ # Beyond this many differing tokens, fall back to a wholesale
8
+ # replaced-block diff instead of an O(n*m) LCS table.
9
+ TOKEN_LIMIT = 1500
10
+
11
+ FieldChange = Struct.new(:handle, :label, :segments, keyword_init: true)
12
+
13
+ def initialize(entry)
14
+ @entry = entry
15
+ @content_type = entry.content_type
16
+ end
17
+
18
+ def changes
19
+ @changes ||= build_changes
20
+ end
21
+
22
+ def any?
23
+ changes.any?
24
+ end
25
+
26
+ # Word-level diff as [[op, text], ...] with op in :eq/:del/:ins.
27
+ # Whitespace is kept as tokens so spacing survives reconstruction.
28
+ def self.word_diff(old_text, new_text)
29
+ a = tokenize(old_text)
30
+ b = tokenize(new_text)
31
+
32
+ prefix = common_prefix_length(a, b)
33
+ suffix = common_suffix_length(a, b, prefix)
34
+ middle_a = a[prefix...(a.length - suffix)]
35
+ middle_b = b[prefix...(b.length - suffix)]
36
+
37
+ segments = []
38
+ segments << [ :eq, a.first(prefix).join ] if prefix.positive?
39
+ segments.concat(diff_middle(middle_a, middle_b))
40
+ segments << [ :eq, a.last(suffix).join ] if suffix.positive?
41
+ merge_segments(segments)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :entry, :content_type
47
+
48
+ def build_changes
49
+ list = []
50
+
51
+ if entry.draft_title.to_s != entry.title.to_s
52
+ list << FieldChange.new(handle: "title", label: "Title",
53
+ segments: self.class.word_diff(entry.title.to_s, entry.draft_title.to_s))
54
+ end
55
+
56
+ live_data = entry.data.to_h
57
+ draft_data = entry.draft_data.to_h["data"] || {}
58
+
59
+ (live_data.keys | draft_data.keys).each do |handle|
60
+ field = field_definition(handle)
61
+ live = normalize(live_data[handle], field)
62
+ draft = normalize(draft_data.key?(handle) ? draft_data[handle] : live_data[handle], field)
63
+ next if live == draft
64
+
65
+ list << FieldChange.new(handle: handle, label: label_for(handle, field),
66
+ segments: self.class.word_diff(live, draft))
67
+ end
68
+
69
+ list
70
+ end
71
+
72
+ def field_definition(handle)
73
+ content_type.fields.find { |field| field["handle"].to_s == handle }
74
+ end
75
+
76
+ def label_for(handle, field)
77
+ field&.dig("label").presence || handle.humanize
78
+ end
79
+
80
+ def normalize(value, field)
81
+ case value
82
+ when nil then ""
83
+ when Hash, Array then JSON.pretty_generate(value)
84
+ when String
85
+ field&.dig("type") == "rich_text" ? html_to_text(value) : value
86
+ else value.to_s
87
+ end
88
+ end
89
+
90
+ def html_to_text(html)
91
+ text = html.gsub(%r{<(br|/p|/h[1-6]|/li|/blockquote|/div|/tr|/figcaption)[^>]*>}i, "\n")
92
+ text = ActionController::Base.helpers.strip_tags(text).to_s
93
+ text.split("\n").map(&:strip).join("\n").gsub(/\n{3,}/, "\n\n").strip
94
+ end
95
+
96
+ class << self
97
+ private
98
+
99
+ def tokenize(text)
100
+ text.to_s.scan(/\S+|\s+/)
101
+ end
102
+
103
+ def common_prefix_length(a, b)
104
+ limit = [ a.length, b.length ].min
105
+ (0...limit).each { |i| return i if a[i] != b[i] }
106
+ limit
107
+ end
108
+
109
+ def common_suffix_length(a, b, prefix)
110
+ limit = [ a.length, b.length ].min - prefix
111
+ (0...limit).each { |i| return i if a[a.length - 1 - i] != b[b.length - 1 - i] }
112
+ limit
113
+ end
114
+
115
+ def diff_middle(a, b)
116
+ return [] if a.empty? && b.empty?
117
+ return [ [ :ins, b.join ] ] if a.empty?
118
+ return [ [ :del, a.join ] ] if b.empty?
119
+ if a.length > TOKEN_LIMIT || b.length > TOKEN_LIMIT
120
+ return [ [ :del, a.join ], [ :ins, b.join ] ]
121
+ end
122
+
123
+ lcs_segments(a, b)
124
+ end
125
+
126
+ # Standard LCS dynamic program with backtracking; inputs are bounded by
127
+ # TOKEN_LIMIT after prefix/suffix trimming.
128
+ def lcs_segments(a, b)
129
+ rows = a.length + 1
130
+ cols = b.length + 1
131
+ table = Array.new(rows) { Array.new(cols, 0) }
132
+
133
+ (a.length - 1).downto(0) do |i|
134
+ (b.length - 1).downto(0) do |j|
135
+ table[i][j] = if a[i] == b[j]
136
+ table[i + 1][j + 1] + 1
137
+ else
138
+ [ table[i + 1][j], table[i][j + 1] ].max
139
+ end
140
+ end
141
+ end
142
+
143
+ segments = []
144
+ i = 0
145
+ j = 0
146
+ while i < a.length && j < b.length
147
+ if a[i] == b[j]
148
+ segments << [ :eq, a[i] ]
149
+ i += 1
150
+ j += 1
151
+ elsif table[i + 1][j] >= table[i][j + 1]
152
+ segments << [ :del, a[i] ]
153
+ i += 1
154
+ else
155
+ segments << [ :ins, b[j] ]
156
+ j += 1
157
+ end
158
+ end
159
+ segments.concat(a[i..].map { |token| [ :del, token ] })
160
+ segments.concat(b[j..].map { |token| [ :ins, token ] })
161
+ segments
162
+ end
163
+
164
+ def merge_segments(segments)
165
+ segments.each_with_object([]) do |(op, text), merged|
166
+ next if text.empty?
167
+
168
+ if merged.last && merged.last[0] == op
169
+ merged.last[1] += text
170
+ else
171
+ merged << [ op, text.dup ]
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -24,11 +24,22 @@ module Plum
24
24
 
25
25
  def hidden_inputs
26
26
  [
27
- hidden_input(form["csrf_param"], form["csrf_token"]),
27
+ honeypot_input,
28
28
  hidden_input("return_to", form["return_to"])
29
29
  ].compact.join("\n ")
30
30
  end
31
31
 
32
+ # Spam trap instead of a CSRF token: tokens are per-session, which would
33
+ # both break under static caching and force a session cookie onto every
34
+ # visitor. Bots fill this in; humans never see it.
35
+ def honeypot_input
36
+ <<~HTML.strip
37
+ <div style="position:absolute;left:-9999px;top:-9999px" aria-hidden="true">
38
+ <input type="text" name="form_submission[website]" tabindex="-1" autocomplete="off">
39
+ </div>
40
+ HTML
41
+ end
42
+
32
43
  def fields_html
33
44
  Array(form["fields"]).map { |field| field_html(field) }.join("\n ")
34
45
  end
@@ -125,8 +125,6 @@ module Plum
125
125
  "handle" => form.handle,
126
126
  "fields" => form.form_fields,
127
127
  "action" => public_form_path(form),
128
- "csrf_token" => form_authenticity_token,
129
- "csrf_param" => "authenticity_token",
130
128
  "return_to" => controller.request.fullpath
131
129
  }
132
130
  end
@@ -8,7 +8,7 @@
8
8
  <%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
9
9
  <%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
10
10
  <%= stylesheet_link_tag "lexxy", "data-turbo-track": "reload" %>
11
- <%= javascript_importmap_tags %>
11
+ <%= javascript_importmap_tags(importmap: Plum.importmap || Rails.application.importmap) %>
12
12
  <script type="module">import "plum/application"</script>
13
13
  <style>
14
14
  :root {
@@ -7,7 +7,7 @@
7
7
  <%= csp_meta_tag %>
8
8
  <%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
9
9
  <%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
10
- <%= javascript_importmap_tags %>
10
+ <%= javascript_importmap_tags(importmap: Plum.importmap || Rails.application.importmap) %>
11
11
  <style>
12
12
  :root { --plum-accent: <%= Plum.configuration.cp_accent_color %>; }
13
13
  .bg-purple-600 { background-color: var(--plum-accent) !important; }
@@ -0,0 +1,140 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="h-full">
3
+ <head>
4
+ <title>Writing — <%= Plum.configuration.cp_name %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= favicon_link_tag "plum-mark.svg", type: "image/svg+xml" %>
9
+ <%= stylesheet_link_tag "plum/control_panel", "data-turbo-track": "reload" %>
10
+ <%= stylesheet_link_tag "lexxy", "data-turbo-track": "reload" %>
11
+ <%= javascript_importmap_tags(importmap: Plum.importmap || Rails.application.importmap) %>
12
+ <script type="module">import "plum/application"</script>
13
+ <style>
14
+ :root { --plum-accent: <%= Plum.configuration.cp_accent_color %>; }
15
+
16
+ body.plum-write {
17
+ background: #faf9f7;
18
+ color: #1f2937;
19
+ }
20
+
21
+ .plum-write-bar {
22
+ position: fixed;
23
+ inset: 0 0 auto 0;
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: space-between;
27
+ padding: 0.875rem 1.5rem;
28
+ background: linear-gradient(to bottom, rgba(250,249,247,0.97), rgba(250,249,247,0.85) 70%, rgba(250,249,247,0));
29
+ z-index: 10;
30
+ opacity: 0.35;
31
+ transition: opacity 0.25s ease;
32
+ }
33
+ .plum-write-bar:hover, .plum-write-bar:focus-within,
34
+ .plum-write-bar.plum-write-bar--active { opacity: 1; }
35
+
36
+ .plum-write-save-state {
37
+ display: inline-flex;
38
+ align-items: center;
39
+ gap: 0.45rem;
40
+ padding: 0.3rem 0.75rem;
41
+ border-radius: 9999px;
42
+ font-size: 0.8125rem;
43
+ font-weight: 500;
44
+ color: #6b7280;
45
+ background: rgba(0,0,0,0.04);
46
+ transition: color 0.2s ease, background 0.2s ease;
47
+ }
48
+ .plum-write-save-state::before {
49
+ content: "";
50
+ width: 0.5rem;
51
+ height: 0.5rem;
52
+ border-radius: 9999px;
53
+ background: #9ca3af;
54
+ flex-shrink: 0;
55
+ transition: background 0.2s ease;
56
+ }
57
+ .plum-write-save-state[data-state="dirty"]::before { background: #f59e0b; }
58
+ .plum-write-save-state[data-state="saving"]::before {
59
+ background: #f59e0b;
60
+ animation: plum-write-pulse 1s ease-in-out infinite;
61
+ }
62
+ .plum-write-save-state[data-state="saved"] { color: #047857; background: #ecfdf5; }
63
+ .plum-write-save-state[data-state="saved"]::before { background: #10b981; }
64
+ .plum-write-save-state[data-state="error"] { color: #b91c1c; background: #fef2f2; }
65
+ .plum-write-save-state[data-state="error"]::before { background: #ef4444; }
66
+
67
+ @keyframes plum-write-pulse {
68
+ 50% { opacity: 0.3; }
69
+ }
70
+
71
+ .plum-write-page {
72
+ max-width: 44rem;
73
+ margin: 0 auto;
74
+ padding: 6.5rem 1.5rem 40vh;
75
+ }
76
+
77
+ .plum-write-title {
78
+ width: 100%;
79
+ border: 0;
80
+ background: transparent;
81
+ resize: none;
82
+ overflow: hidden;
83
+ font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
84
+ font-size: 2.75rem;
85
+ line-height: 1.15;
86
+ font-weight: 700;
87
+ color: #111827;
88
+ outline: none;
89
+ padding: 0;
90
+ }
91
+ .plum-write-title::placeholder { color: #d1d5db; }
92
+
93
+ .plum-write-body { margin-top: 2rem; }
94
+ .plum-write-body lexxy-editor {
95
+ display: block;
96
+ border: 0;
97
+ background: transparent;
98
+ box-shadow: none;
99
+ }
100
+ .plum-write-body .lexxy-content,
101
+ .plum-write-body lexxy-editor [contenteditable] {
102
+ font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
103
+ font-size: 1.3125rem;
104
+ line-height: 1.8;
105
+ color: #1f2937;
106
+ min-height: 55vh;
107
+ border: 0 !important;
108
+ box-shadow: none !important;
109
+ background: transparent !important;
110
+ padding: 0;
111
+ outline: none;
112
+ }
113
+ .plum-write-body lexxy-toolbar {
114
+ position: sticky;
115
+ top: 0;
116
+ opacity: 0.25;
117
+ transition: opacity 0.25s ease;
118
+ background: transparent;
119
+ border: 0;
120
+ }
121
+ .plum-write-body:hover lexxy-toolbar,
122
+ .plum-write-body:focus-within lexxy-toolbar { opacity: 1; }
123
+
124
+ .plum-write-status {
125
+ position: fixed;
126
+ right: 1.5rem;
127
+ bottom: 1.25rem;
128
+ display: flex;
129
+ gap: 1rem;
130
+ font-size: 0.75rem;
131
+ letter-spacing: 0.02em;
132
+ color: #9ca3af;
133
+ z-index: 10;
134
+ }
135
+ </style>
136
+ </head>
137
+ <body class="plum-write h-full">
138
+ <%= yield %>
139
+ </body>
140
+ </html>
@@ -1,6 +1,13 @@
1
- <div class="mb-8">
2
- <h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
3
- <p class="mt-1 text-sm text-gray-500">Welcome back<%= ", #{current_user_label}" if current_user_label %></p>
1
+ <div class="mb-8 flex items-start justify-between">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
4
+ <p class="mt-1 text-sm text-gray-500">Welcome back<%= ", #{current_user_label}" if current_user_label %></p>
5
+ </div>
6
+ <% if Plum::StaticCache.enabled? %>
7
+ <%= button_to "Clear page cache", cp_static_cache_path, method: :delete,
8
+ data: { turbo_confirm: "Clear the cached pages for this site? They re-render on the next visit." },
9
+ class: "px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 cursor-pointer" %>
10
+ <% end %>
4
11
  </div>
5
12
 
6
13
  <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
@@ -17,7 +17,7 @@
17
17
  <div class="space-y-6">
18
18
  <div>
19
19
  <%= f.label :title, class: "block text-sm font-medium text-gray-700" %>
20
- <%= f.text_field :title, class: "mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" %>
20
+ <%= f.text_field :title, value: entry.draft_title, class: "mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" %>
21
21
  </div>
22
22
 
23
23
  <div>
@@ -35,7 +35,7 @@
35
35
  <% @content_type.fields.each do |field| %>
36
36
  <% field_handle = field["handle"].to_s %>
37
37
  <% field_id = "entry_data_#{field_handle.parameterize}" %>
38
- <% field_value = entry.data&.dig(field_handle) %>
38
+ <% field_value = entry.draft_field_value(field_handle) %>
39
39
  <% field_value = field["default"] if entry.new_record? && field_value.nil? && field.key?("default") %>
40
40
  <% condition = field["condition"].presence %>
41
41
  <div class="<%= 'border-b border-gray-200 pb-2 pt-4' if field['type'] == 'section' %>" style="grid-column: span <%= field['type'] == 'section' ? 12 : (field['width'].presence || 12) %> / span <%= field['type'] == 'section' ? 12 : (field['width'].presence || 12) %>;"<% if condition %> data-field-condition="<%= condition.to_json %>"<% end %>>