diff --git a/CHANGELOG.md b/CHANGELOG.md index 8378f3f..66e7e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ ### Enhancements * [[`PR-27`](https://github.com/thiagoesteves/observer_web/pull/27)] Adding Igniter. + * [[`PR-28`](https://github.com/thiagoesteves/observer_web/pull/28)] Updating Tailwind. + * [[`PR-28`](https://github.com/thiagoesteves/observer_web/pull/28)] Adding theme support. + * [[`PR-28`](https://github.com/thiagoesteves/observer_web/pull/28)] Assets organization. ## 0.1.11 🚀 (2025-08-29) diff --git a/assets/css/app.css b/assets/css/app.css index 3b118e1..ccb0067 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,7 +1,19 @@ @import url('https://fonts.googleapis.com/css?family=Oswald&display=swap'); -@import "tailwindcss/base"; -@import "tailwindcss/components"; -@import "tailwindcss/utilities"; +@custom-variant dark (&:where(.dark, .dark *)); -/* This file is for your main application CSS */ +/* Tailwind */ + +@import "tailwindcss"; +@plugin "@tailwindcss/forms"; + +@source "../../lib/**/*.*ex"; + +@theme { + --font-family-sans: "Oswald", sans-serif; + --font-family-mono: "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace; + + --spacing-72: 18rem; + --spacing-84: 21rem; + --spacing-96: 24rem; +} diff --git a/assets/js/app.js b/assets/js/app.js index dd3495c..04a01bd 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,137 +1,37 @@ -// If you want to use Phoenix channels, run `mix help phx.gen.channel` -// to get started and then uncomment the line below. -// import "./user_socket.js" - -// You can include dependencies in two ways. -// -// The simplest option is to put them in assets/vendor and -// import them using relative paths: -// -// import "../vendor/some-package.js" -// -// Alternatively, you can `npm install some-package --prefix assets` and import -// them using a path starting with the package name: -// -// import "some-package" -// - -// Establish Phoenix Socket and LiveView configuration. +// Phoenix assets are imported from dependencies import topbar from "topbar" -import * as echarts from "echarts" -const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") -const liveTran = document.querySelector("meta[name='live-transport']").getAttribute("content") -const livePath = document.querySelector("meta[name='live-path']").getAttribute("content") - -let hooks = {} - -hooks.ScrollBottom = { - mounted() { - this.el.scrollTo(0, this.el.scrollHeight); - }, +import { loadAll } from "./lib/settings"; - updated() { - const pixelsBelowBottom = - this.el.scrollHeight - this.el.clientHeight - this.el.scrollTop; +import LiveMetricsEChart from "./hooks/live_metrics_echart"; +import ObserverEChart from "./hooks/observer_echart"; +import ScrollBottom from "./hooks/scroll_bottom"; +import Themer from "./hooks/themer"; - if (pixelsBelowBottom < this.el.clientHeight * 0.3) { - this.el.scrollTo(0, this.el.scrollHeight); - } - }, +const hooks = { + LiveMetricsEChart, + ObserverEChart, + ScrollBottom, + Themer, }; -hooks.ObserverEChart = { - mounted() { - selector = "#" + this.el.id - - this.chart = echarts.init(this.el.querySelector(selector + "-chart")) - option = JSON.parse(this.el.querySelector(selector + "-data").textContent) - - this.chart.setOption(option) - }, - updated() { - selector = "#" + this.el.id - // This flag will indicate to Echart to not merge the data - let notMerge = !this.el.dataset.merge ?? true; +// Topbar --- - newOption = JSON.parse(this.el.querySelector(selector + "-data").textContent) - - // Compare the new option series with the previous one - if (this.previousSeries && JSON.stringify(this.previousSeries) === JSON.stringify(newOption.series)) { - // If the data is the same, skip the update - console.log('No changes in the data, skipping setOption'); - return; // Exit without updating the chart - } +let topBarScheduled = undefined; - // Save the new option as the previous one for future comparisons - this.previousSeries = newOption.series; - - // Set the callback in the tooltip formatter (or any other part of the option) - var callback = (args) => { - this.pushEventTo(this.el, "request-process", { id: args.data.id, series_name: args.seriesName }); - return args.data.id; - } - - newOption.tooltip = { - formatter: callback - }; - - this.chart.setOption(newOption, notMerge) - } -}; +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) -hooks.LiveMetricsEChart = { - mounted() { - selector = "#" + this.el.id - - const dataConfig = JSON.parse(this.el.dataset.config) - const columns = JSON.parse(this.el.dataset.columns) - - this.chart = echarts.init(this.el.querySelector(selector + "-chart")) - this.chart.setOption(dataConfig) - this.graph_cols = columns - }, - updated() { - const dataConfig = JSON.parse(this.el.dataset.config) - const reset = JSON.parse(this.el.dataset.reset) - const columns = JSON.parse(this.el.dataset.columns) - - if (reset) { - this.chart.setOption(dataConfig) - - } else { - var option = this.chart.getOption(); - var updatedXAxis = option.xAxis[0].data.concat(dataConfig.xAxis.data); - var updatedSeries = option.series.map((series, index) => { - // Concatenate the corresponding dataset to each series - return { - data: series.data.concat(dataConfig.series[index] ? dataConfig.series[index].data : []) - }; - }); - - this.chart.setOption( - { - xAxis: { data: updatedXAxis }, - series: updatedSeries - }) - } - if (columns != this.columns) { - this.chart.resize() - this.columns = columns - } +window.addEventListener("phx:page-loading-start", (info) => { + if (!topBarScheduled) { + topBarScheduled = setTimeout(() => topbar.show(), 500); } -}; - -const liveSocket = new LiveView.LiveSocket(livePath, Phoenix.Socket, { - transport: liveTran === "longpoll" ? Phoenix.LongPoll : WebSocket, - params: { _csrf_token: csrfToken }, - hooks -}) +}); -// Show progress bar on live navigation and form submits -topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) -window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) -window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) +window.addEventListener("phx:page-loading-stop", (info) => { + clearTimeout(topBarScheduled); + topBarScheduled = undefined; + topbar.hide(); +}); window.addEventListener("phx:copy_to_clipboard", event => { if ("clipboard" in navigator) { @@ -164,6 +64,16 @@ window.addEventListener("phx:copy_to_clipboard", event => { }, 1000); }); +const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +const liveTran = document.querySelector("meta[name='live-transport']").getAttribute("content") +const livePath = document.querySelector("meta[name='live-path']").getAttribute("content") + +const liveSocket = new LiveView.LiveSocket(livePath, Phoenix.Socket, { + transport: liveTran === "longpoll" ? Phoenix.LongPoll : WebSocket, + params: { _csrf_token: csrfToken, init_state: loadAll() }, + hooks +}) + // connect if there are any LiveViews on the page liveSocket.connect() diff --git a/assets/js/hooks/live_metrics_echart.js b/assets/js/hooks/live_metrics_echart.js new file mode 100644 index 0000000..b018c98 --- /dev/null +++ b/assets/js/hooks/live_metrics_echart.js @@ -0,0 +1,45 @@ +import * as echarts from "echarts" + +const LiveMetricsEChart = { + mounted() { + selector = "#" + this.el.id + + const dataConfig = JSON.parse(this.el.dataset.config) + const columns = JSON.parse(this.el.dataset.columns) + + this.chart = echarts.init(this.el.querySelector(selector + "-chart")) + this.chart.setOption(dataConfig) + this.graph_cols = columns + }, + updated() { + const dataConfig = JSON.parse(this.el.dataset.config) + const reset = JSON.parse(this.el.dataset.reset) + const columns = JSON.parse(this.el.dataset.columns) + + if (reset) { + this.chart.setOption(dataConfig) + + } else { + var option = this.chart.getOption(); + var updatedXAxis = option.xAxis[0].data.concat(dataConfig.xAxis.data); + var updatedSeries = option.series.map((series, index) => { + // Concatenate the corresponding dataset to each series + return { + data: series.data.concat(dataConfig.series[index] ? dataConfig.series[index].data : []) + }; + }); + + this.chart.setOption( + { + xAxis: { data: updatedXAxis }, + series: updatedSeries + }) + } + if (columns != this.columns) { + this.chart.resize() + this.columns = columns + } + } +}; + +export default LiveMetricsEChart \ No newline at end of file diff --git a/assets/js/hooks/observer_echart.js b/assets/js/hooks/observer_echart.js new file mode 100644 index 0000000..d6ed231 --- /dev/null +++ b/assets/js/hooks/observer_echart.js @@ -0,0 +1,43 @@ +import * as echarts from "echarts" + +const ObserverEChart = { + mounted() { + selector = "#" + this.el.id + + this.chart = echarts.init(this.el.querySelector(selector + "-chart")) + option = JSON.parse(this.el.querySelector(selector + "-data").textContent) + + this.chart.setOption(option) + }, + updated() { + selector = "#" + this.el.id + // This flag will indicate to Echart to not merge the data + let notMerge = !this.el.dataset.merge ?? true; + + newOption = JSON.parse(this.el.querySelector(selector + "-data").textContent) + + // Compare the new option series with the previous one + if (this.previousSeries && JSON.stringify(this.previousSeries) === JSON.stringify(newOption.series)) { + // If the data is the same, skip the update + console.log('No changes in the data, skipping setOption'); + return; // Exit without updating the chart + } + + // Save the new option as the previous one for future comparisons + this.previousSeries = newOption.series; + + // Set the callback in the tooltip formatter (or any other part of the option) + var callback = (args) => { + this.pushEventTo(this.el, "request-process", { id: args.data.id, series_name: args.seriesName }); + return args.data.id; + } + + newOption.tooltip = { + formatter: callback + }; + + this.chart.setOption(newOption, notMerge) + } +}; + +export default ObserverEChart \ No newline at end of file diff --git a/assets/js/hooks/scroll_bottom.js b/assets/js/hooks/scroll_bottom.js new file mode 100644 index 0000000..60f7c9b --- /dev/null +++ b/assets/js/hooks/scroll_bottom.js @@ -0,0 +1,16 @@ +const ScrollBottom = { + mounted() { + this.el.scrollTo(0, this.el.scrollHeight); + }, + + updated() { + const pixelsBelowBottom = + this.el.scrollHeight - this.el.clientHeight - this.el.scrollTop; + + if (pixelsBelowBottom < this.el.clientHeight * 0.3) { + this.el.scrollTo(0, this.el.scrollHeight); + } + }, +}; + +export default ScrollBottom diff --git a/assets/js/hooks/themer.js b/assets/js/hooks/themer.js new file mode 100644 index 0000000..d425bf2 --- /dev/null +++ b/assets/js/hooks/themer.js @@ -0,0 +1,24 @@ +import { load, store } from "../lib/settings" + +const Themer = { + applyTheme() { + const wantsDark = window.matchMedia("(prefers-color-scheme: dark)").matches + const theme = load("theme") + + if (theme === "dark" || (theme === "system" && wantsDark) || (!theme && wantsDark)) { + document.documentElement.classList.add("dark") + } else { + document.documentElement.classList.remove("dark") + } + }, + + mounted() { + this.handleEvent("update-theme", ({ theme }) => { + store("theme", theme) + + this.applyTheme() + }) + }, +} + +export default Themer diff --git a/assets/js/lib/settings.js b/assets/js/lib/settings.js new file mode 100644 index 0000000..658ba5f --- /dev/null +++ b/assets/js/lib/settings.js @@ -0,0 +1,21 @@ +const PREFIX = "observer:" + +export function loadAll() { + const values = {} + + for (const [key, json] of Object.entries(localStorage)) { + if (key.startsWith(PREFIX)) values[key] = JSON.parse(json) + } + + return values +} + +export function load(key) { + const json = localStorage.getItem(PREFIX + key) + + if (json) return JSON.parse(json) +} + +export function store(key, value) { + localStorage.setItem(PREFIX + key, JSON.stringify(value)) +} diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js deleted file mode 100644 index c670a25..0000000 --- a/assets/tailwind.config.js +++ /dev/null @@ -1,46 +0,0 @@ -const colors = require("tailwindcss/colors"); - -module.exports = { - darkMode: "class", - theme: { - fontFamily: { - sans: ["Inter var", "sans-serif"], - mono: ["Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", "monospace"], - oswald: ["Oswald"] - }, - extend: { - spacing: { - "72": "18rem", - "84": "21rem", - "96": "24rem" - } - }, - colors: { - transparent: "transparent", - current: "currentColor", - black: colors.black, - white: colors.white, - blue: colors.sky, - cyan: colors.cyan, - gray: colors.gray, - green: colors.emerald, - indigo: colors.indigo, - orange: colors.orange, - pink: colors.pink, - red: colors.red, - teal: colors.teal, - violet: colors.violet, - yellow: colors.amber, - slate: colors.slate, - zinc: colors.zinc - } - }, - content: ["../lib/**/*.*ex"], - variants: { - display: ["group-hover"] - }, - plugins: [ - require("@tailwindcss/forms") - ] -} - diff --git a/config/config.exs b/config/config.exs index f5fa97b..ef5e5d3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -20,10 +20,9 @@ if config_env() == :dev do ] config :tailwind, - version: "3.4.0", + version: "4.1.0", default: [ args: ~w( - --config=tailwind.config.js --minify --input=css/app.css --output=../priv/static/app.css diff --git a/lib/web/assets.ex b/lib/web/assets.ex new file mode 100644 index 0000000..7fb6512 --- /dev/null +++ b/lib/web/assets.ex @@ -0,0 +1,54 @@ +defmodule Observer.Web.Assets do + @moduledoc false + + @behaviour Plug + + import Plug.Conn + + phoenix_js_paths = + for app <- ~w(phoenix phoenix_html phoenix_live_view)a do + path = Application.app_dir(app, ["priv", "static", "#{app}.js"]) + Module.put_attribute(__MODULE__, :external_resource, path) + path + end + + @static_path Application.app_dir(:observer_web, ["priv", "static"]) + + @external_resource css_path = Path.join(@static_path, "app.css") + @external_resource js_path = Path.join(@static_path, "app.js") + + @css File.read!(css_path) + + @js """ + #{for path <- phoenix_js_paths, do: path |> File.read!() |> String.replace("//# sourceMappingURL=", "// ")} + #{File.read!(js_path)} + """ + + @impl Plug + def init(asset), do: asset + + @impl Plug + def call(conn, :css) do + conn + |> put_resp_header("content-type", "text/css") + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> put_private(:plug_skip_csrf_protection, true) + |> send_resp(200, @css) + |> halt() + end + + def call(conn, :js) do + conn + |> put_resp_header("content-type", "text/javascript") + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> put_private(:plug_skip_csrf_protection, true) + |> send_resp(200, @js) + |> halt() + end + + for {key, val} <- [css: @css, js: @js] do + md5 = Base.encode16(:crypto.hash(:md5, val), case: :lower) + + def current_hash(unquote(key)), do: unquote(md5) + end +end diff --git a/lib/web/components/attention.ex b/lib/web/components/attention.ex index 8c86f15..9b7b0a4 100644 --- a/lib/web/components/attention.ex +++ b/lib/web/components/attention.ex @@ -11,10 +11,10 @@ defmodule Observer.Web.Components.Attention do def content(assigns) do ~H""" -
+