one-for-all-framework 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.env.example +4 -0
- data/Dockerfile +19 -0
- data/Gemfile +20 -0
- data/LICENSE +21 -0
- data/Procfile +1 -0
- data/README.md +99 -0
- data/app/controllers/api_controller.rb +19 -0
- data/app/controllers/application_controller.rb +77 -0
- data/app/controllers/auth_controller.rb +35 -0
- data/app/controllers/dashboard_controller.rb +41 -0
- data/app/controllers/pages_controller.rb +49 -0
- data/app/controllers/posts_controller.rb +48 -0
- data/app/controllers/projects_controller.rb +48 -0
- data/app/helpers/cloudinary_helper.rb +14 -0
- data/app/middleware/auth_middleware.rb +52 -0
- data/app/middleware/csrf_middleware.rb +27 -0
- data/app/models/page.rb +8 -0
- data/app/models/post.rb +8 -0
- data/app/models/project.rb +7 -0
- data/app/models/user.rb +83 -0
- data/app/views/blog_home.erb +70 -0
- data/app/views/cms/pages_form.erb +156 -0
- data/app/views/cms/pages_index.erb +104 -0
- data/app/views/cms/posts_form.erb +182 -0
- data/app/views/cms/posts_index.erb +100 -0
- data/app/views/cms/projects_form.erb +176 -0
- data/app/views/cms/projects_index.erb +96 -0
- data/app/views/dashboard.erb +122 -0
- data/app/views/docs.erb +140 -0
- data/app/views/error.erb +159 -0
- data/app/views/index.erb +70 -0
- data/app/views/layout.erb +251 -0
- data/app/views/login.erb +45 -0
- data/app/views/page.erb +21 -0
- data/app/views/portfolio.erb +73 -0
- data/app/views/post.erb +33 -0
- data/app/views/posts/hello_world.erb +3 -0
- data/app/views/project.erb +39 -0
- data/bin/ofa +499 -0
- data/config/boot.rb +134 -0
- data/config/database.json +4 -0
- data/config/database.rb +142 -0
- data/config/features.json +7 -0
- data/config/locales/en.json +6 -0
- data/config/locales/id.json +6 -0
- data/config/routes.rb +87 -0
- data/config.eks +11 -0
- data/config.ru +11 -0
- data/db/data.sqlite3 +0 -0
- data/db/development.sqlite3 +0 -0
- data/ofa +2 -0
- data/public/css/cms.css +134 -0
- data/public/images/logo.jpg +0 -0
- data/public/images/logo.png +0 -0
- metadata +259 -0
data/app/models/user.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require 'bcrypt'
|
|
2
|
+
|
|
3
|
+
class User < Sequel::Model
|
|
4
|
+
include BCrypt
|
|
5
|
+
|
|
6
|
+
# Password strength rules
|
|
7
|
+
MIN_PASSWORD_LENGTH = 8
|
|
8
|
+
PASSWORD_RULES = [
|
|
9
|
+
[/[A-Z]/, "must contain at least one uppercase letter"],
|
|
10
|
+
[/[0-9]/, "must contain at least one number"],
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
# Helper to check if we are in Mongo mode
|
|
14
|
+
def self.mongo?
|
|
15
|
+
defined?(MONGO_CLIENT) && MONGO_CLIENT
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Override find for Mongo support
|
|
19
|
+
def self.find(params)
|
|
20
|
+
if mongo?
|
|
21
|
+
doc = MONGO_CLIENT[:users].find(params).first
|
|
22
|
+
return nil unless doc
|
|
23
|
+
user = User.new
|
|
24
|
+
# Set virtual ID from Mongo _id
|
|
25
|
+
user.values[:id] = doc[:_id].to_s if doc[:_id]
|
|
26
|
+
user.set(
|
|
27
|
+
username: doc[:username],
|
|
28
|
+
password_hash: doc[:password_hash],
|
|
29
|
+
avatar_url: doc[:avatar_url]
|
|
30
|
+
)
|
|
31
|
+
user
|
|
32
|
+
else
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Override save for Mongo support
|
|
38
|
+
def save
|
|
39
|
+
if self.class.mongo?
|
|
40
|
+
data = {
|
|
41
|
+
username: self.username,
|
|
42
|
+
password_hash: self.password_hash,
|
|
43
|
+
avatar_url: self.avatar_url,
|
|
44
|
+
updated_at: Time.now
|
|
45
|
+
}
|
|
46
|
+
MONGO_CLIENT[:users].update_one({ username: self.username }, { "$set" => data }, { upsert: true })
|
|
47
|
+
self
|
|
48
|
+
else
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def password
|
|
54
|
+
@password ||= Password.new(password_hash)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def password=(new_password)
|
|
58
|
+
errors = validate_password_strength(new_password)
|
|
59
|
+
raise ArgumentError, "Weak password: #{errors.join(', ')}" if errors.any?
|
|
60
|
+
|
|
61
|
+
@password = Password.create(new_password)
|
|
62
|
+
self.password_hash = @password
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.authenticate(username, password)
|
|
66
|
+
user = self.find(username: username)
|
|
67
|
+
return user if user && user.password == password
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Returns an array of error messages. Empty = valid.
|
|
74
|
+
def validate_password_strength(pwd)
|
|
75
|
+
return ["cannot be empty"] if pwd.nil? || pwd.empty?
|
|
76
|
+
errors = []
|
|
77
|
+
errors << "must be at least #{MIN_PASSWORD_LENGTH} characters" if pwd.length < MIN_PASSWORD_LENGTH
|
|
78
|
+
PASSWORD_RULES.each do |regex, message|
|
|
79
|
+
errors << message unless pwd.match?(regex)
|
|
80
|
+
end
|
|
81
|
+
errors
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<div class="header-section anime-element" style="margin-bottom: 3rem;">
|
|
2
|
+
<h1>Blog Feed</h1>
|
|
3
|
+
<p>Thoughts, tutorials, and insights from the edge of technology.</p>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="blog-list grid grid-cols-1 gap-6 text-left">
|
|
7
|
+
<% if @posts.empty? %>
|
|
8
|
+
<div class="glass-card py-20 text-center">
|
|
9
|
+
<div class="w-20 h-20 bg-primary/10 text-primary rounded-3xl flex items-center justify-center mx-auto mb-6 text-3xl">
|
|
10
|
+
<i class="fas fa-feather-pointed"></i>
|
|
11
|
+
</div>
|
|
12
|
+
<p class="text-xl font-bold text-slate-400">No articles published yet.</p>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
<% @posts.each do |post| %>
|
|
17
|
+
<article class="blog-item glass-card !p-0 flex flex-col md:flex-row group cursor-pointer" onclick="location.href='/posts/<%= post.slug %>'">
|
|
18
|
+
<div class="card-glow"></div>
|
|
19
|
+
|
|
20
|
+
<div class="md:w-1/3 h-48 md:h-auto bg-gradient-to-br from-primary/20 to-secondary/20 relative overflow-hidden">
|
|
21
|
+
<% if post.image_url && !post.image_url.empty? %>
|
|
22
|
+
<img src="<%= post.image_url %>" alt="<%= post.title %>" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700">
|
|
23
|
+
<% else %>
|
|
24
|
+
<div class="absolute inset-0 flex items-center justify-center text-primary/30 group-hover:scale-110 transition-transform duration-700">
|
|
25
|
+
<i class="fas fa-file-alt text-6xl"></i>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="p-8 flex-1 relative z-10">
|
|
31
|
+
<div class="flex items-center gap-4 mb-4">
|
|
32
|
+
<span class="badge-premium !mb-0"><%= post.category ? post.category : 'General' %></span>
|
|
33
|
+
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest"><%= post.created_at.strftime('%B %d, %Y') %></span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<h3 class="text-2xl font-black mb-4 tracking-tight group-hover:text-primary transition-colors"><%= post.title %></h3>
|
|
37
|
+
|
|
38
|
+
<div class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed mb-6">
|
|
39
|
+
<%= markdown(post.content.split("\n")[0..1].join("\n") + "...") %>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div class="flex items-center gap-2 text-primary font-bold text-sm">
|
|
43
|
+
Read Article <i class="fas fa-chevron-right text-[10px] group-hover:translate-x-1 transition-transform"></i>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</article>
|
|
47
|
+
<% end %>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<script>
|
|
51
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
52
|
+
anime({
|
|
53
|
+
targets: '.header-section',
|
|
54
|
+
translateY: [-20, 0],
|
|
55
|
+
opacity: [0, 1],
|
|
56
|
+
duration: 800,
|
|
57
|
+
easing: 'easeOutCubic',
|
|
58
|
+
delay: 400
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
anime({
|
|
62
|
+
targets: '.blog-item',
|
|
63
|
+
translateX: [-50, 0],
|
|
64
|
+
opacity: [0, 1],
|
|
65
|
+
duration: 800,
|
|
66
|
+
delay: anime.stagger(200, {start: 600}),
|
|
67
|
+
easing: 'easeOutExpo'
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
</script>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<div class="flex flex-col md:flex-row gap-8 items-start">
|
|
2
|
+
<!-- CMS Sidebar -->
|
|
3
|
+
<aside class="glass-panel w-full md:w-64 p-6 sticky top-24">
|
|
4
|
+
<div class="flex items-center gap-2 mb-8 px-2">
|
|
5
|
+
<div class="w-6 h-6 bg-primary rounded flex items-center justify-center text-white text-xs">
|
|
6
|
+
<i class="fas fa-user-shield"></i>
|
|
7
|
+
</div>
|
|
8
|
+
<h3 class="font-black tracking-tight text-lg">Admin<span class="text-primary">Panel</span></h3>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<nav class="space-y-2">
|
|
12
|
+
<a href="/dashboard" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path == '/dashboard' ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
13
|
+
<i class="fas fa-chart-pie w-5"></i>
|
|
14
|
+
<span class="font-semibold">Dashboard</span>
|
|
15
|
+
</a>
|
|
16
|
+
|
|
17
|
+
<a href="/dashboard/pages" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path.start_with?('/dashboard/pages') ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
18
|
+
<i class="fas fa-file-lines w-5"></i>
|
|
19
|
+
<span class="font-semibold">Pages</span>
|
|
20
|
+
</a>
|
|
21
|
+
|
|
22
|
+
<% if FEATURES_CONFIG['type'] == 'blog' %>
|
|
23
|
+
<a href="/dashboard/posts" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path.start_with?('/dashboard/posts') ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
24
|
+
<i class="fas fa-pen-nib w-5"></i>
|
|
25
|
+
<span class="font-semibold">Blog Posts</span>
|
|
26
|
+
</a>
|
|
27
|
+
<% elsif FEATURES_CONFIG['type'] == 'portfolio' %>
|
|
28
|
+
<a href="/dashboard/projects" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path.start_with?('/dashboard/projects') ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
29
|
+
<i class="fas fa-palette w-5"></i>
|
|
30
|
+
<span class="font-semibold">Projects</span>
|
|
31
|
+
</a>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<div class="h-px bg-white/5 my-4"></div>
|
|
35
|
+
|
|
36
|
+
<form action="/logout" method="POST">
|
|
37
|
+
<%= csrf_tag %>
|
|
38
|
+
<button type="submit" class="w-full flex items-center gap-3 px-4 py-3 rounded-2xl text-red-400 hover:bg-red-500/10 transition-all duration-300 font-semibold">
|
|
39
|
+
<i class="fas fa-door-open w-5"></i>
|
|
40
|
+
<span>Logout</span>
|
|
41
|
+
</button>
|
|
42
|
+
</form>
|
|
43
|
+
</nav>
|
|
44
|
+
</aside>
|
|
45
|
+
|
|
46
|
+
<!-- Main Content -->
|
|
47
|
+
<div class="flex-1 space-y-6 w-full pb-20">
|
|
48
|
+
<div class="flex justify-between items-center bg-white/5 p-6 rounded-3xl border border-white/5">
|
|
49
|
+
<div>
|
|
50
|
+
<h2 class="text-2xl font-black tracking-tight"><%= @page.id ? 'Edit' : 'Add' %> Page</h2>
|
|
51
|
+
<p class="text-slate-500 text-sm">Customize the structure and content of your website pages.</p>
|
|
52
|
+
</div>
|
|
53
|
+
<a href="/dashboard/pages" class="px-6 py-2.5 rounded-xl bg-white/5 text-slate-500 hover:bg-white/10 transition-all font-bold">
|
|
54
|
+
Cancel
|
|
55
|
+
</a>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="glass-panel p-8">
|
|
59
|
+
<form action="<%= @page.id ? "/dashboard/pages/#{@page.id}/update" : "/dashboard/pages" %>" method="POST" id="main-form" class="space-y-6">
|
|
60
|
+
<%= csrf_tag %>
|
|
61
|
+
<div class="space-y-2">
|
|
62
|
+
<label class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 ml-1">Page Title</label>
|
|
63
|
+
<input type="text" name="page[title]" value="<%= @page.title %>" placeholder="Example: About Me" required oninput="generateSlug(this.value)"
|
|
64
|
+
class="w-full px-5 py-4 bg-white/5 border border-white/10 rounded-2xl focus:border-primary focus:ring-4 focus:ring-primary/10 outline-none transition-all text-slate-700 dark:text-slate-200 font-bold text-lg">
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
68
|
+
<div class="space-y-2">
|
|
69
|
+
<label class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 ml-1">URL Slug (Automatic)</label>
|
|
70
|
+
<div class="relative">
|
|
71
|
+
<span class="absolute left-5 top-1/2 -translate-y-1/2 text-slate-500 font-mono text-sm">/</span>
|
|
72
|
+
<input type="text" name="page[slug]" id="slug-input" value="<%= @page.slug %>" placeholder="about-me" required
|
|
73
|
+
class="w-full pl-8 pr-5 py-3 bg-white/5 border border-white/10 rounded-2xl focus:border-primary focus:ring-4 focus:ring-primary/10 outline-none transition-all font-mono text-primary">
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="flex items-end gap-6 pb-2">
|
|
77
|
+
<label class="flex items-center gap-3 cursor-pointer group">
|
|
78
|
+
<div class="relative">
|
|
79
|
+
<input type="checkbox" name="page[is_nav]" <%= @page.is_nav ? 'checked' : '' %> class="sr-only peer">
|
|
80
|
+
<div class="w-11 h-6 bg-slate-200 dark:bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
|
81
|
+
</div>
|
|
82
|
+
<span class="text-sm font-bold text-slate-600 dark:text-slate-400 group-hover:text-primary transition-colors">Show in Navigation</span>
|
|
83
|
+
</label>
|
|
84
|
+
<label class="flex items-center gap-3 cursor-pointer group">
|
|
85
|
+
<div class="relative">
|
|
86
|
+
<input type="checkbox" name="page[is_active]" <%= @page.is_active ? 'checked' : '' %> class="sr-only peer">
|
|
87
|
+
<div class="w-11 h-6 bg-slate-200 dark:bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-500"></div>
|
|
88
|
+
</div>
|
|
89
|
+
<span class="text-sm font-bold text-slate-600 dark:text-slate-400 group-hover:text-green-500 transition-colors">Active</span>
|
|
90
|
+
</label>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div class="space-y-2">
|
|
95
|
+
<div class="flex justify-between items-center px-1">
|
|
96
|
+
<label class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">Page Content (Markdown)</label>
|
|
97
|
+
<button type="button" class="upload-helper-btn text-xs font-bold text-primary hover:underline flex items-center gap-2" onclick="document.getElementById('image-upload').click()">
|
|
98
|
+
<i class="fas fa-image"></i> Insert Image
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
<textarea name="page[content]" rows="12" placeholder="Write page content here..."
|
|
102
|
+
class="w-full px-5 py-4 bg-white/5 border border-white/10 rounded-2xl focus:border-primary focus:ring-4 focus:ring-primary/10 outline-none transition-all text-slate-700 dark:text-slate-300 font-mono leading-relaxed"><%= @page.content %></textarea>
|
|
103
|
+
<input type="file" id="image-upload" class="hidden" onchange="handleImageUpload(this)">
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<button type="submit" class="btn-premium w-full py-4 text-lg">
|
|
107
|
+
<i class="fas fa-save mr-2"></i> Save Page
|
|
108
|
+
</button>
|
|
109
|
+
</form>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
<script>
|
|
116
|
+
function generateSlug(text) {
|
|
117
|
+
const slug = text.toLowerCase()
|
|
118
|
+
.replace(/[^\w ]+/g, '')
|
|
119
|
+
.replace(/ +/g, '-');
|
|
120
|
+
document.getElementById('slug-input').value = slug;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleImageUpload(input) {
|
|
124
|
+
if (!input.files || !input.files[0]) return;
|
|
125
|
+
|
|
126
|
+
const formData = new FormData();
|
|
127
|
+
formData.append('file', input.files[0]);
|
|
128
|
+
formData.append('csrf_token', '<%= csrf_token %>');
|
|
129
|
+
|
|
130
|
+
const btn = document.querySelector('.upload-helper-btn');
|
|
131
|
+
const originalText = btn.innerText;
|
|
132
|
+
btn.innerText = '⌛ Uploading...';
|
|
133
|
+
btn.disabled = true;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch('/dashboard/upload', {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
body: formData
|
|
139
|
+
});
|
|
140
|
+
const data = await response.json();
|
|
141
|
+
if (data.url) {
|
|
142
|
+
const textarea = document.querySelector('textarea');
|
|
143
|
+
const imgHtml = `\n<img src="${data.url}" alt="image" style="max-width: 100%; border-radius: 1rem; margin: 1rem 0;">\n`;
|
|
144
|
+
textarea.value += imgHtml;
|
|
145
|
+
alert('Image uploaded successfully!');
|
|
146
|
+
} else {
|
|
147
|
+
alert('Upload failed: ' + (data.error || 'Unknown error'));
|
|
148
|
+
}
|
|
149
|
+
} catch (e) {
|
|
150
|
+
alert('An error occurred during upload.');
|
|
151
|
+
} finally {
|
|
152
|
+
btn.innerText = originalText;
|
|
153
|
+
btn.disabled = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<div class="flex flex-col md:flex-row gap-8 items-start">
|
|
2
|
+
<!-- CMS Sidebar -->
|
|
3
|
+
<aside class="glass-panel w-full md:w-64 p-6 sticky top-24">
|
|
4
|
+
<div class="flex items-center gap-2 mb-8 px-2">
|
|
5
|
+
<div class="w-6 h-6 bg-primary rounded flex items-center justify-center text-white text-xs">
|
|
6
|
+
<i class="fas fa-user-shield"></i>
|
|
7
|
+
</div>
|
|
8
|
+
<h3 class="font-black tracking-tight text-lg">Admin<span class="text-primary">Panel</span></h3>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<nav class="space-y-2">
|
|
12
|
+
<a href="/dashboard" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path == '/dashboard' ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
13
|
+
<i class="fas fa-chart-pie w-5"></i>
|
|
14
|
+
<span class="font-semibold">Dashboard</span>
|
|
15
|
+
</a>
|
|
16
|
+
|
|
17
|
+
<a href="/dashboard/pages" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path.start_with?('/dashboard/pages') ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
18
|
+
<i class="fas fa-file-lines w-5"></i>
|
|
19
|
+
<span class="font-semibold">Pages</span>
|
|
20
|
+
</a>
|
|
21
|
+
|
|
22
|
+
<% if FEATURES_CONFIG['type'] == 'blog' %>
|
|
23
|
+
<a href="/dashboard/posts" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path.start_with?('/dashboard/posts') ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
24
|
+
<i class="fas fa-pen-nib w-5"></i>
|
|
25
|
+
<span class="font-semibold">Blog Posts</span>
|
|
26
|
+
</a>
|
|
27
|
+
<% elsif FEATURES_CONFIG['type'] == 'portfolio' %>
|
|
28
|
+
<a href="/dashboard/projects" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path.start_with?('/dashboard/projects') ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
29
|
+
<i class="fas fa-palette w-5"></i>
|
|
30
|
+
<span class="font-semibold">Projects</span>
|
|
31
|
+
</a>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<div class="h-px bg-white/5 my-4"></div>
|
|
35
|
+
|
|
36
|
+
<form action="/logout" method="POST">
|
|
37
|
+
<%= csrf_tag %>
|
|
38
|
+
<button type="submit" class="w-full flex items-center gap-3 px-4 py-3 rounded-2xl text-red-400 hover:bg-red-500/10 transition-all duration-300 font-semibold">
|
|
39
|
+
<i class="fas fa-door-open w-5"></i>
|
|
40
|
+
<span>Logout</span>
|
|
41
|
+
</button>
|
|
42
|
+
</form>
|
|
43
|
+
</nav>
|
|
44
|
+
</aside>
|
|
45
|
+
|
|
46
|
+
<!-- Main Content -->
|
|
47
|
+
<div class="flex-1 space-y-6 w-full">
|
|
48
|
+
<div class="flex justify-between items-center bg-white/5 p-6 rounded-3xl border border-white/5">
|
|
49
|
+
<div>
|
|
50
|
+
<h2 class="text-2xl font-black tracking-tight">Page Management</h2>
|
|
51
|
+
<p class="text-slate-500 text-sm">Manage all static pages of your website.</p>
|
|
52
|
+
</div>
|
|
53
|
+
<a href="/dashboard/pages/new" class="btn-premium">
|
|
54
|
+
<i class="fas fa-plus mr-2 text-sm"></i> New Page
|
|
55
|
+
</a>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="glass-panel overflow-hidden border-none shadow-xl">
|
|
59
|
+
<table class="w-full text-left border-collapse">
|
|
60
|
+
<thead>
|
|
61
|
+
<tr class="bg-white/5">
|
|
62
|
+
<th class="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-400">Title</th>
|
|
63
|
+
<th class="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-400">Slug</th>
|
|
64
|
+
<th class="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-400">Created At</th>
|
|
65
|
+
<th class="px-6 py-4 text-xs font-bold uppercase tracking-wider text-slate-400 text-right">Action</th>
|
|
66
|
+
</tr>
|
|
67
|
+
</thead>
|
|
68
|
+
<tbody class="divide-y divide-white/5">
|
|
69
|
+
<% if @pages.empty? %>
|
|
70
|
+
<tr>
|
|
71
|
+
<td colspan="4" class="px-6 py-12 text-center text-slate-500 italic">No pages created yet.</td>
|
|
72
|
+
</tr>
|
|
73
|
+
<% end %>
|
|
74
|
+
<% @pages.each do |page| %>
|
|
75
|
+
<tr class="hover:bg-white/[0.02] transition-colors group">
|
|
76
|
+
<td class="px-6 py-4">
|
|
77
|
+
<span class="font-bold text-slate-700 dark:text-slate-200 group-hover:text-primary transition-colors"><%= page.title %></span>
|
|
78
|
+
</td>
|
|
79
|
+
<td class="px-6 py-4">
|
|
80
|
+
<code class="text-xs bg-primary/10 px-2 py-1 rounded-lg text-primary">/<%= page.slug %></code>
|
|
81
|
+
</td>
|
|
82
|
+
<td class="px-6 py-4 text-sm text-slate-500 dark:text-slate-400">
|
|
83
|
+
<%= page.created_at.strftime('%d %b %Y') %>
|
|
84
|
+
</td>
|
|
85
|
+
<td class="px-6 py-4 text-right">
|
|
86
|
+
<div class="flex justify-end gap-2">
|
|
87
|
+
<a href="/dashboard/pages/<%= page.id %>/edit" class="w-9 h-9 flex items-center justify-center rounded-xl bg-primary/10 text-primary hover:bg-primary hover:text-white transition-all shadow-lg shadow-primary/10">
|
|
88
|
+
<i class="fas fa-edit text-sm"></i>
|
|
89
|
+
</a>
|
|
90
|
+
<form action="/dashboard/pages/<%= page.id %>/delete" method="POST" onsubmit="return confirm('Delete this page?')" class="inline">
|
|
91
|
+
<%= csrf_tag %>
|
|
92
|
+
<button type="submit" class="w-9 h-9 flex items-center justify-center rounded-xl bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white transition-all shadow-lg shadow-red-500/10">
|
|
93
|
+
<i class="fas fa-trash-can text-sm"></i>
|
|
94
|
+
</button>
|
|
95
|
+
</form>
|
|
96
|
+
</div>
|
|
97
|
+
</td>
|
|
98
|
+
</tr>
|
|
99
|
+
<% end %>
|
|
100
|
+
</tbody>
|
|
101
|
+
</table>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<div class="flex flex-col md:flex-row gap-8 items-start">
|
|
2
|
+
<!-- CMS Sidebar -->
|
|
3
|
+
<aside class="glass-panel w-full md:w-64 p-6 sticky top-24">
|
|
4
|
+
<div class="flex items-center gap-2 mb-8 px-2">
|
|
5
|
+
<div class="w-6 h-6 bg-primary rounded flex items-center justify-center text-white text-xs">
|
|
6
|
+
<i class="fas fa-user-shield"></i>
|
|
7
|
+
</div>
|
|
8
|
+
<h3 class="font-black tracking-tight text-lg">Admin<span class="text-primary">Panel</span></h3>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<nav class="space-y-2">
|
|
12
|
+
<a href="/dashboard" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path == '/dashboard' ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
13
|
+
<i class="fas fa-chart-pie w-5"></i>
|
|
14
|
+
<span class="font-semibold">Dashboard</span>
|
|
15
|
+
</a>
|
|
16
|
+
|
|
17
|
+
<a href="/dashboard/pages" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path.start_with?('/dashboard/pages') ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
18
|
+
<i class="fas fa-file-lines w-5"></i>
|
|
19
|
+
<span class="font-semibold">Pages</span>
|
|
20
|
+
</a>
|
|
21
|
+
|
|
22
|
+
<% if FEATURES_CONFIG['type'] == 'blog' %>
|
|
23
|
+
<a href="/dashboard/posts" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path.start_with?('/dashboard/posts') ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
24
|
+
<i class="fas fa-pen-nib w-5"></i>
|
|
25
|
+
<span class="font-semibold">Blog Posts</span>
|
|
26
|
+
</a>
|
|
27
|
+
<% elsif FEATURES_CONFIG['type'] == 'portfolio' %>
|
|
28
|
+
<a href="/dashboard/projects" class="flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-300 <%= @req.path.start_with?('/dashboard/projects') ? 'bg-primary text-white shadow-lg shadow-primary/30' : 'text-slate-500 hover:bg-white/5' %>">
|
|
29
|
+
<i class="fas fa-palette w-5"></i>
|
|
30
|
+
<span class="font-semibold">Projects</span>
|
|
31
|
+
</a>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<div class="h-px bg-white/5 my-4"></div>
|
|
35
|
+
|
|
36
|
+
<form action="/logout" method="POST">
|
|
37
|
+
<%= csrf_tag %>
|
|
38
|
+
<button type="submit" class="w-full flex items-center gap-3 px-4 py-3 rounded-2xl text-red-400 hover:bg-red-500/10 transition-all duration-300 font-semibold">
|
|
39
|
+
<i class="fas fa-door-open w-5"></i>
|
|
40
|
+
<span>Logout</span>
|
|
41
|
+
</button>
|
|
42
|
+
</form>
|
|
43
|
+
</nav>
|
|
44
|
+
</aside>
|
|
45
|
+
|
|
46
|
+
<!-- Main Content -->
|
|
47
|
+
<div class="flex-1 space-y-6 w-full pb-20">
|
|
48
|
+
<div class="flex justify-between items-center bg-white/5 p-6 rounded-3xl border border-white/5">
|
|
49
|
+
<div>
|
|
50
|
+
<h2 class="text-2xl font-black tracking-tight"><%= @post.id ? 'Edit' : 'Add' %> Article</h2>
|
|
51
|
+
<p class="text-slate-500 text-sm">Share your thoughts and stories.</p>
|
|
52
|
+
</div>
|
|
53
|
+
<a href="/dashboard/posts" class="px-6 py-2.5 rounded-xl bg-white/5 text-slate-500 hover:bg-white/10 transition-all font-bold">
|
|
54
|
+
Cancel
|
|
55
|
+
</a>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="glass-panel p-8">
|
|
59
|
+
<form action="<%= @post.id ? "/dashboard/posts/#{@post.id}/update" : "/dashboard/posts" %>" method="POST" id="main-form" class="space-y-6">
|
|
60
|
+
<%= csrf_tag %>
|
|
61
|
+
|
|
62
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
|
|
63
|
+
<div class="md:col-span-2 space-y-2">
|
|
64
|
+
<label class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 ml-1">Article Title</label>
|
|
65
|
+
<input type="text" name="post[title]" value="<%= @post.title %>" placeholder="Example: Learning Ruby Basics" required oninput="generateSlug(this.value)"
|
|
66
|
+
class="w-full px-5 py-4 bg-white/5 border border-white/10 rounded-2xl focus:border-primary focus:ring-4 focus:ring-primary/10 outline-none transition-all text-slate-700 dark:text-slate-200 font-bold text-lg">
|
|
67
|
+
</div>
|
|
68
|
+
<div class="pb-3 pl-2 text-left">
|
|
69
|
+
<label class="flex items-center gap-3 cursor-pointer group">
|
|
70
|
+
<div class="relative">
|
|
71
|
+
<input type="checkbox" name="post[is_active]" <%= @post.is_active ? 'checked' : '' %> class="sr-only peer">
|
|
72
|
+
<div class="w-11 h-6 bg-slate-200 dark:bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-500"></div>
|
|
73
|
+
</div>
|
|
74
|
+
<span class="text-sm font-bold text-slate-600 dark:text-slate-400 group-hover:text-green-500 transition-colors">Publish</span>
|
|
75
|
+
</label>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
80
|
+
<div class="space-y-2">
|
|
81
|
+
<label class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 ml-1">URL Slug (Automatic)</label>
|
|
82
|
+
<div class="relative text-left">
|
|
83
|
+
<span class="absolute left-5 top-1/2 -translate-y-1/2 text-slate-500 font-mono text-sm">/</span>
|
|
84
|
+
<input type="text" name="post[slug]" id="slug-input" value="<%= @post.slug %>" placeholder="learning-ruby" required
|
|
85
|
+
class="w-full pl-8 pr-5 py-3 bg-white/5 border border-white/10 rounded-2xl focus:border-primary focus:ring-4 focus:ring-primary/10 outline-none transition-all font-mono text-primary">
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="space-y-2 text-left">
|
|
89
|
+
<label class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 ml-1">Category</label>
|
|
90
|
+
<input type="text" name="post[category]" value="<%= @post.category %>" placeholder="Tutorial, Tips, etc"
|
|
91
|
+
class="w-full px-5 py-3 bg-white/5 border border-white/10 rounded-2xl focus:border-primary focus:ring-4 focus:ring-primary/10 outline-none transition-all text-slate-700 dark:text-slate-200">
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="space-y-2">
|
|
96
|
+
<div class="flex justify-between items-center px-1">
|
|
97
|
+
<label class="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">Article Content (Markdown)</label>
|
|
98
|
+
<div class="flex gap-4">
|
|
99
|
+
<button type="button" class="text-xs font-bold text-primary hover:underline flex items-center gap-2" onclick="openUploader('thumbnail')">
|
|
100
|
+
<i class="fas fa-image"></i> Set Thumbnail
|
|
101
|
+
</button>
|
|
102
|
+
<button type="button" class="text-xs font-bold text-primary hover:underline flex items-center gap-2" onclick="openUploader('content')">
|
|
103
|
+
<i class="fas fa-file-image"></i> Insert Image
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div id="thumbnail-preview-container" style="<%= @post.image_url && !@post.image_url.empty? ? '' : 'display: none;' %>" class="mb-4 p-2 border-2 border-dashed border-white/10 rounded-2xl text-left">
|
|
109
|
+
<span class="text-[10px] font-bold uppercase text-slate-500 mb-2 block ml-1">Thumbnail Preview</span>
|
|
110
|
+
<img id="thumbnail-preview" src="<%= @post.image_url %>" class="max-h-32 rounded-xl shadow-lg">
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<input type="hidden" name="post[image_url]" id="thumbnail-url" value="<%= @post.image_url %>">
|
|
114
|
+
<textarea name="post[content]" rows="15" placeholder="Start writing your story..."
|
|
115
|
+
class="w-full px-5 py-4 bg-white/5 border border-white/10 rounded-2xl focus:border-primary focus:ring-4 focus:ring-primary/10 outline-none transition-all text-slate-700 dark:text-slate-300 font-mono leading-relaxed text-left"><%= @post.content %></textarea>
|
|
116
|
+
<input type="file" id="image-upload" class="hidden" onchange="handleImageUpload(this)">
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<button type="submit" class="btn-premium w-full py-4 text-lg">
|
|
120
|
+
<i class="fas fa-save mr-2"></i> Publish Now
|
|
121
|
+
</button>
|
|
122
|
+
</form>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<script>
|
|
128
|
+
let activeUploadTarget = 'content';
|
|
129
|
+
|
|
130
|
+
function generateSlug(text) {
|
|
131
|
+
const slug = text.toLowerCase().replace(/[^\w ]+/g, '').replace(/ +/g, '-');
|
|
132
|
+
document.getElementById('slug-input').value = slug;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function openUploader(target) {
|
|
136
|
+
activeUploadTarget = target;
|
|
137
|
+
document.getElementById('image-upload').click();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function handleImageUpload(input) {
|
|
141
|
+
if (!input.files || !input.files[0]) return;
|
|
142
|
+
|
|
143
|
+
const targetBtn = activeUploadTarget === 'thumbnail' ?
|
|
144
|
+
document.querySelector('button[onclick*="thumbnail"]') :
|
|
145
|
+
document.querySelector('button[onclick*="content"]');
|
|
146
|
+
const originalText = targetBtn.innerHTML;
|
|
147
|
+
targetBtn.innerHTML = '⌛ Uploading...';
|
|
148
|
+
targetBtn.disabled = true;
|
|
149
|
+
|
|
150
|
+
const formData = new FormData();
|
|
151
|
+
formData.append('file', input.files[0]);
|
|
152
|
+
formData.append('csrf_token', '<%= csrf_token %>');
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const response = await fetch('/dashboard/upload', {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
body: formData
|
|
158
|
+
});
|
|
159
|
+
const data = await response.json();
|
|
160
|
+
if (data.url) {
|
|
161
|
+
if (activeUploadTarget === 'thumbnail') {
|
|
162
|
+
document.getElementById('thumbnail-url').value = data.url;
|
|
163
|
+
const preview = document.getElementById('thumbnail-preview');
|
|
164
|
+
const container = document.getElementById('thumbnail-preview-container');
|
|
165
|
+
preview.src = data.url;
|
|
166
|
+
container.style.display = 'block';
|
|
167
|
+
} else {
|
|
168
|
+
const textarea = document.querySelector('textarea');
|
|
169
|
+
textarea.value += `\n\n`;
|
|
170
|
+
}
|
|
171
|
+
alert('Image uploaded successfully! Don\'t forget to click PUBLISH/SAVE below.');
|
|
172
|
+
} else {
|
|
173
|
+
alert('Upload failed: ' + (data.error || 'Unknown error'));
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
alert('An error occurred during upload.');
|
|
177
|
+
} finally {
|
|
178
|
+
targetBtn.innerHTML = originalText;
|
|
179
|
+
targetBtn.disabled = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
</script>
|