macron-cms 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +265 -0
  4. data/Rakefile +4 -0
  5. data/app/assets/stylesheets/macron/cms/application.css +2 -0
  6. data/app/assets/tailwind/macron/cms/application.css +4 -0
  7. data/app/components/macron/cms/widget_component.rb +29 -0
  8. data/app/controllers/macron/cms/application_controller.rb +36 -0
  9. data/app/controllers/macron/cms/article_contents_controller.rb +103 -0
  10. data/app/controllers/macron/cms/articles_controller.rb +56 -0
  11. data/app/controllers/macron/cms/content_types_controller.rb +20 -0
  12. data/app/controllers/macron/cms/dashboard_controller.rb +10 -0
  13. data/app/helpers/macron/cms/application_helper.rb +40 -0
  14. data/app/helpers/macron/cms/content_helper.rb +31 -0
  15. data/app/helpers/macron/cms/fields_helper.rb +30 -0
  16. data/app/helpers/macron/cms/seo_helper.rb +37 -0
  17. data/app/jobs/macron/cms/application_job.rb +6 -0
  18. data/app/mailers/macron/cms/application_mailer.rb +8 -0
  19. data/app/models/macron/cms/application_record.rb +7 -0
  20. data/app/models/macron/cms/article.rb +42 -0
  21. data/app/models/macron/cms/article_content.rb +31 -0
  22. data/app/models/macron/cms/content.rb +34 -0
  23. data/app/views/layouts/macron/cms/application.html.erb +129 -0
  24. data/app/views/macron/cms/articles/_form.html.erb +65 -0
  25. data/app/views/macron/cms/articles/_table.html.erb +54 -0
  26. data/app/views/macron/cms/articles/edit.html.erb +15 -0
  27. data/app/views/macron/cms/articles/index.html.erb +28 -0
  28. data/app/views/macron/cms/articles/new.html.erb +14 -0
  29. data/app/views/macron/cms/articles/show.html.erb +366 -0
  30. data/app/views/macron/cms/content_types/index.html.erb +87 -0
  31. data/app/views/macron/cms/content_types/show.html.erb +148 -0
  32. data/app/views/macron/cms/dashboard/index.html.erb +81 -0
  33. data/app/views/macron/cms/fields/_string.html.erb +14 -0
  34. data/app/views/macron/cms/fields/_text.html.erb +13 -0
  35. data/app/views/macron/cms/shared/_breadcrumbs.html.erb +14 -0
  36. data/config/routes.rb +14 -0
  37. data/db/migrate/20261028000001_create_macron_cms_articles.rb +9 -0
  38. data/db/migrate/20261028000002_add_name_and_slug_to_macron_cms_articles.rb +8 -0
  39. data/db/migrate/20261028000003_add_seo_metadata_to_macron_cms_articles.rb +6 -0
  40. data/db/migrate/20261028000004_create_macron_cms_contents.rb +13 -0
  41. data/db/migrate/20261028000005_create_macron_cms_article_contents.rb +13 -0
  42. data/db/migrate/20261028000006_move_payload_to_macron_cms_article_contents.rb +6 -0
  43. data/db/seeds.rb +83 -0
  44. data/lib/generators/macron/cms/content_type/content_type_generator.rb +88 -0
  45. data/lib/macron/cms/configuration.rb +25 -0
  46. data/lib/macron/cms/engine.rb +20 -0
  47. data/lib/macron/cms/version.rb +5 -0
  48. data/lib/macron/cms.rb +26 -0
  49. data/lib/tasks/macron/cms_tasks.rake +75 -0
  50. metadata +164 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1c629d799d5fb1f6ccc3e6fe6efda7f40d5feb72dada6f572c4d81d0acc2dfa4
