eksa-framework 3.3.3 → 3.5.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.
@@ -0,0 +1,110 @@
1
+ module Eksa
2
+ class CmsController < Eksa::Controller
3
+ def index
4
+ return unless require_auth
5
+
6
+ @posts = Eksa::MarkdownPost.all(include_unpublished: true)
7
+ render_internal 'cms/index'
8
+ end
9
+
10
+ def edit
11
+ return unless require_auth
12
+
13
+ slug = params['slug']
14
+ @post = Eksa::MarkdownPost.find(slug)
15
+
16
+ if @post
17
+ render_internal 'cms/edit'
18
+ else
19
+ redirect_to "/cms", notice: "Error: Postingan tidak ditemukan."
20
+ end
21
+ end
22
+
23
+ def update_post
24
+ return unless require_auth
25
+
26
+ slug = params['slug']
27
+ post_path = File.join(Eksa::MarkdownPost::POSTS_DIR, "#{slug}.md")
28
+
29
+ unless File.exist?(post_path)
30
+ return redirect_to "/cms", notice: "Error: Postingan tidak ditemukan."
31
+ end
32
+
33
+ # Existing metadata fetching to preserve unmodified keys like 'date' if not handling them
34
+ # We parse form params
35
+ title = params['title']
36
+ category = params['category']
37
+ author = params['author']
38
+ image = params['image']
39
+ content = params['content']
40
+
41
+ # We'll re-read the original to preserve keys we aren't allowing to edit (like published status and date)
42
+ original_content = File.read(post_path, encoding: 'utf-8')
43
+ metadata = {}
44
+
45
+ if original_content =~ /\A(---\s*\r?\n.*?\r?\n)^(---\s*\r?\n?)/m
46
+ metadata = YAML.safe_load($1, permitted_classes: [Time]) || {}
47
+ end
48
+
49
+ # Update metadata with form values
50
+ metadata['title'] = title
51
+ metadata['category'] = category
52
+ metadata['author'] = author
53
+ metadata['image'] = image unless image.nil? || image.strip.empty?
54
+
55
+ # Dump new YAML
56
+ new_yaml = YAML.dump(metadata)
57
+
58
+ # Join with content
59
+ new_file_content = "#{new_yaml}---\n\n#{content}"
60
+
61
+ # Write changes
62
+ File.write(post_path, new_file_content, encoding: 'utf-8')
63
+
64
+ redirect_to "/cms", notice: "Postingan '#{title}' berhasil diperbarui."
65
+ end
66
+
67
+ def toggle_status
68
+ return unless require_auth
69
+
70
+ slug = params['slug']
71
+ post_path = File.join(Eksa::MarkdownPost::POSTS_DIR, "#{slug}.md")
72
+
73
+ if File.exist?(post_path)
74
+ content = File.read(post_path, encoding: 'utf-8')
75
+
76
+ # Simple string replacement for published status in front matter
77
+ if content.match?(/^published:\s*false/m)
78
+ new_content = content.sub(/^published:\s*false/m, "published: true")
79
+ status = "diaktifkan"
80
+ elsif content.match?(/^published:\s*true/m)
81
+ new_content = content.sub(/^published:\s*true/m, "published: false")
82
+ status = "dinonaktifkan"
83
+ else
84
+ # If no published tag exists, it's implicitly true, so we set it to false
85
+ new_content = content.sub(/\A(---\r?\n)/) { "#{$1}published: false\n" }
86
+ status = "dinonaktifkan"
87
+ end
88
+
89
+ File.write(post_path, new_content)
90
+ redirect_to "/cms", notice: "Postingan #{File.basename(post_path)} berhasil #{status}."
91
+ else
92
+ redirect_to "/cms", notice: "Error: Postingan tidak ditemukan."
93
+ end
94
+ end
95
+
96
+ def delete_post
97
+ return unless require_auth
98
+
99
+ slug = params['slug']
100
+ post_path = File.join(Eksa::MarkdownPost::POSTS_DIR, "#{slug}.md")
101
+
102
+ if File.exist?(post_path)
103
+ File.delete(post_path)
104
+ redirect_to "/cms", notice: "Postingan berhasil dihapus secara permanen."
105
+ else
106
+ redirect_to "/cms", notice: "Error: Postingan tidak ditemukan."
107
+ end
108
+ end
109
+ end
110
+ end
@@ -15,6 +15,23 @@ module Eksa
15
15
  @request.params
