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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +265 -0
- data/Rakefile +4 -0
- data/app/assets/stylesheets/macron/cms/application.css +2 -0
- data/app/assets/tailwind/macron/cms/application.css +4 -0
- data/app/components/macron/cms/widget_component.rb +29 -0
- data/app/controllers/macron/cms/application_controller.rb +36 -0
- data/app/controllers/macron/cms/article_contents_controller.rb +103 -0
- data/app/controllers/macron/cms/articles_controller.rb +56 -0
- data/app/controllers/macron/cms/content_types_controller.rb +20 -0
- data/app/controllers/macron/cms/dashboard_controller.rb +10 -0
- data/app/helpers/macron/cms/application_helper.rb +40 -0
- data/app/helpers/macron/cms/content_helper.rb +31 -0
- data/app/helpers/macron/cms/fields_helper.rb +30 -0
- data/app/helpers/macron/cms/seo_helper.rb +37 -0
- data/app/jobs/macron/cms/application_job.rb +6 -0
- data/app/mailers/macron/cms/application_mailer.rb +8 -0
- data/app/models/macron/cms/application_record.rb +7 -0
- data/app/models/macron/cms/article.rb +42 -0
- data/app/models/macron/cms/article_content.rb +31 -0
- data/app/models/macron/cms/content.rb +34 -0
- data/app/views/layouts/macron/cms/application.html.erb +129 -0
- data/app/views/macron/cms/articles/_form.html.erb +65 -0
- data/app/views/macron/cms/articles/_table.html.erb +54 -0
- data/app/views/macron/cms/articles/edit.html.erb +15 -0
- data/app/views/macron/cms/articles/index.html.erb +28 -0
- data/app/views/macron/cms/articles/new.html.erb +14 -0
- data/app/views/macron/cms/articles/show.html.erb +366 -0
- data/app/views/macron/cms/content_types/index.html.erb +87 -0
- data/app/views/macron/cms/content_types/show.html.erb +148 -0
- data/app/views/macron/cms/dashboard/index.html.erb +81 -0
- data/app/views/macron/cms/fields/_string.html.erb +14 -0
- data/app/views/macron/cms/fields/_text.html.erb +13 -0
- data/app/views/macron/cms/shared/_breadcrumbs.html.erb +14 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20261028000001_create_macron_cms_articles.rb +9 -0
- data/db/migrate/20261028000002_add_name_and_slug_to_macron_cms_articles.rb +8 -0
- data/db/migrate/20261028000003_add_seo_metadata_to_macron_cms_articles.rb +6 -0
- data/db/migrate/20261028000004_create_macron_cms_contents.rb +13 -0
- data/db/migrate/20261028000005_create_macron_cms_article_contents.rb +13 -0
- data/db/migrate/20261028000006_move_payload_to_macron_cms_article_contents.rb +6 -0
- data/db/seeds.rb +83 -0
- data/lib/generators/macron/cms/content_type/content_type_generator.rb +88 -0
- data/lib/macron/cms/configuration.rb +25 -0
- data/lib/macron/cms/engine.rb +20 -0
- data/lib/macron/cms/version.rb +5 -0
- data/lib/macron/cms.rb +26 -0
- data/lib/tasks/macron/cms_tasks.rake +75 -0
- 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
|
+
[](#version)
|
|
4
|
+
[](https://rubyonrails.org)
|
|
5
|
+
[](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,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,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,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
|