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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 277d5241551a05b136eebf29f83e6347c7fc15a9d600054c524a245ab18010b4
4
- data.tar.gz: 336177aba310b7b33b4fddab22190b0ac3a1fec3ad330fb1c09552459ffc8def
3
+ metadata.gz: a62b9b90b91530846a18cac552fcc7ee10cafcd67ef23ea135d12ff0d175f7e9
4
+ data.tar.gz: 7eada3af7d1000b9dbd526b3b7eab29af60de2790fad7cb7d5ddcb56146c8a68
5
5
  SHA512:
6
- metadata.gz: 8ad29dfd4e8927c9259276e8c3d0cd916eb3e3b2ae74327ee32f511c67db9779613190a637b92ef4903cc0f6d440d620a6212103fad74d268202007e54b9e84c
7
- data.tar.gz: e01f9d033d20d606da90e74078155e5d825c727b89162b7e750eeac5c9f3aabc048a7c3c8c29ddb241ba7d3aa4a727458f1150b04475a898c44ba8a1d80f1316
6
+ metadata.gz: f038e184d32a8fb9bb1c0449b7f67328d895f2974e0e71fd7e88d5b0f24be5329ad9c348d9d86e9115d0415507fb9366ac3988d121894691d23741d036e96ee7
7
+ data.tar.gz: eb22b20f634d5ff40d715c53c0c07d31109785305ed27dd6989fd44e923b89a6509efc4ff7289bb9aacf0788c008301c747cf088a888381510af9678e8bb8b04
data/CHANGELOG.md CHANGED
@@ -1,5 +1,254 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - `plum deploy`'s health wait no longer requires a Dockerfile HEALTHCHECK:
6
+ images without one are probed via HTTP /up through the proxy instead
7
+ (resolved to loopback so SNI routing matches).
8
+ - Added `plum promote`: go-live for a preview, in place. The preview app
9
+ is re-homed onto the production domain via `once update --host` — same
10
+ container, same database, all content built during the preview — with
11
+ TLS provisioned on the spot. Preflights preview existence, hostname
12
+ collisions, and DNS. Verified live: a hostname re-home round-trip on a
13
+ running app served on the new domain within seconds, data intact.
14
+ - Added `plum deploy --preview` — the agency preview workflow: ship the
15
+ same image as a separate Once app under a preview hostname, shareable
16
+ with a client before their domain points anywhere. Uses `preview_host:`
17
+ from plum.yml (wildcard-record agency domains) or auto-derives
18
+ `<app>.<server-ip>.sslip.io` with zero DNS setup. Previews are
19
+ independent apps: they never touch the live site, and going live later
20
+ is a plain `plum deploy`.
21
+ - `plum deploy` now preflights DNS on first deploys: Once verifies
22
+ https://<hostname> as its final step and rolls the app back when the
23
+ domain doesn't reach the server, so the CLI checks A records against
24
+ the server's addresses before building anything and asks before
25
+ proceeding (proxied DNS like Cloudflare legitimately mismatches, hence
26
+ a confirm rather than a refusal).
27
+ - Fixed the install generator mounting Plum ABOVE the app's own routes:
28
+ Rails' `route` helper prepends, so an at-"/" mount shadowed everything
29
+ below it — including `get "up"`, the health check that load balancers
30
+ and deploy tooling (Once, kamal-proxy, plum deploy) probe, which made
31
+ freshly scaffolded sites fail health checks with Plum's 404 page. The
32
+ mount is now appended at the bottom of routes.rb with a comment
33
+ explaining why it must stay last. Found by the first real `plum new` →
34
+ `plum deploy` run.
35
+
36
+ - Fixed `plum connect` in a directory with no plum.yml silently targeting
37
+ the globally active project (running it from a fresh repo could show —
38
+ or with --reconfigure, rewrite — a different project's connection).
39
+ Connect now always means "set up the current directory"; only an
40
+ explicit `--project NAME` redirects it.
41
+ - Connect now finishes the journey: when the once app isn't deployed yet
42
+ it offers to build and ship right there (chains into plum deploy), and
43
+ connecting a project always makes it the active one — working in a
44
+ directory means that's the project you're "in". A brand-new site's app
45
+ question also now defaults to "a new app on this server (type its
46
+ hostname)" instead of pre-selecting an already-deployed app, which
47
+ would have silently pointed the repo at another site's container.
48
+ - Untangled project vs. remote in connect's wording: the wizard now asks
49
+ for an "Environment name" (production, staging — plum.yml's remotes)
50
+ with a line explaining the concept, and the closing hint no longer
51
+ prints the nonsensical `plum pull --project production` — it points at
52
+ `plum pull` from the directory, or `--project <site>` from anywhere.
53
+ Terminology now spelled out in docs/plum-cli.md: projects are sites,
54
+ remotes are environments.
55
+ - Connect's server pick-list now knows every stored server, not just the
56
+ plum-* SSH aliases: hosts from every registered project's remotes are
57
+ merged in (annotated "— used by <project>"), so a server appears even
58
+ when its alias offer was declined.
59
+ - Added `plum new NAME`: scaffold a deploy-ready Plum site in one command
60
+ — rails new, the plum-cms gem, the install generator mounted at `/`, a
61
+ prepared database, and a git history. Bootstraps its own toolchain:
62
+ missing Ruby or Rails are offered for install (mise → ruby → rails),
63
+ each step confirm-gated. Refuses clearly when run inside an existing
64
+ Rails app, and registers the new project in the global registry.
65
+ - Added `plum deploy [remote]`: registry-free deploys — build the image
66
+ locally for linux/amd64, stream it to the server over SSH
67
+ (docker save | docker load; no registry, no CI, no accounts), roll the
68
+ Once app to it, and wait for the container health check. Auto-update is
69
+ disabled for these apps: deploys are explicit, never by surprise.
70
+ - Added docs/zero-to-agency.md and a matching tutorial chapter: empty
71
+ directory → client sites on a $6 DigitalOcean droplet with `plum new`,
72
+ `plum connect`, `plum deploy`, and the pull/push/backup rhythm.
73
+
74
+ ## 0.2.2 — 2026-08-13
75
+
76
+ ### Fixed
77
+
78
+ - Fixed `plum:site:replace` (and site destroy generally) aborting with
79
+ `RecordNotDestroyed` on any site that has a homepage entry — i.e. every
80
+ real site. The homepage's can't-be-deleted guard now stands down when the
81
+ entry is being removed as part of its site's dependent cascade. Found by
82
+ the first live `plum pull` against production.
83
+ - Fixed `plum connect`'s deployment detection treating an unedited
84
+ `config/deploy.yml` scaffold (the placeholder file `rails new` ships) as
85
+ proof of a Kamal deployment, which steered Once-deployed repos toward a
86
+ broken `via: kamal` remote. A deploy.yml still carrying `kamal init`
87
+ placeholders now counts as absent.
88
+
89
+ ### Added
90
+
91
+ - Added the Plum CLI (`cli/`, Go): `plum pull` replaces your local site with
92
+ a remote's, `plum sync`/`plum check` apply and verify config-as-code files,
93
+ `plum backup` and `plum run` wrap the remaining rake tasks. Remotes are
94
+ defined per-project in `plum.yml` (`plum init`). Every command wraps an
95
+ engine rake task — the CLI adds transport, never behavior. See
96
+ `docs/plum-cli.md`.
97
+ - Added three transports for CLI remotes (`via: ssh|kamal|once`): plain
98
+ ssh/scp (default, inherits ~/.ssh/config/agent/jump hosts); `kamal`, which
99
+ shells out to a local Kamal binary so `config/deploy.yml` is the only
100
+ source of truth for where the app lives; and `once` for 37signals' Once
101
+ tool, whose CLI runs on the server itself — the transport ssh-es to
102
+ `host:` and runs `once exec <once_app> ...` there (`once_app:` is the
103
+ hostname you gave `once deploy --host ...`, distinct from how you reach
104
+ the box). Verified against a live Once deployment. Picking a transport is
105
+ the only thing that changes — the rake tasks behind every command are
106
+ identical either way.
107
+ - Added `plum logs [remote] [--follow]` — recent app logs, heroku-style
108
+ tailing with `--follow`. Plain ssh tails `log/production.log`, Kamal runs
109
+ `kamal app logs`, Once streams `docker logs` from the app's container
110
+ (resolved by Once's own container label; the Rails image logs to stdout,
111
+ not a file).
112
+ - Gave the CLI a heroku-style voice: `▸` step headers, animated spinners
113
+ that settle into timed `✓ ... (700ms)` result lines, remote rake output
114
+ framed behind a dim `│` gutter, arrow-key selects and styled prompts in
115
+ `plum connect`, a colorized help screen and projects list, and a plum
116
+ accent color adaptive to light/dark terminals (via lipgloss + huh).
117
+ Everything degrades to the old plain sequential output when piped, in
118
+ CI, under `NO_COLOR`, or with `TERM=dumb` — scripted stdin answers and
119
+ log parsing are unaffected; Ctrl-C at any prompt exits quietly (130).
120
+ - `plum connect` now answers its own questions where it can: re-runs
121
+ prefill every prompt from the existing plum.yml (Enter-through-everything
122
+ reproduces the current setup, and the stored deployment type outranks
123
+ file detection), and for Once remotes it asks the server itself — `once
124
+ list` over the just-verified SSH connection — offering the deployed apps
125
+ as a pick-list instead of a blank hostname prompt.
126
+ - On an already-configured project, bare `plum connect` skips the interview
127
+ entirely and becomes a health check: it prints the stored connection and
128
+ verifies SSH login, server tooling, and that the app is deployed, exiting
129
+ nonzero on failure (CI-friendly). The wizard runs only on first setup,
130
+ with `--reconfigure`, or when a new ip/host is passed explicitly.
131
+ - A brand-new project's host prompt now offers the servers you already
132
+ have: plum-* aliases from ~/.ssh/config (the ones `plum connect` itself
133
+ wrote) appear as a pick-list, so connecting a second site to the same
134
+ VPS never re-asks for the IP.
135
+ - `plum check`/`plum push` bootstrap themselves on first run: with no
136
+ plum/ directory in the project they offer to run
137
+ `bin/rails plum:config:export` right there (interactive sessions only;
138
+ scripts keep the hard error). Verified live: a first-time `plum check`
139
+ exported the content model, uploaded it through `once exec`'s stdin tar
140
+ pipe — the last transport path that had never run against a real
141
+ server — and production reported no drift.
142
+ - Renamed `plum sync` to `plum push`: push moves structure up, pull moves
143
+ content down — the familiar "push code, pull data" asymmetry, since in
144
+ Plum's model the content model is code. `plum sync` keeps working as an
145
+ alias with a gentle pointer to the new name.
146
+ - `plum connect` now registers the project in the global registry under
147
+ the directory's name (activating it when nothing is active), so
148
+ `plum use`/`--project` work without a separate `plum projects add`.
149
+ - `plum logs` prettifies structured JSON log lines on a terminal: dim
150
+ local-time stamps, color-coded levels and HTTP statuses, and request
151
+ lines compacted to `GET /up 200 (2ms)` with noisy fields dropped. Plain
152
+ Rails log text passes through untouched, and piped output stays raw so
153
+ `plum logs | jq` keeps working. The CLI is now documented in the README
154
+ alongside the engine features.
155
+ - Added `plum tutorial`: a built-in, full-screen interactive tour of Plum
156
+ and the CLI (bubbletea + glamour). Eight chapters — content model,
157
+ themes, writing mode and drafts, static caching, config as code, and
158
+ the CLI workflow — as styled markdown with ←/→ chapter navigation,
159
+ 1–9 jumps, and a plain-markdown fallback when piped. Chapters slide in
160
+ on harmonica spring physics under an animated gradient progress bar;
161
+ the CLI chapters carry live demos (press `d` to watch `plum pull` or a
162
+ connect health check type itself out, spinners included); and the tour
163
+ ends with a three-question interactive quiz. Harmonica earns its name
164
+ throughout: the tour opens with a splash — the PLUM wordmark springs up
165
+ letter by letter while a plum bounces across it on projectile physics —
166
+ wrong quiz answers shake the question on an underdamped spring, and
167
+ finishing the quiz starts a continuous confetti rain — plum-palette
168
+ particles drifting down the finale on gentle projectile physics for as
169
+ long as you stay to enjoy it.
170
+ The splash is a proper title screen: the show loops (the plum relaunches
171
+ while you watch) and holds until you press a pulsing lipgloss "Start the
172
+ tutorial ↵" button — enter begins, q quits, nothing rushes you.
173
+ - Gamified the tutorial. Progress persists to ~/.config/plum/tutorial.json:
174
+ chapters check off as read, demos as watched, and the tour resumes where
175
+ you left off (the title button becomes "Continue the tutorial"). Reading,
176
+ watching, typing, and passing the quiz earn XP, with plum-themed ranks
177
+ climbing in the header (Seedling → Sprout → Sapling → Grower →
178
+ Orchardist). CLI chapters gained typing challenges: press `t` and type
179
+ the real command with strict per-character feedback — typos flash and
180
+ sting but don't land — and nailing it banks XP and rolls the demo as
181
+ your reward. And the title screen hides Plum Drop (`p`): an arcade game
182
+ of catching falling plums (harmonica projectiles) with an arrow-key
183
+ basket, combo multipliers, and a persisted high score. It's endless —
184
+ misses just break your streak, new bests are celebrated the moment they
185
+ happen, and you play until you quit. SSH-served sessions play everything with an ephemeral save.
186
+ - `plum tutorial --serve [addr]` hosts the tour over SSH via charm's wish:
187
+ `ssh -p 2222 your-server` gets the full animated tutorial with nothing
188
+ installed. Sessions run only the tutorial (no shell, no exec, any or no
189
+ key accepted); the host key persists under ~/.config/plum so returning
190
+ visitors aren't warned.
191
+ - Added a Firebase-CLI-style global project registry (`plum projects
192
+ add/list/remove`, `plum use`, `--project`) so a fleet of Plum sites can be
193
+ managed from one dev machine without `cd`-ing into each repo — a local
194
+ `plum.yml` still always takes precedence when one is present.
195
+ - Added `plum connect [ip-or-host]`: guided first-time server setup with no
196
+ YAML editing required. Detects or generates a local SSH key, tests and (on
197
+ confirmation) fixes passwordless login with `ssh-copy-id`, optionally adds
198
+ a `Host` alias to `~/.ssh/config`, and writes `plum.yml` from the answers.
199
+ It also detects the repo's deployment shape (`config/deploy.yml` → Kamal,
200
+ `Dockerfile` alone → Once) and asks the right questions for it, then
201
+ checks the server's tooling — Docker Engine for Kamal/Once, plus the
202
+ `once` binary for Once — offering to install whatever's missing, each
203
+ step gated on explicit confirmation. Deploying the app itself (images,
204
+ registry credentials) intentionally stays manual.
205
+ - Fixed destroying an entry (or its site) crashing with a foreign-key
206
+ violation when the entry was linked from a nav menu — nav items are now
207
+ removed with their entry.
208
+ - Added `rails plum:site:replace` — the "pull" refresh: swaps the local site
209
+ for a production archive in one transactional step, database-agnostic
210
+ (Postgres prod → SQLite dev), refusing to run in production without FORCE.
211
+ - Fixed Plum rake tasks running their bodies twice in the standalone repo
212
+ (engine railtie and application both load lib/tasks; Rake appends actions
213
+ on re-definition).
214
+ - Added config-as-code (phase 1): `Plum::ConfigSync` syncs content types and
215
+ fieldsets between YAML files (`plum/content_types/*.yml`) and the database
216
+ via `rails plum:config:export`, `plum:config:sync` (with PRUNE/FORCE
217
+ guards that never touch entry data), and `plum:config:check` for CI drift
218
+ detection. See `docs/config-as-code.md`.
219
+ - Added full-page static caching: rendered public pages and theme assets are
220
+ written to disk keyed by host + path, served without touching the database,
221
+ and flushed automatically when site content changes. **Off by default,
222
+ explicit opt-in** (`config.static_cache_enabled = true`) — it is only
223
+ correct on a single-server deployment; enabling it on 2+ nodes/dynos
224
+ silently serves stale pages. The file layout supports direct nginx/Caddy
225
+ `try_files` serving. See `docs/static-caching.md`.
226
+ - Added a "Clear page cache" control to the dashboard for administrators.
227
+ - Added a distraction-free writing mode for entries with a rich text field:
228
+ full-screen typographic surface with debounced autosave, Cmd/Ctrl+S,
229
+ word count, and unsaved-changes protection, reachable from the entry editor.
230
+ - Write-mode saves merge into existing entry data, so partial saves never
231
+ wipe unsubmitted blueprint fields, and identical consecutive saves no
232
+ longer stack duplicate revisions.
233
+ - Added a git-style "Review changes" screen for working drafts: field-by-field
234
+ word diffs of draft vs. live content (rich text compared as readable text,
235
+ structured fields as JSON), with publish/discard actions inline. Linked from
236
+ the entry editor's draft banner and the writing surface.
237
+ - Added working drafts for published entries: writing-mode autosaves on a
238
+ published entry are stored in a new `draft_data` column and never touch the
239
+ live content or flush its cached pages. Editors publish or discard the
240
+ draft explicitly (from the writing surface or the entry editor's draft
241
+ banner); publishing records a revision. Draft-status entries still save
242
+ directly.
243
+
244
+ ### Changed
245
+
246
+ - Public `{% form %}` forms now use a honeypot spam trap instead of a
247
+ per-session CSRF token, so cached pages stay valid and public page views no
248
+ longer set session cookies. The control panel keeps standard CSRF
249
+ protection.
250
+ - Theme assets are served with `Cache-Control: public, max-age=3600`.
251
+
3
252
  ## 0.2.1 — 2026-08-07