16
16
  end
17
17
 
18
+ def session
19
+ @request.session
20
+ end
21
+
22
+ def current_user
23
+ return @current_user if defined?(@current_user)
24
+ @current_user = session['user_id'] ? Eksa::User.find(session['user_id']) : nil
25
+ end
26
+
27
+ def require_auth
28
+ unless current_user
29
+ redirect_to "/auth/login", notice: "Anda harus login untuk mengakses halaman ini."
30
+ return false
31
+ end
32
+ true
33
+ end
34
+
18
35
  def stylesheet_tag(filename)
19
36
  "<link rel='stylesheet' href='/css/#{filename}.css'>"
20
37
  end
@@ -38,22 +55,31 @@ module Eksa
38
55
  variables.each { |k, v| instance_variable_set("@#{k}", v) }
39
56
 
40
57
  content_path = File.expand_path("./app/views/#{template_name}.html.erb")
58
+ internal_content_path = File.expand_path("../../eksa/views/#{template_name}.html.erb", __FILE__)
59
+
41
60
  layout_path = File.expand_path("./app/views/layout.html.erb")
61
+ internal_layout_path = File.expand_path("../../eksa/views/layout.html.erb", __FILE__)
62
+
63
+ actual_content_path = File.exist?(content_path) ? content_path : internal_content_path
64
+ actual_layout_path = File.exist?(layout_path) ? layout_path : internal_layout_path
42
65
 
43
- if File.exist?(content_path)
44
- @content = ERB.new(File.read(content_path)).result(binding)
45
- if File.exist?(layout_path)
46
- ERB.new(File.read(layout_path)).result(binding)
66
+ if File.exist?(actual_content_path)
67
+ @content = ERB.new(File.read(actual_content_path)).result(binding)
68
+ if File.exist?(actual_layout_path)
69
+ ERB.new(File.read(actual_layout_path)).result(binding)
47
70
  else
48
71
  @content
49
72
  end
50
73
  else
51
74
  "<div class='glass' style='padding: 2rem; border-radius: 1rem; color: #ff5555; background: rgba(255,0,0,0.1); backdrop-filter: blur(10px);'>
52
75
  <h2 style='margin-top:0;'>⚠️ View Error</h2>
53
- <p>Template <strong>#{template_name}</strong> tidak ditemukan di:</p>
54
- <code style='display:block; background:rgba(0,0,0,0.2); padding:0.5rem; border-radius:0.5rem;'>#{content_path}</code>
76
+ <p>Template <strong>#{template_name}</strong> tidak ditemukan di app/views atau internal eksa/views.</p>
55
77
  </div>"
56
78
  end
57
79
  end
80
+
81
+ def render_internal(template_name, variables = {})
82
+ render(template_name, variables)
83
+ end
58
84
  end
59
85
  end
