Add LndHub db/models, and quick stats for admin views
This commit is contained in:
parent
09d99ce9c2
commit
dd482d7f2e
3
app/components/quickstats_container_component.html.erb
Normal file
3
app/components/quickstats_container_component.html.erb
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<dl class="grid grid-cols-2 lg:grid-cols-4 gap-6 sm:gap-12">
|
||||||
|
<%= content %>
|
||||||
|
</dl>
|
4
app/components/quickstats_container_component.rb
Normal file
4
app/components/quickstats_container_component.rb
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class QuickstatsContainerComponent < ViewComponent::Base
|
||||||
|
end
|
18
app/components/quickstats_item_component.html.erb
Normal file
18
app/components/quickstats_item_component.html.erb
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<div class="">
|
||||||
|
<dt class="mb-2 text-gray-500">
|
||||||
|
<%= @title %>
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
<% if @type == :number %>
|
||||||
|
<span class="text-2xl"><%= number_with_delimiter @value %></span>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-2xl"><%= @value %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if @unit %>
|
||||||
|
<span><%= @unit %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if @meta %>
|
||||||
|
<span class="text-gray-500"><%= @meta %></span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
13
app/components/quickstats_item_component.rb
Normal file
13
app/components/quickstats_item_component.rb
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class QuickstatsItemComponent < ViewComponent::Base
|
||||||
|
def initialize(type:, title:, value:, unit: nil, meta: nil, icon_name: nil, icon_color_class: nil)
|
||||||
|
@type = type
|
||||||
|
@title = title
|
||||||
|
@value = value
|
||||||
|
@unit = unit
|
||||||
|
@meta = meta
|
||||||
|
@icon_name = icon_name
|
||||||
|
@icon_color_class = icon_color_class
|
||||||
|
end
|
||||||
|
end
|
@ -5,7 +5,11 @@ class Admin::DonationsController < Admin::BaseController
|
|||||||
# GET /donations
|
# GET /donations
|
||||||
# GET /donations.json
|
# GET /donations.json
|
||||||
def index
|
def index
|
||||||
@donations = Donation.all
|
@donations = Donation.all.order('created_at desc')
|
||||||
|
@stats = {
|
||||||
|
overall_sats: @donations.all.sum("amount_sats"),
|
||||||
|
donor_count: Donation.distinct.count(:user_id)
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /donations/1
|
# GET /donations/1
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
class Admin::InvitationsController < Admin::BaseController
|
class Admin::InvitationsController < Admin::BaseController
|
||||||
def index
|
def index
|
||||||
@current_section = :invitations
|
@current_section = :invitations
|
||||||
@invitations_unused_count = Invitation.unused.count
|
|
||||||
@users_with_referrals_count = Invitation.used.distinct.count(:user_id)
|
|
||||||
@invitations_used = Invitation.used.order('used_at desc')
|
@invitations_used = Invitation.used.order('used_at desc')
|
||||||
|
@stats = {
|
||||||
|
available: Invitation.unused.count,
|
||||||
|
accepted: @invitations_used.length,
|
||||||
|
users_with_referrals: Invitation.used.distinct.count(:user_id)
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
12
app/controllers/admin/lightning_controller.rb
Normal file
12
app/controllers/admin/lightning_controller.rb
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
class Admin::LightningController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
@current_section = :lightning
|
||||||
|
|
||||||
|
@users = User.pluck(:cn, :ou, :ln_account)
|
||||||
|
@accounts = LndhubAccount.with_balances.order(balance: :desc).to_a
|
||||||
|
|
||||||
|
@ln = {}
|
||||||
|
@ln[:current_balance] = LndhubAccount.current.joins(:ledgers).sum("account_ledgers.amount")
|
||||||
|
@ln[:users_with_sats] = @accounts.length
|
||||||
|
end
|
||||||
|
end
|
21
app/models/lndhub_account.rb
Normal file
21
app/models/lndhub_account.rb
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
class LndhubAccount < LndhubBase
|
||||||
|
self.table_name = "accounts"
|
||||||
|
self.inheritance_column = :_type_disabled
|
||||||
|
|
||||||
|
has_many :ledgers, class_name: "LndhubAccountLedger",
|
||||||
|
foreign_key: "account_id"
|
||||||
|
|
||||||
|
belongs_to :user, class_name: "LndhubUser",
|
||||||
|
foreign_key: "user_id"
|
||||||
|
|
||||||
|
scope :current, -> { where(type: "current") }
|
||||||
|
scope :outgoing, -> { where(type: "outgoing") }
|
||||||
|
scope :incoming, -> { where(type: "incoming") }
|
||||||
|
scope :fees, -> { where(type: "fees") }
|
||||||
|
|
||||||
|
scope :with_balances, -> {
|
||||||
|
current.joins(:user).joins(:ledgers)
|
||||||
|
.group("accounts.id", "users.login")
|
||||||
|
.select("accounts.id, users.login, SUM(account_ledgers.amount) AS balance")
|
||||||
|
}
|
||||||
|
end
|
3
app/models/lndhub_account_ledger.rb
Normal file
3
app/models/lndhub_account_ledger.rb
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
class LndhubAccountLedger < LndhubBase
|
||||||
|
self.table_name = "account_ledgers"
|
||||||
|
end
|
4
app/models/lndhub_base.rb
Normal file
4
app/models/lndhub_base.rb
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
class LndhubBase < ActiveRecord::Base
|
||||||
|
self.abstract_class = true
|
||||||
|
establish_connection :lndhub
|
||||||
|
end
|
15
app/models/lndhub_user.rb
Normal file
15
app/models/lndhub_user.rb
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
class LndhubUser < LndhubBase
|
||||||
|
self.table_name = "users"
|
||||||
|
self.inheritance_column = :_type_disabled
|
||||||
|
|
||||||
|
has_many :accounts, class_name: "LndhubAccount",
|
||||||
|
foreign_key: "user_id"
|
||||||
|
|
||||||
|
belongs_to :user, class_name: "User",
|
||||||
|
primary_key: "ln_account",
|
||||||
|
foreign_key: "login"
|
||||||
|
|
||||||
|
def balance
|
||||||
|
accounts.current.first.ledgers.sum("account_ledgers.amount")
|
||||||
|
end
|
||||||
|
end
|
@ -5,6 +5,11 @@ class User < ApplicationRecord
|
|||||||
has_many :invitations, dependent: :destroy
|
has_many :invitations, dependent: :destroy
|
||||||
has_many :donations, dependent: :nullify
|
has_many :donations, dependent: :nullify
|
||||||
|
|
||||||
|
has_one :lndhub_user, class_name: "LndhubUser", inverse_of: "user",
|
||||||
|
primary_key: "ln_account", foreign_key: "login"
|
||||||
|
|
||||||
|
has_many :accounts, through: :lndhub_user
|
||||||
|
|
||||||
validates_uniqueness_of :cn
|
validates_uniqueness_of :cn
|
||||||
validates_length_of :cn, :minimum => 3
|
validates_length_of :cn, :minimum => 3
|
||||||
validates_uniqueness_of :email
|
validates_uniqueness_of :email
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
<%= render HeaderComponent.new(title: "Admin Panel") %>
|
<%= render HeaderComponent.new(title: "Admin Panel") %>
|
||||||
|
|
||||||
<%= render MainSimpleComponent.new do %>
|
<%= render MainSimpleComponent.new do %>
|
||||||
<p class="text-center">
|
<div class="text-center">
|
||||||
With great power comes great responsibility.
|
<p class="my-12 inline-flex align-center items-center">
|
||||||
</p>
|
<%= image_tag("/img/illustrations/undraw_vault_re_s4my.svg", class: 'h-48') %>
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-500">
|
||||||
|
With great power comes great responsibility.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -1,7 +1,26 @@
|
|||||||
<%= render HeaderComponent.new(title: "Donations") %>
|
<%= render HeaderComponent.new(title: "Donations") %>
|
||||||
|
|
||||||
<%= render MainSimpleComponent.new do %>
|
<%= render MainSimpleComponent.new do %>
|
||||||
|
<section>
|
||||||
|
<%= render QuickstatsContainerComponent.new do %>
|
||||||
|
<%= render QuickstatsItemComponent.new(
|
||||||
|
type: :number,
|
||||||
|
title: 'Overall',
|
||||||
|
value: @stats[:overall_sats],
|
||||||
|
unit: 'sats'
|
||||||
|
) %>
|
||||||
|
<%= render QuickstatsItemComponent.new(
|
||||||
|
type: :number,
|
||||||
|
title: 'Donors',
|
||||||
|
value: @stats[:donor_count],
|
||||||
|
meta: "/ #{User.count} users"
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
<% if @donations.any? %>
|
<% if @donations.any? %>
|
||||||
|
<h3>Recent Donations</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -11,7 +30,7 @@
|
|||||||
<th class="text-right">in USD</th>
|
<th class="text-right">in USD</th>
|
||||||
<th class="pl-2">Public name</th>
|
<th class="pl-2">Public name</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th colspan="3"></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
@ -23,11 +42,13 @@
|
|||||||
<td class="text-right"><% if donation.amount_eur.present? %><%= number_to_currency donation.amount_eur / 100, unit: "" %><% end %></td>
|
<td class="text-right"><% if donation.amount_eur.present? %><%= number_to_currency donation.amount_eur / 100, unit: "" %><% end %></td>
|
||||||
<td class="text-right"><% if donation.amount_usd.present? %><%= number_to_currency donation.amount_usd / 100, unit: "" %><% end %></td>
|
<td class="text-right"><% if donation.amount_usd.present? %><%= number_to_currency donation.amount_usd / 100, unit: "" %><% end %></td>
|
||||||
<td class="pl-2"><%= donation.public_name %></td>
|
<td class="pl-2"><%= donation.public_name %></td>
|
||||||
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d") : "" %></td>
|
<td><%= donation.paid_at ? donation.paid_at.strftime("%Y-%m-%d (%H:%M UTC)") : "" %></td>
|
||||||
<td><%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %></td>
|
<td class="text-right">
|
||||||
<td><%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %></td>
|
<%= link_to 'Show', admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
|
||||||
<td><%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red',
|
<%= link_to 'Edit', edit_admin_donation_path(donation), class: 'btn btn-sm btn-gray' %>
|
||||||
data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %></td>
|
<%= link_to 'Destroy', admin_donation_path(donation), class: 'btn btn-sm btn-red',
|
||||||
|
data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } %>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -37,6 +58,7 @@
|
|||||||
No donations yet.
|
No donations yet.
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="mt-12">
|
<p class="mt-12">
|
||||||
<%= link_to 'Record an out-of-system donation', new_admin_donation_path, class: 'btn-md btn-gray' %>
|
<%= link_to 'Record an out-of-system donation', new_admin_donation_path, class: 'btn-md btn-gray' %>
|
||||||
|
@ -2,16 +2,28 @@
|
|||||||
|
|
||||||
<%= render MainSimpleComponent.new do %>
|
<%= render MainSimpleComponent.new do %>
|
||||||
<section>
|
<section>
|
||||||
<p>
|
<%= render QuickstatsContainerComponent.new do %>
|
||||||
There are currently <strong><%= @invitations_unused_count %>
|
<%= render QuickstatsItemComponent.new(
|
||||||
unused invitations</strong> available to existing users.
|
type: :number,
|
||||||
<strong><%= @users_with_referrals_count %> users</strong> have successfully
|
title: 'Available',
|
||||||
invited new users.
|
value: @stats[:available],
|
||||||
</p>
|
) %>
|
||||||
|
<%= render QuickstatsItemComponent.new(
|
||||||
|
type: :number,
|
||||||
|
title: 'Accepted',
|
||||||
|
value: @stats[:accepted],
|
||||||
|
) %>
|
||||||
|
<%= render QuickstatsItemComponent.new(
|
||||||
|
type: :number,
|
||||||
|
title: 'Users with referrals',
|
||||||
|
value: @stats[:users_with_referrals],
|
||||||
|
meta: "/ #{User.count}"
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
<% if @invitations_used.any? %>
|
<% if @invitations_used.any? %>
|
||||||
<section>
|
<section>
|
||||||
<h3>Accepted (<%= @invitations_used.length %>)</h3>
|
<h3>Recently Accepted</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
48
app/views/admin/lightning/index.html.erb
Normal file
48
app/views/admin/lightning/index.html.erb
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<%= render HeaderComponent.new(title: "Lightning Network") %>
|
||||||
|
|
||||||
|
<%= render MainSimpleComponent.new do %>
|
||||||
|
<section>
|
||||||
|
<%= render QuickstatsContainerComponent.new do %>
|
||||||
|
<%= render QuickstatsItemComponent.new(
|
||||||
|
type: :number,
|
||||||
|
title: 'Current user balance',
|
||||||
|
value: @ln[:current_balance],
|
||||||
|
unit: 'sats'
|
||||||
|
) %>
|
||||||
|
<%= render QuickstatsItemComponent.new(
|
||||||
|
type: :number,
|
||||||
|
title: 'Users with sats',
|
||||||
|
value: @ln[:users_with_sats],
|
||||||
|
meta: "/ #{User.count}"
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Accounts</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>LN Account</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @accounts.each do |account| %>
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono">
|
||||||
|
<%= account.login[0, 8] %>...<%= account.login[12, 19] %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if user = @users.find{ |u| u[2] == account.login } %>
|
||||||
|
<%= "#{user[0]}@#{user[1]}" %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td><%= number_with_delimiter account.balance.to_i.to_s %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
@ -4,10 +4,10 @@
|
|||||||
<section>
|
<section>
|
||||||
<h3>Password</h3>
|
<h3>Password</h3>
|
||||||
<p class="mb-8">Use the following button to request an email with a password reset link:</p>
|
<p class="mb-8">Use the following button to request an email with a password reset link:</p>
|
||||||
<p>
|
<%= form_with(url: settings_reset_password_path, method: :post) do %>
|
||||||
<%= form_with(url: settings_reset_password_path, method: :post) do %>
|
<p>
|
||||||
<%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %>
|
<%= submit_tag("Send me a password reset link", class: 'btn-md btn-gray w-full sm:w-auto') %>
|
||||||
<% end %>
|
</p>
|
||||||
</p>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
<%= link_to "Dashboard", admin_root_path,
|
<%= link_to "Dashboard", admin_root_path,
|
||||||
class: main_nav_class(@current_section, :dashboard) %>
|
class: main_nav_class(@current_section, :dashboard) %>
|
||||||
|
<%= link_to "Users", admin_ldap_users_path,
|
||||||
|
class: main_nav_class(@current_section, :ldap_users) %>
|
||||||
<%= link_to "Invitations", admin_invitations_path,
|
<%= link_to "Invitations", admin_invitations_path,
|
||||||
class: main_nav_class(@current_section, :invitations) %>
|
class: main_nav_class(@current_section, :invitations) %>
|
||||||
<%= link_to "Donations", admin_donations_path,
|
<%= link_to "Donations", admin_donations_path,
|
||||||
class: main_nav_class(@current_section, :donations) %>
|
class: main_nav_class(@current_section, :donations) %>
|
||||||
<%= link_to "LDAP Users", admin_ldap_users_path,
|
<%= link_to "Lightning", admin_lightning_path,
|
||||||
class: main_nav_class(@current_section, :ldap_users) %>
|
class: main_nav_class(@current_section, :lightning) %>
|
||||||
|
@ -10,21 +10,40 @@ default: &default
|
|||||||
timeout: 5000
|
timeout: 5000
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *default
|
primary:
|
||||||
database: db/development.sqlite3
|
<<: *default
|
||||||
|
database: db/development.sqlite3
|
||||||
|
# lndhub:
|
||||||
|
# <<: *default
|
||||||
|
# database: db/lndhub.sqlite3
|
||||||
|
|
||||||
# Warning: The database defined as "test" will be erased and
|
# Warning: The database defined as "test" will be erased and
|
||||||
# re-generated from your development database when you run "rake".
|
# re-generated from your development database when you run "rake".
|
||||||
# Do not set this db to the same as development or production.
|
# Do not set this db to the same as development or production.
|
||||||
test:
|
test:
|
||||||
<<: *default
|
primary:
|
||||||
database: db/test.sqlite3
|
<<: *default
|
||||||
|
database: db/test.sqlite3
|
||||||
|
lndhub:
|
||||||
|
<<: *default
|
||||||
|
database_tasks: false
|
||||||
|
database: db/test.lndhub.sqlite3
|
||||||
|
|
||||||
production:
|
production:
|
||||||
<<: *default
|
primary:
|
||||||
adapter: postgresql
|
<<: *default
|
||||||
database: akkounts
|
adapter: postgresql
|
||||||
port: 5432
|
database: akkounts
|
||||||
host: <%= Rails.application.credentials.postgres[:host] rescue nil %>
|
port: 5432
|
||||||
username: <%= Rails.application.credentials.postgres[:username] rescue nil %>
|
host: <%= Rails.application.credentials.postgres[:host] rescue nil %>
|
||||||
password: <%= Rails.application.credentials.postgres[:password] rescue nil %>
|
username: <%= Rails.application.credentials.postgres[:username] rescue nil %>
|
||||||
|
password: <%= Rails.application.credentials.postgres[:password] rescue nil %>
|
||||||
|
lndhub:
|
||||||
|
<<: *default
|
||||||
|
adapter: postgresql
|
||||||
|
database_tasks: false
|
||||||
|
database: lndhub
|
||||||
|
port: 5432
|
||||||
|
host: <%= Rails.application.credentials.postgres[:host] rescue nil %>
|
||||||
|
username: <%= Rails.application.credentials.postgres[:username] rescue nil %>
|
||||||
|
password: <%= Rails.application.credentials.postgres[:password] rescue nil %>
|
||||||
|
@ -39,9 +39,10 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
root to: 'dashboard#index'
|
root to: 'dashboard#index'
|
||||||
get 'invitations', to: 'invitations#index'
|
|
||||||
get 'ldap_users', to: 'ldap_users#index'
|
get 'ldap_users', to: 'ldap_users#index'
|
||||||
|
get 'invitations', to: 'invitations#index'
|
||||||
resources :donations
|
resources :donations
|
||||||
|
get 'lightning', to: 'lightning#index'
|
||||||
end
|
end
|
||||||
|
|
||||||
authenticate :user, ->(user) { user.is_admin? } do
|
authenticate :user, ->(user) { user.is_admin? } do
|
||||||
|
1
public/img/illustrations/undraw_vault_re_s4my.svg
Normal file
1
public/img/illustrations/undraw_vault_re_s4my.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 22 KiB |
15
spec/components/quickstats_container_component_spec.rb
Normal file
15
spec/components/quickstats_container_component_spec.rb
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe QuickstatsContainerComponent, type: :component do
|
||||||
|
pending "add some examples to (or delete) #{__FILE__}"
|
||||||
|
|
||||||
|
# it "renders something useful" do
|
||||||
|
# expect(
|
||||||
|
# render_inline(described_class.new(attr: "value")) { "Hello, components!" }.css("p").to_html
|
||||||
|
# ).to include(
|
||||||
|
# "Hello, components!"
|
||||||
|
# )
|
||||||
|
# end
|
||||||
|
end
|
15
spec/components/quickstats_item_component_spec.rb
Normal file
15
spec/components/quickstats_item_component_spec.rb
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe QuickstatsItemComponent, type: :component do
|
||||||
|
pending "add some examples to (or delete) #{__FILE__}"
|
||||||
|
|
||||||
|
# it "renders something useful" do
|
||||||
|
# expect(
|
||||||
|
# render_inline(described_class.new(attr: "value")) { "Hello, components!" }.css("p").to_html
|
||||||
|
# ).to include(
|
||||||
|
# "Hello, components!"
|
||||||
|
# )
|
||||||
|
# end
|
||||||
|
end
|
Loading…
x
Reference in New Issue
Block a user