4
253
 
5
254
  ### Added
data/README.md CHANGED
@@ -243,6 +243,34 @@ bin/rails plum:backup:restore ARCHIVE=/var/backups/plum/site.plum.zip
243
243
  See [the portability guide](docs/portability.md) for archive guarantees and
244
244
  production backup guidance.
245
245
 
246
+ ## The Plum CLI
247
+
248
+ `cli/` ships a Go command line that wraps the engine's rake tasks over SSH,
249
+ [Kamal](https://kamalmanual.com), or [Once](https://once.com) — heroku-style
250
+ ergonomics for self-hosted Plum sites:
251
+
252
+ ```sh
253
+ plum tutorial # interactive guided tour, right in the terminal
254
+ plum new client-site # scaffold a deploy-ready site (installs Rails if needed)
255
+ plum connect 203.0.113.5 # guided setup: SSH keys, server tooling, plum.yml
256
+ plum deploy # build locally, ship over SSH — no registry, no CI
257
+ plum connect # already configured? verifies the setup instead
258
+ plum pull # replace your local site with production's content
259
+ plum push # apply plum/ config-as-code files to the remote
260
+ plum check # fail if the remote drifted from plum/ (CI-friendly)
261
+ plum logs --follow # tail the app's logs, pretty-printed on a TTY
262
+ plum backup # timestamped site archive on the server
263
+ plum run -- TASK # any rake task, any remote
264
+ ```
265
+
266
+ A Firebase-style project registry (`plum use`, `--project`) manages a fleet
267
+ of sites from one machine. Every command degrades to plain, script-friendly
268
+ output when piped or under `NO_COLOR`. See [the CLI guide](docs/plum-cli.md)
269
+ for transports (`via: ssh|kamal|once`), fleet management, and how `pull`
270
+ works across database engines — and [the zero-to-agency
271
+ guide](docs/zero-to-agency.md) for running client sites on a single
272
+ cheap VPS with no registry, CI, or PaaS.
273
+
246
274
  ## Content API
247
275
 
248
276
  Published entries are available through a read-only, site-scoped JSON API:
@@ -287,6 +315,46 @@ Images uploaded through Lexxy use Active Storage Direct Upload and render on the
287
315
  front-end as standard `<img>` tags. Text colors are resolved from Lexxy's CSS
288
316
  variables to real color values at render time.
289
317
 
318
+ ## Writing Mode
319
+
320
+ Entries whose content type has a rich text field get a distraction-free
321
+ writing surface ("Write" in the entry editor): a full-screen typographic page
322
+ with just the title and body, debounced autosave, Cmd/Ctrl+S, and a live word
323
+ count. Saves merge into the entry's existing data, so fields that aren't on
324
+ the writing surface are never touched.
325
+
326
+ On a **published** entry, writing-mode saves become a working draft: the live
327
+ version (and its cached pages) stay exactly as they are while you write, and
328
+ nothing goes public until you hit "Publish changes". Drafts can also be
329
+ published or discarded from the entry editor, and publishing records a
330
+ revision. Unpublished (draft-status) entries save directly, since they aren't
331
+ visible on the site anyway.
332
+
333
+ ## Static Page Caching
334
+
335
+ Optional: rendered public pages can be written to disk and served as static
336
+ files — no database or Liquid work on repeat visits. Content saves flush the
337
+ site's cache automatically, and pages re-render lazily on their next hit. The
338
+ on-disk layout (`{host}/{path}/index.html`) can be served directly by nginx or
339
+ synced to a CDN, so page delivery scales independently of the app.
340
+
341
+ **This is only correct on a single-server deployment** and is **off by
342
+ default** — it must be enabled explicitly:
343
+
344
+ ```ruby
345
+ Plum.configure do |config|
346
+ config.static_cache_enabled = true
347
+ config.static_cache_path = "/var/www/site-cache"
348
+ end
349
+ ```
350
+
351
+ Enabling it on a multi-node platform (2+ Heroku dynos, 2+ Render instances,
352
+ any load-balanced deployment) causes visitors to silently see stale content
353
+ served forever from whichever node missed the invalidation — not a crash,
354
+ just wrong pages. Read
355
+ [docs/static-caching.md](docs/static-caching.md) first, especially "Should
356
+ you enable this?", before turning this on.
357
+
290
358
  ## Globals and Navigation
291
359
 
292
360
  Globals are site-wide JSON objects (company info, social links):
@@ -1,2 +1,2 @@
1
1
  /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */
2
- @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-900:oklch(39.6% .141 25.723);--color-amber-700:oklch(55.5% .163 48.998);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-800:oklch(47.6% .114 61.907);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-normal:0em;--tracking-wider:.05em;--leading-tight:1.25;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}button:not(:disabled),[role=button]:not([aria-disabled=true]){cursor:pointer}button:disabled,[role=button][aria-disabled=true]{cursor:not-allowed}}@layer components{.plum-blueprint-field{background:#f9fafb;border:1px solid #e5e7eb;border-radius:.75rem;grid-template-columns:minmax(0,1fr);gap:1rem;padding:1rem;display:grid}.plum-blueprint-field-header{border-bottom:1px solid #e5e7eb;justify-content:space-between;align-items:center;gap:1rem;padding-bottom:.75rem;display:flex}.plum-blueprint-field-title{color:#111827;text-overflow:ellipsis;white-space:nowrap;font-size:.875rem;font-weight:650;overflow:hidden}.plum-blueprint-field-type{color:#6b7280;margin-left:.5rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.75rem;font-weight:400}@media (min-width:640px){.plum-blueprint-field{grid-template-columns:repeat(3,minmax(0,1fr))}}.plum-rich-text-content{outline:none;min-height:14rem}.plum-rich-text-content>*+*{margin-top:.75rem}.plum-rich-text-content h2{font-size:1.25rem;font-weight:700;line-height:1.3}.plum-rich-text-content ul{padding-left:1.5rem;list-style:outside}.plum-rich-text-content ol{padding-left:1.5rem;list-style:decimal}.plum-rich-text-content blockquote{color:#4b5563;border-left:.25rem solid #d1d5db;padding-left:1rem}.plum-rich-text-content a{color:#7c3aed;text-decoration:underline}.plum-rich-text-content p.is-editor-empty:first-child:before{color:#9ca3af;content:attr(data-placeholder);float:left;pointer-events:none;height:0}}@layer utilities{.\!visible{visibility:visible!important}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.left-0{left:calc(var(--spacing) * 0)}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-28{margin-top:calc(var(--spacing) * 28)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-32{height:calc(var(--spacing) * 32)}.h-44{height:calc(var(--spacing) * 44)}.h-full{height:100%}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-48{min-height:calc(var(--spacing) * 48)}.min-h-full{min-height:100%}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-32{width:calc(var(--spacing) * 32)}.w-64{width:calc(var(--spacing) * 64)}.w-full{width:100%}.max-w-md{max-width:var(--container-md)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.justify-self-start{justify-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-green-200{border-color:var(--color-green-200)}.border-purple-200{border-color:var(--color-purple-200)}.border-purple-300{border-color:var(--color-purple-300)}.border-purple-500{border-color:var(--color-purple-500)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-transparent{border-color:#0000}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-\[\#F6F3EF\]{background-color:#f6f3ef}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-purple-600{background-color:var(--color-purple-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.bg-yellow-100{background-color:var(--color-yellow-100)}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-64{padding-left:calc(var(--spacing) * 64)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-700{color:var(--color-amber-700)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-purple-600{color:var(--color-purple-600)}.text-purple-700{color:var(--color-purple-700)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.text-yellow-800{color:var(--color-yellow-800)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.line-through{text-decoration-line:line-through}.underline{text-decoration-line:underline}.shadow,.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-purple-500{--tw-ring-color:var(--color-purple-500)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.peer-checked\:border-purple-500:is(:where(.peer):checked~*){border-color:var(--color-purple-500)}.peer-checked\:bg-purple-50:is(:where(.peer):checked~*){background-color:var(--color-purple-50)}.peer-checked\:text-purple-700:is(:where(.peer):checked~*){color:var(--color-purple-700)}.peer-focus\:ring-2:is(:where(.peer):focus~*){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.peer-focus\:ring-purple-500:is(:where(.peer):focus~*){--tw-ring-color:var(--color-purple-500)}.peer-focus\:ring-offset-2:is(:where(.peer):focus~*){--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}@media (hover:hover){.hover\:border-purple-400:hover{border-color:var(--color-purple-400)}.hover\:border-purple-500:hover{border-color:var(--color-purple-500)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-purple-50:hover{background-color:var(--color-purple-50)}.hover\:bg-purple-100:hover{background-color:var(--color-purple-100)}.hover\:bg-purple-700:hover{background-color:var(--color-purple-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-purple-700:hover{color:var(--color-purple-700)}.hover\:text-purple-900:hover{color:var(--color-purple-900)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-red-900:hover{color:var(--color-red-900)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-purple-500:focus{border-color:var(--color-purple-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-purple-500:focus{--tw-ring-color:var(--color-purple-500)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.has-checked\:border-purple-500:has(:checked){border-color:var(--color-purple-500)}.has-checked\:ring-2:has(:checked){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-checked\:ring-purple-500:has(:checked){--tw-ring-color:var(--color-purple-500)}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-start-3{grid-column-start:3}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-800:oklch(47.6% .114 61.907);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-normal:0em;--tracking-wider:.05em;--leading-tight:1.25;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}button:not(:disabled),[role=button]:not([aria-disabled=true]){cursor:pointer}button:disabled,[role=button][aria-disabled=true]{cursor:not-allowed}}@layer components{.plum-blueprint-field{background:#f9fafb;border:1px solid #e5e7eb;border-radius:.75rem;grid-template-columns:minmax(0,1fr);gap:1rem;padding:1rem;display:grid}.plum-blueprint-field-header{border-bottom:1px solid #e5e7eb;justify-content:space-between;align-items:center;gap:1rem;padding-bottom:.75rem;display:flex}.plum-blueprint-field-title{color:#111827;text-overflow:ellipsis;white-space:nowrap;font-size:.875rem;font-weight:650;overflow:hidden}.plum-blueprint-field-type{color:#6b7280;margin-left:.5rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.75rem;font-weight:400}@media (min-width:640px){.plum-blueprint-field{grid-template-columns:repeat(3,minmax(0,1fr))}}.plum-rich-text-content{outline:none;min-height:14rem}.plum-rich-text-content>*+*{margin-top:.75rem}.plum-rich-text-content h2{font-size:1.25rem;font-weight:700;line-height:1.3}.plum-rich-text-content ul{padding-left:1.5rem;list-style:outside}.plum-rich-text-content ol{padding-left:1.5rem;list-style:decimal}.plum-rich-text-content blockquote{color:#4b5563;border-left:.25rem solid #d1d5db;padding-left:1rem}.plum-rich-text-content a{color:#7c3aed;text-decoration:underline}.plum-rich-text-content p.is-editor-empty:first-child:before{color:#9ca3af;content:attr(data-placeholder);float:left;pointer-events:none;height:0}}@layer utilities{.\!visible{visibility:visible!important}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.left-0{left:calc(var(--spacing) * 0)}.col-span-full{grid-column:1/-1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-28{margin-top:calc(var(--spacing) * 28)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-32{height:calc(var(--spacing) * 32)}.h-44{height:calc(var(--spacing) * 44)}.h-full{height:100%}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-48{min-height:calc(var(--spacing) * 48)}.min-h-full{min-height:100%}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-32{width:calc(var(--spacing) * 32)}.w-64{width:calc(var(--spacing) * 64)}.w-full{width:100%}.max-w-md{max-width:var(--container-md)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.justify-self-start{justify-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-300{border-color:var(--color-amber-300)}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-green-200{border-color:var(--color-green-200)}.border-purple-200{border-color:var(--color-purple-200)}.border-purple-300{border-color:var(--color-purple-300)}.border-purple-500{border-color:var(--color-purple-500)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-transparent{border-color:#0000}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-\[\#F6F3EF\]{background-color:#f6f3ef}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-200{background-color:var(--color-green-200)}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-purple-600{background-color:var(--color-purple-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-200{background-color:var(--color-red-200)}.bg-white{background-color:var(--color-white)}.bg-yellow-100{background-color:var(--color-yellow-100)}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-10{padding:calc(var(--spacing) * 10)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-64{padding-left:calc(var(--spacing) * 64)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.9375rem\]{font-size:.9375rem}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-purple-600{color:var(--color-purple-600)}.text-purple-700{color:var(--color-purple-700)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-white{color:var(--color-white)}.text-yellow-800{color:var(--color-yellow-800)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.decoration-red-400{-webkit-text-decoration-color:var(--color-red-400);-webkit-text-decoration-color:var(--color-red-400);text-decoration-color:var(--color-red-400)}.shadow,.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-purple-500{--tw-ring-color:var(--color-purple-500)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.peer-checked\:border-purple-500:is(:where(.peer):checked~*){border-color:var(--color-purple-500)}.peer-checked\:bg-purple-50:is(:where(.peer):checked~*){background-color:var(--color-purple-50)}.peer-checked\:text-purple-700:is(:where(.peer):checked~*){color:var(--color-purple-700)}.peer-focus\:ring-2:is(:where(.peer):focus~*){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.peer-focus\:ring-purple-500:is(:where(.peer):focus~*){--tw-ring-color:var(--color-purple-500)}.peer-focus\:ring-offset-2:is(:where(.peer):focus~*){--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}@media (hover:hover){.hover\:border-purple-400:hover{border-color:var(--color-purple-400)}.hover\:border-purple-500:hover{border-color:var(--color-purple-500)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-purple-50:hover{background-color:var(--color-purple-50)}.hover\:bg-purple-100:hover{background-color:var(--color-purple-100)}.hover\:bg-purple-700:hover{background-color:var(--color-purple-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-purple-700:hover{color:var(--color-purple-700)}.hover\:text-purple-900:hover{color:var(--color-purple-900)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-red-900:hover{color:var(--color-red-900)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-purple-500:focus{border-color:var(--color-purple-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-purple-500:focus{--tw-ring-color:var(--color-purple-500)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.has-checked\:border-purple-500:has(:checked){border-color:var(--color-purple-500)}.has-checked\:ring-2:has(:checked){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-checked\:ring-purple-500:has(:checked){--tw-ring-color:var(--color-purple-500)}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:64rem){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-start-3{grid-column-start:3}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}
@@ -11,9 +11,9 @@ module Plum
11
11
  ].freeze
12
12
 
13
13
  before_action :set_content_type
14
- before_action :set_entry, only: [ :edit, :update, :destroy, :image_field, :translate ]
14
+ before_action :set_entry, only: [ :edit, :update, :destroy, :image_field, :translate, :write, :diff, :publish_draft, :discard_draft ]
15
15
  before_action :set_form_collections, only: [ :new, :create, :edit, :update ]
16
- before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy, :image_field, :translate ]
16
+ before_action :require_editor, only: [ :new, :create, :edit, :update, :destroy, :image_field, :translate, :write, :diff, :publish_draft, :discard_draft ]
17
17
 
