eksa-framework 3.3.2 → 3.4.3

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.
data/exe/eksa CHANGED
@@ -10,7 +10,9 @@ def usage
10
10
  puts " eksa init - Inisialisasi project baru"
11
11
  puts " eksa g controller NAME - Generate controller baru"
12
12
  puts " eksa g model NAME - Generate model baru"
13
- puts " eksa g post TITLE - Generate blog post markdown baru"
13
+ puts " eksa g post TITLE - Post baru [args: --category, --author, --image]"
14
+ puts " eksa feature ACTION F - Toggle fitur: action=enable/disable, F=cms/auth"
15
+ puts " eksa reset-password USR PWD - Reset password akun admin"
14
16
  puts " eksa run - Jalankan server aplikasi"
15
17
  puts "-----------------------------\n"
16
18
  end
@@ -115,16 +117,23 @@ def generate_model(name)
115
117
  puts " [OK] Created #{file_path}"
116
118
  end
117
119
 
118
- def generate_post(title)
120
+ def generate_post(title, options = {})
119
121
  FileUtils.mkdir_p("_posts")
120
122
  date = Time.now.strftime("%Y-%m-%d")
121
123
  slug = title.downcase.strip.gsub(' ', '-').gsub(/[^\w-]/, '')
122
124
  filename = "_posts/#{date}-#{slug}.md"
123
125
 
126
+ author = options[:author] || ""
127
+ category = options[:category] || "Uncategorized"
128
+ image = options[:image] || ""
129
+
124
130
  content = <<~MARKDOWN
125
131
  ---
126
132
  title: "#{title}"
127
133
  date: #{Time.now.strftime("%Y-%m-%d %H:%M:%S")}
134
+ author: "#{author}"
135
+ category: "#{category}"
136
+ image: "#{image}"
128
137
  ---
129
138
 
130
139
  Tulis konten blog Anda di sini...
@@ -135,6 +144,67 @@ def generate_post(title)
135
144
  puts " [OK] Created #{filename}"
136
145
  end
137
146
 
147
+ def toggle_feature(action, feature)
148
+ valid_features = ['cms', 'auth']
149
+
150
+ unless valid_features.include?(feature)
151
+ puts "❌ Error: Fitur tidak dikenali. Pilihan: #{valid_features.join(', ')}"
152
+ return
153
+ end
154
+
155
+ unless ['enable', 'disable'].include?(action)
156
+ puts "❌ Error: Aksi tidak dikenali. Gunakan 'enable' atau 'disable'."
157
+ return
158
+ end
159
+
160
+ config_path = File.join(Dir.pwd, '.eksa.json')
161
+
162
+ begin
163
+ config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : { 'cms' => false, 'auth' => false }
164
+ rescue JSON::ParserError
165
+ config = { 'cms' => false, 'auth' => false }
166
+ end
167
+
168
+ config[feature] = (action == 'enable')
169
+
170
+ File.write(config_path, JSON.pretty_generate(config))
171
+
172
+ status_color = action == 'enable' ? "✅ Aktif" : "🚫 Nonaktif"
173
+ puts "⚙️ Fitur '#{feature}' berhasil di #{status_color}."
174
+
175
+ if action == 'enable' && feature == 'cms'
176
+ puts " Tip: Pastikan Anda juga mengaktifkan 'auth' agar CMS Anda aman."
177
+ end
178
+ end
179
+
180
+ def reset_password(username, password)
181
+ require 'bcrypt'
182
+ require 'sqlite3'
183
+ db_path = File.join(Dir.pwd, 'db', 'eksa_app.db')
184
+
185
+ unless File.exist?(db_path)
186
+ puts "❌ Error: Database tidak ditemukan. Pastikan project sudah diinisialisasi."
187
+ return
188
+ end
189
+
190
+ db = SQLite3::Database.new(db_path)
191
+
192
+ begin
193
+ user = db.execute("SELECT id FROM eksa_users WHERE username = ? LIMIT 1", [username]).first
194
+ rescue SQLite3::SQLException
195
+ puts "❌ Error: Tabel user tidak ditemukan. Apakah Anda sudah mengaktifkan auth?"
196
+ return
197
+ end
198
+
199
+ if user
200
+ hash = BCrypt::Password.create(password)
201
+ db.execute("UPDATE eksa_users SET password_hash = ? WHERE username = ?", [hash, username])
202
+ puts "✅ Akses dipulihkan! Password untuk admin '#{username}' berhasil diubah."
203
+ else
204
+ puts "❌ Error: User '#{username}' tidak ditemukan dalam database."
205
+ end
206
+ end
207
+
138
208
  def start_server