4
+ data.tar.gz: c201d162fc7ab490a975358470847fc95d481c7ae3692fbee774fea1331f6bba
5
+ SHA512:
6
+ metadata.gz: c516904323f9a7d51b76c6236654d5f3435a51183fff5740b2cf26fd0116fd515e9535fde2dceeeef771e80502fe31231171cb5c679f030beebe891473d1cf47
7
+ data.tar.gz: 72d5b3adcaed790f9eb56e45b6c577f8a8dc1aa49545ab5969d6c486b4d74f51bb9c035cee1936b00d7b3dd7def2eafa08e03e2bd81a2cf7959c14e40b013177
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Leonid Medovyy
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,265 @@
1
+ # MacronCMS
2
+
3
+ [![Gem Version](https://img.shields.io/badge/version-1.0.0-emerald.svg)](#version)
4
+ [![Rails 8+](https://img.shields.io/badge/rails-8%2B-red.svg)](https://rubyonrails.org)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](MIT-LICENSE)
6
+
7
+ A simple, flexible CMS engine for Ruby on Rails, designed for building highly interactive content with dynamic content blocks, declarative schemas, and ViewComponent widgets.
8
+
9
+ ---
10
+
11
+ ## Overview
12
+
13
+ **MacronCMS** brings simplicity and developer joy back to content management, without sacrificing the power needed to create dynamic digital experiences. Built natively for Ruby on Rails, it gives developers total freedom to define content structures with Rails models while effortlessly powering modern front-end presentations.
14
+
15
+ ---
16
+
17
+ ## Features
18
+
19
+ ### 🛠️ Dedicated Admin Dashboard (`/cms`)
20
+ - **Modern Zinc Aesthetic**: Clean, responsive monochrome layout with sticky sidebar navigation, top breadcrumbs, metric cards, quick-action buttons, and flash notification banners.
21
+ - **Article CRUD & Friendly URLs**: Manage articles (`Macron::Cms::Article`) out-of-the-box with dedicated create, edit, show, and delete interfaces. Automatic slug generation via `friendly_id` produces clean SEO URLs.
22
+ - **Deletion Safeguards**: Browser confirmation dialogs on delete actions with article titles to prevent accidental deletion.
23
+ - **Host App Integration**: Quick links between the CMS admin console and public-facing host views.
24
+
25
+ ### 🧱 Dynamic Article Content Blocks & Payload Storage
26
+ - **Multiple Blocks Per Article**: Associate an arbitrary series of content blocks with an article (`Macron::Cms::ArticleContent`). Add multiple blocks of the same content type (e.g. multiple headings or paragraphs) with distinct values.
27
+ - **Dynamic Field Populating**: When adding or editing a block, input controls are automatically generated based on the content type's defined fields. Values are validated against the schema and saved into the block's `payload` JSONB column.
28
+ - **Extensible Field Kinds**: Out-of-the-box support for `string` (text input) and `text` (multiline textarea), with a partial-based architecture (`app/views/macron/cms/fields/_<kind>.html.erb`) making it effortless to add new field types.
29
+ - **Inline Editing & Deletion**: Edit block payloads inline directly on the Article show page, or remove blocks with confirmation.
30
+
31
+ ### ↕️ Drag-and-Drop & Button Reordering
32
+ - **HTML5 Drag-and-Drop**: Easily re-arrange article content blocks on the Article show page with immediate position updates and asynchronous persistence.
33
+ - **Accessible Move Up / Down Buttons**: Keyboard- and mobile-friendly reordering buttons on each block card as a built-in fallback.
34
+
35
+ ### 🧩 Declarative Content Types & Generators
36
+ - **YAML-Defined Schemas**: Define structured content types with typed fields (`name:kind`) stored directly in source control (`config/content_types/*.yml`).
37
+ - **Full Scaffolding Generator**: Rapidly scaffold content types via `bin/rails generate macron:cms:content_type NAME [field:kind ...]`, which generates both the YAML schema and the host ViewComponent widget files.
38
+ - **Rake Import Pipeline**: Idempotently sync YAML definitions into database records (`Macron::Cms::Content`) with automatic schema introspection and admin dashboard views.
39
+
40
+ ### ⚡ Front-End ViewComponent Widgets
41
+ - **`Macron::Cms::WidgetComponent`**: Base component class providing automatic method accessors for all defined schema fields and payload values.
42
+ - **Seamless Rendering Helpers**: Render all article content blocks ordered by `sort_order` with `<%= cms_render_article_contents(@article) %>`, or render individual blocks via `<%= cms_render_widget(article_content) %>`.
43
+ - **Informative Error Handling**: Raises `Macron::Cms::MissingWidgetError` if a content type does not have a corresponding `Widgets::<Name>Component` in the host application.
44
+
45
+ ### 🔒 Devise Authentication & Authorization
46
+ - **Configurable Access Guard**: Protect the `/cms` engine namespace behind host application authentication.
47
+ - **Role Customization**: Configure custom user models (`user_class`) and admin flags (`admin_flag`) via initializers.
48
+
49
+ ### 🎨 Self-Contained Tailwind CSS v4
50
+ - **Zero Host Setup Required**: MacronCMS packages its own standalone, precompiled Tailwind CSS v4 bundle (`app/assets/stylesheets/macron/cms/application.css`). Host applications do not need Tailwind installed to display the CMS admin interface.
51
+ - **Isolated Styling**: The admin layout operates within its own styling boundary to avoid conflicts with host application design systems.
52
+
53
+ ### 🔍 Built-in SEO Metadata Helpers
54
+ - **`cms_seo_meta(@article)`**: Helper to inject `<title>`, canonical `<link>`, and Open Graph meta tags into the view `<head>` using an article's title, meta title, and meta description overrides.
55
+
56
+ ---
57
+
58
+ ## Coming Soon
59
+
60
+ ### 🌊 React & Three.js Hydration Plugins
61
+ Drop in React plugins where heavy client-side interactivity is needed, making hydrating complex client components—such as interactive 3D [Three.js](https://threejs.org/) scenes, interactive canvases, and custom calculators—effortless and isolated.
62
+
63
+ ### 🤖 AI-Friendly API for Content Migration
64
+ An AI-friendly ingest and transformation API for migrating existing content quickly and programmatically across legacy datasets, CMS exports, and unstructured assets.
65
+
66
+ ---
67
+
68
+ ## Installation
69
+
70
+ Add this line to your application's `Gemfile`:
71
+
72
+ ```ruby
73
+ gem "macron-cms"
74
+ ```
75
+
76
+ And then execute:
77
+
78
+ ```bash
79
+ $ bundle install
80
+ ```
81
+
82
+ Or install it directly:
83
+
84
+ ```bash
85
+ $ gem install macron-cms
86
+ ```
87
+
88
+ ### Engine Mounting & Migrations
89
+
90
+ Mount the engine inside your host application's `config/routes.rb`:
91
+
92
+ ```ruby
93
+ Rails.application.routes.draw do
94
+ mount Macron::Cms::Engine => "/cms"
95
+ end
96
+ ```
97
+
98
+ Copy and run the engine migrations:
99
+
100
+ ```bash
101
+ $ bin/rails macron_cms:install:migrations
102
+ $ bin/rails db:migrate
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Content Types & Widgets
108
+
109
+ ### 1. Generating a Content Type & ViewComponent Widget
110
+
111
+ Scaffold content types using the built-in Rails generator:
112
+
113
+ ```bash
114
+ $ bin/rails generate macron:cms:content_type heading title:string level:string
115
+ $ bin/rails generate macron:cms:content_type paragraph content:text
116
+ $ bin/rails generate macron:cms:content_type image url:string caption:text
117
+ ```
118
+
119
+ This generates:
120
+ 1. `config/content_types/<name>.yml` (the schema definition)
121
+ 2. `app/components/widgets/<name>_component.rb` (the ViewComponent class)
122
+ 3. `app/components/widgets/<name>_component.html.erb` (the ViewComponent template)
123
+
124
+ ### 2. Importing Content Types
125
+
126
+ Import all YAML content type definitions into the database (`Macron::Cms::Content`):
127
+
128
+ ```bash
129
+ $ bin/rails macron:cms:import_content_types
130
+ ```
131
+
132
+ Or using the shorter alias:
133
+
134
+ ```bash
135
+ $ bin/rails macron:cms:content:import
136
+ ```
137
+
138
+ The import task is idempotent and creates or updates existing records. Once imported, content types can be inspected at `/cms/content_types` and added as content blocks to articles in `/cms/articles/:id`.
139
+
140
+ ### 3. Rendering Content Blocks on the Front End
141
+
142
+ In your front-end article view (e.g. `app/views/articles/show.html.erb`), render all blocks ordered by `sort_order`:
143
+
144
+ ```erb
145
+ <% cms_seo_meta(@article) %>
146
+
147
+ <article class="max-w-3xl mx-auto py-12">
148
+ <h1 class="text-4xl font-bold"><%= @article.title %></h1>
149
+
150
+ <div class="mt-8 space-y-6">
151
+ <%= cms_render_article_contents(@article) %>
152
+ </div>
153
+ </article>
154
+ ```
155
+
156
+ Each widget inherits from `Macron::Cms::WidgetComponent`, allowing you to access fields directly by name or via `field_value(name)`:
157
+
158
+ ```ruby
159
+ # app/components/widgets/heading_component.rb
160
+ module Widgets
161
+ class HeadingComponent < Macron::Cms::WidgetComponent
162
+ def tag_name
163
+ level.presence || "h2"
164
+ end
165
+ end
166
+ end
167
+ ```
168
+
169
+ ```erb
170
+ <%# app/components/widgets/heading_component.html.erb %>
171
+ <%= content_tag(tag_name, title, class: "font-bold text-zinc-900") %>
172
+ ```
173
+
174
+ ---
175
+
176
+ ## Configuration
177
+
178
+ Customize CMS options in an initializer:
179
+
180
+ ```ruby
181
+ # config/initializers/macron_cms.rb
182
+ Macron::Cms.configure do |config|
183
+ config.user_class = "User" # Devise user model (default: "User")
184
+ config.admin_flag = :admin # Method or boolean column for admin check (default: :admin)
185
+ config.content_types_path = Rails.root.join("config", "content_types") # Custom schema directory
186
+ end
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Local Development & Dummy App
192
+
193
+ To run and test MacronCMS locally using the included dummy host application:
194
+
195
+ 1. **Set up the database and seed records:**
196
+ ```bash
197
+ cd dummy
198
+ bin/rails db:prepare
199
+ bin/rails db:seed
200
+ ```
201
+
202
+ 2. **Start the local server with asset watchers:**
203
+ ```bash
204
+ bin/dev
205
+ ```
206
+ *`bin/dev` starts Puma on port 3000 alongside watchers for both host CSS and engine Tailwind CSS.*
207
+
208
+ 3. **Access the CMS Dashboard:**
209
+ Visit [`http://localhost:3000/cms`](http://localhost:3000/cms).
210
+
211
+ ### Test User Credentials
212
+
213
+ The database seeds create two test users:
214
+
215
+ | Email | Password | Role / Access | Behavior at `/cms` |
216
+ |---|---|---|---|
217
+ | `admin@example.com` | `password` | **Admin** (`admin: true`) | Grants access to `/cms` (CMS Dashboard) |
218
+ | `user@example.com` | `password` | **Regular User** (`admin: false`) | Denied access; redirected to `/` with alert |
219
+
220
+ ---
221
+
222
+ ## Working with Engine Tailwind CSS
223
+
224
+ MacronCMS compiles standalone Tailwind CSS via `tailwindcss-ruby`.
225
+
226
+ - **Compile engine CSS:**
227
+ ```bash
228
+ bundle exec rake macron:cms:tailwind:build
229
+ ```
230
+
231
+ - **Watch engine CSS for live changes:**
232
+ ```bash
233
+ bundle exec rake macron:cms:tailwind:watch
234
+ ```
235
+
236
+ The source Tailwind configuration resides in [`app/assets/tailwind/macron/cms/application.css`](app/assets/tailwind/macron/cms/application.css) and compiles into [`app/assets/stylesheets/macron/cms/application.css`](app/assets/stylesheets/macron/cms/application.css).
237
+
238
+ ---
239
+
240
+ ## Testing & Quality
241
+
242
+ Run the test suite through the dummy application:
243
+
244
+ ```bash
245
+ cd dummy
246
+ bin/rails test
247
+ ```
248
+
249
+ Run code style and lint checks from the root:
250
+
251
+ ```bash
252
+ bundle exec rubocop
253
+ ```
254
+
255
+ ---
256
+
257
+ ## Contributing
258
+
259
+ Contributions, feedback, and discussions are welcome! Please open an issue or pull request on GitHub.
260
+
261
+ ---
262
+
263
+ ## License
264
+
265
+ The gem is available as open source under the terms of the [MIT License](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+
4
+ Dir.glob("lib/tasks/**/*.rake").each { |f| load f }
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.3 | 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-space-y-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}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, 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-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-rose-800:oklch(45.5% .188 13.697);--color-zinc-50:oklch(98.5% 0 none);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--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);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--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,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, 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:where(:not(iframe)){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}}@layer components;@layer utilities{.static{position:static}.sticky{position:sticky}.top-0{top:0}.z-10{z-index:10}.z-20{z-index:20}.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}.my-4{margin-block:calc(var(--spacing) * 4)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.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)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.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-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:var(--spacing)}.ml-3{margin-left:calc(var(--spacing) * 3)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-full{height:100%}.h-screen{height:100vh}.min-h-screen{min-height:100vh}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-64{width:calc(var(--spacing) * 64)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-grab{cursor:grab}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * 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-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)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-1{row-gap:var(--spacing)}: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-zinc-200>:not(:last-child)){border-color:var(--color-zinc-200)}.self-start{align-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-xl{border-radius:var(--radius-xl)}.rounded-t-xl{border-top-left-radius:var(--radius-xl);border-top-right-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-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-emerald-200{border-color:var(--color-emerald-200)}.border-rose-200{border-color:var(--color-rose-200)}.border-zinc-100{border-color:var(--color-zinc-100)}.border-zinc-200{border-color:var(--color-zinc-200)}.border-zinc-300{border-color:var(--color-zinc-300)}.border-zinc-400{border-color:var(--color-zinc-400)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-white{background-color:var(--color-white)}.bg-zinc-50{background-color:var(--color-zinc-50)}.bg-zinc-50\/30{background-color:#fafafa4d}@supports (color:color-mix(in lab, red, red)){.bg-zinc-50\/30{background-color:color-mix(in oklab, var(--color-zinc-50) 30%, transparent)}}.bg-zinc-50\/50{background-color:#fafafa80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-50\/50{background-color:color-mix(in oklab, var(--color-zinc-50) 50%, transparent)}}.bg-zinc-50\/70{background-color:#fafafab3}@supports (color:color-mix(in lab, red, red)){.bg-zinc-50\/70{background-color:color-mix(in oklab, var(--color-zinc-50) 70%, transparent)}}.bg-zinc-50\/80{background-color:#fafafacc}@supports (color:color-mix(in lab, red, red)){.bg-zinc-50\/80{background-color:color-mix(in oklab, var(--color-zinc-50) 80%, transparent)}}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-zinc-200\/70{background-color:#e4e4e7b3}@supports (color:color-mix(in lab, red, red)){.bg-zinc-200\/70{background-color:color-mix(in oklab, var(--color-zinc-200) 70%, transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-1{padding-inline:var(--spacing)}.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-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.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-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.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-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--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-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.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-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-rose-700{color:var(--color-rose-700)}.text-rose-800{color:var(--color-rose-800)}.text-white{color:var(--color-white)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.text-zinc-700{color:var(--color-zinc-700)}.text-zinc-800{color:var(--color-zinc-800)}.text-zinc-900{color:var(--color-zinc-900)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-50{opacity:.5}.shadow-2xs{--tw-shadow:0 1px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-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)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + 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-emerald-200{--tw-ring-color:var(--color-emerald-200)}.ring-zinc-200{--tw-ring-color:var(--color-zinc-200)}.ring-zinc-300{--tw-ring-color:var(--color-zinc-300)}.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))}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:text-zinc-600:is(:where(.group):hover *){color:var(--color-zinc-600)}.group-hover\:text-zinc-700:is(:where(.group):hover *){color:var(--color-zinc-700)}}.placeholder\:text-zinc-400::placeholder{color:var(--color-zinc-400)}@media (hover:hover){.hover\:bg-rose-100:hover{background-color:var(--color-rose-100)}.hover\:bg-zinc-50:hover{background-color:var(--color-zinc-50)}.hover\:bg-zinc-50\/60:hover{background-color:#fafafa99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-50\/60:hover{background-color:color-mix(in oklab, var(--color-zinc-50) 60%, transparent)}}.hover\:bg-zinc-50\/80:hover{background-color:#fafafacc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-zinc-50\/80:hover{background-color:color-mix(in oklab, var(--color-zinc-50) 80%, transparent)}}.hover\:bg-zinc-100:hover{background-color:var(--color-zinc-100)}.hover\:bg-zinc-800:hover{background-color:var(--color-zinc-800)}.hover\:text-rose-800:hover{color:var(--color-rose-800)}.hover\:text-zinc-900:hover{color:var(--color-zinc-900)}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-xs:hover{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);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-zinc-900:focus{border-color:var(--color-zinc-900)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + 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-zinc-900:focus{--tw-ring-color:var(--color-zinc-900)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-zinc-900:focus-visible{outline-color:var(--color-zinc-900)}.active\:cursor-grabbing:active{cursor:grabbing}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:mt-0{margin-top:0}.sm\:grid{display:grid}.sm\:w-80{width:calc(var(--spacing) * 80)}.sm\:max-w-md{max-width:var(--container-md)}.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\:flex-row{flex-direction:row}.sm\:items-baseline{align-items:baseline}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:gap-6{gap:calc(var(--spacing) * 6)}.sm\:self-auto{align-self:auto}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:px-8{padding-inline:calc(var(--spacing) * 8)}.sm\:py-16{padding-block:calc(var(--spacing) * 16)}.sm\:text-right{text-align:right}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}}@media (min-width:64rem){.lg\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}}}@property --tw-space-y-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}
@@ -0,0 +1,4 @@
1
+ @import "tailwindcss";
2
+
3
+ @source "../../../../app/views/**/*.html.erb";
4
+ @source "../../../../app/helpers/**/*.rb";
@@ -0,0 +1,29 @@
1
+ module Macron
2
+ module Cms
3
+ class WidgetComponent < ViewComponent::Base
4
+ attr_reader :payload, :fields
5
+
6
+ def initialize(payload: {}, fields: [])
7
+ @payload = (payload || {}).to_h.stringify_keys
8
+ @fields = Array(fields).map { |f| f.respond_to?(:stringify_keys) ? f.stringify_keys : f }
9
+
10
+ # Define dynamic reader methods on the singleton class of this instance
11
+ # ensuring fields like `content` override ViewComponent::Base#content (slot block content)
12
+ singleton = class << self; self; end
13
+ field_keys = (@payload.keys + defined_field_names).uniq
14
+ field_keys.each do |key|
15
+ next if key.blank?
16
+ singleton.define_method(key) { @payload[key] }
17
+ end
18
+ end
19
+
20
+ def field_value(name)
21
+ @payload[name.to_s]
22
+ end
23
+
24
+ def defined_field_names
25
+ @fields.filter_map { |f| f["name"] if f.is_a?(Hash) }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,36 @@
1
+ module Macron
2
+ module Cms
3
+ class ApplicationController < ActionController::Base
4
+ before_action :authenticate_cms_admin!
5
+
6
+ private
7
+
8
+ def authenticate_cms_admin!
9
+ auth_method = Macron::Cms.configuration.authenticate_method
10
+ unless respond_to?(auth_method, true)
11
+ raise NotImplementedError, "Macron CMS requires Devise authentication helper `#{auth_method}`. Ensure Devise is configured for #{Macron::Cms.configuration.user_class}."
12
+ end
13
+
14
+ send(auth_method)
15
+ return if performed?
16
+
17
+ current_resource = send(Macron::Cms.configuration.current_user_method)
18
+ admin_flag = Macron::Cms.configuration.admin_flag
19
+
20
+ is_admin = if current_resource.nil?
21
+ false
22
+ elsif current_resource.respond_to?("#{admin_flag}?")
23
+ current_resource.public_send("#{admin_flag}?")
24
+ elsif current_resource.respond_to?(admin_flag)
25
+ current_resource.public_send(admin_flag)
26
+ else
27
+ false
28
+ end
29
+
30
+ unless is_admin
31
+ redirect_to main_app.root_path, alert: "You are not authorized to access this area."
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,103 @@
1
+ module Macron
2
+ module Cms
3
+ class ArticleContentsController < ApplicationController
4
+ before_action :set_article
5
+ before_action :set_article_content, only: %i[update destroy move]
6
+
7
+ def create
8
+ content = Content.find(params.dig(:article_content, :content_id))
9
+ @article_content = @article.article_contents.build(content: content)
10
+ @article_content.payload = @article_content.filter_payload(payload_param)
11
+
12
+ if @article_content.save
13
+ redirect_to article_path(@article), notice: "Content block was successfully added."
14
+ else
15
+ redirect_to article_path(@article), alert: "Failed to add content block: #{@article_content.errors.full_messages.to_sentence}"
16
+ end
17
+ end
18
+
19
+ def update
20
+ filtered = @article_content.filter_payload(payload_param)
21
+ if @article_content.update(payload: filtered)
22
+ redirect_to article_path(@article), notice: "Content block was successfully updated."
23
+ else
24
+ redirect_to article_path(@article), alert: "Failed to update content block: #{@article_content.errors.full_messages.to_sentence}"
25
+ end
26
+ end
27
+
28
+ def destroy
29
+ @article_content.destroy
30
+ normalize_sort_orders!
31
+ redirect_to article_path(@article), notice: "Content block was successfully removed."
32
+ end
33
+
34
+ def move
35
+ direction = params[:direction].to_s
36
+ ordered = @article.article_contents.order(:sort_order, :id).to_a
37
+ current_index = ordered.index(@article_content)
38
+
39
+ if current_index
40
+ target_index = direction == "up" ? current_index - 1 : current_index + 1
41
+ if target_index >= 0 && target_index < ordered.length
42
+ # Swap
43
+ ordered[current_index], ordered[target_index] = ordered[target_index], ordered[current_index]
44
+ ActiveRecord::Base.transaction do
45
+ ordered.each_with_index do |item, idx|
46
+ item.update_column(:sort_order, idx + 1)
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ redirect_to article_path(@article), notice: "Content block order updated."
53
+ end
54
+
55
+ def reorder
56
+ ordered_ids = Array(params[:ordered_ids]).map(&:to_i)
57
+ if ordered_ids.any?
58
+ contents_by_id = @article.article_contents.index_by(&:id)
59
+ ActiveRecord::Base.transaction do
60
+ ordered_ids.each_with_index do |id, idx|
61
+ item = contents_by_id[id]
62
+ item.update_column(:sort_order, idx + 1) if item
63
+ end
64
+ end
65
+ end
66
+
67
+ respond_to do |format|
68
+ format.json { render json: { status: "ok" } }
69
+ format.html { redirect_to article_path(@article), notice: "Content block order updated." }
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def set_article
76
+ @article = Article.friendly.find(params[:article_id])
77
+ end
78
+
79
+ def set_article_content
80
+ @article_content = @article.article_contents.find(params[:id])
81
+ end
82
+
83
+ def payload_param
84
+ raw = params.dig(:article_content, :payload)
85
+ if raw.respond_to?(:permit!)
86
+ raw.permit!.to_h
87
+ elsif raw.respond_to?(:to_unsafe_h)
88
+ raw.to_unsafe_h
89
+ elsif raw.respond_to?(:to_h)
90
+ raw.to_h
91
+ else
92
+ {}
93
+ end
94
+ end
95
+
96
+ def normalize_sort_orders!
97
+ @article.article_contents.order(:sort_order, :id).each_with_index do |item, idx|
98
+ item.update_column(:sort_order, idx + 1)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,56 @@
1
+ module Macron
2
+ module Cms
3
+ class ArticlesController < ApplicationController
4
+ before_action :set_article, only: %i[show edit update destroy]
5
+
6
+ def index
7
+ @articles = Article.order(created_at: :desc)
8
+ end
9
+
10
+ def show
11
+ @article_contents = @article.article_contents.includes(:content).order(:sort_order, :id)
12
+ @content_types = Content.order(:name)
13
+ end
14
+
15
+ def new
16
+ @article = Article.new
17
+ end
18
+
19
+ def edit
20
+ end
21
+
22
+ def create
23
+ @article = Article.new(article_params)
24
+
25
+ if @article.save
26
+ redirect_to article_path(@article), notice: "Article was successfully created."
27
+ else
28
+ render :new, status: :unprocessable_entity
29
+ end
30
+ end
31
+
32
+ def update
33
+ if @article.update(article_params)
34
+ redirect_to article_path(@article), notice: "Article was successfully updated."
35
+ else
36
+ render :edit, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ def destroy
41
+ @article.destroy
42
+ redirect_to articles_path, notice: "Article was successfully deleted."
43
+ end
44
+
45
+ private
46
+
47
+ def set_article
48
+ @article = Article.friendly.find(params[:id])
49
+ end
50
+
51
+ def article_params
52
+ params.require(:article).permit(:title, :name, :meta_title, :meta_description)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,20 @@
1
+ module Macron
2
+ module Cms
3
+ class ContentTypesController < ApplicationController
4
+ before_action :set_content_type, only: %i[show]
5
+
6
+ def index
7
+ @content_types = Content.order(:name)
8
+ end
9
+
10
+ def show
11
+ end
12
+
13
+ private
14
+
15
+ def set_content_type
16
+ @content_type = Content.find(params[:id])
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,10 @@
1
+ module Macron
2
+ module Cms
3
+ class DashboardController < ApplicationController
4
+ def index
5
+ @articles_count = Article.count
6
+ @recent_articles = Article.order(created_at: :desc).limit(5)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,40 @@
1
+ module Macron
2
+ module Cms
3
+ module ApplicationHelper
4
+ def cms_current_user
5
+ auth_user_method = Macron::Cms.configuration.current_user_method
6
+ send(auth_user_method) if respond_to?(auth_user_method, true)
7
+ end
8
+
9
+ def cms_user_display_name(user)
10
+ return "Admin" if user.nil?
11
+
12
+ if user.respond_to?(:email) && user.email.present?
13
+ user.email
14
+ elsif user.respond_to?(:name) && user.name.present?
15
+ user.name
16
+ else
17
+ "Admin User"
18
+ end
19
+ end
20
+
21
+ def cms_nav_link_classes(is_active)
22
+ base = "flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors"
23
+ if is_active
24
+ "#{base} bg-zinc-900 text-white shadow-sm"
25
+ else
26
+ "#{base} text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100"
27
+ end
28
+ end
29
+
30
+ def cms_breadcrumb(label, path = nil)
31
+ @_cms_breadcrumbs ||= []
32
+ @_cms_breadcrumbs << { label: label, path: path }
33
+ end
34
+
35
+ def cms_breadcrumbs
36
+ @_cms_breadcrumbs ||= []
37
+ end
38
+ end
39
+ end
40
+ end