18
18
  def index
19
19
  @entries = @content_type.entries.order(updated_at: :desc)
@@ -38,13 +38,45 @@ module Plum
38
38
  def edit
39
39
  end
40
40
 
41
+ # Distraction-free writing surface for the entry's first rich text
42
+ # field. Saves go through #update with write_mode, which merges the
43
+ # submitted fields into the existing data instead of replacing it.
44
+ def write
45
+ @field = @content_type.fields.find { |field| field["type"] == "rich_text" && field["handle"].present? }
46
+ unless @field
47
+ return redirect_to edit_cp_content_type_entry_path(@content_type, @entry),
48
+ alert: "This content type has no rich text field to write in"
49
+ end
50
+
51
+ render layout: "plum/write"
52
+ end
53
+
41
54
  def update
55
+ # Writing-mode saves on a live entry become a working draft so the
56
+ # published version (and its cached pages) stay untouched.
57
+ return save_write_mode_draft if write_mode? && @entry.published?
58
+
42
59
  @entry.assign_attributes(entry_params)
60
+ # The full form displays draft values when a draft exists, so a form
61
+ # save publishes what the editor saw — the draft is no longer pending.
62
+ @entry.draft_data = nil unless write_mode?
43
63
 
44
64
  if save_entry(@entry)
45
- redirect_to edit_cp_content_type_entry_path(@content_type, @entry), notice: "Entry updated"
65
+ respond_to do |format|
66
+ format.html do
67
+ if write_mode?
68
+ redirect_to write_cp_content_type_entry_path(@content_type, @entry), notice: "Saved"
69
+ else
70
+ redirect_to edit_cp_content_type_entry_path(@content_type, @entry), notice: "Entry updated"
71
+ end
72
+ end
73
+ format.json { render json: { saved: true, saved_at: Time.current.iso8601 } }
74
+ end
46
75
  else
