belt 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/belt/cli/deploy_command.rb +40 -0
  3. data/lib/belt/cli/destroy_command.rb +21 -8
  4. data/lib/belt/cli/environment_command.rb +4 -12
  5. data/lib/belt/cli/frontend_command.rb +11 -13
  6. data/lib/belt/cli/frontend_setup_command.rb +14 -25
  7. data/lib/belt/cli/generate_command.rb +106 -32
  8. data/lib/belt/cli/new_command.rb +82 -35
  9. data/lib/belt/cli/routes_command.rb +1 -9
  10. data/lib/belt/cli/setup_command.rb +2 -8
  11. data/lib/belt/cli/tables_command.rb +58 -25
  12. data/lib/belt/cli/views_command.rb +11 -8
  13. data/lib/belt/inflector.rb +58 -0
  14. data/lib/belt/lambda_handler.rb +11 -0
  15. data/lib/belt/route_dsl.rb +3 -11
  16. data/lib/belt/table_inference.rb +4 -18
  17. data/lib/belt/version.rb +1 -1
  18. data/lib/templates/environment/backend.tf.erb +2 -2
  19. data/lib/templates/environment/main.tf.erb +14 -18
  20. data/lib/templates/environment/outputs.tf.erb +28 -3
  21. data/lib/templates/environment/terraform.tfvars.erb +5 -0
  22. data/lib/templates/environment/variables.tf.erb +6 -0
  23. data/lib/templates/generate/controller.rb.erb +0 -1
  24. data/lib/templates/generate/model.rb.erb +0 -2
  25. data/lib/templates/module/dns.tf.erb +83 -0
  26. data/lib/templates/module/frontend.tf.erb +179 -0
  27. data/lib/templates/module/main.tf.erb +42 -0
  28. data/lib/templates/module/outputs.tf.erb +24 -0
  29. data/lib/templates/module/variables.tf.erb +27 -0
  30. data/lib/templates/new_app/lambda/config/environment.rb.erb +7 -1
  31. data/lib/templates/views/Form.jsx.erb +63 -7
  32. data/lib/templates/views/Index.jsx.erb +41 -10
  33. data/lib/templates/views/Show.jsx.erb +12 -2
  34. metadata +21 -2
  35. data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +0 -23
@@ -32,25 +32,21 @@ provider "conveyor-belt" {
32
32
  aws_region = var.aws_region
33
33
  }
34
34
 
