rails new Demo --database=postgresql --skip-action-mailer --skip-action-mailbox --skip-test --skip-system-test --skip-jbuilder --css=tailwind
cd Demo
bundle install
bin/rails db:create
Open a New Terminal Tab & run:
bin/rails tailwindcss:watch
Now back to the Terminal Tab you began run the server to test that everything is behaving as supposed
bin/rails s
^C Exiting
Let's install devise:
bundle add devise
bundle install
bin/rails g devise:install
Follow device instructions
Install devise views
bin/rails g devise:views
Let's enable and install Active storage:
Uncomment the following line
./Gemfile
gem "image_processing", "~> 1.2"
Now run:
bin/rails active_storage:install
bin/rails db:migrate
Now let's install an Admin model for devise
bin/rails g devise Author birthday:date nickname biography:text background:decimal
Uncomment the trakable information from the migration generated by devise
t.integer :sign_in_count, default: 0, null: false
t.datetime :current_sign_in_at
t.datetime :last_sign_in_at
t.string :current_sign_in_ip
t.string :last_sign_in_ip
Now migrate it
bin/rails db:migrate
Generate The admin main page and derivates
bin/rails g controller Authors control_panel profile settings --no-helper
Add an avatar image to the writer and enable trackable
class Author < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:trackable
has_one_attached :avatar
end
Force author to authenticate on the author controller Add the line to the controller:
before_action :authenticate_author!
Now let's test it:
bin/rails s
Enable a custom layout for devise: https://github.com/heartcombo/devise/wiki/How-To:-Create-custom-layouts
class ApplicationController < ActionController::Base
layout :layout_by_resource
private
def layout_by_resource
if devise_controller?
"devise"
else
"application"
end
end
end
Now create a layout: layouts/devise.html.erb
Customize your font if you want Follow the instructions here: https://tailwindcss.com/docs/font-family#customizing-your-theme
If you download the font, you can create a directory under app/assets/fonts Then drop the font directory in that path
Then add the font with the following: (Let Exo be my font of choice)
@layer base {
@font-face {
font-family: 'Exo Regular';
font-optical-sizing: auto;
font-display: swap;
src: url('Exo/static/Exo-Regular.ttf');
}
}
Now let's customize our devise layout. To enable dark mode, add the following to the tailwind config:
module.exports = {
darkMode: 'selector',
// ...
}
And now let's use this configuration to our advantage. Basically we can add the class "dark" to our html element and it will implement the dark mode. First let's create a stimulus controller that allows us to setup and toggle the themes.
app/javascript/controllers/theme_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
// Loading
this.setTheme()
// Finished loading
}
enableLightTheme() {
localStorage.theme = 'light'
this.setTheme()
}
enableDarkTheme() {
localStorage.theme = 'dark'
this.setTheme()
}
removeTheme() {
localStorage.removeItem('theme')
this.setTheme()
}
setTheme() {
console.log("Setting theme...")
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
}
Taking the following example let's create our new session: https://tailwindui.com/components/application-ui/forms/sign-in-forms
<!DOCTYPE html>
<html class="h-full">
<head>
<title>Login/Register</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" type="image/x-icon" href="<%= asset_path("icon.ico") %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body
class="h-full bg-white dark:bg-gray-800"
data-controller="theme"
>
<div class="flex min-h-full">
<div class="flex-1 flex min-h-full flex-col justify-center px-4 py-8 lg:px-8">
<!-- Logo and title -->
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<img class="mx-auto h-14 w-auto hidden dark:block" src="<%= asset_path("icons/logo-dark.svg") %>" alt="Your Company">
<img class="mx-auto h-14 w-auto block dark:hidden" src="<%= asset_path("icons/logo-light.svg") %>" alt="Your Company">
<h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 dark:text-gray-300"><%= yield(:devise_title) %></h2>
</div>
<!-- Content -->
<main class="mt-2 sm:mx-auto sm:w-full sm:max-w-sm">
<%= yield %>
</main>
<!-- Footer -->
<div class="mt-10 flex justify-center items-center gap-2">
<div class="bg-indigo-500 rounded-full p-1 flex items-center justify-between gap-4">
<button
class="w-8 h-auto bg-gray-100 dark:bg-gray-400 hover:bg-gray-600 dark:hover:none inline-block rounded-full p-1"
data-action="click->theme#enableDarkTheme"
>
<img src="<%= asset_path("icons/dark-mode.svg") %>">
</button>
<button
class="w-8 h-auto dark:bg-gray-100 bg-gray-400 dark:hover:bg-gray-600 inline-block rounded-full p-1"
data-action="click->theme#enableLightTheme"
>
<img src="<%= asset_path("icons/light-mode.svg") %>">
</button>
</div>
</div>
</div>
<!-- Large Devices Curtain -->
<div class="flex-1 bg-white bg-cover bg-center bg-no-repeat hidden md:block" style="background-image: url(<%= asset_path('notebook-bg.jpg') %>);">
<div class="h-full w-full bg-gray-500 dark:bg-gray-800 opacity-70"></div>
</div>
</div>
</body>
</html>
Notes: I've included an ico so the page will have a cute icon. I've included two logos, for dark mode and light mode the icons are two svgs. You can copy and paste them from the hidden block bellow. I used a background image as well, you can use whatever image you want, I used this one:
Light:
<svg viewBox="0 0 128 64" fill="#1C274C" xmlns="http://www.w3.org/2000/svg">
<g style="transform: scale(2) translateX(15%);">
<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z"></path>
<path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.3313C16.1654 17.0234 16.1904 16.5492 16.4983 16.2721L18.1947 14.7452C18.9826 14.0362 19.5138 13.5558 19.8579 13.1467C20.1882 12.7541 20.2603 12.525 20.2603 12.3295C20.2603 12.1341 20.1882 11.9049 19.8579 11.5123C19.5138 11.1033 18.9826 10.6229 18.1947 9.91383L16.4983 8.387C16.1904 8.10991 16.1654 7.63569 16.4425 7.32781Z"></path>
<path d="M7.50178 8.387C7.80966 8.10991 7.83462 7.63569 7.55752 7.32781C7.28043 7.01993 6.80621 6.99497 6.49833 7.27206L4.76084 8.8358C4.0245 9.49847 3.41369 10.0482 2.99428 10.5467C2.55325 11.071 2.23975 11.6342 2.23975 12.3295C2.23975 13.0248 2.55325 13.5881 2.99428 14.1123C3.41369 14.6109 4.02449 15.1606 4.76082 15.8232L6.49833 17.387C6.80621 17.6641 7.28043 17.6391 7.55752 17.3313C7.83462 17.0234 7.80966 16.5492 7.50178 16.2721L5.80531 14.7452C5.01743 14.0362 4.48623 13.5558 4.14213 13.1467C3.81188 12.7541 3.73975 12.525 3.73975 12.3295C3.73975 12.1341 3.81188 11.9049 4.14213 11.5123C4.48623 11.1033 5.01743 10.6229 5.80531 9.91383L7.50178 8.387Z"></path>
</g>
<text x="50%" y="85%" dominant-baseline="middle" text-anchor="middle" style="font-family: 'Exo Regular';font-size: 12px;">PULGAMECANICA</text>
</svg>
Dark:
<svg viewBox="0 0 128 64" fill="#e5e7eb" xmlns="http://www.w3.org/2000/svg">
<g style="transform: scale(2) translateX(15%);">
<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z"></path>
<path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.3313C16.1654 17.0234 16.1904 16.5492 16.4983 16.2721L18.1947 14.7452C18.9826 14.0362 19.5138 13.5558 19.8579 13.1467C20.1882 12.7541 20.2603 12.525 20.2603 12.3295C20.2603 12.1341 20.1882 11.9049 19.8579 11.5123C19.5138 11.1033 18.9826 10.6229 18.1947 9.91383L16.4983 8.387C16.1904 8.10991 16.1654 7.63569 16.4425 7.32781Z"></path>
<path d="M7.50178 8.387C7.80966 8.10991 7.83462 7.63569 7.55752 7.32781C7.28043 7.01993 6.80621 6.99497 6.49833 7.27206L4.76084 8.8358C4.0245 9.49847 3.41369 10.0482 2.99428 10.5467C2.55325 11.071 2.23975 11.6342 2.23975 12.3295C2.23975 13.0248 2.55325 13.5881 2.99428 14.1123C3.41369 14.6109 4.02449 15.1606 4.76082 15.8232L6.49833 17.387C6.80621 17.6641 7.28043 17.6391 7.55752 17.3313C7.83462 17.0234 7.80966 16.5492 7.50178 16.2721L5.80531 14.7452C5.01743 14.0362 4.48623 13.5558 4.14213 13.1467C3.81188 12.7541 3.73975 12.525 3.73975 12.3295C3.73975 12.1341 3.81188 11.9049 4.14213 11.5123C4.48623 11.1033 5.01743 10.6229 5.80531 9.91383L7.50178 8.387Z"></path>
</g>
<text x="50%" y="85%" dominant-baseline="middle" text-anchor="middle" style="font-family: 'Exo Regular';font-size: 12px;">PULGAMECANICA</text>
</svg>
Modify the views/devise/sessions/new.html.erb file:
<% content_for :devise_title do %>
Sign in to your account
<% end %>
<%= form_for(resource, as: resource_name, url: session_path(resource_name), html: {class: "space-y-6"}) do |f| %>
<div>
<%= f.label :email, class: "block text-sm font-medium leading-6 text-gray-900 dark:text-gray-300" %>
<div class="mt-2">
<%= f.email_field :email, autofocus: true, autocomplete: "email", required: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" %>
</div>
</div>
<div>
<%= f.label :password, class: "block text-sm font-medium leading-6 text-gray-900 dark:text-gray-300" %>
<%= f.password_field :password, autocomplete: "current-password", required: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" %>
</div>
<% if devise_mapping.rememberable? %>
<div class="field">
<%= f.check_box :remember_me %>
<%= f.label :remember_me %>
</div>
<% end %>
<div>
<%= f.submit "Log in", class: "flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"%>
</div>
<% end %>
<%= render "devise/shared/links" %>
You should have a nice login page now. We can implement some cool dynamics for the flash alert and notice.
Let's change slightly our devise layout:
<!DOCTYPE html>
<html class="h-full">
...
<body
class="h-full bg-white dark:bg-gray-800"
data-controller="theme"
>
<div class="flex min-h-full">
<div class="flex-1 flex relative min-h-full flex-col justify-center px-4 py-8 lg:px-8">
<!-- Alert and Notice -->
<div
data-controller="hide"
class="absolute w-full left-0 top-0 opacity-90 flex flex-col gap-1 py-3 px-1 md:px-10 justify-center items-center"
data-action="mouseover->hide#appear mouseout->hide#disappear"
>
<% [[notice, 'bg-indigo-500'], [alert, 'bg-red-500']].each do |msg, bg| %>
<% if not msg.nil? %>
<div class="<%= bg %> rounded-full w-full flex items-center text-white text-sm font-bold px-4 py-3 justify-center" role="alert">
<div class="flex-1 flex items-center justify-center gap-2">
<svg class="flex-shrink-0 w-5 h-5" viewBox="0 0 24.00 24.00" fill="none">
<path d="M12 7V13" stroke="#f6f5f4" stroke-width="2.4" stroke-linecap="round"></path>
<circle cx="12" cy="16" r="1" fill="#f6f5f4"></circle>
<path d="M7 3.33782C8.47087 2.48697 10.1786 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 10.1786 2.48697 8.47087 3.33782 7" stroke="#f6f5f4" stroke-width="2.4" stroke-linecap="round"></path>
</svg>
<p><%= msg %></p>
</div>
<button data-action="click->hide#close" class="w-6 h-6 rounded-full p-0 hover:bg-gray-200 flex items-center justify-center"><img src="<%= asset_path("icons/close.svg") %>"></button>
</div>
<% end %>
<% end %>
</div>
...
...
...
</div>
</div>
</body>
</html>
As you would have guessed it, we need to create the stimulus controller which we are calling "hide"
rails g stimulus hide
And create the following controller:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.disappear()
}
disappear() {
this.element.style.transition = "transform 4s ease-in-out 1s"
this.element.style.transform = "translateY(-100%)"
}
appear() {
this.element.style.transition = "none"
this.element.style.transform = "translateY(0%)"
}
close() {
this.element.style.display = "none"
}
}
Now we have a beautiful login page with alerts and message which disapear automatically.
Go ahead and create an Author:
rails c
> Author.create!(email: "[email protected]", birthday: Time.now, nickname: "pulgamecanica", biography: "Awesome Programmer", background: 1, password: "author123", password_confirmation: "author123")
Now make sure you can login with this author on the login page!
Let's move on and continue developing the 3 pages we created before:
Let's begin by creating an admin layout
cp app/views/layouts/application.html.erb app/views/layouts/author.html.erb
And add this layout to the author controller:
class AuthorsController < ApplicationController
layout 'author'
before_action :authenticate_author!
def control_panel
end
def profile
end
def settings
end
end
This page will be interesting if you like front-end development.
We will use Hotwire, Stimulus, and d3 to generate an page where authors can manage their posts and many more features.
We will use the gem "Fiendly ID" this gem allows us to easily map our id's to slug names for our pages. Instead of doing GET /post/1 we would do post/ruby_is_awesome_post which is more friendly for the users. And easier to visualize.
Add the following gem to your gemfile: https://github.com/norman/friendly_id
gem 'friendly_id', '~> 5.5.0'
And install it:
bundle install
Now generate the migration for the gem to work
bin/rails generate friendly_id
bin/rails db:migrate
If you look at the migration you can somehow understand how the gem works, it'w quite simple and awesome!
Let's generatte our Posts sturcture:
rails g scaffold authors/Post title description:text published_at:datetime published:boolean author:references slug:uniq --no-helper
bin/rails db:migrate
I don't like this setup just quite... If you try to visit /authors/posts you will realize that it's not useing the author layout, which is unfortunate. Also you might have noticed that the scaffold generated a file on the models authors.rb
We don't want to use namespaces for our Author resources, we want to use modules, and we want the resources which belong to the author to inherit directly it's controller. This way the resourcess will have access to the current_author which will be very convenient.
First let's modify a little our posts_controller.rb
post_controller.rb
# From this
class Authors::PostsController < ApplicationController
# To this
module Authors
class PostsController < AuthorsController
...
...
...
end
end
Now if you reload the page, you will see that it will immediately inherit from AuthorsController, therfore also implementing the author layout!
NameError: uninitialized constant Authors::Post::FriendlyId
Let's implement friendly id's for our posts slug, in the models/post.rb
class Authors::Post < ApplicationRecord
extend FriendlyId
friendly_id :title, use: :slugged
belongs_to :author
end
And if course we must setup the Author model in order to support author.posts
models/author.rb
class Author < ApplicationRecord
devise :database_authenticatable,
:rememberable, :validatable, :trackable
has_one_attached :avatar
has_many :posts, class_name: "Authors::Post"
end
It is necessary to specify explicitly the class_name since it's a module model, see more here: https://guides.rubyonrails.org/association_basics.html#controlling-association-scope
Modify the following function on the posts_controller.rb to support friendly id's
def set_authors_post
@authors_post = Authors::Post.friendly.find(params[:id])
end
And now the following functions also from posts_controller.rb in order to build the posts from the current author, and in the future omit the author_id
# GET /authors/posts/new
def new
@authors_post = current_author.posts.build
end
# GET /authors/posts/1/edit
def edit
end
# POST /authors/posts
def create
@authors_post = current_author.posts.build(authors_post_params)
if @authors_post.save
redirect_to @authors_post, notice: "Post was successfully created."
else
render :new, status: :unprocessable_entity
end
end
Now try and create a post: It will work.
The index will stop working because rails didn't concider the author was scoped and generated edit_post_path instead of edit_authors_post_path
but we can just easily fix that by putting the correct one: edit_authors_post_path
Now we should focus on creating an amazing spotify-like interface. Let's get rolling with that!
Start simple:
<!DOCTYPE html>
<html class="h-full">
<head>
<title>Authors- Pulgamecanica</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" type="image/x-icon" href="<%= asset_path("icon.ico") %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body
class="h-full bg-white dark:bg-gray-800"
data-controller="theme"
>
<!-- Main Divides the quick bottom menu and the rest of the application-->
<main class="flex flex-col flex-nowrap w-full h-full items-center justify-between">
<!-- Application main section-->
<section class="flex-1 w-full h-full flex flex-wrap gap-1 items-stretch p-1">
<div class="flex flex-col gap-1">
<div class="w-16 bg-purple-600 rounded-lg">Home / Search</div>
<div class="w-16 flex-1 bg-purple-600 rounded-lg">Posts / List</div>
<div class="w-16 flex-1 bg-purple-600 rounded-lg">Projects / List</div>
</div>
<div class="flex-1 bg-purple-600 rounded-lg">Main Content</div>
<div class="w-16 bg-purple-600 rounded-lg">Featured Content</div>
</section>
<!-- Bottom Menu -->
<section class="w-full bg-black flex justify-between items-center px-5 gap-1">
<!--1--><img class="h-8 w-auto" src="<%= asset_path("icons/logo-dark.svg") %>">
<!--2--><div class="text-white">by pulgmecanica</div>
<!--3--><div class="flex justify-center items-center py-1">
<div class="bg-gray-500 rounded-full p-1 flex items-center justify-between gap-2">
<button
class="w-6 h-auto bg-gray-100 dark:bg-gray-400 hover:bg-gray-600 dark:hover:none inline-block rounded-full p-1"
data-action="click->theme#enableDarkTheme"
>
<img src="<%= asset_path("icons/dark-mode.svg") %>">
</button>
<button
class="w-6 h-auto dark:bg-gray-100 bg-gray-400 dark:hover:bg-gray-600 inline-block rounded-full p-1"
data-action="click->theme#enableLightTheme"
>
<img src="<%= asset_path("icons/light-mode.svg") %>">
</button>
</div>
</div>
</section>
</main>
</body>
</html>
With a simple layout divided first in 2
The Upper and Bottom parts. Upper => The whole application Bottom => Quick black horizontal menu
Then the upper is divided in 3 Horizontally, from left to right. The menus, the content of the application, and the feauted content (this later can be closed)
It's time to implement a very fun feature which I love about Spotify, the resizisable boxes.
For this we will be using d3.
Let's begin by generating our stimulus controller:
rails g stimulus resizable
Note: if you want to load the js as they appear in the DOM instead of loading every controller all the time,
you must edit the import map and append preload: false
to the stimulus controllers
Then on the index.js enable the lazyLoadControllersFrom
and comment the eagerLoadControllersFrom
Let's add the resizers and implement the resizable data controllers:
<!-- Application main section-->
<section class="flex-1 w-full h-full flex flex-wrap items-stretch p-1">
<div
class="flex"
data-controller="resizable"
data-resizable-storage-value="menu"
>
<div class="relative flex-1 flex flex-col gap-1">
<div class="bg-purple-600 rounded-lg">Home / Search</div>
<div class="flex-1 bg-purple-600 rounded-lg">Posts / List</div>
<div class="flex-1 bg-purple-600 rounded-lg">Projects / List</div>
</div>
<div
class="w-2 cursor-col-resize bg-black"
data-resizable-target="resizer"
></div>
</div>
<div class="flex-1 bg-purple-600 rounded-lg">Main Content</div>
<div
class="relative flex flex-row-reverse"
data-controller="resizable"
data-resizable-storage-value="featured"
data-resizable-right-to-left-value="false"
>
<div class="flex-1 bg-purple-600 rounded-lg">Featured Content</div>
<div
class="w-2 cursor-col-resize bg-black"
data-resizable-target="resizer"
></div>
</div>
</section>
...
Now let's install d3:
bin/importmap pin d3
And for each pin, append preload: false
so that it wont be loaded by default
Now place the following d3 code for resizing in your controller
resize_controller.js
import { Controller } from "@hotwired/stimulus"
import { select, selectAll, drag, pointer, scaleLinear } from "d3"
// LocalStorage Helper Functions
const getLocalStorageOrDefault = (storage) => {
const currSettings = JSON.parse(localStorage.getItem(storage));
if (currSettings === null)
return 100;
else
return currSettings;
}
function saveToLocalStorage(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
// Scale Helpers & Functions
const SIDEBAR_WIDTH_MIN = 50
const SIDEBAR_WIDTH_MAX = 600
const myScale = scaleLinear()
.domain([SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX])
.range([SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX])
.clamp(true)
// When using this controller be careful and don't give the value to your data-values
// that could conflict with another local Storage elemnt, such as 'theme'
export default class extends Controller {
static targets = [ "resizer" ]
static values = {
storage: String,
rightToLeft: { type: Boolean, default: true }
}
connect() {
// Create a d3 reference to the resizable element
const resizable = select(this.element)
if (window.innerWidth > 480) { // Do not resize for small resolutions
resizable.style("width", getLocalStorageOrDefault(this.storageValue) + "px")
}
// Debugggggg
console.log(this.storageValue, resizable.style("width"))
// Resize D3:drag component
select(this.resizerTarget).call(drag()
.on('drag', () => {
// Get the width based on the pointer position
let x = pointer(event, resizable)[0]
if (this.rightToLeftValue == false) {
x = window.innerWidth - x
}
// Resize the element
resizable.style('width', myScale(x) + 'px')
// sidebarIconsResponsive(x);
})
.on('end', () => {
// On drag end save the width to localStorage
let x = pointer(event, resizable)[0]
if (this.rightToLeftValue == false) {
x = window.innerWidth - x
}
saveToLocalStorage(this.storageValue, myScale(x))
})
);
}
}
Now you should be able to resize freely both ends of the boxes. Now we must concider responsivity. Phones don't need to resize they don't even use a mouse. Also taking as an example spotify we can make something similar.
The follwing is more compatible with phones:
<!DOCTYPE html>
<html class="h-full">
<head>
<title>Authors- Pulgamecanica</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" type="image/x-icon" href="<%= asset_path("icon.ico") %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body
class="h-full bg-white dark:bg-black"
data-controller="theme"
>
<!-- Main Divides the quick bottom menu and the rest of the application-->
<main class="w-full h-full pb-10 text-gray-800 dark:text-gray-200">
<!-- Application main section-->
<section class="w-full h-full flex flex-wrap md:flex-nowrap items-stretch p-1 gap-1 md:gap-0">
<div
class="flex w-full md:w-auto"
data-controller="resizable"
data-resizable-storage-value="menu"
>
<div class="flex-1 block overflow-hidden break-words">
<div class="flex h-full flex-col gap-1">
<!-- Home / Search -->
<div class="bg-gray-200 dark:bg-gray-800 rounded-[0.5rem]">
<% [[authors_control_panel_path, "HOME", asset_path("icons/home-light.svg"), asset_path("icons/home-dark.svg")],[authors_control_panel_path, "SEARCH", asset_path("icons/search-light.svg"), asset_path("icons/search-dark.svg")]].each do |link, text, light_icon_path, dark_icon_path| %>
<%= link_to link, class: "flex w-full items-center gap-2 group p-2" do %>
<div
class="opacity-60 group-hover:opacity-100 w-8 h-auto hidden dark:inline-block rounded-full p-1"
>
<img src="<%= light_icon_path %>">
</div>
<div
class="opacity-60 group-hover:opacity-100 w-8 h-auto inline-block dark:hidden rounded-full p-1"
>
<img src="<%= dark_icon_path %>">
</div>
<h1 class="group-hover:underline group-hover:text-gray-800 dark:group-hover:text-white text-gray-600 dark:text-gray-400 font-bold"><%= text %></h1>
<% end %>
<% end %>
</div>
<!-- Posts -->
<div class="flex-1 bg-gray-200 dark:bg-gray-800 rounded-[0.5rem] flex flex-nowrap flex-col">
<div class="flex flex-row items-center justify-between">
<%= link_to authors_posts_path, class: "flex-1 flex w-full items-center gap-2 group p-2" do %>
<div
class="opacity-60 group-hover:opacity-100 w-8 h-auto hidden dark:inline-block rounded-full p-1"
data-action="click->theme#enableDarkTheme"
>
<img src="<%= asset_path("icons/library-light.svg") %>">
</div>
<div
class="opacity-60 group-hover:opacity-100 w-8 h-auto inline-block dark:hidden rounded-full p-1"
data-action="click->theme#enableLightTheme"
>
<img src="<%= asset_path("icons/library-dark.svg") %>">
</div>
<h1 class="group-hover:underline group-hover:text-gray-800 dark:group-hover:text-white text-gray-600 dark:text-gray-400 font-bold">POSTS</h1>
<% end %>
<%= link_to new_authors_post_path, class: "group w-8 h-auto inline-block dark:hidden rounded-full p-1" do %>
<img class="opacity-60 group-hover:opacity-100" src="<%= asset_path("icons/add-dark.svg") %>">
<% end %>
<%= link_to new_authors_post_path, class: "group w-8 h-auto hidden dark:inline-block rounded-full p-1" do %>
<img class="opacity-60 group-hover:opacity-100" src="<%= asset_path("icons/add-light.svg") %>">
<% end %>
</div>
<button class="flex group justify-end items-center gap-2">
<p class="group-hover:underline group-hover:text-gray-800 dark:group-hover:text-white text-gray-600 dark:text-gray-400 text-sm">Recents</p>
<img class="hidden dark:inline-block h-4 w-4 opacity-60 group-hover:opacity-100" src="<%= asset_path("icons/filter-light.svg") %>">
<img class="inline-block dark:hidden h-4 w-4 opacity-60 group-hover:opacity-100" src="<%= asset_path("icons/filter-dark.svg") %>">
</button>
<div class="flex-1 text-white">
</div>
</div>
<!-- Projects -->
<div class="flex-1 bg-gray-200 dark:bg-gray-800 rounded-[0.5rem] flex flex-nowrap flex-col">
<div class="flex flex-row items-center justify-between">
<%= link_to authors_posts_path, class: "flex-1 flex w-full items-center gap-2 group p-2" do %>
<div
class="opacity-60 group-hover:opacity-100 w-8 h-auto hidden dark:inline-block rounded-full p-1"
data-action="click->theme#enableDarkTheme"
>
<img src="<%= asset_path("icons/library-light.svg") %>">
</div>
<div
class="opacity-60 group-hover:opacity-100 w-8 h-auto inline-block dark:hidden rounded-full p-1"
data-action="click->theme#enableLightTheme"
>
<img src="<%= asset_path("icons/library-dark.svg") %>">
</div>
<h1 class="group-hover:underline group-hover:text-gray-800 dark:group-hover:text-white text-gray-600 dark:text-gray-400">POSTS</h1>
<% end %>
<%= link_to new_authors_post_path, class: "group w-8 h-auto inline-block dark:hidden rounded-full p-1" do %>
<img class="opacity-60 group-hover:opacity-100" src="<%= asset_path("icons/add-dark.svg") %>">
<% end %>
<%= link_to new_authors_post_path, class: "group w-8 h-auto hidden dark:inline-block rounded-full p-1" do %>
<img class="opacity-60 group-hover:opacity-100" src="<%= asset_path("icons/add-light.svg") %>">
<% end %>
</div>
<button class="flex group justify-end items-center gap-2">
<p class="group-hover:underline group-hover:text-gray-800 dark:group-hover:text-white text-gray-600 dark:text-gray-400 text-sm">Recents</p>
<img class="hidden dark:inline-block h-4 w-4 opacity-60 group-hover:opacity-100" src="<%= asset_path("icons/filter-light.svg") %>">
<img class="inline-block dark:hidden h-4 w-4 opacity-60 group-hover:opacity-100" src="<%= asset_path("icons/filter-dark.svg") %>">
</button>
<div class="flex-1 text-white">
</div>
</div>
</div>
</div>
<div
class="hidden md:block w-1 cursor-col-resize hover:bg-gray-300 dark:hover:bg-gray-700"
data-resizable-target="resizer"
></div>
</div>
<div class="w-full md:w-auto flex-1 flex-col flex bg-gray-200 dark:bg-gray-800 rounded-[0.5rem] overflow-y-scroll p-2">
<div class="flex justify-end gap-1">
<!-- Light svg, Dark svg, contrller-target-open-->
<% [ [
asset_path("icons/bell-light.svg"),
asset_path("icons/bell-dark.svg"),
"notifications"
], [
asset_path("icons/profile-light.svg"),
asset_path("icons/profile-dark.svg"),
"profile_links"
]
].each do |light_icon_path, dark_icon_path, partial| %>
<div
data-controller="toggle-display"
data-toggle-display-display-value="flex"
class="relative font-bold text-gray-700 dark:text-gray-300"
>
<button
class="p-2 flex dark:hidden bg-gray-100 justify-center items-center rounded-full group"
data-action="click->toggle-display#toggle"
>
<img class="opacity-80 group-hover:opacity-100 h-5 w-5" src="<%= dark_icon_path %>">
</button>
<button
class="p-2 hidden dark:flex bg-gray-900 justify-center items-center rounded-full group"
data-action="click->toggle-display#toggle"
>
<img class="opacity-80 group-hover:opacity-100 h-5 w-5" src="<%= light_icon_path %>">
</button>
<div
data-toggle-display-target="toggleableBox"
class="absolute right-0 top-10 bg-gray-100 dark:bg-gray-900 rounded-sm p-1 flex flex-col hidden shadow-lg shadow-slate-900"
>
<%= render partial %>
</div>
</div>
<% end %>
</div>
<div class="flex-1"><%= yield %></div>
</div>
<div
class="w-full md:w-auto flex flex flex-row-reverse break-words"
data-controller="resizable"
data-resizable-storage-value="featured"
data-resizable-right-to-left-value="false"
>
<div class="flex-1 bg-gray-200 dark:bg-gray-800 rounded-[0.5rem] overflow-hidden inline-block">
<!-- Featured Content Here -->
</div>
<div
class="hidden md:block w-1 cursor-col-resize hover:bg-gray-300 dark:hover:bg-gray-700"
data-resizable-target="resizer"
></div>
</div>
</section>
<!-- Bottom Menu -->
<section class="w-full bg-black fixed md:relative bottom-0 left-0 flex justify-between items-center px-5 gap-1">
<!--1--><img class="h-8 w-auto" src="<%= asset_path("icons/logo-dark.svg") %>">
<!--2--><div class="text-white">by pulgmecanica</div>
<!--3--><div class="flex justify-center items-center py-1">
<div class="bg-gray-500 rounded-full p-1 flex items-center justify-between gap-2">
<button
class="w-6 h-auto bg-gray-100 dark:bg-gray-400 hover:bg-gray-600 dark:hover:none inline-block rounded-full p-1"
data-action="click->theme#enableDarkTheme"
>
<img src="<%= asset_path("icons/dark-mode.svg") %>">
</button>
<button
class="w-6 h-auto dark:bg-gray-100 bg-gray-400 dark:hover:bg-gray-600 inline-block rounded-full p-1"
data-action="click->theme#enableLightTheme"
>
<img src="<%= asset_path("icons/light-mode.svg") %>">
</button>
</div>
</div>
</section>
</main>
</body>
</html>
You need to generate a controller to handle toggle display boxes
rails g stimulus toggleDisplay
rails g stimulus worldSphereMap
toggle_display_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="toggle-display"
export default class extends Controller {
static targets = [ "toggleableBox" ]
static values = {
display: {type: String, default: "block"}
}
toggle() {
if (this.toggleableBoxTarget.style.display === this.displayValue)
this.toggleableBoxTarget.style.display = "none"
else
this.toggleableBoxTarget.style.display = this.displayValue
}
}
world_sphere_controller.js
import { Controller } from "@hotwired/stimulus"
import { select, geoOrthographic, geoPath, drag, zoom, json } from "d3"
const WIDTH = 200
const HEIGHT = 200
// Connects to data-controller="world-sphere-map"
export default class extends Controller {
connect() {
console.log("Enabling Map")
let width = WIDTH
let height = HEIGHT
const sensitivity = 75
let projection = geoOrthographic()
.scale(100)
.center([0, 0])
.rotate([0,-30])
.translate([width / 2, height / 2])
const initialScale = projection.scale()
let path = geoPath().projection(projection)
let svg = select(this.element)
.attr("width", width)
.attr("height", height)
let globe = svg.append("circle")
.attr("fill", "#EEE")
.attr("stroke", "#000")
.attr("stroke-width", "0.2")
.attr("cx", width/2)
.attr("cy", height/2)
.attr("r", initialScale)
svg.call(drag().on('drag', (event) => {
event.stopPropagation
const rotate = projection.rotate()
const k = sensitivity / projection.scale()
projection.rotate([
rotate[0] + event.dx * k,
rotate[1] - event.dy * k
])
path = geoPath().projection(projection)
svg.selectAll("path").attr("d", path)
}))
.call(zoom().on('zoom', (event) => {
event.stopPropagation
if(event.transform.k > 0.3) {
projection.scale(initialScale * event.transform.k)
path = geoPath().projection(projection)
svg.selectAll("path").attr("d", path)
globe.attr("r", projection.scale())
}
else {
event.transform.k = 0.3
}
}))
// .on("dblclick.zoom", null)
// .on("mousewheel.zoom", null)
// .on("DOMMouseScroll.zoom",null)
let map = svg.append("g")
json("/world.json").then(data => {
map.append("g")
.attr("class", "countries" )
.selectAll("path")
.data(data.features)
.enter().append("path")
.attr("class", d => "country_" + d.properties.name.replace(" ","_"))
.attr("d", path)
.attr("fill", "gray")
.style('stroke', 'gray')
.style('stroke-width', 0)
.style("opacity",0.8)
})
}
}
Now you should have a very responsive design.
We will implement a featured content. We can do this with hotwire or stimulus. With stimulus you can fetch a json response and render however you want to an .erb In this case I decided to try out the modern framework which comes for free and built-in Turbo + HotWire!!!
Since we only want to change the content of the featured box and not the whole page (or the URL) we must implement turbo frames.
Let's now add our turbo frame:
On the author layout change the following line
<!-- Featured Content Here -->
For this:
<%= turbo_frame_tag :featured, src: authors_profile_path do %>
<% end %>