administration-one 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 (84) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +51 -0
  3. data/.gitattributes +9 -0
  4. data/.gitignore +37 -0
  5. data/.rubocop.yml +8 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +2 -0
  8. data/Gemfile +8 -0
  9. data/Gemfile.lock +21 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +52 -0
  12. data/Rakefile +4 -0
  13. data/administration_one.gemspec +22 -0
  14. data/lib/administration-one.rb +1 -0
  15. data/lib/administration_one/version.rb +3 -0
  16. data/lib/administration_one.rb +4 -0
  17. data/lib/generators/admin/install/USAGE +5 -0
  18. data/lib/generators/admin/install/install_generator.rb +142 -0
  19. data/lib/generators/admin/install/templates/controllers/admin/base_controller.rb +20 -0
  20. data/lib/generators/admin/install/templates/controllers/admin/home_controller.rb +4 -0
  21. data/lib/generators/admin/install/templates/controllers/admin/passwords_controller.rb +35 -0
  22. data/lib/generators/admin/install/templates/controllers/admin/profile_controller.rb +23 -0
  23. data/lib/generators/admin/install/templates/controllers/admin/sessions_controller.rb +24 -0
  24. data/lib/generators/admin/install/templates/controllers/admin/users_controller.rb +54 -0
  25. data/lib/generators/admin/install/templates/css/custom.css +85 -0
  26. data/lib/generators/admin/install/templates/erb/admin/base/_ejs_tags.html.erb +8 -0
  27. data/lib/generators/admin/install/templates/erb/admin/base/_flash_messages.html.erb +9 -0
  28. data/lib/generators/admin/install/templates/erb/admin/base/_footer.html.erb +34 -0
  29. data/lib/generators/admin/install/templates/erb/admin/base/_form_errors_messages.html.erb +10 -0
  30. data/lib/generators/admin/install/templates/erb/admin/base/_javascript_tags.html.erb +5 -0
  31. data/lib/generators/admin/install/templates/erb/admin/base/_page_header.html.erb +7 -0
  32. data/lib/generators/admin/install/templates/erb/admin/base/_page_header_actions.html.erb +5 -0
  33. data/lib/generators/admin/install/templates/erb/admin/base/_page_header_breadcrumb.html.erb +12 -0
  34. data/lib/generators/admin/install/templates/erb/admin/base/_primary_navbar.html.erb +63 -0
  35. data/lib/generators/admin/install/templates/erb/admin/base/_secondary_navbar.html.erb +12 -0
  36. data/lib/generators/admin/install/templates/erb/admin/base/_secondary_navbar_links.html.erb +20 -0
  37. data/lib/generators/admin/install/templates/erb/admin/base/_stylesheet_link_tags.html.erb +5 -0
  38. data/lib/generators/admin/install/templates/erb/admin/home/index.html.erb +8 -0
  39. data/lib/generators/admin/install/templates/erb/admin/passwords/edit.html.erb +16 -0
  40. data/lib/generators/admin/install/templates/erb/admin/passwords/new.html.erb +11 -0
  41. data/lib/generators/admin/install/templates/erb/admin/profile/edit.html.erb +48 -0
  42. data/lib/generators/admin/install/templates/erb/admin/profile/show.html.erb +22 -0
  43. data/lib/generators/admin/install/templates/erb/admin/sessions/new.html.erb +21 -0
  44. data/lib/generators/admin/install/templates/erb/admin/users/_form.html.erb +48 -0
  45. data/lib/generators/admin/install/templates/erb/admin/users/edit.html.erb +17 -0
  46. data/lib/generators/admin/install/templates/erb/admin/users/index.html.erb +78 -0
  47. data/lib/generators/admin/install/templates/erb/admin/users/new.html.erb +16 -0
  48. data/lib/generators/admin/install/templates/erb/admin/users/show.html.erb +33 -0
  49. data/lib/generators/admin/install/templates/erb/layouts/admin/authentication.html.erb +32 -0
  50. data/lib/generators/admin/install/templates/erb/layouts/admin/base.html.erb +31 -0
  51. data/lib/generators/admin/install/templates/helpers/admin/application_helper.rb +25 -0
  52. data/lib/generators/admin/install/templates/images/admin/default_avatar.png +0 -0
  53. data/lib/generators/admin/install/templates/images/admin/logo.svg +4 -0
  54. data/lib/generators/admin/install/templates/jobs/attach_avatar_to_user_job.rb +21 -0
  55. data/lib/generators/admin/install/templates/js/editor.js +101 -0
  56. data/lib/generators/admin/install/templates/js/flash_message.js +5 -0
  57. data/lib/generators/admin/install/templates/js/stimulus.js +3 -0
  58. data/lib/generators/admin/install/templates/js/theme_switcher.js +93 -0
  59. data/lib/generators/admin/install/templates/js/time_zone.js +3 -0
  60. data/lib/generators/admin/install/templates/migrations/create_users.rb.tt +15 -0
  61. data/lib/generators/admin/install/templates/models/admin/application_record.rb +3 -0
  62. data/lib/generators/admin/install/templates/models/application_record.rb +3 -0
  63. data/lib/generators/admin/install/templates/models/user.rb +34 -0
  64. data/lib/generators/admin/install/templates/modules/ejs_parser.rb +119 -0
  65. data/lib/generators/admin/install/templates/passwords_mailer/reset_admin.html.erb +4 -0
  66. data/lib/generators/admin/install/templates/passwords_mailer/reset_admin.text.erb +2 -0
  67. data/lib/generators/admin/install/templates/seeds.rb +12 -0
  68. data/lib/generators/admin/install/templates/test_unit/controllers/admin/home_controller_test.rb +12 -0
  69. data/lib/generators/admin/install/templates/test_unit/controllers/admin/profile_controller_test.rb +26 -0
  70. data/lib/generators/admin/install/templates/test_unit/controllers/admin/sessions_controller_test.rb +36 -0
  71. data/lib/generators/admin/install/templates/test_unit/controllers/admin/users_controller_test.rb +55 -0
  72. data/lib/generators/admin/install/templates/test_unit/models/user_test.rb +34 -0
  73. data/lib/generators/admin/install/templates/test_unit/test_helper.rb +25 -0
  74. data/lib/generators/admin/install/templates/test_unit/users.yml +13 -0
  75. data/lib/generators/admin/scaffold/USAGE +5 -0
  76. data/lib/generators/admin/scaffold/scaffold_generator.rb +132 -0
  77. data/lib/generators/admin/scaffold/templates/controller.rb.tt +58 -0
  78. data/lib/generators/admin/scaffold/templates/erb/_form.html.erb.tt +48 -0
  79. data/lib/generators/admin/scaffold/templates/erb/edit.html.erb.tt +17 -0
  80. data/lib/generators/admin/scaffold/templates/erb/index.html.erb.tt +76 -0
  81. data/lib/generators/admin/scaffold/templates/erb/new.html.erb.tt +16 -0
  82. data/lib/generators/admin/scaffold/templates/erb/show.html.erb.tt +39 -0
  83. data/lib/generators/admin/scaffold/templates/functional_test.rb.tt +53 -0
  84. metadata +124 -0