@@ -0,0 +1,154 @@
1
+ require 'mongo'
2
+
3
+ module Eksa
4
+ class MongoAdapter
5
+ def initialize(config)
6
+ @uri = config&.[]('uri')
7
+ @client = nil
8
+ end
9
+
10
+ def connection
11
+ return @client if @client
12
+ raise "MongoDB URI not configured in .eksa.json" if @uri.nil? || @uri.empty?
13
+
14
+ # Atlas URIs usually contain the database name, if not, we use 'eksa_db'
15
+ @client = Mongo::Client.new(@uri)
16
+ @client
17
+ end
18
+
19
+ def execute(sql, params = [])
20
+ # Minimal SQL-to-Mongo translator for Eksa models
21
+
22
+ # 1. CREATE TABLE IF NOT EXISTS [table]
23
+ if sql =~ /CREATE TABLE IF NOT EXISTS (\w+)/i
24
+ # Mongo creates collections on the fly
25
+ return []
26
+ end
27
+
28
+ # 2. SELECT * FROM [table] ...
29
+ if sql =~ /SELECT (.*) FROM (\w+)/i
30
+ table_name = $2
31
+ query = {}
32
+
33
+ # Handle WHERE clauses
34
+ if sql =~ /WHERE (.*) ORDER BY/i || sql =~ /WHERE (.*) LIMIT/i || sql =~ /WHERE (.*)$/i
35
+ where_clause = $1
36
+
37
+ # Handle OR + LIKE (specific for Search)
38
+ if where_clause =~ /(\w+) LIKE \? OR (\w+) LIKE \?/i
39
+ field1 = $1
40
+ field2 = $2
41
+ # Extract keyword from params (strip % if present)
42
+ term1 = params[0].to_s.gsub('%', '')
43
+ term2 = params[1].to_s.gsub('%', '')
44
+
45
+ query = {
46
+ '$or' => [
47
+ { field1.to_sym => /#{Regexp.escape(term1)}/i },
48
+ { field2.to_sym => /#{Regexp.escape(term2)}/i }
49
+ ]
50
+ }
51
+ # Handle single LIKE
52
+ elsif where_clause =~ /(\w+) LIKE \?/i
53
+ field = $1
54
+ term = params[0].to_s.gsub('%', '')
55
+ query = { field.to_sym => /#{Regexp.escape(term)}/i }
56
+ # Handle simple field = ?
57
+ elsif where_clause =~ /id = \?/i || where_clause =~ /_id = \?/i
58
+ val = params[0]
59
+ query = { _id: val.is_a?(String) ? BSON::ObjectId.from_string(val) : val }
60
+ elsif where_clause =~ /(\w+) = \?/i
61
+ query = { $1.to_sym => params[0] }
62
+ end
63
+ end
64
+
65
+ view = connection[table_name.to_sym].find(query)
66
+
67
+ # Handle ORDER BY
68
+ if sql =~ /ORDER BY (\w+)(?: (ASC|DESC))?/i
69
+ sort_field = $1
70
+ direction = ($2 || 'ASC').upcase == 'DESC' ? -1 : 1
71
+ # Mongo id maps to _id
72
+ sort_field = '_id' if sort_field == 'id'
73
+ view = view.sort({ sort_field => direction })
74
+ end
75
+
76
+ view = view.limit(1) if sql =~ /LIMIT 1/i
77
+ results = view.to_a
78
+
79
+ return results.map do |doc|
80
+ # Try to make it look like a flat array similar to SQLite row
81
+ # We don't know the exact schema, but we can return the values
82
+ # For Eksa::User we specifically need [id, username, password_hash]
83
+ if table_name == 'eksa_users'
84
+ [doc['_id'].to_s, doc['username'], doc['password_hash'], doc['created_at']]
85
+ else
86
+ # For other models, return a hash-like object if possible,
87
+ # or just the values in order. Since User specifically uses indices,
88
+ # and generic models might use indices or keys, this is tricky.
89
+ # Most Eksa models result in array-of-arrays from SQLite gem.
90
+ doc.values.map { |v| v.is_a?(BSON::ObjectId) ? v.to_s : v }
91
+ end
92
+ end
93
+ end
94
+
95
+ # 3. INSERT INTO [table] ([cols]) VALUES ([vals])
96
+ if sql =~ /INSERT INTO (\w+) \((.*)\) VALUES/i
97
+ table_name = $1
98
+ cols = $2.split(',').map(&:strip)
99
+ doc = {}
100
+ cols.each_with_index do |col, i|
101
+ doc[col.to_sym] = params[i]
102
+ end
103
+ doc[:created_at] ||= Time.now
104
+
105
+ connection[table_name.to_sym].insert_one(doc)
106
+ return []
107
+ end
108
+
109
+ # 4. UPDATE [table] SET [col1] = ?, [col2] = ? WHERE [col] = ?
110
+ if sql =~ /UPDATE (\w+) SET (.*) WHERE (\w+) = \?/i
111
+ table_name = $1
112
+ set_clause = $2
113
+ where_col = $3
114
+
115
+ # Parse set assignments (e.g., "col1 = ?, col2 = ?")
116
+ # We assume the order matches params[0...n-1]
117
+ cols = set_clause.split(',').map { |s| s.split('=').first.strip }
118
+
119
+ set_data = {}
120
+ cols.each_with_index do |col, i|
121
+ set_data[col.to_sym] = params[i]
122
+ end
123
+
124
+ # The last parameter is the WHERE value
125
+ where_val = params.last
126
+ selector = if where_col == 'id'
127
+ { _id: where_val.is_a?(String) ? BSON::ObjectId.from_string(where_val) : where_val }
128
+ else
129
+ { where_col.to_sym => where_val }
130
+ end
131
+
132
+ connection[table_name.to_sym].update_one(selector, { '$set' => set_data })
133
+ return []
134
+ end
135
+
136
+ # 5. DELETE FROM [table] WHERE (\w+) = ?
137
+ if sql =~ /DELETE FROM (\w+) WHERE (\w+) = \?/i
138
+ table_name = $1
139
+ where_col = $2
140
+
141
+ selector = if where_col == 'id'
142
+ { _id: params[0].is_a?(String) ? BSON::ObjectId.from_string(params[0]) : params[0] }
143
+ else
144
+ { where_col.to_sym => params[0] }
145
+ end
146
+
147
+ connection[table_name.to_sym].delete_one(selector)
148
+ return []
149
+ end
150
+
151
+ raise "Unsupported SQL query for MongoAdapter: #{sql}"
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,26 @@
1
+ require 'sqlite3'
2
+ require 'fileutils'
3
+
4
+ module Eksa
5
+ class SqliteAdapter
6
+ def initialize(config)
7
+ @path = config&.[]('path') || File.expand_path("../../../db/eksa_app.db", __FILE__)
8
+ ensure_db_dir
9
+ end
10
+
11
+ def connection
12
+ @connection ||= SQLite3::Database.new(@path)
13
+ end
14
+
15
+ def execute(sql, params = [])
16
+ connection.execute(sql, params)
17
+ end
18
+
19
+ private
20
+
21
+ def ensure_db_dir
22
+ db_dir = File.dirname(@path)
23
+ FileUtils.mkdir_p(db_dir) unless Dir.exist?(db_dir)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,47 @@
1
+ require 'json'
2
+ begin
3
+ require 'dotenv'
4
+ Dotenv.load if File.exist?('.env')
5
+ rescue LoadError
6
+ # Dotenv not available, rely on existing ENV
7
+ end
8
+
9
+ module Eksa
10
+ module Database
11
+ def self.adapter
12
+ @adapter ||= create_adapter
13
+ end
14
+
15
+ def self.reset_adapter
16
+ @adapter = nil
17
+ end
18
+
19
+ def self.create_adapter
20
+ config = load_config
21
+ type = config.dig('database', 'type') || 'sqlite'
22
+
23
+ case type
24
+ when 'mongo', 'mongodb'
25
+ require_relative 'database/mongo_adapter'
26
+ mongo_config = config.dig('database', 'mongodb') || {}
27
+
28
+ # Prioritize Environment Variable for Security
29
+ env_uri = ENV['EKSA_MONGODB_URI'] || ENV['MONGODB_URI']
30
+ mongo_config['uri'] = env_uri if env_uri
31
+
32
+ MongoAdapter.new(mongo_config)
33
+ else
34
+ require_relative 'database/sqlite_adapter'
35
+ SqliteAdapter.new(config.dig('database', 'sqlite'))
36
+ end
37
+ end
38
+
39
+ def self.load_config
40
+ config_path = File.join(Dir.pwd, '.eksa.json')
41
+ return {} unless File.exist?(config_path)
42
+ JSON.parse(File.read(config_path))
43
+ rescue
44
+ {}
45
+ end
46
+ end
47
+ end
@@ -10,16 +10,19 @@ module Eksa
10
10
 
11
11
  POSTS_DIR = "_posts"
12
12
 
13
- def self.all
13
+ def self.all(include_unpublished: false)
14
14
  return [] unless Dir.exist?(POSTS_DIR)
15
15
 
16
- Dir.glob(File.join(POSTS_DIR, "*.md")).map do |file|
16
+ posts = Dir.glob(File.join(POSTS_DIR, "*.md")).map do |file|
17
17
  new(file)
18
- end.sort_by { |p| p.date }.reverse
18
+ end
19
+
20
+ posts.reject! { |p| !p.published? } unless include_unpublished
21
+ posts.sort_by { |p| p.date }.reverse
19
22
  end
20
23
 
21
24
  def self.find(slug)
22
- all.find { |p| p.slug == slug }
25
+ all(include_unpublished: true).find { |p| p.slug == slug && p.published? }
23
26
  end
24
27
 
25
28
  def initialize(file_path)
@@ -48,6 +51,10 @@ module Eksa
48
51
  @metadata['image'] || ""
49
52
  end
50
53
 
54
+ def published?
55
+ @metadata.key?('published') ? @metadata['published'] : true
56
+ end
57
+
51
58
  def body_html
52
59
  Kramdown::Document.new(@content, input: 'GFM').to_html
53
60
  end
data/lib/eksa/model.rb CHANGED
@@ -1,24 +1,10 @@
1
- require 'sqlite3'
2
- require 'fileutils'
1
+ require_relative 'database'
3
2
 
4
3
  module Eksa
5
4
  class Model
6
- class << self
7
- attr_accessor :database_path
8
- end
9
-
10
5
  def self.db
11
- path = database_path || default_db_path
12
- db_dir = File.dirname(path)
13
- FileUtils.mkdir_p(db_dir) unless Dir.exist?(db_dir)
14
-
15
- @db ||= SQLite3::Database.new(path)
16
6
  ensure_schema
17
- @db
18
- end
19
-
20
- def self.default_db_path
21
- File.expand_path("../../db/eksa_app.db", __dir__)
7
+ Eksa::Database.adapter
22
8
  end
23
9
 
24
10
  def self.ensure_schema
data/lib/eksa/user.rb ADDED
@@ -0,0 +1,47 @@
1
+ module Eksa
2
+ class User < Eksa::Model
3
+ require 'bcrypt'
4
+
5
+ def self.setup_schema
6
+ db.execute <<~SQL
7
+ CREATE TABLE IF NOT EXISTS eksa_users (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ username TEXT UNIQUE,
10
+ password_hash TEXT,
11
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
12
+ )
13
+ SQL
14
+ end
15
+
16
+ def self.all
17
+ db.execute("SELECT id, username FROM eksa_users")
18
+ end
19
+
20
+ def self.create(username, password)
21
+ hash = BCrypt::Password.create(password)
22
+ db.execute("INSERT INTO eksa_users (username, password_hash) VALUES (?, ?)", [username, hash])
23
+ end
24
+
25
+ def self.update_password(username, new_password)
26
+ hash = BCrypt::Password.create(new_password)
27
+ db.execute("UPDATE eksa_users SET password_hash = ? WHERE username = ?", [hash, username])
28
+ end
29
+
30
+ def self.authenticate(username, password)
31
+ user_data = db.execute("SELECT id, username, password_hash FROM eksa_users WHERE username = ? LIMIT 1", [username]).first
32
+ return nil unless user_data
33
+
34
+ stored_hash = BCrypt::Password.new(user_data[2])
35
+ if stored_hash == password
36
+ { id: user_data[0], username: user_data[1] }
37
+ else
38
+ nil
39
+ end
40
+ end
41
+
42
+ def self.find(id)
43
+ user_data = db.execute("SELECT id, username FROM eksa_users WHERE id = ? LIMIT 1", [id]).first
44
+ user_data ? { id: user_data[0], username: user_data[1] } : nil
45
+ end
46
+ end
47
+ end
data/lib/eksa/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Eksa
2
- VERSION = "3.3.3"
2
+ VERSION = "3.5.0"
3
3
  end
@@ -0,0 +1,44 @@
1
+ <div class="glass max-w-md mx-auto rounded-3xl p-8 animate__animated animate__fadeIn relative overflow-hidden">
2
+ <div class="absolute inset-0 bg-indigo-500/10 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div>
3
+
4
+ <div class="relative z-10 text-center mb-8">
5
+ <div class="w-16 h-16 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-white/20 shadow-xl">
6
+ <i data-lucide="lock" class="w-8 h-8 text-indigo-300"></i>
7
+ </div>
8
+ <h1 class="text-3xl font-extrabold text-white tracking-tight mb-2">Masuk CMS</h1>
9
+ <p class="text-white/50 text-sm">Masuk untuk mengelola postingan blog Anda.</p>
10
+ </div>
11
+
12
+ <% if @flash && @flash[:notice] %>
13
+ <div class="relative z-10 mb-6 p-4 rounded-xl bg-red-500/20 border border-red-500/30 text-red-200 text-sm text-center font-medium animate__animated animate__shakeX">
14
+ <i data-lucide="alert-circle" class="w-4 h-4 inline-block mr-1 -mt-0.5"></i>
15
+ <%= @flash[:notice] %>
16
+ </div>
17
+ <% end %>
18
+
19
+ <form action="/auth/process_login" method="post" class="relative z-10 space-y-4">
20
+ <div>
21
+ <label class="block text-xs font-bold text-indigo-300 uppercase tracking-widest mb-2">Username</label>
22
+ <input type="text" name="username" required
23
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition placeholder-white/20"
24
+ placeholder="Masukkan username...">
25
+ </div>
26
+
27
+ <div>
28
+ <label class="block text-xs font-bold text-indigo-300 uppercase tracking-widest mb-2">Password</label>
29
+ <input type="password" name="password" required
30
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition placeholder-white/20"
31
+ placeholder="••••••••">
32
+ </div>
33
+
34
+ <button type="submit" class="w-full mt-4 bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 px-4 rounded-xl transition duration-300 shadow-lg shadow-indigo-600/30 flex items-center justify-center gap-2">
35
+ <i data-lucide="log-in" class="w-4 h-4"></i> Sign In
36
+ </button>
37
+ </form>
38
+
39
+ <div class="relative z-10 mt-6 text-center">
40
+ <p class="text-white/40 text-xs">Belum punya akun admin?
41
+ <a href="/auth/register" class="text-indigo-400 hover:text-white transition font-medium">Register di sini</a>
42
+ </p>
43
+ </div>
44
+ </div>
@@ -0,0 +1,53 @@
1
+ <div class="glass max-w-md mx-auto rounded-3xl p-8 animate__animated animate__fadeIn relative overflow-hidden">
2
+ <div class="absolute inset-0 bg-emerald-500/10 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div>
3
+
4
+ <div class="relative z-10 text-center mb-8">
5
+ <div class="w-16 h-16 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-white/20 shadow-xl">
6
+ <i data-lucide="user-plus" class="w-8 h-8 text-emerald-300"></i>
7
+ </div>
8
+ <h1 class="text-3xl font-extrabold text-white tracking-tight mb-2">Registrasi Admin</h1>
9
+ <p class="text-white/50 text-sm">Buat akun untuk mengelola Eksa Framework.</p>
10
+ </div>
11
+
12
+ <% if @admin_exists %>
13
+ <div class="relative z-10 text-center bg-emerald-500/20 border border-emerald-500/30 rounded-xl p-6 text-emerald-200">
14
+ <i data-lucide="badge-check" class="w-12 h-12 mx-auto mb-3 text-emerald-400"></i>
15
+ <h2 class="text-lg font-bold mb-2 text-white">Akun Admin Sudah Dibuat</h2>
16
+ <p class="text-sm opacity-80 text-white/70">Hanya satu akun admin yang diizinkan untuk keamanan CMS.</p>
17
+ </div>
18
+ <% else %>
19
+
20
+ <% if @flash && @flash[:notice] %>
21
+ <div class="relative z-10 mb-6 p-4 rounded-xl bg-red-500/20 border border-red-500/30 text-red-200 text-sm text-center font-medium animate__animated animate__shakeX">
22
+ <i data-lucide="alert-circle" class="w-4 h-4 inline-block mr-1 -mt-0.5"></i>
23
+ <%= @flash[:notice] %>
24
+ </div>
25
+ <% end %>
26
+
27
+ <form action="/auth/process_register" method="post" class="relative z-10 space-y-4">
28
+ <div>
29
+ <label class="block text-xs font-bold text-emerald-300 uppercase tracking-widest mb-2">Username</label>
30
+ <input type="text" name="username" required
31
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition placeholder-white/20"
32
+ placeholder="Pilih username...">
33
+ </div>
34
+
35
+ <div>
36
+ <label class="block text-xs font-bold text-emerald-300 uppercase tracking-widest mb-2">Password</label>
37
+ <input type="password" name="password" required minlength="6"
38
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition placeholder-white/20"
39
+ placeholder="Minimal 6 karakter">
40
+ </div>
41
+
42
+ <button type="submit" class="w-full mt-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-3 px-4 rounded-xl transition duration-300 shadow-lg shadow-emerald-600/30 flex items-center justify-center gap-2">
43
+ <i data-lucide="shield-check" class="w-4 h-4"></i> Buat Akun
44
+ </button>
45
+ </form>
46
+ <% end %>
47
+
48
+ <div class="relative z-10 mt-6 text-center">
49
+ <p class="text-white/40 text-xs">Sudah punya akun?
50
+ <a href="/auth/login" class="text-emerald-400 hover:text-white transition font-medium">Login sekarang</a>
51
+ </p>
52
+ </div>
53
+ </div>