tiny_admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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