139
209
  if File.exist?('config.ru')
140
210
  puts "🚀 Memulai Eksa Framework Server..."
@@ -160,10 +230,35 @@ when 'g', 'generate'
160
230
  elsif subcommand == 'model' && name
161
231
  generate_model(name)
162
232
  elsif subcommand == 'post' && name
163
- generate_post(name)
233
+ options = {}
234
+ while (arg = ARGV.shift)
235
+ case arg
236
+ when '--category' then options[:category] = ARGV.shift
237
+ when '--author' then options[:author] = ARGV.shift
238
+ when '--image' then options[:image] = ARGV.shift
239
+ end
240
+ end
241
+ generate_post(name, options)
242
+ else
243
+ usage
244
+ end
245
+ when 'feature'
246
+ action = ARGV.shift
247
+ feature = ARGV.shift
248
+ if action && feature
249
+ require 'json'
250
+ toggle_feature(action, feature)
164
251
  else
165
252
  usage
166
253
  end
254
+ when 'reset-password'
255
+ username = ARGV.shift
256
+ password = ARGV.shift
257
+ if username && password
258
+ reset_password(username, password)
259
+ else
260
+ puts "❌ Error: Argumen tidak lengkap. Gunakan: eksa reset-password USERNAME NEW_PASSWORD"
261
+ end
167
262
  else
168
263
  usage
169
264
  end
@@ -0,0 +1,50 @@
1
+ module Eksa
2
+ class AuthController < Eksa::Controller
3
+ def login
4
+ return redirect_to "/cms" if current_user
5
+ render_internal 'auth/login'
6
+ end
7
+
8
+ def register
9
+ return redirect_to "/cms" if current_user
10
+ @admin_exists = Eksa::User.all.any?
11
+ render_internal 'auth/register'
12
+ end
13
+
14
+ def process_login
15
+ username = params['username']
16
+ password = params['password']
17
+
18
+ user = Eksa::User.authenticate(username, password)
19
+ if user
20
+ session['user_id'] = user[:id]
21
+ redirect_to "/cms", notice: "Selamat datang kembali, #{username}!"
22
+ else
23
+ redirect_to "/auth/login", notice: "Username atau password salah."
24
+ end
25
+ end
26
+
27
+ def process_register
28
+ if Eksa::User.all.any?
29
+ return redirect_to "/auth/login", notice: "Registrasi ditutup. Hanya satu admin yang diizinkan."
30
+ end
31
+
32
+ username = params['username']
33
+ password = params['password']
34
+
35
+ if username && !username.empty? && password && password.length >= 6
36
+ Eksa::User.create(username, password)
37
+ user = Eksa::User.authenticate(username, password)
38
+ session['user_id'] = user[:id]
39
+ redirect_to "/cms", notice: "Akun berhasil dibuat. Selamat datang!"
40
+ else
41
+ redirect_to "/auth/register", notice: "Data tidak valid. Password minimal 6 karakter."
42
+ end
43
+ end
44
+
45
+ def logout
46
+ session.delete('user_id')
47
+ redirect_to "/", notice: "Anda telah berhasil logout."
48
+ end
49
+ end
50
+ end
@@ -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
@@ -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)
@@ -36,6 +39,22 @@ module Eksa
36
39
  @metadata['date'] || File.mtime(File.join(POSTS_DIR, @filename))
37
40
  end
38
41
 
42
+ def author
43
+ @metadata['author'] || ""
44
+ end
45
+
46
+ def category
47
+ @metadata['category'] || "Uncategorized"
48
+ end
49
+
50
+ def image
51
+ @metadata['image'] || ""
52
+ end
53
+
54
+ def published?
55
+ @metadata.key?('published') ? @metadata['published'] : true
56
+ end
57
+
39
58
  def body_html
40
59
  Kramdown::Document.new(@content, input: 'GFM').to_html