35
- resource "conveyor_belt" "main" {
36
- provider = conveyor-belt
37
- source = "${path.module}/../routes.tf.rb"
38
- app_name = var.app_name
39
- lambda_source_dir = "${path.module}/../../lambda"
40
- lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
41
- frontend_urls = ["http://localhost:3000"]
42
- friendly_errors = var.environment != "prod"
35
+ module "app" {
36
+ source = "../modules/app"
43
37
 
44
- # Per-lambda configuration: env vars, timeouts, memory
45
- lambda_config = {
46
- api = {
47
- # timeout = 30
48
- # memory_size = 256
38
+ app_name = var.app_name
39
+ environment = var.environment
40
+ aws_region = var.aws_region
41
+ domain = var.domain
42
+ frontend_urls = concat(
43
+ var.domain != "" ? ["https://${var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"}"] : [],
44
+ var.environment == "prod" ? [] : ["http://localhost:3000"]
45
+ )
49
46
 
50
- env_vars = {
51
- WELCOME_TITLE = "Welcome to ${var.app_name}"
52
- WELCOME_SUBTITLE = "API Gateway → Lambda → DynamoDB — all connected."
53
- }
54
- }
47
+ providers = {
48
+ aws = aws
49
+ random = random
50
+ conveyor-belt = conveyor-belt
55
51
  }
56
52
  }
@@ -1,14 +1,39 @@
1
1
  output "api_url" {
2
2
  description = "API Gateway base URL"
3
- value = values(conveyor_belt.main.api_gateway_urls)[0]
3
+ value = module.app.api_url
4
4
  }
5
5
 
6
6
  output "api_urls" {
7
7
  description = "Map of gateway names to API Gateway URLs"
8
- value = conveyor_belt.main.api_gateway_urls
8
+ value = module.app.api_urls
9
9
  }
10
10
 
11
11
  output "lambda_functions" {
12
12
  description = "Map of action names to Lambda function ARNs"
13
- value = conveyor_belt.main.lambda_functions
13
+ value = module.app.lambda_functions
14
+ }
15
+
16
+ output "app_domain" {
17
+ description = "The application domain for this environment"
18
+ value = module.app.app_domain
19
+ }
20
+
21
+ output "name_servers" {
22
+ description = "NS records to configure at your domain registrar (only when domain is set)"
23
+ value = try(module.app.name_servers, [])
24
+ }
25
+
26
+ output "frontend_bucket_name" {
27
+ description = "S3 bucket for frontend assets"
28
+ value = try(module.app.frontend_bucket_name, "")
29
+ }
30
+
31
+ output "frontend_distribution_id" {
32
+ description = "CloudFront distribution ID"
33
+ value = try(module.app.frontend_distribution_id, "")
34
+ }
35
+
36
+ output "frontend_url" {
37
+ description = "Frontend URL"
38
+ value = try(module.app.frontend_url, "")
14
39
  }
@@ -1 +1,6 @@
1
1
  environment = "<%= @env_name %>"
2
+ <% if @domain && !@domain.empty? -%>
3
+ domain = "<%= @domain %>"
4
+ <% else -%>
5
+ # domain = "myapp.com"
6
+ <% end -%>
@@ -14,3 +14,9 @@ variable "aws_region" {
14
14
  type = string
15
15
  default = "us-east-1"
16
16
  }
17
+
18
+ variable "domain" {
19
+ description = "Root domain (e.g., myapp.com). Leave empty to use default CloudFront/API Gateway URLs."
20
+ type = string
21
+ default = ""
22
+ }
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'application_controller'
4
- require_relative '../../models/<%= @singular_name %>'
5
4
 
6
5
  module <%= @module_name %>Controllers
7
6
  class <%= @class_name %>sController < ApplicationController
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class <%= @class_name %> < ApplicationRecord
4
- include Concerns::Timestampable
5
-
6
4
  <% @fields.each do |field| -%>
7
5
  attr_accessor :<%= field[:name] %>
8
6
  <% end -%>
@@ -0,0 +1,83 @@
1
+ # DNS configuration for Belt application
2
+ # Convention: prod → mydomain.com, other envs → <env>.mydomain.com
3
+
4
+ locals {
5
+ # Determine the domain for this environment
6
+ app_domain = var.domain != "" ? (
7
+ var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"
8
+ ) : ""
9
+
10
+ # API subdomain: api.mydomain.com (prod) or api.<env>.mydomain.com (non-prod)
11
+ api_domain = var.domain != "" ? "api.${local.app_domain}" : ""
12
+
13
+ # Whether DNS is enabled
14
+ dns_enabled = var.domain != ""
15
+ }
16
+
17
+ # --- Route53 Hosted Zone ---
18
+ # One zone per environment subdomain (or root domain for prod)
19
+ resource "aws_route53_zone" "app" {
20
+ count = local.dns_enabled ? 1 : 0
21
+ name = local.app_domain
22
+ }
23
+
24
+ # --- ACM Certificate ---
25
+ # Wildcard cert covers the app domain + all subdomains (api.*, www.*, etc.)
26
+ resource "aws_acm_certificate" "app" {
27
+ count = local.dns_enabled ? 1 : 0
28
+ domain_name = local.app_domain
29
+ subject_alternative_names = ["*.${local.app_domain}"]
30
+ validation_method = "DNS"
31
+
32
+ lifecycle {
33
+ create_before_destroy = true
34
+ }
35
+ }
36
+
37
+ # DNS validation record for the certificate
38
+ resource "aws_route53_record" "cert_validation" {
39
+ for_each = local.dns_enabled ? {
40
+ for dvo in aws_acm_certificate.app[0].domain_validation_options : dvo.domain_name => {
41
+ name = dvo.resource_record_name
42
+ type = dvo.resource_record_type
43
+ record = dvo.resource_record_value
44
+ }
45
+ } : {}
46
+
47
+ zone_id = aws_route53_zone.app[0].zone_id
48
+ name = each.value.name
49
+ type = each.value.type
50
+ ttl = 300
51
+ records = [each.value.record]
52
+ }
53
+
54
+ resource "aws_acm_certificate_validation" "app" {
55
+ count = local.dns_enabled ? 1 : 0
56
+ certificate_arn = aws_acm_certificate.app[0].arn
57
+ validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
58
+ }
59
+
60
+ # --- API Gateway Custom Domain ---
61
+ resource "aws_api_gateway_domain_name" "api" {
62
+ count = local.dns_enabled ? 1 : 0
63
+ domain_name = local.api_domain
64
+ regional_certificate_arn = aws_acm_certificate_validation.app[0].certificate_arn
65
+
66
+ endpoint_configuration {
67
+ types = ["REGIONAL"]
68
+ }
69
+ }
70
+
71
+ # Route53 A record for api.<domain>
72
+ resource "aws_route53_record" "api" {
73
+ count = local.dns_enabled ? 1 : 0
74
+ zone_id = aws_route53_zone.app[0].zone_id
75
+ name = local.api_domain
76
+ type = "A"
77
+
78
+ alias {
79
+ name = aws_api_gateway_domain_name.api[0].regional_domain_name
80
+ zone_id = aws_api_gateway_domain_name.api[0].regional_zone_id
81
+ evaluate_target_health = false
82
+ }
83
+ }
@@ -0,0 +1,179 @@
1
+ # Frontend hosting — S3 + CloudFront
2
+ # Custom domain attached when var.domain is set
3
+
4
+ resource "random_string" "frontend_suffix" {
5
+ length = 8
6
+ special = false
7
+ upper = false
8
+ }
9
+
10
+ # S3 bucket for frontend static assets
11
+ resource "aws_s3_bucket" "frontend" {
12
+ bucket = "${var.app_name}-frontend-${var.environment}-${random_string.frontend_suffix.result}"
13
+
14
+ lifecycle {
15
+ ignore_changes = [bucket]
16
+ }
17
+ }
18
+
19
+ resource "aws_s3_bucket_public_access_block" "frontend" {
20
+ bucket = aws_s3_bucket.frontend.id
21
+
22
+ block_public_acls = true
23
+ block_public_policy = true
24
+ ignore_public_acls = true
25
+ restrict_public_buckets = true
26
+ }
27
+
28
+ resource "aws_s3_bucket_server_side_encryption_configuration" "frontend" {
29
+ bucket = aws_s3_bucket.frontend.id
30
+
31
+ rule {
32
+ apply_server_side_encryption_by_default {
33
+ sse_algorithm = "AES256"
34
+ }
35
+ bucket_key_enabled = true
36
+ }
37
+ }
38
+
39
+ # CloudFront OAC
40
+ resource "aws_cloudfront_origin_access_control" "frontend" {
41
+ name = "${var.app_name}-${var.environment}-frontend-oac"
42
+ description = "OAC for frontend bucket"
43
+ origin_access_control_origin_type = "s3"
44
+ signing_behavior = "always"
45
+ signing_protocol = "sigv4"
46
+ }
47
+
48
+ # CloudFront distribution
49
+ resource "aws_cloudfront_distribution" "frontend" {
50
+ origin {
51
+ domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
52
+ origin_id = "S3-${aws_s3_bucket.frontend.id}"
53
+ origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
54
+ }
55
+
56
+ enabled = true
57
+ is_ipv6_enabled = true
58
+ default_root_object = "index.html"
59
+
60
+ # Custom domain aliases (if DNS configured)
61
+ aliases = local.dns_enabled ? compact([
62
+ local.app_domain,
63
+ var.environment == "prod" ? "www.${var.domain}" : ""
64
+ ]) : []
65
+
66
+ default_cache_behavior {
67
+ allowed_methods = ["GET", "HEAD", "OPTIONS"]
68
+ cached_methods = ["GET", "HEAD"]
69
+ target_origin_id = "S3-${aws_s3_bucket.frontend.id}"
70
+ compress = true
71
+ viewer_protocol_policy = "redirect-to-https"
72
+
73
+ forwarded_values {
74
+ query_string = false
75
+ cookies {
76
+ forward = "none"
77
+ }
78
+ }
79
+ }
80
+
81
+ # SPA routing — serve index.html for 404/403
82
+ custom_error_response {
83
+ error_code = 404
84
+ response_code = 200
85
+ response_page_path = "/index.html"
86
+ }
87
+
88
+ custom_error_response {
89
+ error_code = 403
90
+ response_code = 200
91
+ response_page_path = "/index.html"
92
+ }
93
+
94
+ restrictions {
95
+ geo_restriction {
96
+ restriction_type = "none"
97
+ }
98
+ }
99
+
100
+ viewer_certificate {
101
+ cloudfront_default_certificate = !local.dns_enabled
102
+ acm_certificate_arn = local.dns_enabled ? aws_acm_certificate_validation.app[0].certificate_arn : null
103
+ ssl_support_method = local.dns_enabled ? "sni-only" : null
104
+ minimum_protocol_version = local.dns_enabled ? "TLSv1.2_2021" : null
105
+ }
106
+
107
+ tags = {
108
+ Name = "${var.app_name}-${var.environment}-frontend"
109
+ Environment = var.environment
110
+ ManagedBy = "Terraform"
111
+ }
112
+ }
113
+
114
+ # S3 bucket policy — CloudFront OAC access only
115
+ resource "aws_s3_bucket_policy" "frontend" {
116
+ bucket = aws_s3_bucket.frontend.id
117
+
118
+ policy = jsonencode({
119
+ Version = "2012-10-17"
120
+ Statement = [
121
+ {
122
+ Sid = "AllowCloudFrontServicePrincipal"
123
+ Effect = "Allow"
124
+ Principal = {
125
+ Service = "cloudfront.amazonaws.com"
126
+ }
127
+ Action = "s3:GetObject"
128
+ Resource = "${aws_s3_bucket.frontend.arn}/*"
129
+ Condition = {
130
+ StringEquals = {
131
+ "AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
132
+ }
133
+ }
134
+ }
135
+ ]
136
+ })
137
+
138
+ depends_on = [aws_s3_bucket_public_access_block.frontend]
139
+ }
140
+
141
+ # Route53 record for frontend (app domain → CloudFront)
142
+ resource "aws_route53_record" "frontend" {
143
+ count = local.dns_enabled ? 1 : 0
144
+ zone_id = aws_route53_zone.app[0].zone_id
145
+ name = local.app_domain
146
+ type = "A"
147
+
148
+ alias {
149
+ name = aws_cloudfront_distribution.frontend.domain_name
150
+ zone_id = aws_cloudfront_distribution.frontend.hosted_zone_id
151
+ evaluate_target_health = false
152
+ }
153
+ }
154
+
155
+ # www record for prod only
156
+ resource "aws_route53_record" "frontend_www" {
157
+ count = local.dns_enabled && var.environment == "prod" ? 1 : 0
158
+ zone_id = aws_route53_zone.app[0].zone_id
159
+ name = "www.${var.domain}"
160
+ type = "A"
161
+
162
+ alias {
163
+ name = aws_cloudfront_distribution.frontend.domain_name
164
+ zone_id = aws_cloudfront_distribution.frontend.hosted_zone_id
165
+ evaluate_target_health = false
166
+ }
167
+ }
168
+
169
+ output "frontend_bucket_name" {
170
+ value = aws_s3_bucket.frontend.id
171
+ }
172
+
173
+ output "frontend_distribution_id" {
174
+ value = aws_cloudfront_distribution.frontend.id
175
+ }
176
+
177
+ output "frontend_url" {
178
+ value = local.dns_enabled ? "https://${local.app_domain}" : "https://${aws_cloudfront_distribution.frontend.domain_name}"
179
+ }
@@ -0,0 +1,42 @@
1
+ # Belt application module — defines all shared infrastructure.
2
+ # Environments call this module with different variables.
3
+
4
+ terraform {
5
+ required_providers {
6
+ aws = {
7
+ source = "hashicorp/aws"
8
+ version = "~> 5.0"
9
+ }
10
+ random = {
11
+ source = "hashicorp/random"
12
+ version = "~> 3.0"
13
+ }
14
+ conveyor-belt = {
15
+ source = "stowzilla/conveyor-belt"
16
+ version = "~> 0.0.1"
17
+ }
18
+ }
19
+ }
20
+
21
+ resource "conveyor_belt" "main" {
22
+ provider = conveyor-belt
23
+
24
+ source = "${path.module}/../../routes.tf.rb"
25
+ app_name = var.app_name
26
+ lambda_source_dir = "${path.module}/../../../lambda"
27
+ lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
28
+ frontend_urls = var.frontend_urls
29
+ friendly_errors = var.environment != "prod"
30
+
31
+ custom_domain_name = local.dns_enabled ? local.api_domain : ""
32
+
33
+ # Per-lambda configuration: env vars, timeouts, memory
34
+ lambda_config = {
35
+ api = {
36
+ env_vars = {
37
+ WELCOME_TITLE = "Welcome to ${var.app_name}"
38
+ WELCOME_SUBTITLE = "API Gateway → Lambda → DynamoDB — all connected."
39
+ }
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,24 @@
1
+ output "api_url" {
2
+ description = "API Gateway base URL (custom domain if configured, otherwise default)"
3
+ value = var.domain != "" ? "https://${local.api_domain}" : values(conveyor_belt.main.api_gateway_urls)[0]
4
+ }
5
+
6
+ output "api_urls" {
7
+ description = "Map of gateway names to API Gateway URLs"
8
+ value = conveyor_belt.main.api_gateway_urls
9
+ }
10
+
11
+ output "lambda_functions" {
12
+ description = "Map of action names to Lambda function ARNs"
13
+ value = conveyor_belt.main.lambda_functions
14
+ }
15
+
16
+ output "app_domain" {
17
+ description = "The application domain for this environment"
18
+ value = local.app_domain
19
+ }
20
+
21
+ output "name_servers" {
22
+ description = "NS records to configure at your registrar"
23
+ value = local.dns_enabled ? aws_route53_zone.app[0].name_servers : []
24
+ }
@@ -0,0 +1,27 @@
1
+ variable "app_name" {
2
+ description = "Name of the application"
3
+ type = string
4
+ }
5
+
6
+ variable "environment" {
7
+ description = "Environment name (e.g., dev, prod, staging)"
8
+ type = string
9
+ }
10
+
11
+ variable "aws_region" {
12
+ description = "AWS region"
13
+ type = string
14
+ default = "us-east-1"
15
+ }
16
+
17
+ variable "domain" {
18
+ description = "Root domain for the application (e.g., myapp.com). Leave empty to skip DNS."
19
+ type = string
20
+ default = ""
21
+ }
22
+
23
+ variable "frontend_urls" {
24
+ description = "URLs allowed for CORS frontend access"
25
+ type = list(string)
26
+ default = []
27
+ }
@@ -15,4 +15,10 @@ end
15
15
 
16
16
  # Load lib and models
17
17
  Dir[File.join(__dir__, '..', 'lib', '**', '*.rb')].sort.each { |f| require f }
18
- Dir[File.join(__dir__, '..', 'models', '**', '*.rb')].sort.each { |f| require f }
18
+
19
+ models_dir = File.join(__dir__, '..', 'models')
20
+ all_models = Dir[File.join(models_dir, '**', '*.rb')].sort
21
+ app_record = all_models.find { |f| File.basename(f) == 'application_record.rb' }
22
+ rest = all_models - [app_record].compact
23
+ require app_record if app_record
24
+ rest.each { |f| require f }
@@ -3,7 +3,13 @@ import { Link } from 'react-router-dom'
3
3
 
4
4
  export default function <%= @class_name %>Form({ initialValues = {}, onSubmit, submitLabel = 'Create' }) {
5
5
  <% @fields.each do |field| -%>
6
+ <% if field[:type] == 'boolean' -%>
7
+ const [<%= field[:name] %>, set<%= field[:name].split('_').map(&:capitalize).join %>] = useState(initialValues.<%= field[:name] %> || false)
8
+ <% elsif field[:type] == 'integer' || field[:type] == 'float' -%>
9
+ const [<%= field[:name] %>, set<%= field[:name].split('_').map(&:capitalize).join %>] = useState(initialValues.<%= field[:name] %> ?? '')
10
+ <% else -%>
6
11
  const [<%= field[:name] %>, set<%= field[:name].split('_').map(&:capitalize).join %>] = useState(initialValues.<%= field[:name] %> || '')
12
+ <% end -%>
7
13
  <% end -%>
8
14
 
9
15
  function handleSubmit(e) {
@@ -15,20 +21,70 @@ export default function <%= @class_name %>Form({ initialValues = {}, onSubmit, s
15
21
  <form onSubmit={handleSubmit}>
16
22
  <% @fields.each do |field| -%>
17
23
  <div style={{ marginBottom: '1rem' }}>
18
- <label style={{ display: 'block', marginBottom: '0.25rem' }}><%= field[:name].split('_').map(&:capitalize).join(' ') %></label>
19
- <% if field[:type] == 'text' -%>
24
+ <label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: 500 }}><%= field[:name].split('_').map(&:capitalize).join(' ') %></label>
25
+ <% case field[:type] -%>
26
+ <% when 'text' -%>
20
27
  <textarea value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
21
- rows={6} style={{ width: '100%' }} />
28
+ rows={6} style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
29
+ <% when 'boolean' -%>
30
+ <label style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer', gap: '0.5rem' }}>
31
+ <span style={{
32
+ position: 'relative',
33
+ display: 'inline-block',
34
+ width: '44px',
35
+ height: '24px',
36
+ borderRadius: '12px',
37
+ backgroundColor: <%= field[:name] %> ? '#4f46e5' : '#d1d5db',
38
+ transition: 'background-color 0.2s'
39
+ }}>
40
+ <span style={{
41
+ position: 'absolute',
42
+ top: '2px',
43
+ left: <%= field[:name] %> ? '22px' : '2px',
44
+ width: '20px',
45
+ height: '20px',
46
+ borderRadius: '50%',
47
+ backgroundColor: '#fff',
48
+ transition: 'left 0.2s',
49
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)'
50
+ }} />
51
+ </span>
52
+ <input type="checkbox" checked={<%= field[:name] %>}
53
+ onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.checked)}
54
+ style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }} />
55
+ <span style={{ fontSize: '0.875rem', color: '#6b7280' }}>{<%= field[:name] %> ? 'Yes' : 'No'}</span>
56
+ </label>
57
+ <% when 'date' -%>
58
+ <input type="date" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
59
+ style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
60
+ <% when 'datetime' -%>
61
+ <input type="datetime-local" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
62
+ style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
63
+ <% when 'integer' -%>
64
+ <input type="number" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
65
+ step="1" style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
66
+ <% when 'float' -%>
67
+ <input type="number" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
68
+ step="any" style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
22
69
  <% else -%>
23
70
  <input type="text" value={<%= field[:name] %>} onChange={e => set<%= field[:name].split('_').map(&:capitalize).join %>(e.target.value)}
24
- style={{ width: '100%', padding: '0.5rem' }} />
71
+ style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
25
72
  <% end -%>
26
73
  </div>
27
74
  <% end -%>
28
75
 
29
- <button type="submit">{submitLabel}</button>
30
- {' '}
31
- <Link to="/<%= @resource_name %>">Cancel</Link>
76
+ <div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: '1.5rem' }}>
77
+ <button type="submit" style={{
78
+ padding: '0.5rem 1.5rem',
79
+ backgroundColor: '#4f46e5',
80
+ color: '#fff',
81
+ border: 'none',
82
+ borderRadius: '0.375rem',
83
+ cursor: 'pointer',
84
+ fontWeight: 500
85
+ }}>{submitLabel}</button>
86
+ <Link to="/<%= @resource_name %>" style={{ color: '#4f46e5' }}>Cancel</Link>
87
+ </div>
32
88
  </form>