@@ -0,0 +1,25 @@
1
+ require "pagy/extras/bootstrap"
2
+
3
+ module Admin::ApplicationHelper
4
+ include Pagy::Frontend
5
+
6
+ def title
7
+ content_for(:title) || Rails.application.class.to_s.split("::").first
8
+ end
9
+
10
+ def active_nav_item(*names)
11
+ names.include?(controller_path) ? "active" : ""
12
+ end
13
+
14
+ def true_or_false_icon(statement)
15
+ if statement == true
16
+ '<span class="true"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-check"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l5 5l10 -10" /></svg></span>'.html_safe
17
+ else
18
+ '<span class="false"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-x"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg></span>'.html_safe
19
+ end
20
+ end
21
+
22
+ def path_to_avatar(user)
23
+ user.avatar.attached? ? rails_blob_path(user.avatar, only_path: true) : "admin/default_avatar.png"
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 232 68">
2
+ <path d="M64.6 16.2C63 9.9 58.1 5 51.8 3.4 40 1.5 28 1.5 16.2 3.4 9.9 5 5 9.9 3.4 16.2 1.5 28 1.5 40 3.4 51.8 5 58.1 9.9 63 16.2 64.6c11.8 1.9 23.8 1.9 35.6 0C58.1 63 63 58.1 64.6 51.8c1.9-11.8 1.9-23.8 0-35.6zM33.3 36.3c-2.8 4.4-6.6 8.2-11.1 11-1.5.9-3.3.9-4.8.1s-2.4-2.3-2.5-4c0-1.7.9-3.3 2.4-4.1 2.3-1.4 4.4-3.2 6.1-5.3-1.8-2.1-3.8-3.8-6.1-5.3-2.3-1.3-3-4.2-1.7-6.4s4.3-2.9 6.5-1.6c4.5 2.8 8.2 6.5 11.1 10.9 1 1.4 1 3.3.1 4.7zM49.2 46H37.8c-2.1 0-3.8-1-3.8-3s1.7-3 3.8-3h11.4c2.1 0 3.8 1 3.8 3s-1.7 3-3.8 3z" fill="#206bc4"/>
3
+ <path d="M105.8 46.1c.4 0 .9.2 1.2.6s.6 1 .6 1.7c0 .9-.5 1.6-1.4 2.2s-2 .9-3.2.9c-2 0-3.7-.4-5-1.3s-2-2.6-2-5.4V31.6h-2.2c-.8 0-1.4-.3-1.9-.8s-.9-1.1-.9-1.9c0-.7.3-1.4.8-1.8s1.2-.7 1.9-.7h2.2v-3.1c0-.8.3-1.5.8-2.1s1.3-.8 2.1-.8 1.5.3 2 .8.8 1.3.8 2.1v3.1h3.4c.8 0 1.4.3 1.9.8s.8 1.2.8 1.9-.3 1.4-.8 1.8-1.2.7-1.9.7h-3.4v13c0 .7.2 1.2.5 1.5s.8.5 1.4.5c.3 0 .6-.1 1.1-.2.5-.2.8-.3 1.2-.3zm28-20.7c.8 0 1.5.3 2.1.8.5.5.8 1.2.8 2.1v20.3c0 .8-.3 1.5-.8 2.1-.5.6-1.2.8-2.1.8s-1.5-.3-2-.8-.8-1.2-.8-2.1c-.8.9-1.9 1.7-3.2 2.4-1.3.7-2.8 1-4.3 1-2.2 0-4.2-.6-6-1.7-1.8-1.1-3.2-2.7-4.2-4.7s-1.6-4.3-1.6-6.9c0-2.6.5-4.9 1.5-6.9s2.4-3.6 4.2-4.8c1.8-1.1 3.7-1.7 5.9-1.7 1.5 0 3 .3 4.3.8 1.3.6 2.5 1.3 3.4 2.1 0-.8.3-1.5.8-2.1.5-.5 1.2-.7 2-.7zm-9.7 21.3c2.1 0 3.8-.8 5.1-2.3s2-3.4 2-5.7-.7-4.2-2-5.8c-1.3-1.5-3-2.3-5.1-2.3-2 0-3.7.8-5 2.3-1.3 1.5-2 3.5-2 5.8s.6 4.2 1.9 5.7 3 2.3 5.1 2.3zm32.1-21.3c2.2 0 4.2.6 6 1.7 1.8 1.1 3.2 2.7 4.2 4.7s1.6 4.3 1.6 6.9-.5 4.9-1.5 6.9-2.4 3.6-4.2 4.8c-1.8 1.1-3.7 1.7-5.9 1.7-1.5 0-3-.3-4.3-.9s-2.5-1.4-3.4-2.3v.3c0 .8-.3 1.5-.8 2.1-.5.6-1.2.8-2.1.8s-1.5-.3-2.1-.8c-.5-.5-.8-1.2-.8-2.1V18.9c0-.8.3-1.5.8-2.1.5-.6 1.2-.8 2.1-.8s1.5.3 2.1.8c.5.6.8 1.3.8 2.1v10c.8-1 1.8-1.8 3.2-2.5 1.3-.7 2.8-1 4.3-1zm-.7 21.3c2 0 3.7-.8 5-2.3s2-3.5 2-5.8-.6-4.2-1.9-5.7-3-2.3-5.1-2.3-3.8.8-5.1 2.3-2 3.4-2 5.7.7 4.2 2 5.8c1.3 1.6 3 2.3 5.1 2.3zm23.6 1.9c0 .8-.3 1.5-.8 2.1s-1.3.8-2.1.8-1.5-.3-2-.8-.8-1.3-.8-2.1V18.9c0-.8.3-1.5.8-2.1s1.3-.8 2.1-.8 1.5.3 2 .8.8 1.3.8 2.1v29.7zm29.3-10.5c0 .8-.3 1.4-.9 1.9-.6.5-1.2.7-2 .7h-15.8c.4 1.9 1.3 3.4 2.6 4.4 1.4 1.1 2.9 1.6 4.7 1.6 1.3 0 2.3-.1 3.1-.4.7-.2 1.3-.5 1.8-.8.4-.3.7-.5.9-.6.6-.3 1.1-.4 1.6-.4.7 0 1.2.2 1.7.7s.7 1 .7 1.7c0 .9-.4 1.6-1.3 2.4-.9.7-2.1 1.4-3.6 1.9s-3 .8-4.6.8c-2.7 0-5-.6-7-1.7s-3.5-2.7-4.6-4.6-1.6-4.2-1.6-6.6c0-2.8.6-5.2 1.7-7.2s2.7-3.7 4.6-4.8 3.9-1.7 6-1.7 4.1.6 6 1.7 3.4 2.7 4.5 4.7c.9 1.9 1.5 4.1 1.5 6.3zm-12.2-7.5c-3.7 0-5.9 1.7-6.6 5.2h12.6v-.3c-.1-1.3-.8-2.5-2-3.5s-2.5-1.4-4-1.4zm30.3-5.2c1 0 1.8.3 2.4.8.7.5 1 1.2 1 1.9 0 1-.3 1.7-.8 2.2-.5.5-1.1.8-1.8.7-.5 0-1-.1-1.6-.3-.2-.1-.4-.1-.6-.2-.4-.1-.7-.1-1.1-.1-.8 0-1.6.3-2.4.8s-1.4 1.3-1.9 2.3-.7 2.3-.7 3.7v11.4c0 .8-.3 1.5-.8 2.1-.5.6-1.2.8-2.1.8s-1.5-.3-2.1-.8c-.5-.6-.8-1.3-.8-2.1V28.8c0-.8.3-1.5.8-2.1.5-.6 1.2-.8 2.1-.8s1.5.3 2.1.8c.5.6.8 1.3.8 2.1v.6c.7-1.3 1.8-2.3 3.2-3 1.3-.7 2.8-1 4.3-1z" fill-rule="evenodd" clip-rule="evenodd" fill="#4a4a4a"/>
4
+ </svg>
@@ -0,0 +1,21 @@
1
+ require "open-uri"
2
+ class AttachAvatarToUserJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(**args)
6
+ user = args[:user]
7
+ size = 512
8
+ colors = %w[ 066fd1 ]
9
+
10
+ unless user.nil?
11
+ seed = URI.encode_uri_component user.username
12
+ url = "https://api.dicebear.com/9.x/initials/png?size=#{size}&seed=#{seed}&backgroundColor=#{colors.sample}&scale=120"
13
+
14
+ begin
15
+ user.avatar.attach(io: OpenURI.open_uri(url), filename: "#{user.id}_avatar.png") unless user.avatar.attached?
16
+ rescue
17
+ AttachAvatarToUserJob.set(wait: 1.minutes).perform_later(user: user)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,101 @@
1
+ import { Controller } from "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm";
2
+
3
+ // data-controller = "editorjs"
4
+ Stimulus.register("editorjs", class extends Controller {
5
+ connect() {
6
+ const initialData = this.getinitialData();
7
+
8
+ this.editorJS = new EditorJS({
9
+ data: initialData,
10
+ holder: document.getElementById("editorjs_content"),
11
+ tools: {
12
+ image: {
13
+ class: ImageTool,
14
+ config: {
15
+ uploader: {
16
+ uploadByFile(file) {
17
+ return getBase64(file, function (e) { }).then((data) => {
18
+ return {
19
+ success: 1,
20
+ file: {
21
+ url: data
22
+ }
23
+ }
24
+ })
25
+ }
26
+ }
27
+ },
28
+ },
29
+ header: {
30
+ class: Header,
31
+ },
32
+ quote: {
33
+ class: Quote,
34
+ },
35
+ list: {
36
+ class: EditorjsList,
37
+ inlineToolbar: true,
38
+ toolbox: [
39
+ {
40
+ data: {
41
+ style: 'unordered',
42
+ }
43
+ },
44
+ {
45
+ data: {
46
+ style: 'ordered',
47
+ }
48
+ }
49
+ ]
50
+ },
51
+ paragraph: {
52
+ class: Paragraph,
53
+ config: {
54
+ inlineToolbar: true,
55
+ },
56
+ },
57
+ code: CodeTool,
58
+ delimiter: Delimiter,
59
+ },
60
+ });
61
+
62
+ this.element.addEventListener("submit", this.saveEditorData.bind(this));
63
+ }
64
+ csrfToken() {
65
+ const metaTag = document.querySelector("meta[name='csrf-token']");
66
+
67
+ return metaTag ? metaTag.content : "";
68
+ }
69
+
70
+ getinitialData() {
71
+ const hiddenContentField = document.getElementById(
72
+ "editorjs_content_hidden"
73
+ );
74
+ if (hiddenContentField && hiddenContentField.value) {
75
+ return JSON.parse(hiddenContentField.value);
76
+ }
77
+ return {};
78
+ }
79
+
80
+ async saveEditorData(event) {
81
+ event.preventDefault();
82
+
83
+ const outputData = await this.editorJS.save();
84
+ const postForm = this.element;
85
+
86
+ const hiddenInput = document.getElementById("editorjs_content_hidden");
87
+
88
+ hiddenInput.value = JSON.stringify(outputData);
89
+ postForm.submit();
90
+ };
91
+ });
92
+
93
+ function getBase64(file, onLoadCallback) {
94
+ return new Promise(function (resolve, reject) {
95
+ var reader = new FileReader();
96
+ reader.onload = function () { return resolve(reader.result); };
97
+ reader.onerror = reject;
98
+ reader.readAsDataURL(file);
99
+ });
100
+ };
101
+
@@ -0,0 +1,5 @@
1
+ import { Controller } from "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm"
2
+
3
+ Stimulus.register("flash-message", class extends Controller {
4
+ remove() { this.element.remove() }
5
+ });
@@ -0,0 +1,3 @@
1
+ import { Application, Controller } from "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm"
2
+ window.Stimulus = Application.start()
3
+
@@ -0,0 +1,93 @@
1
+ import { Controller } from "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm"
2
+
3
+ Stimulus.register("theme-switcher", class extends Controller {
4
+ connect() {
5
+ // Auto-switcher part
6
+
7
+ const getStoredTheme = () => localStorage.getItem('theme');
8
+
9
+ function getPreferredTheme() {
10
+ const storedTheme = getStoredTheme();
11
+
12
+ if (storedTheme === 'light') {
13
+ return 'light';
14
+ } else if (storedTheme === 'dark') {
15
+ return 'dark';
16
+ } else if (storedTheme === 'auto') {
17
+ return 'auto';
18
+ } else {
19
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
20
+ };
21
+ };
22
+
23
+ function setTheme(theme) {
24
+ if (theme !== 'light' && theme !== 'dark') {
25
+ const theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
26
+ document.documentElement.setAttribute('data-bs-theme', theme);
27
+ } else {
28
+ document.documentElement.setAttribute('data-bs-theme', theme);
29
+ };
30
+ };
31
+
32
+ // Switch by hand
33
+
34
+ const themeSwitcher = document.getElementsByClassName('theme-switcher')[0];
35
+ const switcherIcon = themeSwitcher.getElementsByClassName('switcher')[0];
36
+
37
+ const lightThemeIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-sun"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /><path d="M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7" /></svg>';
38
+ const darkThemeIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-moon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z" /></svg>';
39
+ const autoThemeIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-percentage-50"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 0 0 -18m0 0v18" fill="currentColor" stroke="none" /><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" /></svg>';
40
+
41
+ function setSwitcherIconAndTooltip(theme) {
42
+ if (theme == 'light') {
43
+ themeSwitcher.innerHTML = lightThemeIcon;
44
+ themeSwitcher.setAttribute('data-bs-original-title', 'Switch theme to dark');
45
+ } else if (theme == 'dark') {
46
+ themeSwitcher.innerHTML = darkThemeIcon;
47
+ themeSwitcher.setAttribute('data-bs-original-title', 'Switch theme to system');
48
+ } else {
49
+ themeSwitcher.innerHTML = autoThemeIcon;
50
+ themeSwitcher.setAttribute('data-bs-original-title', 'Switch theme to light');
51
+ };
52
+ };
53
+
54
+ function switchTheme() {
55
+ let theme = getPreferredTheme()
56
+ if (theme === 'light') {
57
+ localStorage.setItem('theme', 'dark');
58
+ setTheme('dark');
59
+ setSwitcherIconAndTooltip('dark');
60
+ } else if (theme === 'dark') {
61
+ localStorage.setItem('theme', 'auto');
62
+ setTheme(getPreferredTheme());
63
+ setSwitcherIconAndTooltip('auto');
64
+ } else {
65
+ localStorage.setItem('theme', 'light');
66
+ setTheme('light');
67
+ setSwitcherIconAndTooltip('light');
68
+ };
69
+ };
70
+
71
+ // Triggers
72
+
73
+ setTheme(getPreferredTheme());
74
+ setSwitcherIconAndTooltip(getPreferredTheme());
75
+
76
+ window.addEventListener('turbo:load', () => {
77
+ setTheme(getPreferredTheme());
78
+ setSwitcherIconAndTooltip(getPreferredTheme());
79
+ });
80
+
81
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
82
+ const storedTheme = getStoredTheme();
83
+ if (storedTheme !== 'light' && storedTheme !== 'dark') {
84
+ setTheme(getPreferredTheme());
85
+ setSwitcherIconAndTooltip(getPreferredTheme());
86
+ };
87
+ });
88
+
89
+ themeSwitcher.onclick = () => {
90
+ switchTheme();
91
+ };
92
+ };
93
+ });
@@ -0,0 +1,3 @@
1
+ import jsCookie from "https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/+esm"
2
+ const { timeZone } = new Intl.DateTimeFormat().resolvedOptions()
3
+ jsCookie.set("time_zone", timeZone, { expires: 365 })
@@ -0,0 +1,15 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :users do |t|
4
+ t.string :username, null: false
5
+ t.string :email_address, null: false
6
+ t.string :password_digest, null: false
7
+ t.boolean :is_admin, default: false
8
+
9
+ t.timestamps
10
+ end
11
+
12
+ add_index :users, :email_address, unique: true
13
+ add_index :users, :username, unique: true
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ class Admin::ApplicationRecord < ActiveRecord::Base
2
+ include SpreadsheetArchitect; self.abstract_class = true; self.table_name_prefix = "admin_"
3
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationRecord < ActiveRecord::Base
2
+ include SpreadsheetArchitect; primary_abstract_class
3
+ end
@@ -0,0 +1,34 @@
1
+ class User < ApplicationRecord
2
+ has_secure_password
3
+ has_many :sessions, dependent: :destroy
4
+ has_one_attached :avatar, service: :avatars_local_storage
5
+ after_create -> { AttachAvatarToUserJob.perform_later(user: self) }
6
+ after_update :reattach_avatar_to_user
7
+
8
+ validates :username,
9
+ length: { minimum: 1, maximum: 255 },
10
+ format: { with: /\A([A-Za-z0-9\-\_\.\s]+)+\z/ },
11
+ uniqueness: true
12
+
13
+ validates :email_address,
14
+ presence: true, length: { maximum: 255 },
15
+ format: { with: URI::MailTo::EMAIL_REGEXP },
16
+ uniqueness: true
17
+
18
+ normalizes :email_address, with: ->(e) { e.strip.downcase }
19
+
20
+ def self.ransackable_attributes(auth_object = nil)
21
+ %w[email_address created_at is_admin]
22
+ end
23
+
24
+ private
25
+
26
+ def reattach_avatar_to_user
27
+ if saved_change_to_attribute?(:username)
28
+ self.avatar.purge
29
+ AttachAvatarToUserJob.perform_now(user: self)
30
+ self.avatar.purge
31
+ AttachAvatarToUserJob.perform_later(user: self)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,119 @@
1
+ class EJSParser
2
+ # EJSParser::build_html_from_ejs_content(content: @post.content, classes: { header: "foo", paragraph: "bar" })
3
+ # Classes are optional, method applies @default_classes if none are passed as an arg.
4
+ # "Otherwise, the classes passed as an argument will be used for the specified elements. For example,
5
+ # classes: { header: "foo" } will apply the 'foo' class to the 'header' element, while the remaining elements will
6
+ # still use the classes defined in @default_classes."
7
+
8
+ def self.build_html_from_ejs_content(**args)
9
+ begin
10
+ content_blocks = JSON.parse(args[:content])["blocks"]
11
+ rescue
12
+ return "Unable to show content."
13
+ end
14
+
15
+ @classes = args[:classes] || {}
16
+
17
+ html_content = ""
18
+
19
+ content_blocks.each do |block|
20
+ type = block["type"]
21
+ data = block["data"]
22
+
23
+ @classes[type.to_sym].nil? ? @css_classes = @default_classes[type.to_sym] : @css_classes = @classes[type.to_sym]
24
+
25
+ begin
26
+ parsed_data = eval("parse_#{type}_block(#{data}, \"#{@css_classes}\")")
27
+ rescue
28
+ parsed_data = ""
29
+ end
30
+
31
+ html_content += parsed_data
32
+ end
33
+
34
+ html_content.html_safe
35
+ end
36
+
37
+ # EJSParser::summary_for_ejs_content(content: @post.content, truncate: 500)
38
+ # Truncate is optional.
39
+ def self.summary_for_ejs_content(**args)
40
+ begin
41
+ first_paragraph = JSON.parse(args[:content])["blocks"].find {|block| block["type"] == "paragraph"}
42
+ brief = first_paragraph["data"]["text"]
43
+ truncate = args[:truncate]
44
+ truncate.nil? ? brief : brief.truncate(truncate)
45
+ rescue
46
+ return "Unable to show content."
47
+ end
48
+ end
49
+
50
+ # E.g.: { "text"=>"Header", "level"=>2 }
51
+ def self.parse_header_block(*args)
52
+ data = args[0]
53
+ text, level = data["text"], data["level"]
54
+ # For Bulma CSS.
55
+ classes = "is-size-#{level} " + args[1] || "is-size-#{level}"
56
+
57
+ "<h#{level} class=\"#{classes}\">#{text}</h#{level}>"
58
+ end
59
+
60
+ def self.parse_paragraph_block(*args)
61
+ text = args[0]["text"]
62
+ classes = args[1]
63
+ "<p class = \"#{classes}\">" + text + "</p>"
64
+ end
65
+
66
+ def self.parse_image_block(*args)
67
+ data, classes = args[0], args[1]
68
+ caption, url = data["caption"], data["file"]["url"]
69
+
70
+ # The following image parameters from EditorJS are currently ignored:
71
+ # bordered, stretched, with_background = data["withBorder"], data["stretched"], data["withBackground"]
72
+
73
+ "<figure class=\"image #{classes}\">" + "<img src=#{url} loading=\"lazy\">" + "</figure>"
74
+ end
75
+
76
+ def self.parse_quote_block(*args)
77
+ data, classes = args[0], args[1]
78
+ text, caption, alignment = data["text"], data["caption"], data["alignment"]
79
+ classes += " has-text-centered" if alignment == "center" # Bulma CSS
80
+
81
+ "<blockquote class=\"#{classes}\">" + text +
82
+ "<p class=\"is-size-7 is-italic pt-3 has-text-grey\">&mdash; #{caption}</p>" +
83
+ "</blockquote>"
84
+ end
85
+
86
+ def self.parse_list_block(*args)
87
+ data, classes = args[0], args[1]
88
+ list_items = data["items"]
89
+ list_items.map! { "<li>" + it["content"] + "</li>" }
90
+ list_content = "" ; list_items.each { list_content += it }
91
+
92
+ if data["style"] == "ordered"
93
+ "<ol class=\"#{classes}\">" + list_content + "</ol>"
94
+ else
95
+ "<ul class=\"#{classes}\">" + list_content + "</ul>"
96
+ end
97
+ end
98
+
99
+ def self.parse_delimiter_block(*args)
100
+ classes = args[1]
101
+ "<hr class=\"#{classes}\">"
102
+ end
103
+
104
+ def self.parse_code_block(*args)
105
+ data, classes = args[0], args[1]
106
+ escaped_code = CGI.escapeHTML(data["code"])
107
+ "<pre><code class=\"#{classes}\">#{escaped_code}</code></pre>"
108
+ end
109
+
110
+ @default_classes = {
111
+ header: "",
112
+ paragraph: "",
113
+ image: "",
114
+ quote: "",
115
+ list: "",
116
+ delimiter: "",
117
+ code: ""
118
+ }
119
+ end
@@ -0,0 +1,4 @@
1
+ <p>
2
+ You can reset your admin's password within the next 15 minutes on
3
+ <%= link_to "this password reset page", edit_admin_password_url(@user.password_reset_token) %>.
4
+ </p>
@@ -0,0 +1,2 @@
1
+ You can reset your admin's password within the next 15 minutes on this password reset page:
2
+ <%= edit_admin_password_url(@user.password_reset_token) %>
@@ -0,0 +1,12 @@
1
+ User.create(username: "admin",
2
+ email_address: "admin@example.com",
3
+ password: "Password1234",
4
+ password_confirmation: "Password1234",
5
+ is_admin: true)
6
+
7
+ User.create(username: "guest",
8
+ email_address: "guest@example.com",
9
+ password: "Password1234",
10
+ password_confirmation: "Password1234")
11
+
12
+ User.all.each { |user| AttachAvatarToUserJob.perform_now(user: user) }
@@ -0,0 +1,12 @@
1
+ require "test_helper"
2
+
3
+ class Admin::HomeControllerTest < ActionDispatch::IntegrationTest
4
+ setup do
5
+ sign_in_as_admin_user users(:admin)
6
+ end
7
+
8
+ test "should get index" do
9
+ get admin_root_url
10
+ assert_response :success
11
+ end
12
+ end
@@ -0,0 +1,26 @@
1
+ require "test_helper"
2
+
3
+ class Admin::ProfileControllerTest < ActionDispatch::IntegrationTest
4
+ def setup
5
+ sign_in_as_admin_user users(:admin)
6
+ end
7
+
8
+ test "should get show" do
9
+ get admin_profile_path
10
+ assert_response :success
11
+ end
12
+
13
+ test "should get edit" do
14
+ get admin_edit_profile_path
15
+ assert_response :success
16
+ end
17
+
18
+ test "should update profile" do
19
+ @user = users(:admin)
20
+ patch admin_profile_path params: { user: { username: "administrator" } }
21
+ @user.reload
22
+
23
+ assert @user.username == "administrator"
24
+ assert_redirected_to admin_profile_path
25
+ end
26
+ end
@@ -0,0 +1,36 @@
1
+ require "test_helper"
2
+
3
+ class Admin::SessionsControllerTest < ActionDispatch::IntegrationTest
4
+ setup do
5
+ @user = users(:admin)
6
+ end
7
+
8
+ test "should get new" do
9
+ get admin_sign_in_url
10
+ assert_response :success
11
+ end
12
+
13
+ test "should sign in" do
14
+ post admin_sign_in_url, params: { email_address: @user.email_address, password: "Password1234" }
15
+ assert_redirected_to admin_root_url
16
+
17
+ get admin_root_url
18
+ assert_response :success
19
+ end
20
+
21
+ test "should not sign in with wrong credentials" do
22
+ post admin_sign_in_url, params: { email_address: @user.email_address, password: "Incorect4321" }
23
+ assert_redirected_to admin_sign_in_url
24
+ assert_equal "Try another email address or password.", flash[:alert]
25
+
26
+ get admin_root_url
27
+ assert_redirected_to admin_sign_in_url
28
+ end
29
+
30
+ test "should sign out" do
31
+ sign_in_as_admin_user users(:admin)
32
+
33
+ post admin_sign_out_url
34
+ assert_redirected_to admin_sign_in_url
35
+ end
36
+ end
@@ -0,0 +1,55 @@
1
+ require "test_helper"
2
+
3
+ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
4
+ setup do
5
+ @user = sign_in_as_admin_user users(:admin)
6
+ end
7
+
8
+ test "should get index" do
9
+ get admin_users_url
10
+ assert_response :success
11
+ end
12
+
13
+ test "should get new" do
14
+ get new_admin_user_url
15
+ assert_response :success
16
+ end
17
+
18
+ test "should create user" do
19
+ assert_difference("User.all.count") do
20
+ post admin_users_url, params: { user: {
21
+ username: "Jim",
22
+ email_address: "jimdoe@example.com",
23
+ password: "Password1234",
24
+ password_confirmation: "Password1234" } }
25
+ end
26
+
27
+ assert_redirected_to admin_user_url(User.last)
28
+ end
29
+
30
+ test "should show admin_user" do
31
+ get admin_user_url(@user)
32
+ assert_response :success
33
+ end
34
+
35
+ test "should get edit" do
36
+ get edit_admin_user_url(@user)
37
+ assert_response :success
38
+ end
39
+
40
+ test "should update admin_user" do
41
+ patch admin_user_url(@user), params: { user: { username: "James" } }
42
+ @user.reload
43
+
44
+ assert_redirected_to admin_user_url(@user)
45
+ assert @user.username == "James"
46
+ end
47
+
48
+ test "should destroy admin_user" do
49
+ assert_difference("User.count", -1) do
50
+ delete admin_user_url(@user)
51
+ end
52
+
53
+ assert_redirected_to admin_users_url
54
+ end
55
+ end
@@ -0,0 +1,34 @@
1
+ require "test_helper"
2
+
3
+ class UserTest < ActiveSupport::TestCase
4
+ def setup
5
+ @user = users(:admin)
6
+ end
7
+
8
+ test "valid usernames" do
9
+ usernames = %w[ John Gorlock_The_Destroyer Stalin-3000 Max.Pain ]
10
+ usernames.each do |u|
11
+ @user.username = u
12
+ assert @user.valid?
13
+ end
14
+ end
15
+
16
+ test "invalid usernames" do
17
+ usernames = %w[ 松本行弘 $uperM@n_2025 Поручик_Ржевский ]
18
+ usernames.append ("a" * 256)
19
+ usernames.each do |u|
20
+ @user.username = u
21
+ assert_not @user.valid?
22
+ end
23
+ end
24
+
25
+ test "username should be uniq" do
26
+ @user.username = "Jane Doe"
27
+ assert_not @user.valid?
28
+ end
29
+
30
+ test "email should be uniq" do
31
+ @user.email_address = "guest@example.com"
32
+ assert_not @user.valid?
33
+ end
34
+ end
@@ -0,0 +1,25 @@
1
+ ENV["RAILS_ENV"] ||= "test"
2
+ require_relative "../config/environment"
3
+ require "rails/test_help"
4
+
5
+ class ActiveSupport::TestCase
6
+ parallelize(workers: :number_of_processors)
7
+
8
+ fixtures :all
9
+
10
+ def sign_in_as_admin_user(user)
11
+ post admin_sign_in_url, params: {
12
+ email_address: user.email_address,
13
+ password: "Password1234"
14
+ }
15
+ user
16
+ end
17
+
18
+ def sign_in_as(user)
19
+ post session_path, params: {
20
+ email_address: user.email_address,
21
+ password: "Password1234"
22
+ }
23
+ user
24
+ end
25
+ end