41
60
  end
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.2"
2
+ VERSION = "3.4.3"
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>
@@ -0,0 +1,63 @@
1
+ <div class="glass max-w-4xl mx-auto rounded-3xl p-10 text-white animate__animated animate__zoomIn animate__faster relative overflow-hidden">
2
+ <div class="absolute inset-0 bg-yellow-500/10 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div>
3
+
4
+ <div class="relative z-10 flex items-center justify-between mb-8 pb-6 border-b border-white/10">
5
+ <div class="flex items-center gap-4">
6
+ <div class="w-14 h-14 bg-yellow-500/20 rounded-2xl flex items-center justify-center border border-yellow-500/30 shadow-xl shadow-yellow-500/20">
7
+ <i data-lucide="edit-3" class="w-7 h-7 text-yellow-300"></i>
8
+ </div>
9
+ <div>
10
+ <h1 class="text-3xl font-extrabold tracking-tight">Edit <span class="text-yellow-300">Post</span></h1>
11
+ <p class="text-white/50 text-sm mt-1">Mengedit <%= @post.filename %></p>
12
+ </div>
13
+ </div>
14
+ <a href="/cms" class="px-4 py-2 bg-white/5 hover:bg-white/10 rounded-xl transition border border-white/10 text-sm font-medium flex items-center gap-2">
15
+ <i data-lucide="arrow-left" class="w-4 h-4"></i> Kembali
16
+ </a>
17
+ </div>
18
+
19
+ <form action="/cms/update/<%= @post.slug %>" method="post" class="relative z-10 space-y-6">
20
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
21
+ <div class="space-y-2">
22
+ <label class="block text-xs font-bold text-yellow-300 uppercase tracking-widest">Judul Postingan</label>
23
+ <input type="text" name="title" value="<%= @post.title %>" required
24
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-yellow-500 focus:ring-1 focus:ring-yellow-500 transition">
25
+ </div>
26
+
27
+ <div class="space-y-2">
28
+ <label class="block text-xs font-bold text-yellow-300 uppercase tracking-widest">Kategori</label>
29
+ <input type="text" name="category" value="<%= @post.category %>" 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-yellow-500 focus:ring-1 focus:ring-yellow-500 transition">
31
+ </div>
32
+
33
+ <div class="space-y-2">
34
+ <label class="block text-xs font-bold text-yellow-300 uppercase tracking-widest">Penulis (Author)</label>
35
+ <input type="text" name="author" value="<%= @post.author %>" required
36
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-yellow-500 focus:ring-1 focus:ring-yellow-500 transition">
37
+ </div>
38
+
39
+ <div class="space-y-2">
40
+ <label class="block text-xs font-bold text-yellow-300 uppercase tracking-widest">Image Thumbnail URL</label>
41
+ <input type="text" name="image" value="<%= @post.image %>"
42
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-yellow-500 focus:ring-1 focus:ring-yellow-500 transition"
43
+ placeholder="Contoh: header.jpg atau kosongkan">
44
+ </div>
45
+ </div>
46
+
47
+ <div class="space-y-2 pt-4">
48
+ <label class="block text-xs font-bold text-yellow-300 uppercase tracking-widest">Konten (Markdown)</label>
49
+ <div class="relative group">
50
+ <textarea name="content" required rows="15"
51
+ class="w-full bg-black/40 border border-white/10 rounded-xl px-5 py-4 text-white focus:outline-none focus:border-yellow-500 focus:ring-1 focus:ring-yellow-500 font-mono text-sm leading-relaxed transition"><%= @post.content.strip %></textarea>
52
+ <!-- Decorative subtle icon -->
53
+ <i data-lucide="file-text" class="absolute bottom-4 right-4 w-12 h-12 text-white/5 pointer-events-none transition group-hover:text-yellow-500/10"></i>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="pt-6 border-t border-white/5 flex justify-end">
58
+ <button type="submit" class="bg-yellow-500 hover:bg-yellow-400 text-yellow-950 font-bold py-3 px-8 rounded-xl transition duration-300 shadow-xl shadow-yellow-500/20 flex items-center gap-2">
59
+ <i data-lucide="save" class="w-5 h-5"></i> Simpan Perubahan
60
+ </button>
61
+ </div>
62
+ </form>
63
+ </div>