33
89
  )
34
90
  }
@@ -18,21 +18,52 @@ export default function <%= @class_name %>sIndex() {
18
18
  <main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
19
19
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
20
20
  <h1><%= @class_name %>s</h1>
21
- <Link to="/<%= @resource_name %>/new">New <%= @class_name %></Link>
21
+ <Link to="/<%= @resource_name %>/new" style={{
22
+ padding: '0.5rem 1rem',
23
+ backgroundColor: '#4f46e5',
24
+ color: '#fff',
25
+ borderRadius: '0.375rem',
26
+ textDecoration: 'none',
27
+ fontWeight: 500
28
+ }}>New <%= @class_name %></Link>
22
29
  </div>
23
30
 
24
31
  {<%= @resource_name %>.length === 0 ? (
25
32
  <p>No <%= @resource_name %> yet.</p>
26
33
  ) : (
27
- <ul style={{ listStyle: 'none', padding: 0 }}>
28
- {<%= @resource_name %>.map(<%= @singular_name %> => (
29
- <li key={<%= @singular_name %>.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid #eee' }}>
30
- <Link to={`/<%= @resource_name %>/${<%= @singular_name %>.id}`}>
31
- {<%= @singular_name %>.<%= @fields.first&.dig(:name) || 'id' %>}
32
- </Link>
33
- </li>
34
- ))}
35
- </ul>
34
+ <table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '1rem' }}>
35
+ <thead>
36
+ <tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
37
+ <% @fields.each do |field| -%>
38
+ <th style={{ padding: '0.5rem' }}><%= field[:name].split('_').map(&:capitalize).join(' ') %></th>
39
+ <% end -%>
40
+ <th style={{ padding: '0.5rem' }}></th>
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ {<%= @resource_name %>.map(<%= @singular_name %> => (
45
+ <tr key={<%= @singular_name %>.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
46
+ <% @fields.each do |field| -%>
47
+ <% case field[:type] -%>
48
+ <% when 'boolean' -%>
49
+ <td style={{ padding: '0.5rem' }}>{<%= @singular_name %>.<%= field[:name] %> ? '✓' : '✗'}</td>
50
+ <% when 'date' -%>
51
+ <td style={{ padding: '0.5rem' }}>{<%= @singular_name %>.<%= field[:name] %> ? new Date(<%= @singular_name %>.<%= field[:name] %>).toLocaleDateString() : '—'}</td>
52
+ <% when 'datetime' -%>
53
+ <td style={{ padding: '0.5rem' }}>{<%= @singular_name %>.<%= field[:name] %> ? new Date(<%= @singular_name %>.<%= field[:name] %>).toLocaleString() : '—'}</td>
54
+ <% when 'text' -%>
55
+ <td style={{ padding: '0.5rem', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{<%= @singular_name %>.<%= field[:name] %>}</td>
56
+ <% else -%>
57
+ <td style={{ padding: '0.5rem' }}>{<%= @singular_name %>.<%= field[:name] %>}</td>
58
+ <% end -%>
59
+ <% end -%>
60
+ <td style={{ padding: '0.5rem' }}>
61
+ <Link to={`/<%= @resource_name %>/${<%= @singular_name %>.id}`}>Show</Link>
62
+ </td>
63
+ </tr>
64
+ ))}
65
+ </tbody>
66
+ </table>
36
67
  )}
37
68
  </main>
38
69
  )