rivet_cms 0.1.0.pre
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 +28 -0
- data/Rakefile +8 -0
- data/app/assets/builds/rivet_cms.css +2 -0
- data/app/assets/builds/rivet_cms.js +9536 -0
- data/app/assets/builds/rivet_cms.js.map +7 -0
- data/app/assets/stylesheets/rivet_cms/application.tailwind.css +25 -0
- data/app/assets/stylesheets/rivet_cms/brand_colors.css +168 -0
- data/app/controllers/rivet_cms/api/docs_controller.rb +38 -0
- data/app/controllers/rivet_cms/application_controller.rb +5 -0
- data/app/controllers/rivet_cms/components_controller.rb +7 -0
- data/app/controllers/rivet_cms/content_types_controller.rb +61 -0
- data/app/controllers/rivet_cms/dashboard_controller.rb +10 -0
- data/app/controllers/rivet_cms/fields_controller.rb +109 -0
- data/app/helpers/rivet_cms/application_helper.rb +7 -0
- data/app/helpers/rivet_cms/brand_color_helper.rb +71 -0
- data/app/helpers/rivet_cms/flash_helper.rb +37 -0
- data/app/helpers/rivet_cms/sign_out_helper.rb +11 -0
- data/app/javascript/controllers/content_type_form_controller.js +53 -0
- data/app/javascript/controllers/field_layout_controller.js +709 -0
- data/app/javascript/rivet_cms.js +29 -0
- data/app/jobs/rivet_cms/application_job.rb +4 -0
- data/app/mailers/rivet_cms/application_mailer.rb +6 -0
- data/app/models/rivet_cms/application_record.rb +5 -0
- data/app/models/rivet_cms/component.rb +4 -0
- data/app/models/rivet_cms/content.rb +4 -0
- data/app/models/rivet_cms/content_type.rb +40 -0
- data/app/models/rivet_cms/content_value.rb +4 -0
- data/app/models/rivet_cms/field.rb +82 -0
- data/app/models/rivet_cms/field_values/base.rb +11 -0
- data/app/models/rivet_cms/field_values/boolean.rb +4 -0
- data/app/models/rivet_cms/field_values/integer.rb +4 -0
- data/app/models/rivet_cms/field_values/string.rb +4 -0
- data/app/models/rivet_cms/field_values/text.rb +4 -0
- data/app/services/rivet_cms/open_api_generator.rb +245 -0
- data/app/views/layouts/rivet_cms/application.html.erb +49 -0
- data/app/views/rivet_cms/api/docs/show.html.erb +47 -0
- data/app/views/rivet_cms/content_types/_form.html.erb +98 -0
- data/app/views/rivet_cms/content_types/edit.html.erb +27 -0
- data/app/views/rivet_cms/content_types/index.html.erb +151 -0
- data/app/views/rivet_cms/content_types/new.html.erb +19 -0
- data/app/views/rivet_cms/content_types/show.html.erb +147 -0
- data/app/views/rivet_cms/dashboard/index.html.erb +263 -0
- data/app/views/rivet_cms/fields/_form.html.erb +111 -0
- data/app/views/rivet_cms/fields/edit.html.erb +25 -0
- data/app/views/rivet_cms/fields/index.html.erb +126 -0
- data/app/views/rivet_cms/fields/new.html.erb +25 -0
- data/app/views/rivet_cms/shared/_navigation.html.erb +153 -0
- data/config/i18n-tasks.yml +178 -0
- data/config/locales/en.yml +14 -0
- data/config/routes.rb +56 -0
- data/db/migrate/20250317194359_create_core_tables.rb +90 -0
- data/lib/rivet_cms/engine.rb +55 -0
- data/lib/rivet_cms/version.rb +3 -0
- data/lib/rivet_cms.rb +44 -0
- data/lib/tasks/rivet_cms_tasks.rake +4 -0
- metadata +231 -0
@@ -0,0 +1,29 @@
|
|
1
|
+
import "@hotwired/turbo-rails"
|
2
|
+
import { Application } from "@hotwired/stimulus"
|
3
|
+
import FieldLayoutController from "./controllers/field_layout_controller"
|
4
|
+
import ContentTypeFormController from "./controllers/content_type_form_controller"
|
5
|
+
|
6
|
+
const application = Application.start()
|
7
|
+
|
8
|
+
// Configure Stimulus development experience
|
9
|
+
application.debug = false
|
10
|
+
window.Stimulus = application
|
11
|
+
|
12
|
+
// Register controllers
|
13
|
+
application.register("field-layout", FieldLayoutController)
|
14
|
+
application.register("content-type-form", ContentTypeFormController)
|
15
|
+
document.addEventListener("turbo:load", function(event) {
|
16
|
+
// Mobile menu toggle
|
17
|
+
const mobileMenuButton = document.querySelector('[aria-controls="mobile-menu"]')
|
18
|
+
const mobileMenu = document.getElementById('mobile-menu')
|
19
|
+
|
20
|
+
if (mobileMenuButton && mobileMenu) {
|
21
|
+
mobileMenuButton.addEventListener('click', () => {
|
22
|
+
const expanded = mobileMenuButton.getAttribute('aria-expanded') === 'true'
|
23
|
+
mobileMenuButton.setAttribute('aria-expanded', !expanded)
|
24
|
+
mobileMenu.classList.toggle('hidden')
|
25
|
+
})
|
26
|
+
}
|
27
|
+
});
|
28
|
+
|
29
|
+
export { application }
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module RivetCms
|
2
|
+
class ContentType < ApplicationRecord
|
3
|
+
has_prefix_id :content_type, minimum_length: RivetCms.configuration.prefixed_ids_length, salt: RivetCms.configuration.prefixed_ids_salt, alphabet: RivetCms.configuration.prefixed_ids_alphabet
|
4
|
+
|
5
|
+
has_many :contents, dependent: :destroy
|
6
|
+
has_many :fields, dependent: :destroy
|
7
|
+
|
8
|
+
validates :name, presence: true
|
9
|
+
validates :slug, presence: true
|
10
|
+
validates :slug, format: { with: /\A[a-z0-9\-]+\z/, message: "can only contain lowercase letters, numbers, and hyphens" }
|
11
|
+
|
12
|
+
# Default to collection type (is_single = false)
|
13
|
+
after_initialize :set_default_type, if: :new_record?
|
14
|
+
|
15
|
+
after_commit :invalidate_api_docs_cache
|
16
|
+
|
17
|
+
def collection?
|
18
|
+
!is_single
|
19
|
+
end
|
20
|
+
|
21
|
+
def single?
|
22
|
+
is_single
|
23
|
+
end
|
24
|
+
|
25
|
+
# For compatibility with existing views
|
26
|
+
def is_collection
|
27
|
+
!is_single
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def set_default_type
|
33
|
+
self.is_single = false if self.is_single.nil?
|
34
|
+
end
|
35
|
+
|
36
|
+
def invalidate_api_docs_cache
|
37
|
+
Rails.cache.delete("rivet_cms_api_docs")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module RivetCms
|
2
|
+
class Field < ApplicationRecord
|
3
|
+
has_prefix_id :fld, minimum_length: RivetCms.configuration.prefixed_ids_length, salt: RivetCms.configuration.prefixed_ids_salt, alphabet: RivetCms.configuration.prefixed_ids_alphabet
|
4
|
+
|
5
|
+
# Field widths for form layout
|
6
|
+
WIDTHS = %w[full half].freeze
|
7
|
+
attribute :width, :string, default: 'full'
|
8
|
+
|
9
|
+
belongs_to :content_type
|
10
|
+
belongs_to :component, optional: true
|
11
|
+
has_many :field_values, dependent: :destroy
|
12
|
+
|
13
|
+
# Define available field types
|
14
|
+
FIELD_TYPES = %w[string text integer boolean media relation component]
|
15
|
+
|
16
|
+
validates :name, presence: true, uniqueness: { scope: :content_type_id }
|
17
|
+
validates :field_type, presence: true, inclusion: { in: FIELD_TYPES }
|
18
|
+
validates :width, inclusion: { in: WIDTHS }
|
19
|
+
|
20
|
+
# Default scope to order fields by position
|
21
|
+
default_scope { order(position: :asc) }
|
22
|
+
|
23
|
+
# Set default position before create
|
24
|
+
before_create :set_default_position
|
25
|
+
|
26
|
+
# Field type options for select
|
27
|
+
def self.field_types_for_select
|
28
|
+
[
|
29
|
+
['Short text', 'string'],
|
30
|
+
['Long text', 'text'],
|
31
|
+
['Number', 'integer'],
|
32
|
+
['True/False', 'boolean'],
|
33
|
+
['Media', 'media'],
|
34
|
+
['Relation', 'relation'],
|
35
|
+
['Component', 'component']
|
36
|
+
]
|
37
|
+
end
|
38
|
+
|
39
|
+
# Human-readable field type
|
40
|
+
def field_type_name
|
41
|
+
case field_type
|
42
|
+
when 'string' then 'Short text'
|
43
|
+
when 'text' then 'Long text'
|
44
|
+
when 'integer' then 'Number'
|
45
|
+
when 'boolean' then 'True/False'
|
46
|
+
when 'media' then 'Media'
|
47
|
+
when 'relation' then 'Relation'
|
48
|
+
when 'component' then 'Component'
|
49
|
+
else field_type.humanize
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Update positions for a set of fields
|
54
|
+
def self.update_positions(ordered_ids)
|
55
|
+
return if ordered_ids.blank?
|
56
|
+
|
57
|
+
transaction do
|
58
|
+
ordered_ids.each_with_index do |prefixed_id, index|
|
59
|
+
field = find_by_prefix_id(prefixed_id)
|
60
|
+
field&.update_column(:position, index + 1)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
after_commit :invalidate_api_docs_cache
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def set_default_position
|
70
|
+
return if position.present?
|
71
|
+
|
72
|
+
max_position = content_type.fields.maximum(:position) || 0
|
73
|
+
self.position = max_position + 1
|
74
|
+
end
|
75
|
+
|
76
|
+
def invalidate_api_docs_cache
|
77
|
+
if content_type
|
78
|
+
Rails.cache.delete("rivet_cms_api_docs")
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,245 @@
|
|
1
|
+
module RivetCms
|
2
|
+
class OpenApiGenerator
|
3
|
+
class << self
|
4
|
+
def generate
|
5
|
+
Rails.cache.fetch("rivet_cms_api_docs", expires_in: 1.hour) do
|
6
|
+
generate_schema
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def generate_schema
|
13
|
+
schema = {
|
14
|
+
"openapi" => "3.0.1",
|
15
|
+
"info" => {
|
16
|
+
"title" => "RivetCMS API",
|
17
|
+
"version" => "1.0.0",
|
18
|
+
"description" => "API documentation for content types"
|
19
|
+
},
|
20
|
+
"paths" => generate_paths,
|
21
|
+
"components" => {
|
22
|
+
"schemas" => generate_schemas,
|
23
|
+
"securitySchemes" => {
|
24
|
+
"bearerAuth" => {
|
25
|
+
"type" => "http",
|
26
|
+
"scheme" => "bearer",
|
27
|
+
"bearerFormat" => "JWT"
|
28
|
+
}
|
29
|
+
}
|
30
|
+
},
|
31
|
+
"security" => [
|
32
|
+
{ "bearerAuth" => [] }
|
33
|
+
]
|
34
|
+
}
|
35
|
+
|
36
|
+
deep_stringify_keys(schema)
|
37
|
+
end
|
38
|
+
|
39
|
+
def deep_stringify_keys(obj)
|
40
|
+
case obj
|
41
|
+
when Hash
|
42
|
+
obj.each_with_object({}) do |(key, value), result|
|
43
|
+
result[key.to_s] = deep_stringify_keys(value)
|
44
|
+
end
|
45
|
+
when Array
|
46
|
+
obj.map { |item| deep_stringify_keys(item) }
|
47
|
+
else
|
48
|
+
obj
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def generate_paths
|
53
|
+
paths = {}
|
54
|
+
|
55
|
+
ContentType.all.each do |content_type|
|
56
|
+
if content_type.is_single
|
57
|
+
paths.merge!(generate_single_type_paths(content_type))
|
58
|
+
else
|
59
|
+
paths.merge!(generate_collection_type_paths(content_type))
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
paths
|
64
|
+
end
|
65
|
+
|
66
|
+
def generate_single_type_paths(content_type)
|
67
|
+
{
|
68
|
+
"/api/v1/#{content_type.slug}" => {
|
69
|
+
"get" => {
|
70
|
+
"tags" => [content_type.name],
|
71
|
+
"summary" => "Get #{content_type.name}",
|
72
|
+
"responses" => {
|
73
|
+
"200" => {
|
74
|
+
"description" => "Returns the #{content_type.name}",
|
75
|
+
"content" => {
|
76
|
+
"application/json" => {
|
77
|
+
"schema" => { "$ref" => "#/components/schemas/#{content_type.slug}" }
|
78
|
+
}
|
79
|
+
}
|
80
|
+
}
|
81
|
+
}
|
82
|
+
}
|
83
|
+
}
|
84
|
+
}
|
85
|
+
end
|
86
|
+
|
87
|
+
def generate_collection_type_paths(content_type)
|
88
|
+
{
|
89
|
+
"/api/v1/#{content_type.slug}" => {
|
90
|
+
"get" => {
|
91
|
+
"tags" => [content_type.name],
|
92
|
+
"summary" => "List all #{content_type.name.pluralize}",
|
93
|
+
"parameters" => [
|
94
|
+
{
|
95
|
+
"name" => "page",
|
96
|
+
"in" => "query",
|
97
|
+
"description" => "Page number for pagination",
|
98
|
+
"schema" => { "type" => "integer", "default" => 1 },
|
99
|
+
"required" => false
|
100
|
+
},
|
101
|
+
{
|
102
|
+
"name" => "per_page",
|
103
|
+
"in" => "query",
|
104
|
+
"description" => "Number of items per page",
|
105
|
+
"schema" => { "type" => "integer", "default" => 25 },
|
106
|
+
"required" => false
|
107
|
+
}
|
108
|
+
],
|
109
|
+
"responses" => {
|
110
|
+
"200" => {
|
111
|
+
"description" => "Returns list of #{content_type.name.pluralize}",
|
112
|
+
"content" => {
|
113
|
+
"application/json" => {
|
114
|
+
"schema" => {
|
115
|
+
"type" => "object",
|
116
|
+
"properties" => {
|
117
|
+
"data" => {
|
118
|
+
"type" => "array",
|
119
|
+
"items" => { "$ref" => "#/components/schemas/#{content_type.slug}" }
|
120
|
+
},
|
121
|
+
"meta" => {
|
122
|
+
"type" => "object",
|
123
|
+
"properties" => {
|
124
|
+
"current_page" => { "type" => "integer" },
|
125
|
+
"total_pages" => { "type" => "integer" },
|
126
|
+
"total_count" => { "type" => "integer" }
|
127
|
+
}
|
128
|
+
}
|
129
|
+
}
|
130
|
+
}
|
131
|
+
}
|
132
|
+
}
|
133
|
+
}
|
134
|
+
}
|
135
|
+
}
|
136
|
+
},
|
137
|
+
"/api/v1/#{content_type.slug}/{id}" => {
|
138
|
+
"get" => {
|
139
|
+
"tags" => [content_type.name],
|
140
|
+
"summary" => "Get a specific #{content_type.name}",
|
141
|
+
"parameters" => [
|
142
|
+
{
|
143
|
+
"name" => "id",
|
144
|
+
"in" => "path",
|
145
|
+
"required" => true,
|
146
|
+
"schema" => { "type" => "string" },
|
147
|
+
"description" => "The ID of the #{content_type.name} to retrieve"
|
148
|
+
}
|
149
|
+
],
|
150
|
+
"responses" => {
|
151
|
+
"200" => {
|
152
|
+
"description" => "Returns the #{content_type.name}",
|
153
|
+
"content" => {
|
154
|
+
"application/json" => {
|
155
|
+
"schema" => { "$ref" => "#/components/schemas/#{content_type.slug}" }
|
156
|
+
}
|
157
|
+
}
|
158
|
+
},
|
159
|
+
"404" => {
|
160
|
+
"description" => "#{content_type.name} not found"
|
161
|
+
}
|
162
|
+
}
|
163
|
+
}
|
164
|
+
}
|
165
|
+
}
|
166
|
+
end
|
167
|
+
|
168
|
+
def generate_schemas
|
169
|
+
schemas = {}
|
170
|
+
|
171
|
+
ContentType.all.each do |content_type|
|
172
|
+
schemas[content_type.slug] = generate_content_type_schema(content_type)
|
173
|
+
end
|
174
|
+
|
175
|
+
schemas
|
176
|
+
end
|
177
|
+
|
178
|
+
def generate_content_type_schema(content_type)
|
179
|
+
{
|
180
|
+
"type" => "object",
|
181
|
+
"properties" => generate_properties(content_type)
|
182
|
+
}
|
183
|
+
end
|
184
|
+
|
185
|
+
def generate_properties(content_type)
|
186
|
+
properties = {
|
187
|
+
"id" => { "type" => "string" },
|
188
|
+
"title" => { "type" => "string" },
|
189
|
+
"slug" => { "type" => "string" },
|
190
|
+
"status" => {
|
191
|
+
"type" => "string",
|
192
|
+
"enum" => ["draft", "published", "archived"]
|
193
|
+
},
|
194
|
+
"created_at" => { "type" => "string", "format" => "date-time" },
|
195
|
+
"updated_at" => { "type" => "string", "format" => "date-time" }
|
196
|
+
}
|
197
|
+
|
198
|
+
content_type.fields.each do |field|
|
199
|
+
properties[field.name] = field_type_to_schema(field)
|
200
|
+
end
|
201
|
+
|
202
|
+
properties
|
203
|
+
end
|
204
|
+
|
205
|
+
def field_type_to_schema(field)
|
206
|
+
case field.field_type
|
207
|
+
when 'string', 'text'
|
208
|
+
{ "type" => "string" }
|
209
|
+
when 'integer'
|
210
|
+
{ "type" => "integer" }
|
211
|
+
when 'boolean'
|
212
|
+
{ "type" => "boolean" }
|
213
|
+
when 'media'
|
214
|
+
{
|
215
|
+
"type" => "object",
|
216
|
+
"properties" => {
|
217
|
+
"url" => { "type" => "string" },
|
218
|
+
"filename" => { "type" => "string" },
|
219
|
+
"content_type" => { "type" => "string" }
|
220
|
+
}
|
221
|
+
}
|
222
|
+
when 'relation'
|
223
|
+
{
|
224
|
+
"type" => "object",
|
225
|
+
"properties" => {
|
226
|
+
"id" => { "type" => "string" },
|
227
|
+
"title" => { "type" => "string" }
|
228
|
+
}
|
229
|
+
}
|
230
|
+
when 'component'
|
231
|
+
{
|
232
|
+
"type" => "object",
|
233
|
+
"properties" => {
|
234
|
+
"id" => { "type" => "string" },
|
235
|
+
"type" => { "type" => "string" },
|
236
|
+
"fields" => { "type" => "object" }
|
237
|
+
}
|
238
|
+
}
|
239
|
+
else
|
240
|
+
{ "type" => "string" }
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html class="h-full bg-gray-50">
|
3
|
+
<head>
|
4
|
+
<title>Rivet CMS</title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<%= csrf_meta_tags %>
|
7
|
+
<%= csp_meta_tag %>
|
8
|
+
|
9
|
+
<%= yield :head %>
|
10
|
+
|
11
|
+
<%= javascript_include_tag "rivet_cms", "data-turbo-track": "reload", type: "module" %>
|
12
|
+
<%= stylesheet_link_tag "rivet_cms", "data-turbo-track": "reload", time: Time.now.to_i %>
|
13
|
+
|
14
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
15
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
16
|
+
<link href="https://fonts.googleapis.com/css2?family=Passion+One:wght@400;700;900&display=swap" rel="stylesheet">
|
17
|
+
<%= brand_color_css.html_safe %>
|
18
|
+
</head>
|
19
|
+
<body class="h-full font-sans antialiased text-gray-900">
|
20
|
+
<div class="min-h-full">
|
21
|
+
<!-- Top navigation -->
|
22
|
+
<%= render "rivet_cms/shared/navigation" %>
|
23
|
+
|
24
|
+
<!-- Flash messages -->
|
25
|
+
<% if flash.any? %>
|
26
|
+
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 mt-4">
|
27
|
+
<% flash.each do |type, message| %>
|
28
|
+
<div class="<%= flash_class_for(type) %> p-4 rounded-md mb-4">
|
29
|
+
<div class="flex">
|
30
|
+
<div class="flex-shrink-0">
|
31
|
+
<%= flash_icon_for(type) %>
|
32
|
+
</div>
|
33
|
+
<div class="ml-3">
|
34
|
+
<p class="text-sm font-medium"><%= message %></p>
|
35
|
+
</div>
|
36
|
+
</div>
|
37
|
+
</div>
|
38
|
+
<% end %>
|
39
|
+
</div>
|
40
|
+
<% end %>
|
41
|
+
|
42
|
+
<main>
|
43
|
+
<div class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
|
44
|
+
<%= yield %>
|
45
|
+
</div>
|
46
|
+
</main>
|
47
|
+
</div>
|
48
|
+
</body>
|
49
|
+
</html>
|
@@ -0,0 +1,47 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>RivetCMS - API Documentation</title>
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
6
|
+
<%= stylesheet_link_tag "rivet_cms", "data-turbo-track": "reload", time: Time.now.to_i %>
|
7
|
+
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
9
|
+
<style>
|
10
|
+
.swagger-ui {
|
11
|
+
margin-top: 1rem;
|
12
|
+
}
|
13
|
+
.swagger-ui .topbar {
|
14
|
+
display: none;
|
15
|
+
}
|
16
|
+
</style>
|
17
|
+
</head>
|
18
|
+
<body class="bg-gray-50">
|
19
|
+
<div class="bg-white border-b border-gray-200 sticky top-0 z-50">
|
20
|
+
<nav class="flex h-12">
|
21
|
+
<%= link_to root_path, class: "inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-900" do %>
|
22
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layout-dashboard mr-2"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>
|
23
|
+
Dashboard
|
24
|
+
<% end %>
|
25
|
+
</nav>
|
26
|
+
</div>
|
27
|
+
<div id="swagger-ui"></div>
|
28
|
+
<script>
|
29
|
+
window.onload = function() {
|
30
|
+
SwaggerUIBundle({
|
31
|
+
url: "<%= api_docs_path(format: :yaml) %>",
|
32
|
+
dom_id: '#swagger-ui',
|
33
|
+
deepLinking: true,
|
34
|
+
presets: [
|
35
|
+
SwaggerUIBundle.presets.apis,
|
36
|
+
SwaggerUIBundle.SwaggerUIStandalonePreset
|
37
|
+
],
|
38
|
+
layout: "BaseLayout",
|
39
|
+
docExpansion: 'list',
|
40
|
+
defaultModelsExpandDepth: 1,
|
41
|
+
defaultModelExpandDepth: 1,
|
42
|
+
displayRequestDuration: true
|
43
|
+
});
|
44
|
+
};
|
45
|
+
</script>
|
46
|
+
</body>
|
47
|
+
</html>
|
@@ -0,0 +1,98 @@
|
|
1
|
+
<%= form_with(model: content_type, class: "space-y-8", id: "content_type_form", data: { controller: "content-type-form" }) do |form| %>
|
2
|
+
<div class="bg-white shadow-md rounded-lg overflow-hidden border border-gray-100">
|
3
|
+
<div class="px-6 py-5 border-b border-gray-200 bg-gray-50">
|
4
|
+
<h3 class="text-lg font-medium leading-6 text-gray-900">Content Type Details</h3>
|
5
|
+
<p class="mt-1 text-sm text-gray-500">
|
6
|
+
Define the basic information for your content type.
|
7
|
+
</p>
|
8
|
+
</div>
|
9
|
+
|
10
|
+
<div class="px-6 py-6 space-y-8">
|
11
|
+
<div>
|
12
|
+
<%= form.label :name, class: "block text-sm font-medium text-gray-700 mb-1" %>
|
13
|
+
<div class="relative mt-1 group">
|
14
|
+
<%= form.text_field :name,
|
15
|
+
class: "block w-full pl-10 px-4 py-3 rounded-md #{content_type.errors[:name].any? ? 'text-red-800 placeholder-red-300 focus:ring-1 focus:ring-red-500 focus:outline-none' : 'focus:ring-1 border-gray-300 focus:ring-gray-500 focus:outline-none'} shadow-sm sm:text-sm transition-all duration-200",
|
16
|
+
placeholder: "e.g. Article, Product, Homepage",
|
17
|
+
data: { content_type_form_target: "name" } %>
|
18
|
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
19
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 #{content_type.errors[:name].any? ? 'text-red-400' : 'text-gray-400 group-hover:text-brand-500'} transition-colors duration-200" viewBox="0 0 20 20" fill="currentColor">
|
20
|
+
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
|
21
|
+
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
|
22
|
+
</svg>
|
23
|
+
</div>
|
24
|
+
</div>
|
25
|
+
<% if content_type.errors[:name].any? %>
|
26
|
+
<p class="mt-2 text-sm text-red-600">Name <%= content_type.errors[:name].join(", ") %></p>
|
27
|
+
<% else %>
|
28
|
+
<p class="mt-2 text-sm text-gray-500">A human-readable name for your content type.</p>
|
29
|
+
<% end %>
|
30
|
+
</div>
|
31
|
+
|
32
|
+
<div>
|
33
|
+
<%= form.label :slug, class: "block text-sm font-medium text-gray-700 mb-1" %>
|
34
|
+
<div class="relative mt-1 group">
|
35
|
+
<%= form.text_field :slug,
|
36
|
+
class: "block w-full pl-10 px-4 py-3 rounded-md #{content_type.errors[:slug].any? ? 'text-red-800 placeholder-red-300 focus:ring-1 focus:ring-red-500 focus:outline-none' : 'focus:ring-1 focus:ring-gray-500 focus:outline-none'} shadow-sm sm:text-sm font-mono transition-all duration-200",
|
37
|
+
placeholder: "e.g. article, product, homepage",
|
38
|
+
data: { content_type_form_target: "slug" } %>
|
39
|
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
40
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 #{content_type.errors[:slug].any? ? 'text-red-400' : 'text-gray-400 group-hover:text-brand-500'} transition-colors duration-200" viewBox="0 0 20 20" fill="currentColor">
|
41
|
+
<path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd" />
|
42
|
+
</svg>
|
43
|
+
</div>
|
44
|
+
</div>
|
45
|
+
<% if content_type.errors[:slug].any? %>
|
46
|
+
<p class="mt-2 text-sm text-red-600">Slug <%= content_type.errors[:slug].join(", ") %></p>
|
47
|
+
<% else %>
|
48
|
+
<p class="mt-2 text-sm text-gray-500">The API identifier for this content type. Used in API routes.</p>
|
49
|
+
<% end %>
|
50
|
+
</div>
|
51
|
+
|
52
|
+
<div>
|
53
|
+
<%= form.label :description, class: "block text-sm font-medium text-gray-700 mb-1" %>
|
54
|
+
<div class="relative mt-1 group">
|
55
|
+
<%= form.text_area :description,
|
56
|
+
rows: 3,
|
57
|
+
class: "block w-full pl-10 px-4 py-3 rounded-md #{content_type.errors[:description].any? ? 'border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-brand-500 focus:border-brand-500'} shadow-sm sm:text-sm transition-all duration-200 group-hover:border-brand-300",
|
58
|
+
placeholder: "Description of this content type" %>
|
59
|
+
<div class="absolute top-3 left-0 pl-3 flex items-start pointer-events-none">
|
60
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 #{content_type.errors[:description].any? ? 'text-red-400' : 'text-gray-400 group-hover:text-brand-500'} transition-colors duration-200" viewBox="0 0 20 20" fill="currentColor">
|
61
|
+
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
62
|
+
</svg>
|
63
|
+
</div>
|
64
|
+
</div>
|
65
|
+
<% if content_type.errors[:description].any? %>
|
66
|
+
<p class="mt-2 text-sm text-red-600">Description <%= content_type.errors[:description].join(", ") %></p>
|
67
|
+
<% else %>
|
68
|
+
<p class="mt-2 text-sm text-gray-500">Optional description for this content type.</p>
|
69
|
+
<% end %>
|
70
|
+
</div>
|
71
|
+
|
72
|
+
<div class="pt-2">
|
73
|
+
<div class="relative flex items-start">
|
74
|
+
<div class="flex items-center h-5">
|
75
|
+
<%= form.check_box :is_single,
|
76
|
+
class: "h-5 w-5 rounded #{content_type.errors[:is_single].any? ? 'border-red-300 text-red-600 focus:ring-red-500' : 'border-gray-300 text-brand-600 focus:ring-brand-500'} transition-all duration-200",
|
77
|
+
data: { content_type_form_target: "isSingle" } %>
|
78
|
+
</div>
|
79
|
+
<div class="ml-3 text-sm">
|
80
|
+
<%= form.label :is_single, "Single Type", class: "font-medium text-gray-700" %>
|
81
|
+
<% if content_type.errors[:is_single].any? %>
|
82
|
+
<p class="text-red-600">Single Type <%= content_type.errors[:is_single].join(", ") %></p>
|
83
|
+
<% else %>
|
84
|
+
<p class="text-gray-500">If checked, this will be a single type (one entry). If unchecked, it will be a collection type (multiple entries).</p>
|
85
|
+
<% end %>
|
86
|
+
</div>
|
87
|
+
</div>
|
88
|
+
</div>
|
89
|
+
</div>
|
90
|
+
</div>
|
91
|
+
|
92
|
+
<div class="flex justify-end pt-4">
|
93
|
+
<%= link_to content_types_path, class: "rounded-md border border-gray-300 bg-white py-3 px-5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 transition-all duration-200" do %>
|
94
|
+
Cancel
|
95
|
+
<% end %>
|
96
|
+
<%= form.submit class: "ml-3 inline-flex items-center rounded-md bg-brand-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600 cursor-pointer" %>
|
97
|
+
</div>
|
98
|
+
<% end %>
|