47
- render :edit, status: :unprocessable_entity
76
+ respond_to do |format|
77
+ format.html { render :edit, status: :unprocessable_entity }
78
+ format.json { render json: { saved: false, errors: @entry.errors.full_messages }, status: :unprocessable_entity }
79
+ end
48
80
  end
49
81
  end
50
82
 
@@ -68,6 +100,34 @@ module Plum
68
100
  end
69
101
  end
70
102
 
103
+ # Git-style review of draft vs. live content.
104
+ def diff
105
+ unless @entry.has_draft?
106
+ return redirect_to edit_cp_content_type_entry_path(@content_type, @entry),
107
+ notice: "No draft changes to review"
108
+ end
109
+
110
+ @diff = Plum::DraftDiff.new(@entry)
111
+ end
112
+
113
+ def publish_draft
114
+ published = @entry.publish_draft!(editor: current_user)
115
+
116
+ respond_to do |format|
117
+ format.html do
118
+ redirect_back fallback_location: edit_cp_content_type_entry_path(@content_type, @entry),
119
+ notice: published ? "Draft published" : "No draft changes to publish"
120
+ end
121
+ format.json { render json: { published: published, published_at: Time.current.iso8601 } }
122
+ end
123
+ end
124
+
125
+ def discard_draft
126
+ @entry.discard_draft!
127
+ redirect_back fallback_location: edit_cp_content_type_entry_path(@content_type, @entry),
128
+ notice: "Draft discarded"
129
+ end
130
+
71
131
  def destroy
