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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +4 -0
  3. data/Dockerfile +19 -0
  4. data/Gemfile +20 -0
  5. data/LICENSE +21 -0
  6. data/Procfile +1 -0
  7. data/README.md +99 -0
  8. data/app/controllers/api_controller.rb +19 -0
  9. data/app/controllers/application_controller.rb +77 -0
  10. data/app/controllers/auth_controller.rb +35 -0
  11. data/app/controllers/dashboard_controller.rb +41 -0
  12. data/app/controllers/pages_controller.rb +49 -0
  13. data/app/controllers/posts_controller.rb +48 -0
  14. data/app/controllers/projects_controller.rb +48 -0
  15. data/app/helpers/cloudinary_helper.rb +14 -0
  16. data/app/middleware/auth_middleware.rb +52 -0
  17. data/app/middleware/csrf_middleware.rb +27 -0
  18. data/app/models/page.rb +8 -0
  19. data/app/models/post.rb +8 -0
  20. data/app/models/project.rb +7 -0
  21. data/app/models/user.rb +83 -0
  22. data/app/views/blog_home.erb +70 -0
  23. data/app/views/cms/pages_form.erb +156 -0
  24. data/app/views/cms/pages_index.erb +104 -0
  25. data/app/views/cms/posts_form.erb +182 -0
  26. data/app/views/cms/posts_index.erb +100 -0
  27. data/app/views/cms/projects_form.erb +176 -0
  28. data/app/views/cms/projects_index.erb +96 -0
  29. data/app/views/dashboard.erb +122 -0
  30. data/app/views/docs.erb +140 -0
  31. data/app/views/error.erb +159 -0
  32. data/app/views/index.erb +70 -0
  33. data/app/views/layout.erb +251 -0
  34. data/app/views/login.erb +45 -0
  35. data/app/views/page.erb +21 -0
  36. data/app/views/portfolio.erb +73 -0
  37. data/app/views/post.erb +33 -0
  38. data/app/views/posts/hello_world.erb +3 -0
  39. data/app/views/project.erb +39 -0
  40. data/bin/ofa +499 -0
  41. data/config/boot.rb +134 -0
  42. data/config/database.json +4 -0
  43. data/config/database.rb +142 -0
  44. data/config/features.json +7 -0
  45. data/config/locales/en.json +6 -0
  46. data/config/locales/id.json +6 -0
  47. data/config/routes.rb +87 -0
  48. data/config.eks +11 -0
  49. data/config.ru +11 -0
  50. data/db/data.sqlite3 +0 -0
  51. data/db/development.sqlite3 +0 -0
  52. data/ofa +2 -0
  53. data/public/css/cms.css +134 -0
  54. data/public/images/logo.jpg +0 -0
  55. data/public/images/logo.png +0 -0
  56. metadata +259 -0
