tiny_admin 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ class Settings
5
+ include Singleton
6
+ include Utils
7
+
8
+ attr_accessor :authentication,
9
+ :components,
10
+ :extra_styles,
11
+ :navbar,
12
+ :page_not_found,
13
+ :record_not_found,
14
+ :repository,
15
+ :root,
16
+ :root_path,
17
+ :sections,
18
+ :scripts,
19
+ :style_links
20
+
21
+ attr_reader :pages, :resources
22
+
23
+ def load_settings
24
+ @authentication ||= {}
25
+ @authentication[:plugin] ||= Plugins::NoAuth
26
+ @authentication[:login] ||= Views::Pages::SimpleAuthLogin
27
+ @authentication[:plugin] = Object.const_get(authentication[:plugin]) if authentication[:plugin].is_a?(String)
28
+
29
+ @page_not_found ||= Views::Pages::PageNotFound
30
+ @page_not_found = Object.const_get(@page_not_found) if @page_not_found.is_a?(String)
31
+ @record_not_found ||= Views::Pages::RecordNotFound
32
+ @record_not_found = Object.const_get(@record_not_found) if @record_not_found.is_a?(String)
33
+
34
+ @pages ||= {}
35
+ @repository ||= Plugins::ActiveRecordRepository
36
+ @resources ||= {}
37
+ @root_path ||= '/admin'
38
+ @sections ||= []
39
+
40
+ @root ||= {}
41
+ @root[:title] ||= 'TinyAdmin'
42
+ @root[:path] ||= root_path
43
+ @root[:page] ||= Views::Pages::Root
44
+
45
+ if @authentication[:plugin] == Plugins::SimpleAuth
46
+ @authentication[:logout] ||= ['logout', "#{root_path}/auth/logout"]
47
+ end
48
+
49
+ @components ||= {}
50
+ @components[:flash] ||= Views::Components::Flash
51
+ @components[:head] ||= Views::Components::Head
52
+ @components[:navbar] ||= Views::Components::Navbar
53
+ @components[:pagination] ||= Views::Components::Pagination
54
+
55
+ @navbar = prepare_navbar(sections, root_path: root_path, logout: authentication[:logout])
56
+ end
57
+
58
+ def prepare_navbar(sections, root_path:, logout:)
59
+ items = sections.each_with_object({}) do |section, list|
60
+ slug = section[:slug]
61
+ case section[:type]&.to_sym
62
+ when :url
63
+ list[slug] = [section[:name], section[:url], section[:options]]
64
+ when :page
65
+ page = section[:page]
66
+ pages[slug] = page.is_a?(String) ? Object.const_get(page) : page
67
+ list[slug] = [section[:name], "#{root_path}/#{slug}"]
68
+ when :resource
69
+ repository = section[:repository] || settings.repository
70
+ resources[slug] = {
71
+ model: section[:model].is_a?(String) ? Object.const_get(section[:model]) : section[:model],
72
+ repository: repository.is_a?(String) ? Object.const_get(repository) : repository
73
+ }
74
+ resources[slug].merge! section.slice(:resource, :only, :index, :show, :collection_actions, :member_actions)
75
+ hidden = section[:options] && (section[:options].include?(:hidden) || section[:options].include?('hidden'))
76
+ list[slug] = [section[:name], "#{root_path}/#{slug}"] unless hidden
77
+ end
78
+ end
79
+ items['auth/logout'] = logout if logout
80
+ items
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Utils
5
+ def params_to_s(params)
6
+ list = params.each_with_object([]) do |(param, value), result|
7
+ if value.is_a?(Hash)
8
+ result.concat(value.map { |k, v| "#{param}[#{k}]=#{v}" })
9
+ else
10
+ result.push(["#{param}=#{value}"])
11
+ end
12
+ end
13
+ list.join('&')
14
+ end
15
+
16
+ def prepare_page(page_class, title: nil, context: nil, query_string: '', options: [])
17
+ page_class.new.tap do |page|
18
+ page.setup_page(title: title, query_string: query_string, settings: settings)
19
+ page.setup_options(
20
+ context: context,
21
+ compact_layout: options.include?(:compact_layout),
22
+ no_menu: options.include?(:no_menu)
23
+ )
24
+ yield(page) if block_given?
25
+ end
26
+ end
27
+
28
+ def route_for(section, reference: nil, action: nil)
29
+ [settings.root_path, section, reference, action].compact.join("/")
30
+ end
31
+
32
+ def context
33
+ TinyAdmin::Context.instance
34
+ end
35
+
36
+ def settings
37
+ TinyAdmin::Settings.instance
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Actions
6
+ class Index < DefaultLayout
7
+ attr_reader :current_page, :fields, :pages, :prepare_record, :records
8
+ attr_accessor :actions, :filters
9
+
10
+ def setup_pagination(current_page:, pages:)
11
+ @current_page = current_page
12
+ @pages = pages
13
+ end
14
+
15
+ def setup_records(records:, fields:, prepare_record:)
16
+ @records = records
17
+ @fields = fields.index_by(&:name)
18
+ @prepare_record = prepare_record
19
+ end
20
+
21
+ def template
22
+ @filters ||= {}
23
+
24
+ super do
25
+ div(class: 'index') {
26
+ div(class: 'row') {
27
+ div(class: 'col-4') {
28
+ h1(class: 'title') { title }
29
+ }
30
+ div(class: 'col-8') {
31
+ ul(class: 'nav justify-content-end') {
32
+ (actions || []).each do |action|
33
+ li(class: 'nav-item') {
34
+ href = route_for(context.slug, action: action)
35
+ a(href: href, class: 'nav-link btn btn-outline-secondary') { action }
36
+ }
37
+ end
38
+ }
39
+ }
40
+ }
41
+
42
+ div(class: 'row') {
43
+ div_class = filters.any? ? 'col-9' : 'col-12'
44
+ div(class: div_class) {
45
+ table(class: 'table') {
46
+ table_header if fields.any?
47
+
48
+ table_body
49
+ }
50
+ }
51
+
52
+ if filters.any?
53
+ div(class: 'col-3') {
54
+ filters_form_attrs = { section_path: route_for(context.slug), filters: filters }
55
+ render TinyAdmin::Views::Components::FiltersForm.new(**filters_form_attrs)
56
+ }
57
+ end
58
+ }
59
+
60
+ if pages
61
+ render components[:pagination].new(current: current_page, pages: pages, query_string: query_string)
62
+ end
63
+ }
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def table_header
70
+ thead {
71
+ tr {
72
+ fields.each_value do |field|
73
+ td(class: "field-header-#{field.name} field-header-type-#{field.type}") { field.title }
74
+ end
75
+ td { whitespace }
76
+ }
77
+ }
78
+ end
79
+
80
+ def table_body
81
+ tbody {
82
+ records.each_with_index do |record, index|
83
+ tr(class: "row_#{index + 1}") {
84
+ attributes = prepare_record.call(record)
85
+ attributes.each do |key, value|
86
+ field = fields[key]
87
+ td(class: "field-value-#{field.name} field-value-type-#{field.type}") {
88
+ if field.options && field.options[:link_to]
89
+ reference = record.send(field.options[:field])
90
+ a(href: route_for(field.options[:link_to], reference: reference)) { value }
91
+ else
92
+ value
93
+ end
94
+ }
95
+ end
96
+ td(class: 'actions') {
97
+ a(href: route_for(context.slug, reference: record.id)) { 'show' }
98
+ }
99
+ }
100
+ end
101
+ }
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Actions
6
+ class Show < DefaultLayout
7
+ attr_reader :fields, :prepare_record, :record
8
+ attr_accessor :actions
9
+
10
+ def setup_record(record:, fields:, prepare_record:)
11
+ @record = record
12
+ @fields = fields
13
+ @prepare_record = prepare_record
14
+ end
15
+
16
+ def template
17
+ super do
18
+ div(class: 'show') {
19
+ div(class: 'row') {
20
+ div(class: 'col-4') {
21
+ h1(class: 'title') { title }
22
+ }
23
+ div(class: 'col-8') {
24
+ ul(class: 'nav justify-content-end') {
25
+ (actions || []).each do |action|
26
+ li(class: 'nav-item') {
27
+ href = route_for(context.slug, reference: context.reference, action: action)
28
+ a(href: href, class: 'nav-link btn btn-outline-secondary') { action }
29
+ }
30
+ end
31
+ }
32
+ }
33
+ }
34
+
35
+ prepare_record.call(record).each_with_index do |(_key, value), index|
36
+ field = fields[index]
37
+ div(class: "field-#{field.name} row lh-lg") {
38
+ if field
39
+ div(class: 'field-header col-2') { field.title }
40
+ end
41
+ div(class: 'field-value col-10') {
42
+ if field.options && field.options[:link_to]
43
+ reference = record.send(field.options[:field])
44
+ a(href: route_for(field.options[:link_to], reference: reference)) { value }
45
+ else
46
+ value
47
+ end
48
+ }
49
+ }
50
+ end
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Components
6
+ class FiltersForm < Phlex::HTML
7
+ attr_reader :filters, :section_path
8
+
9
+ def initialize(section_path:, filters:)
10
+ @section_path = section_path
11
+ @filters = filters
12
+ end
13
+
14
+ def template
15
+ form(class: 'form_filters', method: 'get') {
16
+ filters.each do |field, filter|
17
+ name = field.name
18
+ filter_data = filter[:filter]
19
+ div(class: 'mb-3') {
20
+ label(for: "filter-#{name}", class: 'form-label') { field.title }
21
+ case filter_data[:type]&.to_sym || field.type
22
+ when :boolean
23
+ select(class: 'form-select', id: "filter-#{name}", name: "q[#{name}]") {
24
+ option(value: '') { '-' }
25
+ option(value: '0', selected: filter[:value] == '0') { 'false' }
26
+ option(value: '1', selected: filter[:value] == '1') { 'true' }
27
+ }
28
+ when :date
29
+ input(type: 'date', class: 'form-control', id: "filter-#{name}", name: "q[#{name}]", value: filter[:value])
30
+ when :datetime
31
+ input(type: 'datetime-local', class: 'form-control', id: "filter-#{name}", name: "q[#{name}]", value: filter[:value])
32
+ when :integer
33
+ input(type: 'number', class: 'form-control', id: "filter-#{name}", name: "q[#{name}]", value: filter[:value])
34
+ when :select
35
+ select(class: 'form-select', id: "filter-#{name}", name: "q[#{name}]") {
36
+ option(value: '') { '-' }
37
+ filter_data[:values].each do |value|
38
+ option(selected: filter[:value] == value) { value }
39
+ end
40
+ }
41
+ else
42
+ input(type: 'text', class: 'form-control', id: "filter-#{name}", name: "q[#{name}]", value: filter[:value])
43
+ end
44
+ }
45
+ end
46
+
47
+ div(class: 'mt-3') {
48
+ a(href: section_path, class: 'button_clear btn btn-secondary text-white') { 'clear' }
49
+ whitespace
50
+ button(type: 'submit', class: 'button_filter btn btn-secondary') { 'filter' }
51
+ }
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Components
6
+ class Flash < Phlex::HTML
7
+ attr_reader :errors, :notices, :warnings
8
+
9
+ def initialize(notices: [], warnings: [], errors: [])
10
+ @notices = notices
11
+ @warnings = warnings
12
+ @errors = errors
13
+ end
14
+
15
+ def template
16
+ div(class: 'flash') {
17
+ div(class: 'notices alert alert-success', role: 'alert') { notices.join(', ') } if notices&.any?
18
+ div(class: 'notices alert alert-warning', role: 'alert') { warnings.join(', ') } if warnings&.any?
19
+ div(class: 'notices alert alert-danger', role: 'alert') { errors.join(', ') } if errors&.any?
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Components
6
+ class Head < Phlex::HTML
7
+ attr_reader :extra_styles, :page_title, :style_links
8
+
9
+ def initialize(page_title, style_links: [], extra_styles: nil)
10
+ @page_title = page_title
11
+ @style_links = style_links
12
+ @extra_styles = extra_styles
13
+ end
14
+
15
+ def template
16
+ head {
17
+ meta charset: 'utf-8'
18
+ meta name: 'viewport', content: 'width=device-width, initial-scale=1'
19
+ title {
20
+ page_title
21
+ }
22
+ style_links.each do |style_link|
23
+ link(**style_link)
24
+ end
25
+ style { extra_styles } if extra_styles
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Components
6
+ class Navbar < Phlex::HTML
7
+ attr_reader :current_slug, :items, :root
8
+
9
+ def initialize(root:, items: [], current_slug: nil)
10
+ @root = root
11
+ @items = items || []
12
+ @current_slug = current_slug
13
+ end
14
+
15
+ def template
16
+ nav(class: 'navbar navbar-expand-lg') {
17
+ div(class: 'container') {
18
+ a(class: 'navbar-brand', href: root[:path]) { root[:title] }
19
+ button(class: 'navbar-toggler', type: 'button', 'data-bs-toggle' => 'collapse', 'data-bs-target' => '#navbarNav', 'aria-controls' => 'navbarNav', 'aria-expanded' => 'false', 'aria-label' => 'Toggle navigation') {
20
+ span(class: 'navbar-toggler-icon')
21
+ }
22
+ div(class: 'collapse navbar-collapse', id: 'navbarNav') {
23
+ ul(class: 'navbar-nav') {
24
+ items.each do |slug, (name, path, options)|
25
+ classes = %w[nav-link]
26
+ classes << 'active' if slug == current_slug
27
+ link_attributes = { class: classes.join(' '), href: path, 'aria-current' => 'page' }
28
+ link_attributes.merge!(options) if options
29
+
30
+ li(class: 'nav-item') {
31
+ a(**link_attributes) { name }
32
+ }
33
+ end
34
+ }
35
+ }
36
+ }
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Components
6
+ class Pagination < Phlex::HTML
7
+ attr_reader :current, :pages, :query_string
8
+
9
+ def initialize(current:, pages:, query_string:)
10
+ @current = current
11
+ @pages = pages
12
+ @query_string = query_string
13
+ end
14
+
15
+ def template
16
+ div(class: 'pagination-div') {
17
+ nav('aria-label' => 'Pagination') {
18
+ ul(class: 'pagination justify-content-center') {
19
+ 1.upto(pages) do |i|
20
+ li_class = (i == current ? 'page-item active' : 'page-item')
21
+ li(class: li_class) {
22
+ href = query_string.empty? ? "?p=#{i}" : "?#{query_string}&p=#{i}"
23
+ a(class: 'page-link', href: href) { i }
24
+ }
25
+ end
26
+ }
27
+ }
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ class DefaultLayout < Phlex::HTML
6
+ include Utils
7
+
8
+ attr_reader :compact_layout,
9
+ :context,
10
+ :errors,
11
+ :no_menu,
12
+ :notices,
13
+ :query_string,
14
+ :settings,
15
+ :title,
16
+ :warnings
17
+
18
+ def setup_page(title:, query_string:, settings:)
19
+ @title = title
20
+ @query_string = query_string
21
+ @settings = settings
22
+ end
23
+
24
+ def setup_options(context:, compact_layout:, no_menu:)
25
+ @context = context
26
+ @compact_layout = compact_layout
27
+ @no_menu = no_menu
28
+ end
29
+
30
+ def setup_flash_messages(notices: [], warnings: [], errors: [])
31
+ @notices = notices
32
+ @warnings = warnings
33
+ @errors = errors
34
+ end
35
+
36
+ def template(&block)
37
+ items = no_menu ? [] : settings.navbar
38
+
39
+ doctype
40
+ html {
41
+ render components[:head].new(title, style_links: style_links, extra_styles: settings.extra_styles)
42
+
43
+ body(class: body_class) {
44
+ render components[:navbar].new(current_slug: context&.slug, root: settings.root, items: items)
45
+
46
+ main_content {
47
+ render components[:flash].new(notices: notices, warnings: warnings, errors: errors)
48
+ yield_content(&block)
49
+ }
50
+
51
+ render_scripts
52
+ }
53
+ }
54
+ end
55
+
56
+ private
57
+
58
+ def components
59
+ settings.components
60
+ end
61
+
62
+ def style_links
63
+ settings.style_links || [
64
+ # Bootstrap CDN
65
+ {
66
+ href: 'https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css',
67
+ rel: 'stylesheet',
68
+ integrity: 'sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65',
69
+ crossorigin: 'anonymous'
70
+ }
71
+ ]
72
+ end
73
+
74
+ def body_class
75
+ "module-#{self.class.to_s.split('::').last.downcase}"
76
+ end
77
+
78
+ def main_content
79
+ div(class: 'container main-content py-4') do
80
+ if compact_layout
81
+ div(class: 'row justify-content-center') {
82
+ div(class: 'col-6') {
83
+ yield
84
+ }
85
+ }
86
+ else
87
+ yield
88
+ end
89
+ end
90
+ end
91
+
92
+ def render_scripts
93
+ return unless settings.scripts
94
+
95
+ settings.scripts.each do |script_attrs|
96
+ script(**script_attrs)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Pages
6
+ class PageNotFound < DefaultLayout
7
+ def template
8
+ super do
9
+ div(class: 'page_not_found') {
10
+ h1(class: 'title') { title }
11
+ }
12
+ end
13
+ end
14
+
15
+ def title
16
+ 'Page not found'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Pages
6
+ class RecordNotFound < DefaultLayout
7
+ def template
8
+ super do
9
+ div(class: 'record_not_found') {
10
+ h1(class: 'title') { title }
11
+ }
12
+ end
13
+ end
14
+
15
+ def title
16
+ 'Record not found'
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Pages
6
+ class Root < DefaultLayout
7
+ def template
8
+ super do
9
+ div(class: 'root') {
10
+ h1(class: 'title') { 'Tiny Admin' }
11
+ }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinyAdmin
4
+ module Views
5
+ module Pages
6
+ class SimpleAuthLogin < DefaultLayout
7
+ def template
8
+ super do
9
+ div(class: 'simple_auth_login') {
10
+ h1(class: 'title') { title }
11
+
12
+ form(class: 'form_login', method: 'post') {
13
+ div(class: 'mt-3') {
14
+ label(for: 'secret', class: 'form-label') { 'Password' }
15
+ input(type: 'password', name: 'secret', class: 'form-control', id: 'secret')
16
+ }
17
+
18
+ div(class: 'mt-3') {
19
+ button(type: 'submit', class: 'button_login btn btn-primary') { 'login' }
20
+ }
21
+ }
22
+ }
23
+ end
24
+ end
25
+
26
+ def title
27
+ 'Login'
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end