72
132
  @entry.destroy
73
133
  redirect_to cp_content_type_entries_path(@content_type), notice: "Entry deleted"
@@ -121,11 +181,34 @@ module Plum
121
181
  def entry_params
122
182
  permitted = params.require(:entry).permit(:title, :slug, :status, :published_at, :locale)
123
183
  if params[:entry][:data].is_a?(ActionController::Parameters)
124
- permitted[:data] = params[:entry][:data].permit!.to_h
184
+ submitted = params[:entry][:data].permit!.to_h
185
+ # Writing mode only submits the field being written, so merge it
186
+ # into the stored data rather than wiping the other fields.
187
+ submitted = @entry.data.to_h.merge(submitted) if write_mode? && @entry&.persisted?
188
+ permitted[:data] = submitted
125
189
  end
126
190
  permitted
127
191
  end
128
192
 
193
+ def write_mode?
194
+ params.dig(:entry, :write_mode).present?
195
+ end
196
+
197
+ def save_write_mode_draft
198
+ submitted = params[:entry][:data].is_a?(ActionController::Parameters) ? params[:entry][:data].permit!.to_h : {}
199
+ @entry.save_draft!(title: params.dig(:entry, :title), data: submitted)
200
+
201
+ respond_to do |format|
202
+ format.html { redirect_to write_cp_content_type_entry_path(@content_type, @entry), notice: "Draft saved" }
203
+ format.json { render json: { saved: true, draft: true, saved_at: Time.current.iso8601 } }
204
+ end
205
+ rescue ActiveRecord::RecordInvalid => e
206
+ respond_to do |format|
207
+ format.html { redirect_to write_cp_content_type_entry_path(@content_type, @entry), alert: e.record.errors.full_messages.to_sentence }
208
+ format.json { render json: { saved: false, errors: e.record.errors.full_messages }, status: :unprocessable_entity }
209
+ end
210
+ end
211
+
129
212
  def save_entry(entry)
130
213
  saved = false
131
214
  entry.transaction do
@@ -0,0 +1,12 @@
1
+ module Plum
2
+ module Cp
3
+ class StaticCacheController < BaseController
4
+ before_action :require_admin
5
+
6
+ def destroy
7
+ Plum::StaticCache.flush_site!(current_site)
8
+ redirect_back fallback_location: cp_root_path, notice: "Page cache cleared"
9
+ end
10
+ end
11
+ end
12
+ end