macron-cms 1.0.0 → 2.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 +4 -4
- data/README.md +298 -21
- data/app/assets/javascripts/macron/cms/codemirror.js +6 -0
- data/app/assets/javascripts/macron/cms/editor.js +101 -0
- data/app/assets/stylesheets/macron/cms/application.css +1 -1
- data/app/assets/stylesheets/macron/cms/codemirror.css +64 -0
- data/app/assets/tailwind/macron/cms/application.css +1 -0
- data/app/components/macron/cms/widget_component.rb +25 -3
- data/app/controllers/macron/cms/article_contents_controller.rb +117 -10
- data/app/controllers/macron/cms/articles_controller.rb +16 -2
- data/app/helpers/macron/cms/content_helper.rb +31 -0
- data/app/helpers/macron/cms/fields_helper.rb +11 -1
- data/app/models/macron/cms/article.rb +36 -0
- data/app/models/macron/cms/article_content.rb +33 -1
- data/app/views/layouts/macron/cms/application.html.erb +6 -18
- data/app/views/macron/cms/articles/_content_blocks_section.html.erb +531 -0
- data/app/views/macron/cms/articles/_form.html.erb +121 -0
- data/app/views/macron/cms/articles/show.html.erb +11 -298
- data/app/views/macron/cms/content_types/show.html.erb +33 -9
- data/app/views/macron/cms/fields/_color.html.erb +35 -0
- data/app/views/macron/cms/fields/_dropdown.html.erb +28 -0
- data/app/views/macron/cms/fields/_file.html.erb +27 -0
- data/app/views/macron/cms/fields/_json.html.erb +17 -0
- data/app/views/macron/cms/fields/_markdown.html.erb +17 -0
- data/app/views/macron/cms/shared/_flash.html.erb +34 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20261028000007_add_background_fields_to_macron_cms_articles.rb +6 -0
- data/db/migrate/20261028000008_add_text_color_to_macron_cms_articles.rb +5 -0
- data/lib/macron/cms/version.rb +1 -1
- metadata +55 -1
|
@@ -35,6 +35,127 @@
|
|
|
35
35
|
<p class="mt-1 text-xs text-zinc-500">Optional. Unique identifier used by FriendlyId to generate clean URLs (derived from title if left blank).</p>
|
|
36
36
|
</div>
|
|
37
37
|
|
|
38
|
+
<div class="pt-6 border-t border-zinc-200 space-y-4">
|
|
39
|
+
<div>
|
|
40
|
+
<h3 class="text-sm font-semibold text-zinc-900">Styling & Background Settings</h3>
|
|
41
|
+
<p class="text-xs text-zinc-500">Configure page background color, background image, text color, and display options.</p>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
45
|
+
<!-- Background Color -->
|
|
46
|
+
<div class="space-y-1.5">
|
|
47
|
+
<%= form.label :background_color, "Background Color", class: "block text-sm font-medium text-zinc-700" %>
|
|
48
|
+
<%= form.hidden_field :background_color, id: "article_background_color" %>
|
|
49
|
+
<div class="flex items-center gap-3">
|
|
50
|
+
<input
|
|
51
|
+
type="color"
|
|
52
|
+
id="article_background_color_picker"
|
|
53
|
+
value="<%= article.background_color.presence || "#ffffff" %>"
|
|
54
|
+
class="h-9 w-12 cursor-pointer rounded-lg border border-zinc-300 bg-white p-1 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
|
|
55
|
+
oninput="document.getElementById('article_background_color').value = this.value; document.getElementById('article_background_color_label').textContent = this.value;"
|
|
56
|
+
/>
|
|
57
|
+
<span id="article_background_color_label" class="text-xs font-mono text-zinc-600">
|
|
58
|
+
<%= article.background_color.presence || "Default (transparent / theme)" %>
|
|
59
|
+
</span>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
class="rounded-md border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-600 shadow-2xs hover:bg-zinc-50 hover:text-zinc-900 transition-colors cursor-pointer"
|
|
63
|
+
onclick="document.getElementById('article_background_color').value = ''; document.getElementById('article_background_color_label').textContent = 'Default (transparent / theme)';"
|
|
64
|
+
>
|
|
65
|
+
Reset
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
<p class="text-xs text-zinc-500">Optional hex color for the page background.</p>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<!-- Text Color -->
|
|
72
|
+
<div class="space-y-1.5">
|
|
73
|
+
<%= form.label :text_color, "Text Color", class: "block text-sm font-medium text-zinc-700" %>
|
|
74
|
+
<%= form.hidden_field :text_color, id: "article_text_color" %>
|
|
75
|
+
<div class="flex items-center gap-3">
|
|
76
|
+
<input
|
|
77
|
+
type="color"
|
|
78
|
+
id="article_text_color_picker"
|
|
79
|
+
value="<%= article.text_color.presence || "#000000" %>"
|
|
80
|
+
class="h-9 w-12 cursor-pointer rounded-lg border border-zinc-300 bg-white p-1 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
|
|
81
|
+
oninput="document.getElementById('article_text_color').value = this.value; document.getElementById('article_text_color_label').textContent = this.value;"
|
|
82
|
+
/>
|
|
83
|
+
<span id="article_text_color_label" class="text-xs font-mono text-zinc-600">
|
|
84
|
+
<%= article.text_color.presence || "Default (theme color)" %>
|
|
85
|
+
</span>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
class="rounded-md border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-600 shadow-2xs hover:bg-zinc-50 hover:text-zinc-900 transition-colors cursor-pointer"
|
|
89
|
+
onclick="document.getElementById('article_text_color').value = ''; document.getElementById('article_text_color_label').textContent = 'Default (theme color)';"
|
|
90
|
+
>
|
|
91
|
+
Reset
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
<p class="text-xs text-zinc-500">Optional text color applied to widgets across this article.</p>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<!-- Background Image -->
|
|
99
|
+
<div class="space-y-1.5">
|
|
100
|
+
<%= form.label :background_image, "Background Image", class: "block text-sm font-medium text-zinc-700" %>
|
|
101
|
+
<%= form.file_field :background_image, accept: "image/*", class: "block w-full text-xs text-zinc-600 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border border-zinc-300 file:text-xs file:font-semibold file:bg-white hover:file:bg-zinc-50 file:cursor-pointer" %>
|
|
102
|
+
|
|
103
|
+
<% if article.persisted? && article.background_image.attached? %>
|
|
104
|
+
<div class="mt-2 flex items-center gap-4 p-3 rounded-lg border border-zinc-200 bg-zinc-50">
|
|
105
|
+
<div class="h-12 w-12 shrink-0 rounded border border-zinc-200 overflow-hidden bg-white flex items-center justify-center">
|
|
106
|
+
<%= image_tag main_app.rails_blob_path(article.background_image), class: "h-full w-full object-cover" %>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="min-w-0 flex-1 text-xs">
|
|
109
|
+
<p class="font-medium text-zinc-900 truncate"><%= article.background_image.filename %></p>
|
|
110
|
+
<p class="text-zinc-500"><%= number_to_human_size(article.background_image.byte_size) %></p>
|
|
111
|
+
</div>
|
|
112
|
+
<%= link_to "Delete image", purge_background_image_article_path(article), data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to remove the background image?" }, class: "rounded-md border border-rose-200 bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700 hover:bg-rose-100 transition-colors cursor-pointer shrink-0" %>
|
|
113
|
+
</div>
|
|
114
|
+
<% end %>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<!-- Background Display Options -->
|
|
118
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
|
|
119
|
+
<div>
|
|
120
|
+
<label for="article_background_options_repeat" class="block text-xs font-medium text-zinc-700">Tiling / Repeat</label>
|
|
121
|
+
<select name="article[background_options][repeat]" id="article_background_options_repeat" class="mt-1 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900">
|
|
122
|
+
<option value="no-repeat" <%= "selected" if article.background_repeat == "no-repeat" %>>No repeat</option>
|
|
123
|
+
<option value="repeat" <%= "selected" if article.background_repeat == "repeat" %>>Tile (repeat both)</option>
|
|
124
|
+
<option value="repeat-x" <%= "selected" if article.background_repeat == "repeat-x" %>>Tile horizontally</option>
|
|
125
|
+
<option value="repeat-y" <%= "selected" if article.background_repeat == "repeat-y" %>>Tile vertically</option>
|
|
126
|
+
</select>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div>
|
|
130
|
+
<label for="article_background_options_size" class="block text-xs font-medium text-zinc-700">Sizing</label>
|
|
131
|
+
<select name="article[background_options][size]" id="article_background_options_size" class="mt-1 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900">
|
|
132
|
+
<option value="cover" <%= "selected" if article.background_size == "cover" %>>Cover (fill area)</option>
|
|
133
|
+
<option value="contain" <%= "selected" if article.background_size == "contain" %>>Contain (fit inside)</option>
|
|
134
|
+
<option value="auto" <%= "selected" if article.background_size == "auto" %>>Auto (original size)</option>
|
|
135
|
+
</select>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div>
|
|
139
|
+
<label for="article_background_options_position" class="block text-xs font-medium text-zinc-700">Position</label>
|
|
140
|
+
<select name="article[background_options][position]" id="article_background_options_position" class="mt-1 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900">
|
|
141
|
+
<option value="center" <%= "selected" if article.background_position == "center" %>>Center</option>
|
|
142
|
+
<option value="top" <%= "selected" if article.background_position == "top" %>>Top</option>
|
|
143
|
+
<option value="bottom" <%= "selected" if article.background_position == "bottom" %>>Bottom</option>
|
|
144
|
+
<option value="left" <%= "selected" if article.background_position == "left" %>>Left</option>
|
|
145
|
+
<option value="right" <%= "selected" if article.background_position == "right" %>>Right</option>
|
|
146
|
+
</select>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div>
|
|
150
|
+
<label for="article_background_options_attachment" class="block text-xs font-medium text-zinc-700">Attachment</label>
|
|
151
|
+
<select name="article[background_options][attachment]" id="article_background_options_attachment" class="mt-1 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900">
|
|
152
|
+
<option value="scroll" <%= "selected" if article.background_attachment == "scroll" %>>Scroll (scrolls with page)</option>
|
|
153
|
+
<option value="fixed" <%= "selected" if article.background_attachment == "fixed" %>>Fixed (fixed in place)</option>
|
|
154
|
+
</select>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
38
159
|
<div class="pt-6 border-t border-zinc-200 space-y-4">
|
|
39
160
|
<div>
|
|
40
161
|
<h3 class="text-sm font-semibold text-zinc-900">SEO Settings</h3>
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
<%= @article.slug %>
|
|
13
13
|
</span>
|
|
14
14
|
</div>
|
|
15
|
-
<
|
|
16
|
-
Created
|
|
17
|
-
|
|
15
|
+
<div class="text-xs text-zinc-500 space-y-0.5">
|
|
16
|
+
<p>Created: <%= @article.created_at.strftime("%B %d, %Y at %H:%M UTC") %></p>
|
|
17
|
+
<p>Updated: <%= time_ago_in_words(@article.updated_at) %> ago</p>
|
|
18
|
+
</div>
|
|
18
19
|
</div>
|
|
19
20
|
|
|
20
21
|
<!-- Actions -->
|
|
@@ -63,304 +64,16 @@
|
|
|
63
64
|
</div>
|
|
64
65
|
|
|
65
66
|
<!-- Content Blocks Section -->
|
|
66
|
-
<div
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
</span>
|
|
74
|
-
</div>
|
|
75
|
-
<p class="text-xs text-zinc-500 mt-1">Re-order blocks using drag-and-drop or the Move Up/Down buttons.</p>
|
|
76
|
-
</div>
|
|
77
|
-
</div>
|
|
78
|
-
|
|
79
|
-
<!-- Content Blocks List -->
|
|
80
|
-
<div
|
|
81
|
-
id="content-blocks-list"
|
|
82
|
-
data-reorder-url="<%= reorder_article_article_contents_path(@article) %>"
|
|
83
|
-
class="space-y-4"
|
|
84
|
-
>
|
|
85
|
-
<% if @article_contents.any? %>
|
|
86
|
-
<% @article_contents.each_with_index do |ac, index| %>
|
|
87
|
-
<% content_type = ac.content %>
|
|
88
|
-
<% fields = content_type ? content_type.normalized_fields : [] %>
|
|
89
|
-
<div
|
|
90
|
-
id="article-content-<%= ac.id %>"
|
|
91
|
-
data-id="<%= ac.id %>"
|
|
92
|
-
draggable="true"
|
|
93
|
-
class="content-block-item group rounded-xl border border-zinc-200 bg-white shadow-2xs transition-shadow hover:shadow-xs"
|
|
94
|
-
>
|
|
95
|
-
<!-- Card Header & Actions -->
|
|
96
|
-
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-100 bg-zinc-50/70 px-5 py-3.5 rounded-t-xl">
|
|
97
|
-
<div class="flex items-center gap-3">
|
|
98
|
-
<!-- Drag Handle -->
|
|
99
|
-
<div class="cursor-grab active:cursor-grabbing text-zinc-400 group-hover:text-zinc-600 transition-colors" title="Drag to reorder">
|
|
100
|
-
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
101
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" />
|
|
102
|
-
</svg>
|
|
103
|
-
</div>
|
|
104
|
-
|
|
105
|
-
<span class="inline-flex items-center rounded-md bg-zinc-200/70 px-2 py-0.5 text-xs font-mono font-bold text-zinc-800 position-badge">
|
|
106
|
-
#<%= index + 1 %>
|
|
107
|
-
</span>
|
|
108
|
-
|
|
109
|
-
<span class="inline-flex items-center rounded-md bg-emerald-50 px-2.5 py-0.5 text-xs font-mono font-medium text-emerald-800 ring-1 ring-inset ring-emerald-200">
|
|
110
|
-
<%= content_type ? content_type.name : "Unknown" %>
|
|
111
|
-
</span>
|
|
112
|
-
</div>
|
|
113
|
-
|
|
114
|
-
<!-- Action Buttons -->
|
|
115
|
-
<div class="flex items-center gap-1.5">
|
|
116
|
-
<!-- Move Up -->
|
|
117
|
-
<%= button_to move_article_article_content_path(@article, ac, direction: "up"), method: :patch, disabled: (index == 0), class: "inline-flex items-center justify-center w-8 h-8 rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" do %>
|
|
118
|
-
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
119
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
|
120
|
-
</svg>
|
|
121
|
-
<% end %>
|
|
122
|
-
|
|
123
|
-
<!-- Move Down -->
|
|
124
|
-
<%= button_to move_article_article_content_path(@article, ac, direction: "down"), method: :patch, disabled: (index == @article_contents.size - 1), class: "inline-flex items-center justify-center w-8 h-8 rounded-lg border border-zinc-200 bg-white text-zinc-600 hover:bg-zinc-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" do %>
|
|
125
|
-
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
|
126
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
127
|
-
</svg>
|
|
128
|
-
<% end %>
|
|
129
|
-
|
|
130
|
-
<!-- Edit Toggle Button -->
|
|
131
|
-
<button
|
|
132
|
-
type="button"
|
|
133
|
-
onclick="document.getElementById('edit-form-<%= ac.id %>').classList.toggle('hidden'); document.getElementById('view-payload-<%= ac.id %>').classList.toggle('hidden');"
|
|
134
|
-
class="inline-flex items-center gap-1 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 transition-colors cursor-pointer"
|
|
135
|
-
>
|
|
136
|
-
<svg class="w-3.5 h-3.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
137
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z" />
|
|
138
|
-
</svg>
|
|
139
|
-
Edit
|
|
140
|
-
</button>
|
|
141
|
-
|
|
142
|
-
<!-- Delete Block Button -->
|
|
143
|
-
<% del_block_confirm = "Are you sure you want to remove this #{content_type ? content_type.name : 'content'} block?" %>
|
|
144
|
-
<%= button_to article_article_content_path(@article, ac), method: :delete, onclick: "return confirm(#{del_block_confirm.to_json});", data: { turbo_confirm: del_block_confirm }, class: "inline-flex items-center justify-center w-8 h-8 rounded-lg border border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100 transition-colors cursor-pointer" do %>
|
|
145
|
-
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
146
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
147
|
-
</svg>
|
|
148
|
-
<% end %>
|
|
149
|
-
</div>
|
|
150
|
-
</div>
|
|
151
|
-
|
|
152
|
-
<!-- View Payload Mode -->
|
|
153
|
-
<div id="view-payload-<%= ac.id %>" class="p-5">
|
|
154
|
-
<% if fields.any? %>
|
|
155
|
-
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
156
|
-
<% fields.each do |f| %>
|
|
157
|
-
<% fname = f["name"] %>
|
|
158
|
-
<% fkind = f["kind"] %>
|
|
159
|
-
<% fval = ac.payload[fname] %>
|
|
160
|
-
<div class="<%= fkind == 'text' ? 'sm:col-span-2' : '' %>">
|
|
161
|
-
<dt class="text-xs font-mono font-medium text-zinc-500 uppercase tracking-wide">
|
|
162
|
-
<%= fname %>
|
|
163
|
-
<span class="text-[10px] text-zinc-400 lowercase font-normal">(<%= fkind %>)</span>
|
|
164
|
-
</dt>
|
|
165
|
-
<dd class="mt-1 text-sm text-zinc-900 bg-zinc-50/80 rounded-lg p-2.5 border border-zinc-100 font-sans">
|
|
166
|
-
<% if fval.present? %>
|
|
167
|
-
<% if fkind == 'text' %>
|
|
168
|
-
<div class="whitespace-pre-wrap"><%= fval %></div>
|
|
169
|
-
<% else %>
|
|
170
|
-
<span><%= fval %></span>
|
|
171
|
-
<% end %>
|
|
172
|
-
<% else %>
|
|
173
|
-
<span class="italic text-zinc-400 text-xs">Empty</span>
|
|
174
|
-
<% end %>
|
|
175
|
-
</dd>
|
|
176
|
-
</div>
|
|
177
|
-
<% end %>
|
|
178
|
-
</dl>
|
|
179
|
-
<% else %>
|
|
180
|
-
<p class="text-xs text-zinc-500 italic">No fields defined for this content type.</p>
|
|
181
|
-
<% end %>
|
|
182
|
-
</div>
|
|
183
|
-
|
|
184
|
-
<!-- Inline Edit Form -->
|
|
185
|
-
<div id="edit-form-<%= ac.id %>" class="hidden p-5 border-t border-zinc-100 bg-zinc-50/30">
|
|
186
|
-
<%= form_with(model: [@article, ac], class: "space-y-4") do |f| %>
|
|
187
|
-
<div class="space-y-4">
|
|
188
|
-
<% fields.each do |field_def| %>
|
|
189
|
-
<% fname = field_def["name"] %>
|
|
190
|
-
<%= cms_render_field(
|
|
191
|
-
field_def,
|
|
192
|
-
value: ac.payload[fname],
|
|
193
|
-
input_name: "article_content[payload][#{fname}]",
|
|
194
|
-
input_id: "edit_article_content_#{ac.id}_payload_#{fname}"
|
|
195
|
-
) %>
|
|
196
|
-
<% end %>
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
<div class="flex items-center gap-3 pt-2">
|
|
200
|
-
<%= f.submit "Save Changes", class: "rounded-lg bg-zinc-900 px-4 py-2 text-xs font-semibold text-white shadow-2xs hover:bg-zinc-800 transition-colors cursor-pointer" %>
|
|
201
|
-
<button
|
|
202
|
-
type="button"
|
|
203
|
-
onclick="document.getElementById('edit-form-<%= ac.id %>').classList.add('hidden'); document.getElementById('view-payload-<%= ac.id %>').classList.remove('hidden');"
|
|
204
|
-
class="rounded-lg border border-zinc-300 bg-white px-3.5 py-2 text-xs font-medium text-zinc-700 hover:bg-zinc-50 transition-colors cursor-pointer"
|
|
205
|
-
>
|
|
206
|
-
Cancel
|
|
207
|
-
</button>
|
|
208
|
-
</div>
|
|
209
|
-
<% end %>
|
|
210
|
-
</div>
|
|
211
|
-
</div>
|
|
212
|
-
<% end %>
|
|
213
|
-
<% else %>
|
|
214
|
-
<div class="rounded-xl border border-dashed border-zinc-300 bg-white p-8 text-center">
|
|
215
|
-
<svg class="mx-auto h-10 w-10 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
216
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
|
217
|
-
</svg>
|
|
218
|
-
<h3 class="mt-2 text-sm font-semibold text-zinc-900">No content blocks yet</h3>
|
|
219
|
-
<p class="mt-1 text-xs text-zinc-500">Choose a content type below to add the first block to this article.</p>
|
|
220
|
-
</div>
|
|
221
|
-
<% end %>
|
|
222
|
-
</div>
|
|
223
|
-
|
|
224
|
-
<!-- Add Content Block Card -->
|
|
225
|
-
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xs">
|
|
226
|
-
<div class="px-6 py-4 border-b border-zinc-200 bg-zinc-50 flex items-center justify-between">
|
|
227
|
-
<div>
|
|
228
|
-
<h3 class="text-sm font-semibold text-zinc-900">Add Content Block</h3>
|
|
229
|
-
<p class="text-xs text-zinc-500 mt-0.5">Select a Content Type and populate its fields.</p>
|
|
230
|
-
</div>
|
|
231
|
-
</div>
|
|
232
|
-
|
|
233
|
-
<div class="p-6">
|
|
234
|
-
<% if @content_types.any? %>
|
|
235
|
-
<div class="space-y-4">
|
|
236
|
-
<div>
|
|
237
|
-
<label for="new_content_type_select" class="block text-xs font-semibold text-zinc-900 mb-1.5">
|
|
238
|
-
Select Content Type
|
|
239
|
-
</label>
|
|
240
|
-
<select
|
|
241
|
-
id="new_content_type_select"
|
|
242
|
-
class="block w-full sm:w-80 rounded-lg border border-zinc-300 bg-white px-3.5 py-2 text-sm text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
|
|
243
|
-
onchange="switchNewContentType(this.value);"
|
|
244
|
-
>
|
|
245
|
-
<option value="">-- Choose a Content Type --</option>
|
|
246
|
-
<% @content_types.each do |ct| %>
|
|
247
|
-
<option value="<%= ct.id %>"><%= ct.name %> (<%= pluralize(ct.normalized_fields.size, "field") %>)</option>
|
|
248
|
-
<% end %>
|
|
249
|
-
</select>
|
|
250
|
-
</div>
|
|
251
|
-
|
|
252
|
-
<!-- Dynamic Form per Content Type -->
|
|
253
|
-
<% @content_types.each do |ct| %>
|
|
254
|
-
<% ct_fields = ct.normalized_fields %>
|
|
255
|
-
<div id="new-content-type-form-<%= ct.id %>" class="new-content-type-form hidden pt-4 border-t border-zinc-100">
|
|
256
|
-
<%= form_with(url: article_article_contents_path(@article), scope: :article_content, class: "space-y-4") do |f| %>
|
|
257
|
-
<%= f.hidden_field :content_id, value: ct.id %>
|
|
258
|
-
|
|
259
|
-
<div class="space-y-4">
|
|
260
|
-
<% ct_fields.each do |field_def| %>
|
|
261
|
-
<% fname = field_def["name"] %>
|
|
262
|
-
<%= cms_render_field(
|
|
263
|
-
field_def,
|
|
264
|
-
value: nil,
|
|
265
|
-
input_name: "article_content[payload][#{fname}]",
|
|
266
|
-
input_id: "new_content_#{ct.id}_payload_#{fname}"
|
|
267
|
-
) %>
|
|
268
|
-
<% end %>
|
|
269
|
-
</div>
|
|
270
|
-
|
|
271
|
-
<div class="pt-2">
|
|
272
|
-
<%= f.submit "Add #{ct.name.titleize} Block", class: "inline-flex items-center gap-1.5 rounded-lg bg-zinc-900 px-4 py-2 text-sm font-semibold text-white shadow-2xs hover:bg-zinc-800 transition-colors cursor-pointer" %>
|
|
273
|
-
</div>
|
|
274
|
-
<% end %>
|
|
275
|
-
</div>
|
|
276
|
-
<% end %>
|
|
277
|
-
</div>
|
|
278
|
-
<% else %>
|
|
279
|
-
<div class="text-center py-4">
|
|
280
|
-
<p class="text-sm text-zinc-500">No content types defined yet.</p>
|
|
281
|
-
<p class="text-xs text-zinc-400 mt-1">Run <code class="font-mono bg-zinc-100 px-1 py-0.5 rounded">bin/rails macron:cms:import_content_types</code> to import defined schemas.</p>
|
|
282
|
-
</div>
|
|
283
|
-
<% end %>
|
|
284
|
-
</div>
|
|
285
|
-
</div>
|
|
67
|
+
<div id="content-blocks-section">
|
|
68
|
+
<%= render "macron/cms/articles/content_blocks_section",
|
|
69
|
+
article: @article,
|
|
70
|
+
article_contents: @article_contents,
|
|
71
|
+
content_types: @content_types,
|
|
72
|
+
newly_added_id: nil,
|
|
73
|
+
updated_id: nil %>
|
|
286
74
|
</div>
|
|
287
75
|
|
|
288
76
|
<div class="pt-2">
|
|
289
77
|
<%= link_to "← Back to all articles".html_safe, articles_path, class: "text-sm font-medium text-zinc-600 hover:text-zinc-900 transition-colors" %>
|
|
290
78
|
</div>
|
|
291
79
|
</div>
|
|
292
|
-
|
|
293
|
-
<script>
|
|
294
|
-
function switchNewContentType(selectedId) {
|
|
295
|
-
document.querySelectorAll('.new-content-type-form').forEach(function(el) {
|
|
296
|
-
el.classList.add('hidden');
|
|
297
|
-
});
|
|
298
|
-
if (selectedId) {
|
|
299
|
-
var target = document.getElementById('new-content-type-form-' + selectedId);
|
|
300
|
-
if (target) target.classList.remove('hidden');
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// HTML5 Drag and Drop Reordering
|
|
305
|
-
(function initDragAndDrop() {
|
|
306
|
-
var container = document.getElementById('content-blocks-list');
|
|
307
|
-
if (!container) return;
|
|
308
|
-
|
|
309
|
-
var draggedItem = null;
|
|
310
|
-
|
|
311
|
-
container.addEventListener('dragstart', function(e) {
|
|
312
|
-
var item = e.target.closest('.content-block-item');
|
|
313
|
-
if (!item) return;
|
|
314
|
-
draggedItem = item;
|
|
315
|
-
e.dataTransfer.effectAllowed = 'move';
|
|
316
|
-
item.classList.add('opacity-50', 'border-zinc-400');
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
container.addEventListener('dragend', function(e) {
|
|
320
|
-
var item = e.target.closest('.content-block-item');
|
|
321
|
-
if (item) {
|
|
322
|
-
item.classList.remove('opacity-50', 'border-zinc-400');
|
|
323
|
-
}
|
|
324
|
-
draggedItem = null;
|
|
325
|
-
sendNewOrder();
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
container.addEventListener('dragover', function(e) {
|
|
329
|
-
e.preventDefault();
|
|
330
|
-
e.dataTransfer.dropEffect = 'move';
|
|
331
|
-
var targetItem = e.target.closest('.content-block-item');
|
|
332
|
-
if (targetItem && targetItem !== draggedItem) {
|
|
333
|
-
var rect = targetItem.getBoundingClientRect();
|
|
334
|
-
var next = (e.clientY - rect.top) / (rect.bottom - rect.top) > 0.5;
|
|
335
|
-
container.insertBefore(draggedItem, next ? targetItem.nextSibling : targetItem);
|
|
336
|
-
}
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
function sendNewOrder() {
|
|
340
|
-
var items = container.querySelectorAll('.content-block-item');
|
|
341
|
-
var orderedIds = [];
|
|
342
|
-
items.forEach(function(item, idx) {
|
|
343
|
-
orderedIds.push(item.getAttribute('data-id'));
|
|
344
|
-
var badge = item.querySelector('.position-badge');
|
|
345
|
-
if (badge) badge.textContent = '#' + (idx + 1);
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
var reorderUrl = container.getAttribute('data-reorder-url');
|
|
349
|
-
var csrfToken = document.querySelector('meta[name="csrf-token"]');
|
|
350
|
-
|
|
351
|
-
if (reorderUrl && orderedIds.length > 0) {
|
|
352
|
-
fetch(reorderUrl, {
|
|
353
|
-
method: 'PATCH',
|
|
354
|
-
headers: {
|
|
355
|
-
'Content-Type': 'application/json',
|
|
356
|
-
'X-CSRF-Token': csrfToken ? csrfToken.content : '',
|
|
357
|
-
'Accept': 'application/json'
|
|
358
|
-
},
|
|
359
|
-
body: JSON.stringify({ ordered_ids: orderedIds })
|
|
360
|
-
}).catch(function(err) {
|
|
361
|
-
console.error('Failed to update content block order:', err);
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
})();
|
|
366
|
-
</script>
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
<%= pluralize(field_list.count, "field") %>
|
|
23
23
|
</span>
|
|
24
24
|
</div>
|
|
25
|
-
<
|
|
26
|
-
Created
|
|
27
|
-
|
|
25
|
+
<div class="text-xs text-zinc-500 space-y-0.5">
|
|
26
|
+
<p>Created: <%= @content_type.created_at.strftime("%B %d, %Y at %H:%M UTC") rescue "—" %></p>
|
|
27
|
+
<p>Updated: <%= @content_type.updated_at ? "#{time_ago_in_words(@content_type.updated_at)} ago" : "—" rescue "—" %></p>
|
|
28
|
+
</div>
|
|
28
29
|
</div>
|
|
29
30
|
|
|
30
31
|
<!-- Actions -->
|
|
@@ -107,14 +108,37 @@
|
|
|
107
108
|
</td>
|
|
108
109
|
<td class="px-6 py-3.5 text-xs text-zinc-600">
|
|
109
110
|
<% if extra_keys.any? %>
|
|
110
|
-
<
|
|
111
|
+
<div class="space-y-1.5">
|
|
111
112
|
<% extra_keys.each do |k, v| %>
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
<% if k.to_s == "options" && v.is_a?(Array) %>
|
|
114
|
+
<div class="flex items-start gap-1.5 flex-wrap">
|
|
115
|
+
<span class="font-mono text-zinc-500 shrink-0">options:</span>
|
|
116
|
+
<div class="flex flex-wrap gap-1">
|
|
117
|
+
<% v.each do |opt| %>
|
|
118
|
+
<%
|
|
119
|
+
opt_label = if opt.is_a?(Hash)
|
|
120
|
+
h = opt.stringify_keys
|
|
121
|
+
name_part = h["name"] || h["label"] || h["value"]
|
|
122
|
+
val_part = h["value"]
|
|
123
|
+
name_part == val_part ? name_part : "#{name_part} (#{val_part})"
|
|
124
|
+
else
|
|
125
|
+
opt.to_s
|
|
126
|
+
end
|
|
127
|
+
%>
|
|
128
|
+
<span class="inline-flex items-center rounded bg-zinc-100 px-1.5 py-0.5 text-[11px] font-mono text-zinc-700 ring-1 ring-inset ring-zinc-200">
|
|
129
|
+
<%= opt_label %>
|
|
130
|
+
</span>
|
|
131
|
+
<% end %>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
<% else %>
|
|
135
|
+
<div class="flex items-center gap-1">
|
|
136
|
+
<span class="font-mono text-zinc-500"><%= k %>:</span>
|
|
137
|
+
<span class="font-mono font-medium text-zinc-800"><%= v.inspect %></span>
|
|
138
|
+
</div>
|
|
139
|
+
<% end %>
|
|
116
140
|
<% end %>
|
|
117
|
-
</
|
|
141
|
+
</div>
|
|
118
142
|
<% else %>
|
|
119
143
|
<span class="text-zinc-600 italic">None</span>
|
|
120
144
|
<% end %>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<div class="space-y-1.5">
|
|
2
|
+
<label for="<%= input_id %>_picker" class="block text-xs font-mono font-semibold text-zinc-700">
|
|
3
|
+
<%= field_name %>
|
|
4
|
+
<span class="ml-1 text-[10px] font-normal text-zinc-400">(color)</span>
|
|
5
|
+
</label>
|
|
6
|
+
|
|
7
|
+
<input
|
|
8
|
+
type="hidden"
|
|
9
|
+
name="<%= input_name %>"
|
|
10
|
+
id="<%= input_id %>"
|
|
11
|
+
value="<%= value %>"
|
|
12
|
+
/>
|
|
13
|
+
|
|
14
|
+
<div class="flex items-center gap-3">
|
|
15
|
+
<input
|
|
16
|
+
type="color"
|
|
17
|
+
id="<%= input_id %>_picker"
|
|
18
|
+
value="<%= value.presence || "#000000" %>"
|
|
19
|
+
class="h-9 w-12 cursor-pointer rounded-lg border border-zinc-300 bg-white p-1 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
|
|
20
|
+
oninput="document.getElementById('<%= input_id %>').value = this.value; document.getElementById('<%= input_id %>_label').textContent = this.value;"
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
<span id="<%= input_id %>_label" class="text-xs font-mono text-zinc-600">
|
|
24
|
+
<%= value.presence || "Default (theme color)" %>
|
|
25
|
+
</span>
|
|
26
|
+
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="rounded-md border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-600 shadow-2xs hover:bg-zinc-50 hover:text-zinc-900 transition-colors cursor-pointer"
|
|
30
|
+
onclick="document.getElementById('<%= input_id %>').value = ''; document.getElementById('<%= input_id %>_label').textContent = 'Default (theme color)';"
|
|
31
|
+
>
|
|
32
|
+
Reset to default
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<%
|
|
2
|
+
raw_options = field["options"] || field[:options] || []
|
|
3
|
+
normalized_options = raw_options.map do |opt|
|
|
4
|
+
if opt.is_a?(Hash)
|
|
5
|
+
opt_hash = opt.stringify_keys
|
|
6
|
+
label = opt_hash["name"].presence || opt_hash["label"].presence || opt_hash["value"].to_s
|
|
7
|
+
[label, opt_hash["value"]]
|
|
8
|
+
else
|
|
9
|
+
[opt.to_s, opt.to_s]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
%>
|
|
13
|
+
<div class="space-y-1.5">
|
|
14
|
+
<label for="<%= input_id %>" class="block text-xs font-mono font-semibold text-zinc-700">
|
|
15
|
+
<%= field_name %>
|
|
16
|
+
<span class="ml-1 text-[10px] font-normal text-zinc-400">(dropdown)</span>
|
|
17
|
+
</label>
|
|
18
|
+
<select
|
|
19
|
+
name="<%= input_name %>"
|
|
20
|
+
id="<%= input_id %>"
|
|
21
|
+
class="block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-2xs focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
|
|
22
|
+
>
|
|
23
|
+
<option value="">Select <%= field_name.to_s.humanize.downcase %>...</option>
|
|
24
|
+
<% normalized_options.each do |opt_name, opt_val| %>
|
|
25
|
+
<option value="<%= opt_val %>"<%= " selected" if value.present? && opt_val.to_s == value.to_s %>><%= opt_name %></option>
|
|
26
|
+
<% end %>
|
|
27
|
+
</select>
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<div class="space-y-1.5">
|
|
2
|
+
<label for="<%= input_id %>" class="block text-xs font-mono font-semibold text-zinc-700">
|
|
3
|
+
<%= field_name %>
|
|
4
|
+
<span class="ml-1 text-[10px] font-normal text-zinc-400">(file)</span>
|
|
5
|
+
</label>
|
|
6
|
+
|
|
7
|
+
<% if value.present? %>
|
|
8
|
+
<div class="flex items-center gap-2 p-2 bg-zinc-50 border border-zinc-200 rounded-lg text-xs text-zinc-700 font-mono">
|
|
9
|
+
<svg class="w-4 h-4 text-zinc-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
10
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
|
11
|
+
</svg>
|
|
12
|
+
<span class="truncate font-medium"><%= value %></span>
|
|
13
|
+
<span class="text-[10px] text-zinc-400 font-sans ml-auto shrink-0">Current file</span>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<input
|
|
18
|
+
type="file"
|
|
19
|
+
name="<%= input_name %>"
|
|
20
|
+
id="<%= input_id %>"
|
|
21
|
+
class="block w-full text-xs text-zinc-700 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-zinc-900 file:text-white hover:file:bg-zinc-800 file:cursor-pointer border border-zinc-300 rounded-lg bg-white shadow-2xs focus:outline-none focus:border-zinc-900 cursor-pointer"
|
|
22
|
+
/>
|
|
23
|
+
|
|
24
|
+
<% if value.present? %>
|
|
25
|
+
<p class="text-[11px] text-zinc-400">Choose a new file to replace the existing one.</p>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<div class="space-y-1.5">
|
|
2
|
+
<div class="flex items-center justify-between">
|
|
3
|
+
<label for="<%= input_id %>" class="block text-xs font-mono font-semibold text-zinc-700">
|
|
4
|
+
<%= field_name %>
|
|
5
|
+
<span class="ml-1 text-[10px] font-normal text-amber-600">(json)</span>
|
|
6
|
+
</label>
|
|
7
|
+
<span class="text-[10px] text-zinc-400 font-mono">CodeMirror</span>
|
|
8
|
+
</div>
|
|
9
|
+
<textarea
|
|
10
|
+
name="<%= input_name %>"
|
|
11
|
+
id="<%= input_id %>"
|
|
12
|
+
rows="6"
|
|
13
|
+
data-cms-editor="json"
|
|
14
|
+
class="block w-full font-mono rounded-lg border border-zinc-300 bg-white px-3 py-2 text-xs text-zinc-900 shadow-2xs placeholder:text-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
|
|
15
|
+
placeholder="{}"
|
|
16
|
+
><%= value %></textarea>
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<div class="space-y-1.5">
|
|
2
|
+
<div class="flex items-center justify-between">
|
|
3
|
+
<label for="<%= input_id %>" class="block text-xs font-mono font-semibold text-zinc-700">
|
|
4
|
+
<%= field_name %>
|
|
5
|
+
<span class="ml-1 text-[10px] font-normal text-emerald-600">(markdown)</span>
|
|
6
|
+
</label>
|
|
7
|
+
<span class="text-[10px] text-zinc-400 font-mono">CodeMirror</span>
|
|
8
|
+
</div>
|
|
9
|
+
<textarea
|
|
10
|
+
name="<%= input_name %>"
|
|
11
|
+
id="<%= input_id %>"
|
|
12
|
+
rows="6"
|
|
13
|
+
data-cms-editor="markdown"
|
|
14
|
+
class="block w-full font-mono rounded-lg border border-zinc-300 bg-white px-3 py-2 text-xs text-zinc-900 shadow-2xs placeholder:text-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900"
|
|
15
|
+
placeholder="Enter markdown content..."
|
|
16
|
+
><%= value %></textarea>
|
|
17
|
+
</div>
|