data/config/boot.rb ADDED
@@ -0,0 +1,134 @@
1
+ require 'bundler/setup'
2
+ require 'dotenv/load'
3
+ Bundler.require(:default)
4
+ require 'eks-cent'
5
+ require 'json'
6
+ require 'kramdown'
7
+
8
+ # Basic project structure constants
9
+ APP_ROOT ||= File.expand_path('..', __dir__)
10
+
11
+ # Environment
12
+ EKS_ENV = ENV['EKS_ENV'] || 'development'
13
+
14
+ # Load features config
15
+ FEATURES_CONFIG = JSON.parse(File.read(File.join(APP_ROOT, 'config', 'features.json')))
16
+
17
+ # Load Locales (from framework)
18
+ LOCALES = {
19
+ 'en' => JSON.parse(File.read(File.join(__dir__, 'locales', 'en.json'))),
20
+ 'id' => JSON.parse(File.read(File.join(__dir__, 'locales', 'id.json')))
21
+ }
22
+
23
+ # Monkeypatch EksCent
24
+ module EksCent
25
+ def self.secret_key_base
26
+ ENV['SECRET_KEY_BASE'] || 'd8a8b8c8d8e8f8g8h8i8j8k8l8m8n8o8p8q8r8s8t8u8v8w8x8y8z8'
27
+ end
28
+
29
+ class Response
30
+ def render(template_name, layout: true, **locals)
31
+ require 'erb'
32
+ template_path = File.join(APP_ROOT, 'app', 'views', "#{template_name}.erb")
33
+ unless File.file?(template_path)
34
+ raise "Template not found: #{template_path}"
35
+ end
36
+
37
+ template_content = File.read(template_path)
38
+ context = Object.new
39
+ context.extend(ERB::Util)
40
+ req = @request
41
+ context.instance_variable_set("@req", req)
42
+ context.instance_variable_set("@res", self)
43
+
44
+ # I18n Helper
45
+ lang = (req && req.params['lang']) || 'en'
46
+ context.define_singleton_method(:t) { |key| LOCALES[lang][key.to_s] || key }
47
+ context.define_singleton_method(:csrf_tag) { "<input type='hidden' name='csrf_token' value='#{req.env['eks_cent.csrf_token']}'>" }
48
+ context.define_singleton_method(:csrf_token) { req ? req.env['eks_cent.csrf_token'] : nil }
49
+ context.define_singleton_method(:markdown) { |text| Kramdown::Document.new(text.to_s, input: 'GFM').to_html }
50
+
51
+ context.define_singleton_method(:session) { req ? (req.env['eks_cent.session'] || req.env['rack.session'] || {}) : {} }
52
+ context.define_singleton_method(:h) { |s| CGI.escapeHTML(s.to_s) }
53
+ locals.each { |k, v| context.instance_variable_set("@#{k}", v) }
54
+
55
+ result = ERB.new(template_content).result(context.instance_eval { binding })
56
+
57
+ if layout
58
+ layout_name = layout == true ? 'layout' : layout.to_s
59
+ layout_path = File.join(APP_ROOT, 'app', 'views', "#{layout_name}.erb")
60
+ if File.file?(layout_path)
61
+ context.instance_variable_set("@content", result)
62
+ layout_content = File.read(layout_path)
63
+ result = ERB.new(layout_content).result(context.instance_eval { binding })
64
+ end
65
+ end
66
+
67
+ @headers['Content-Type'] ||= 'text/html'
68
+ @body << result
69
+ end
70
+ end
71
+ end
72
+
73
+ # Load core framework components
74
+ require_relative 'database'
75
+
76
+ # Autoloading (Framework Core first, then APP_ROOT)
77
+ framework_app = File.expand_path('../app', __dir__)
78
+ ['controllers', 'models', 'middleware', 'helpers'].each do |folder|
79
+ loaded = []
80
+ # Load framework core
81
+ Dir.glob(File.join(framework_app, folder, '*.rb')).each do |f|
82
+ require f
83
+ loaded << File.basename(f)
84
+ end
85
+
86
+ # Load project specific (if not already loaded by framework)
87
+ if framework_app != APP_ROOT
88
+ Dir.glob(File.join(APP_ROOT, 'app', folder, '*.rb')).each do |f|
89
+ require f unless loaded.include?(File.basename(f))
90
+ end
91
+ end
92
+ end
93
+
94
+ # ─── Routes DSL Extensions ─────────────────────────────────────────────────────
95
+ # Extends EksCent::Router with a `resources` helper that auto-generates RESTful
96
+ # routes (index, show, new, create, edit, update, destroy) for a given resource.
97
+ #
98
+ # Usage in config/routes.rb:
99
+ # resources :posts # All 7 RESTful routes
100
+ # resources :posts, only: [:index, :show]
101
+ module EksCent
102
+ class Router
103
+ # Generate RESTful routes for a resource
104
+ # Maps to a controller: e.g. :posts => PostsController
105
+ def resources(name, only: nil, prefix: nil)
106
+ controller_name = name.to_s.split('_').map(&:capitalize).join + "Controller"
107
+ plural = name.to_s
108
+ path_prefix = prefix ? "#{prefix}/#{plural}" : "/#{plural}"
109
+
110
+ all_actions = {
111
+ index: { method: :get, path: path_prefix },
112
+ new: { method: :get, path: "#{path_prefix}/new" },
113
+ show: { method: :get, path: "#{path_prefix}/:id" },
114
+ create: { method: :post, path: path_prefix },
115
+ edit: { method: :get, path: "#{path_prefix}/:id/edit" },
116
+ update: { method: :post, path: "#{path_prefix}/:id/update" },
117
+ destroy: { method: :post, path: "#{path_prefix}/:id/delete" },
118
+ }
119
+
120
+ actions = only ? all_actions.slice(*Array(only)) : all_actions
121
+
122
+ actions.each do |action, config|
123
+ send(config[:method], config[:path]) do |req, res|
124
+ controller_class = Object.const_get(controller_name)
125
+ controller_class.new(req, res).public_send(action)
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ # ───────────────────────────────────────────────────────────────────────────────
132
+
133
+ # Load routes
134
+ require_relative 'routes'
@@ -0,0 +1,4 @@
1
+ {
2
+ "adapter": "sqlite",
3
+ "database": "db/development.sqlite3"
4
+ }
@@ -0,0 +1,142 @@
1
+ require 'sequel'
2
+ require 'fileutils'
3
+ require 'json'
4
+
5
+ # Load config from JSON
6
+ config_file = File.join(APP_ROOT, 'config', 'database.json')
7
+ DB_CONFIG = JSON.parse(File.read(config_file)) rescue { "adapter" => "sqlite", "database" => "db/development.sqlite3" }
8
+
9
+ # Decide if we should use Environment Variable or JSON Config
10
+ # Priority:
11
+ # 1. If database.json adapter is 'env', STRICTLY use DATABASE_URL from .env
12
+ # 2. Otherwise, STRICTLY use JSON config (this allows local dev to override .env)
13
+
14
+ use_env = (DB_CONFIG['adapter'] == 'env')
15
+ database_url = ENV['DATABASE_URL'] if use_env
16
+
17
+ if use_env && database_url
18
+ if database_url.start_with?('mongodb')
19
+ require 'mongo'
20
+ begin
21
+ MONGO_CLIENT = Mongo::Client.new(database_url)
22
+ FileUtils.mkdir_p('db')
23
+ DB = Sequel.connect("sqlite://db/data.sqlite3")
24
+ puts "ℹ INFO: MongoDB Atlas connected via ENV." unless ENV['EKS_ENV'] == 'test'
25
+ rescue => e
26
+ puts "⚠ WARNING: Failed to connect to MongoDB Atlas. #{e.message}"
27
+ FileUtils.mkdir_p('db')
28
+ DB = Sequel.connect("sqlite://db/data.sqlite3")
29
+ end
30
+ else
31
+ begin
32
+ DB = Sequel.connect(database_url)
33
+ rescue => e
34
+ puts "⚠ WARNING: Connection failed to DATABASE_URL. Fallback to memory."
35
+ FileUtils.mkdir_p('db')
36
+ DB = Sequel.connect("sqlite://db/data.sqlite3")
37
+ end
38
+ end
39
+ else
40
+ # Use JSON config (SQLite, MySQL, etc.)
41
+ case DB_CONFIG['adapter']
42
+ when 'sqlite'
43
+ db_path = DB_CONFIG['database'] || 'db/development.sqlite3'
44
+ FileUtils.mkdir_p('db') unless Dir.exist?('db')
45
+ DB = Sequel.connect("sqlite://#{db_path}")
46
+ when 'mongodb'
47
+ begin
48
+ require 'mongo'
49
+ MONGO_CLIENT = Mongo::Client.new([ DB_CONFIG['host'] || '127.0.0.1:27017' ], database: DB_CONFIG['database'])
50
+ FileUtils.mkdir_p('db')
51
+ DB = Sequel.connect("sqlite://db/data.sqlite3")
52
+ rescue => e
53
+ FileUtils.mkdir_p('db')
54
+ DB = Sequel.connect("sqlite://db/data.sqlite3")
55
+ end
56
+ else
57
+ begin
58
+ # For MySQL, MariaDB, Postgres
59
+ DB = Sequel.connect("#{DB_CONFIG['adapter']}://#{DB_CONFIG['user']}:#{DB_CONFIG['password']}@#{DB_CONFIG['host']}/#{DB_CONFIG['database']}")
60
+ rescue => e
61
+ FileUtils.mkdir_p('db')
62
+ DB = Sequel.connect("sqlite://db/data.sqlite3")
63
+ end
64
+ end
65
+ end
66
+
67
+ if DB
68
+ DB.extension :pagination
69
+ unless DB.table_exists?(:users)
70
+ DB.create_table :users do
71
+ primary_key :id
72
+ String :username, unique: true, null: false
73
+ String :password_hash, null: false
74
+ String :avatar_url
75
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
76
+ end
77
+ end
78
+
79
+ unless DB.table_exists?(:pages)
80
+ DB.create_table :pages do
81
+ primary_key :id
82
+ String :title, null: false
83
+ String :slug, unique: true, null: false
84
+ String :content, text: true
85
+ TrueClass :is_active, default: true
86
+ TrueClass :is_nav, default: true
87
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
88
+ end
89
+ end
90
+
91
+ unless DB.table_exists?(:posts)
92
+ DB.create_table :posts do
93
+ primary_key :id
94
+ String :title, null: false
95
+ String :slug, unique: true, null: false
96
+ String :content, text: true
97
+ String :image_url
98
+ String :category
99
+ TrueClass :is_active, default: true
100
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
101
+ end
102
+ end
103
+
104
+ unless DB.table_exists?(:projects)
105
+ DB.create_table :projects do
106
+ primary_key :id
107
+ String :title, null: false
108
+ String :slug, unique: true, null: false
109
+ String :description, text: true
110
+ String :link
111
+ String :image_url
112
+ TrueClass :is_active, default: true
113
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
114
+ end
115
+ end
116
+
117
+ # Quick Migration for existing tables
118
+ if DB.table_exists?(:pages)
119
+ DB.alter_table(:pages) { add_column :is_active, TrueClass, default: true unless DB[:pages].columns.include?(:is_active) }
120
+ DB.alter_table(:pages) { add_column :is_nav, TrueClass, default: true unless DB[:pages].columns.include?(:is_nav) }
121
+ end
122
+ if DB.table_exists?(:posts)
123
+ DB.alter_table(:posts) { add_column :is_active, TrueClass, default: true unless DB[:posts].columns.include?(:is_active) }
124
+ end
125
+ if DB.table_exists?(:projects)
126
+ DB.alter_table(:projects) { add_column :is_active, TrueClass, default: true unless DB[:projects].columns.include?(:is_active) }
127
+ DB.alter_table(:projects) { add_column :slug, String, unique: true unless DB[:projects].columns.include?(:slug) }
128
+ end
129
+ # Seed default content if empty
130
+ if DB[:pages].count == 0
131
+ DB[:pages].insert(title: 'About Me', slug: 'about', content: '<h1>About Me</h1><p>Welcome to my website.</p>')
132
+ DB[:pages].insert(title: 'Contact', slug: 'contact', content: '<h1>Contact Me</h1><p>Email: admin@example.com</p>')
133
+ end
134
+
135
+ if DB[:posts].count == 0
136
+ DB[:posts].insert(title: 'Hello World', slug: 'hello-world', content: 'This is my first post.', category: 'General')
137
+ end
138
+
139
+ if DB[:projects].count == 0
140
+ DB[:projects].insert(title: 'My First Project', slug: 'my-first-project', description: 'Description of my first project.', link: 'https://github.com')
141
+ end
142
+ end
@@ -0,0 +1,7 @@
1
+ {
2
+ "auth": true,
3
+ "cms": true,
4
+ "type": "landing_page",
5
+ "theme": "light_glass",
6
+ "storage": "cloudinary"
7
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "welcome": "Welcome to One-For-All",
3
+ "login": "Login",
4
+ "logout": "Logout",
5
+ "dashboard": "Dashboard"
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "welcome": "Selamat datang di One-For-All",
3
+ "login": "Masuk",
4
+ "logout": "Keluar",
5
+ "dashboard": "Dasbor"
6
+ }
data/config/routes.rb ADDED
@@ -0,0 +1,87 @@
1
+ require 'eks-cent'
2
+
3
+ ROUTES = EksCent::Router.new do
4
+ # Main Pages - Dynamic based on Type
5
+ get '/' do |req, res|
6
+ type = FEATURES_CONFIG['type'] || 'landing_page'
7
+ case type
8
+ when 'portfolio'
9
+ projects = Project.where(is_active: true).order(Sequel.desc(:created_at)).all
10
+ res.render 'portfolio', title: "Portfolio - One-For-All", projects: projects
11
+ when 'blog'
12
+ posts = Post.where(is_active: true).order(Sequel.desc(:created_at)).all
13
+ res.render 'blog_home', title: "Blog - One-For-All", posts: posts
14
+ else
15
+ res.render 'index', title: "One-For-All Framework"
16
+ end
17
+ end
18
+
19
+ get '/docs' do |req, res|
20
+ res.render 'docs', title: "Documentation - One-For-All"
21
+ end
22
+
23
+ # --- CMS Dashboard ---
24
+ get '/dashboard' do |req, res|
25
+ DashboardController.new(req, res).index
26
+ end
27
+
28
+ post '/dashboard/upload' do |req, res|
29
+ DashboardController.new(req, res).upload
30
+ end
31
+
32
+ # Resourceful CMS Routes
33
+ resources :pages, prefix: '/dashboard'
34
+ resources :posts, prefix: '/dashboard'
35
+ resources :projects, prefix: '/dashboard'
36
+
37
+ # Auth Routes
38
+ get '/login' do |req, res|
39
+ AuthController.new(req, res).show_login
40
+ end
41
+
42
+ post '/login' do |req, res|
43
+ AuthController.new(req, res).login
44
+ end
45
+
46
+ post '/logout' do |req, res|
47
+ AuthController.new(req, res).logout
48
+ end
49
+
50
+ # API Namespace
51
+ namespace '/api' do
52
+ get '/status' do |req, res|
53
+ ApiController.new(req, res).status
54
+ end
55
+ end
56
+ # --- Dynamic Content ---
57
+ get '/posts/:slug' do |req, res|
58
+ post = Post.find(slug: req.params['slug'], is_active: true)
59
+ if post
60
+ res.render('post', title: post.title, post: post)
61
+ else
62
+ res.status = 404
63
+ res.render('error', layout: false, message: "Article not found.")
64
+ end
65
+ end
66
+
67
+ get '/projects/:slug' do |req, res|
68
+ project = Project.find(slug: req.params['slug'], is_active: true)
69
+ if project
70
+ res.render('project', title: project.title, project: project)
71
+ else
72
+ res.status = 404
73
+ res.render('error', layout: false, message: "Project not found.")
74
+ end
75
+ end
76
+
77
+ # --- Dynamic Pages (Catch-All) ---
78
+ get '/:slug' do |req, res|
79
+ page = Page.find(slug: req.params['slug'], is_active: true)
80
+ if page
81
+ res.render('page', title: page.title, content: page.content)
82
+ else
83
+ res.status = 404
84
+ res.render('error', layout: false, message: "Page '#{req.params['slug']}' not found.")
85
+ end
86
+ end
87
+ end
data/config.eks ADDED
@@ -0,0 +1,11 @@
1
+ require_relative 'config/boot'
2
+
3
+ # Middleware setup
4
+ use EksCent::Middleware::ShowExceptions
5
+ use EksCent::Middleware::Session, secret: EksCent.secret_key_base
6
+ use AuthMiddleware
7
+ use CSRFMiddleware
8
+ use EksCent::Middleware::Static, root: 'public'
9
+ use EksCent::Middleware::Logger
10
+
11
+ run ROUTES
data/config.ru ADDED
@@ -0,0 +1,11 @@
1
+ require_relative 'config/boot'
2
+
3
+ # Middleware setup
4
+ use EksCent::Middleware::ShowExceptions
5
+ use EksCent::Middleware::Session, secret: EksCent.secret_key_base
6
+ use AuthMiddleware
7
+ use CSRFMiddleware
8
+ use EksCent::Middleware::Static, root: 'public'
9
+ use EksCent::Middleware::Logger
10
+
11
+ run ROUTES
data/db/data.sqlite3 ADDED
Binary file
Binary file
data/ofa ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ ruby bin/ofa "$@"
@@ -0,0 +1,134 @@
1
+ :root {
2
+ --cms-primary: #6366f1;
3
+ --cms-bg: #0f172a;
4
+ --cms-card: rgba(30, 41, 59, 0.7);
5
+ --cms-border: rgba(255, 255, 255, 0.1);
6
+ --cms-text: #f8fafc;
7
+ --cms-text-muted: #94a3b8;
8
+ }
9
+
10
+ .cms-layout {
11
+ display: grid;
12
+ grid-template-columns: 280px 1fr;
13
+ gap: 2rem;
14
+ min-height: 100vh;
15
+ padding: 2rem;
16
+ background: var(--cms-bg);
17
+ color: var(--cms-text);
18
+ }
19
+
20
+ .cms-sidebar {
21
+ background: var(--cms-card);
22
+ backdrop-filter: blur(20px);
23
+ border: 1px solid var(--cms-border);
24
+ border-radius: 1.5rem;
25
+ padding: 2rem;
26
+ height: calc(100vh - 4rem);
27
+ position: sticky;
28
+ top: 2rem;
29
+ }
30
+
31
+ .sidebar-header h3 {
32
+ font-size: 1.5rem;
33
+ margin-bottom: 2.5rem;
34
+ font-weight: 800;
35
+ }
36
+ .sidebar-header span { color: var(--cms-primary); }
37
+
38
+ .sidebar-nav {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 0.75rem;
42
+ }
43
+
44
+ .sidebar-nav a {
45
+ padding: 1rem 1.5rem;
46
+ border-radius: 1rem;
47
+ text-decoration: none;
48
+ color: var(--cms-text-muted);
49
+ font-weight: 600;
50
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
51
+ }
52
+
53
+ .sidebar-nav a:hover {
54
+ background: rgba(255, 255, 255, 0.05);
55
+ color: var(--cms-text);
56
+ transform: translateX(5px);
57
+ }
58
+
59
+ .sidebar-nav a.active {
60
+ background: var(--cms-primary);
61
+ color: white;
62
+ box-shadow: 0 10px 20px -5px rgba(99, 102, 241, 0.5);
63
+ }
64
+
65
+ .nav-divider {
66
+ height: 1px;
67
+ background: var(--cms-border);
68
+ margin: 1.5rem 0;
69
+ }
70
+
71
+ .cms-main {
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 2rem;
75
+ }
76
+
77
+ .cms-header h2 { font-size: 2.5rem; font-weight: 800; }
78
+ .cms-header p { color: var(--cms-text-muted); }
79
+
80
+ .stats-grid {
81
+ display: grid;
82
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
83
+ gap: 1.5rem;
84
+ }
85
+
86
+ .stat-card {
87
+ background: var(--cms-card);
88
+ border: 1px solid var(--cms-border);
89
+ padding: 2rem;
90
+ border-radius: 1.5rem;
91
+ text-align: center;
92
+ transition: transform 0.3s;
93
+ }
94
+ .stat-card:hover { transform: translateY(-5px); }
95
+ .stat-card h4 { color: var(--cms-text-muted); font-size: 1rem; margin-bottom: 0.5rem; }
96
+ .stat-card .value { font-size: 3rem; font-weight: 800; color: var(--cms-primary); }
97
+
98
+ .glass-card {
99
+ background: var(--cms-card);
100
+ backdrop-filter: blur(20px);
101
+ border: 1px solid var(--cms-border);
102
+ border-radius: 1.5rem;
103
+ padding: 2rem;
104
+ }
105
+
106
+ .header-with-action {
107
+ display: flex;
108
+ justify-content: space-between;
109
+ align-items: center;
110
+ }
111
+
112
+ /* Table Styles */
113
+ .table-container { padding: 0; overflow: hidden; }
114
+ table { width: 100%; border-collapse: collapse; }
115
+ th { text-align: left; padding: 1.5rem; color: var(--cms-text-muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 1px; background: rgba(0,0,0,0.2); }
116
+ td { padding: 1.5rem; border-bottom: 1px solid var(--cms-border); }
117
+
118
+ .table-actions { display: flex; gap: 1rem; }
119
+ .logout-btn {
120
+ background: rgba(239, 68, 68, 0.1);
121
+ border: 1px solid #ef4444;
122
+ color: #ef4444;
123
+ padding: 0.8rem;
124
+ border-radius: 1rem;
125
+ cursor: pointer;
126
+ width: 100%;
127
+ font-weight: 700;
128
+ }
129
+ .logout-btn:hover { background: #ef4444; color: white; }
130
+
131
+ @media (max-width: 1024px) {
132
+ .cms-layout { grid-template-columns: 1fr; }
133
+ .cms-sidebar { height: auto; position: static; }
134
+ }
Binary file
Binary file