From 1217aabd8f14826c0b14de9a5574d203393d69bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:02:33 +0000 Subject: [PATCH 1/3] Initial plan From 3fa8b7c04aa1b3ca51eb6442982fd212dc639200 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:23:24 +0000 Subject: [PATCH 2/3] Convert datafruits Ember home layout to Rails Hotwire with Web Components Co-authored-by: mcfiredrill <66243+mcfiredrill@users.noreply.github.com> --- .bundle/config | 1 + Gemfile | 5 + Gemfile.lock | 22 + app/assets/stylesheets/application.css | 13 + app/assets/stylesheets/home.css | 300 ++++++++++++++ app/channels/application_cable/channel.rb | 2 + app/channels/application_cable/connection.rb | 2 + app/channels/metadata_channel.rb | 19 + app/controllers/html_controller.rb | 15 + app/controllers/landing_controller.rb | 20 +- app/javascript/application.js | 15 + app/javascript/components/audio-visualizer.js | 113 ++++++ .../components/datafruits-player.js | 382 ++++++++++++++++++ app/javascript/controllers/application.js | 9 + app/javascript/controllers/home_controller.js | 101 +++++ app/javascript/controllers/index.js | 4 + app/views/landing/index.html.erb | 102 +++++ app/views/layouts/hotwire.html.erb | 23 ++ app/views/shared/_add_datafruit.html.erb | 16 + app/views/shared/_datafruits_player.html.erb | 135 +++++++ app/views/shared/_dj_donate_modal.html.erb | 36 ++ app/views/shared/_hack_button.html.erb | 14 + app/views/shared/_login_modal.html.erb | 42 ++ app/views/shared/_pc_nav.html.erb | 210 ++++++++++ app/views/shared/_random_banner_ad.html.erb | 13 + app/views/shared/_sp_nav.html.erb | 44 ++ app/views/shared/_user_menu.html.erb | 18 + app/views/shared/_version_notifier.html.erb | 11 + config/application.rb | 6 +- config/importmap.rb | 8 + spec/controllers/landing_controller_spec.rb | 38 ++ 31 files changed, 1735 insertions(+), 4 deletions(-) create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/stylesheets/home.css create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/channels/metadata_channel.rb create mode 100644 app/controllers/html_controller.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/components/audio-visualizer.js create mode 100644 app/javascript/components/datafruits-player.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/home_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/views/landing/index.html.erb create mode 100644 app/views/layouts/hotwire.html.erb create mode 100644 app/views/shared/_add_datafruit.html.erb create mode 100644 app/views/shared/_datafruits_player.html.erb create mode 100644 app/views/shared/_dj_donate_modal.html.erb create mode 100644 app/views/shared/_hack_button.html.erb create mode 100644 app/views/shared/_login_modal.html.erb create mode 100644 app/views/shared/_pc_nav.html.erb create mode 100644 app/views/shared/_random_banner_ad.html.erb create mode 100644 app/views/shared/_sp_nav.html.erb create mode 100644 app/views/shared/_user_menu.html.erb create mode 100644 app/views/shared/_version_notifier.html.erb create mode 100644 config/importmap.rb diff --git a/.bundle/config b/.bundle/config index 236922881..4ae7e6d71 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,2 +1,3 @@ --- BUNDLE_PATH: "vendor/bundle" +BUNDLE_WITHOUT: "taglib" diff --git a/Gemfile b/Gemfile index 5a962a4d2..323e813c7 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,11 @@ source 'https://rubygems.org/' gem 'rails', '7.0.5' gem 'pg' +gem 'turbo-rails' +gem 'stimulus-rails' +gem 'importmap-rails' +gem 'sprockets-rails' + gem 'spring', group: :development gem 'devise' diff --git a/Gemfile.lock b/Gemfile.lock index 912071230..69a17c190 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1626,6 +1626,10 @@ GEM image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) jmespath (1.6.2) json (2.6.3) json-api-vanilla (1.0.4) @@ -1653,6 +1657,7 @@ GEM mimemagic (~> 0.3.0) terrapin (~> 0.6.0) language_server-protocol (3.17.0.3) + logger (1.7.0) lograge (0.12.0) actionpack (>= 4) activesupport (>= 4) @@ -1842,9 +1847,19 @@ GEM capistrano (>= 3.8.1) sorbet-runtime (0.5.10914) spring (4.1.1) + sprockets (4.2.2) + concurrent-ruby (~> 1.0) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) + sprockets (>= 3.0.0) sshkit (1.21.4) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) + stimulus-rails (1.3.4) + railties (>= 6.0.0) syntax_tree (6.1.1) prettier_print (>= 1.2.0) syslog_protocol (0.9.2) @@ -1857,6 +1872,9 @@ GEM tilt (2.1.0) timecop (0.9.6) timeout (0.4.0) + turbo-rails (2.0.12) + actionpack (>= 6.0.0) + railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) tzinfo-data (1.2023.3) @@ -1918,6 +1936,7 @@ DEPENDENCIES headless httparty image_processing (>= 1.2) + importmap-rails jsonapi-serializer kaminari kt-paperclip (~> 6.4, >= 6.4.1) @@ -1950,9 +1969,12 @@ DEPENDENCIES slackistrano so_id3! spring + sprockets-rails + stimulus-rails terrapin thwait timecop + turbo-rails tzinfo-data unicorn vcr diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 000000000..8894e9935 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,13 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS (not SASS) file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles in any other CSS files in this directory. + * + *= require home + *= require_self + */ diff --git a/app/assets/stylesheets/home.css b/app/assets/stylesheets/home.css new file mode 100644 index 000000000..d3cea22d1 --- /dev/null +++ b/app/assets/stylesheets/home.css @@ -0,0 +1,300 @@ +/* + * Datafruits main stylesheet for the Hotwire-rendered home layout. + * Mirrors the base styles from the Ember/Tailwind frontend. + */ + +/* CSS custom properties for theme support */ +:root { + --color-df-pink: #ff93ba; + --color-df-green: #17ff82; + --color-df-blue: #0000ff; + --color-text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.4); +} + +/* Base layout */ +html, body { + height: 100%; + margin: 0; + padding: 0; + font-family: 'Montserrat', 'Source Sans Pro', sans-serif; + background: #000; + color: #fff; +} + +.h-handle-resize { + height: 100vh; +} + +/* Skip to content link for accessibility */ +.skip-to-content-link { + position: absolute; + left: -9999px; + z-index: 999; + padding: 0.5em 1em; + background-color: var(--color-df-pink); + color: #000; + font-weight: bold; + text-decoration: none; +} +.skip-to-content-link:focus { + left: 50%; + transform: translateX(-50%); +} + +/* Navigation */ +#desktop-navbar { + background-color: var(--color-df-pink); + padding: 0.5rem; + font-size: 1.5rem; +} + +#desktop-navbar ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +#desktop-navbar a, +#desktop-navbar button { + color: #fff; + text-decoration: none; + text-shadow: var(--color-text-shadow); +} +#desktop-navbar a:hover { + color: #ccc; +} + +.its-just-a-website-menu-item, +.its-just-a-website-sub-menu-item { + color: #fff; + text-shadow: var(--color-text-shadow); + padding: 0.25rem 0.5rem; + cursor: pointer; +} + +/* Mobile nav */ +#mobile-navbar { + background-color: var(--color-df-pink); + padding: 0.5rem; +} + +.hamburger-button { + background: none; + border: none; + color: #fff; + font-size: 1.5rem; + cursor: pointer; +} + +/* User menu */ +#user-menu { + position: absolute; + top: 4rem; + right: 0; + background: #222; + border: 2px solid var(--color-df-pink); + padding: 1rem; + z-index: 100; + min-width: 200px; +} + +/* Player bar */ +#now-playing-bar { + background-color: var(--color-df-pink); + color: #fff; + padding: 0.5rem 1rem; + font-size: 1.25rem; + min-height: 60px; +} + +.player-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.player-controls a, +.player-controls button { + color: #fff; + text-decoration: none; + cursor: pointer; + background: none; + border: none; + font-size: inherit; +} + +.now-playing { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.radio-container { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* Volume control */ +.jp-volume-controls { + position: relative; + cursor: pointer; +} + +.volume-control { + width: 80px; +} + +/* Seek bar */ +.seek { + padding: 0 1rem; +} + +.seek-bar { + width: 100%; +} + +/* Bottom bar */ +#add-datafruit-bar { + background-color: var(--color-df-green); + color: #000; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +/* Modals */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 500; + display: flex; + justify-content: center; + align-items: center; +} + +.modal-box { + background: #111; + border: 2px solid var(--color-df-pink); + padding: 2rem; + max-width: 400px; + width: 90%; + border-radius: 4px; +} + +.modal-box h2 { + color: var(--color-df-pink); + margin-top: 0; +} + +/* Login form */ +.login-form input[type="text"], +.login-form input[type="password"], +.login-form input[type="email"] { + width: 100%; + padding: 0.5rem; + margin: 0.25rem 0 0.75rem; + background: #222; + border: 1px solid #555; + color: #fff; + border-radius: 2px; + box-sizing: border-box; +} + +/* Buttons */ +.cool-button { + background-color: var(--color-df-pink); + color: #000; + border: none; + padding: 0.35rem 0.75rem; + cursor: pointer; + font-weight: bold; + font-family: inherit; + font-size: 0.9rem; + text-decoration: none; + display: inline-block; +} + +.cool-button:hover { + background-color: #ff70a0; +} + +.logo { + font-size: 3rem; + color: #fff; + text-decoration: none; +} + +/* Version notifier */ +#version-notifier { + background: #333; + color: #fff; + padding: 0.5rem 1rem; + text-align: center; + display: none; +} +#version-notifier.visible { + display: block; +} + +/* Visuals overlay */ +#datafruits-visuals { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + pointer-events: none; + z-index: 0; +} + +/* Notifications */ +#notifications-container { + position: fixed; + bottom: 80px; + right: 1rem; + z-index: 200; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* April fools easter eggs */ +.tiny-guy, .tiny-grumby { + position: fixed; + z-index: 999; + width: 50px; + cursor: pointer; +} +.tiny-guy { + bottom: 80px; + right: 1rem; +} +.tiny-grumby { + bottom: 80px; + right: 60px; +} + +/* Utility */ +.hidden { + display: none !important; +} + +/* Responsive */ +@media (min-width: 768px) { + #mobile-navbar { + display: none; + } +} +@media (max-width: 767px) { + #desktop-navbar { + display: none; + } +} diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 000000000..1a38744ce --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,2 @@ +class ApplicationCable::Channel < ActionCable::Channel::Base +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..12d37bb49 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,2 @@ +class ApplicationCable::Connection < ActionCable::Connection::Base +end diff --git a/app/channels/metadata_channel.rb b/app/channels/metadata_channel.rb new file mode 100644 index 000000000..88c9fbfa3 --- /dev/null +++ b/app/channels/metadata_channel.rb @@ -0,0 +1,19 @@ +## +# MetadataChannel - ActionCable channel for real-time now-playing metadata updates. +# +# Replaces the Ember `metadata` service which received WebSocket metadata updates +# from the Icecast/Liquidsoap stream. This channel broadcasts metadata to all +# connected clients via Turbo Streams, updating the player title in real-time. +# +# The datafruits-player Web Component listens for "metadataUpdate" DOM events +# which are dispatched by Turbo Stream broadcasts to this channel. +# +class MetadataChannel < ApplicationCable::Channel + def subscribed + stream_from "metadata_channel" + end + + def unsubscribed + stop_all_streams + end +end diff --git a/app/controllers/html_controller.rb b/app/controllers/html_controller.rb new file mode 100644 index 000000000..16e6bbc4d --- /dev/null +++ b/app/controllers/html_controller.rb @@ -0,0 +1,15 @@ +## +# HtmlController - Base controller for all HTML-serving controllers. +# +# Unlike ApplicationController (which inherits from ActionController::API for JSON API endpoints), +# this controller inherits from ActionController::Base to support: +# - View rendering with ERB templates +# - CSRF protection +# - Flash messages +# - Hotwire (Turbo + Stimulus) +# +# API controllers continue to inherit from ApplicationController < ActionController::API, +# preserving the existing JSON API behavior. +class HtmlController < ActionController::Base + protect_from_forgery with: :exception +end diff --git a/app/controllers/landing_controller.rb b/app/controllers/landing_controller.rb index 9e1632f0c..973339d82 100644 --- a/app/controllers/landing_controller.rb +++ b/app/controllers/landing_controller.rb @@ -1,10 +1,28 @@ -class LandingController < ApplicationController +class LandingController < HtmlController + layout "hotwire" + before_action :save_referer + def index + @current_show = fetch_current_show + @april_fools = april_fools? + @site_settings = {} end private + def save_referer session['referer'] = request.env["HTTP_REFERER"] end + + def fetch_current_show + ScheduledShow.current.first + rescue StandardError + nil + end + + def april_fools? + today = Time.zone.today + today.month == 4 && today.day == 1 + end end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 000000000..8de7848e6 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,15 @@ +import "@hotwired/turbo-rails" +import { Application } from "@hotwired/stimulus" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" + +// Import web components +import "components/datafruits-player" +import "components/audio-visualizer" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +eagerLoadControllersFrom("controllers", application) diff --git a/app/javascript/components/audio-visualizer.js b/app/javascript/components/audio-visualizer.js new file mode 100644 index 000000000..3dc8f8140 --- /dev/null +++ b/app/javascript/components/audio-visualizer.js @@ -0,0 +1,113 @@ +/** + * AudioVisualizer Web Component + * + * Converts the Ember AudioVisualizer component to a standard Web Component. + * Renders a real-time waveform/frequency visualizer using the Web Audio API + * connected to the radio player audio element. + */ +class AudioVisualizerElement extends HTMLElement { + constructor() { + super() + this._animationId = null + this._audioContext = null + this._analyser = null + this._source = null + } + + connectedCallback() { + this._canvas = this.querySelector("canvas") || this._createCanvas() + this._ctx = this._canvas.getContext("2d") + this._bindToAudioPlayer() + } + + disconnectedCallback() { + if (this._animationId) { + cancelAnimationFrame(this._animationId) + this._animationId = null + } + if (this._audioContext) { + this._audioContext.close() + this._audioContext = null + } + } + + _createCanvas() { + const canvas = document.createElement("canvas") + canvas.id = "visualizer" + canvas.width = 4000 + canvas.height = 150 + canvas.className = "h-20" + this.appendChild(canvas) + return canvas + } + + _bindToAudioPlayer() { + const audio = document.getElementById("radio-player") + if (!audio) { + setTimeout(() => this._bindToAudioPlayer(), 500) + return + } + + audio.addEventListener("play", () => this._startVisualizer(audio), { once: false }) + audio.addEventListener("pause", () => this._stopDraw()) + } + + _startVisualizer(audio) { + if (this._audioContext) return + + try { + this._audioContext = new (window.AudioContext || window.webkitAudioContext)() + this._analyser = this._audioContext.createAnalyser() + this._analyser.fftSize = 256 + this._source = this._audioContext.createMediaElementSource(audio) + this._source.connect(this._analyser) + this._analyser.connect(this._audioContext.destination) + this._draw() + } catch (e) { + console.warn("AudioVisualizer: Web Audio API not supported or error occurred", e) + } + } + + _stopDraw() { + if (this._animationId) { + cancelAnimationFrame(this._animationId) + this._animationId = null + } + } + + _draw() { + if (!this._analyser || !this._canvas) return + + const bufferLength = this._analyser.frequencyBinCount + const dataArray = new Uint8Array(bufferLength) + + const render = () => { + this._animationId = requestAnimationFrame(render) + this._analyser.getByteFrequencyData(dataArray) + + const width = this._canvas.width + const height = this._canvas.height + this._ctx.clearRect(0, 0, width, height) + + const barWidth = (width / bufferLength) * 2.5 + let x = 0 + + for (let i = 0; i < bufferLength; i++) { + const barHeight = dataArray[i] + const r = barHeight + 25 * (i / bufferLength) + const g = 250 * (i / bufferLength) + const b = 50 + + this._ctx.fillStyle = `rgb(${r},${g},${b})` + this._ctx.fillRect(x, height - barHeight / 2, barWidth, barHeight / 2) + x += barWidth + 1 + } + } + + render() + } +} + +if (!customElements.get("audio-visualizer")) { + customElements.define("audio-visualizer", AudioVisualizerElement) +} diff --git a/app/javascript/components/datafruits-player.js b/app/javascript/components/datafruits-player.js new file mode 100644 index 000000000..0dc02b7a7 --- /dev/null +++ b/app/javascript/components/datafruits-player.js @@ -0,0 +1,382 @@ +/** + * DatafruitsPlayer Web Component + * + * Converts the Ember DatafruitsPlayer component to a standard Web Component. + * Handles audio playback for both the live stream and podcast episodes. + * + * Ember services replaced by: + * - metadata service → custom "metadataupdate" events via ActionCable/Turbo Streams + * - eventBus service → native DOM CustomEvents + * - fastboot service → not needed (server-rendered) + * - videoStream service → VideoStreamElement web component (optional integration) + */ +class DatafruitsPlayerElement extends HTMLElement { + constructor() { + super() + this._playerState = "paused" // "playing" | "loading" | "paused" + this._muted = false + this._showingVolumeControl = false + this._volume = 0.8 + this._oldVolume = 0.8 + this._playButtonPressed = false + this._playingPodcast = false + this._title = "" + this._episodeId = "" + this._showSeriesId = "" + this._playTime = 0.0 + this._duration = 0.0 + this._playTimePercentage = 0.0 + this._podcastTrackId = "" + this._debounceVolumeHideTimer = null + } + + connectedCallback() { + this._volume = parseFloat(localStorage.getItem("datafruits-volume")) || 0.8 + + this._bindEventListeners() + this._setupAudioElement() + this._setRadioTitle() + this._render() + } + + disconnectedCallback() { + document.removeEventListener("trackPlayed", this._onTrackPlayed) + document.removeEventListener("trackPaused", this._onTrackPaused) + document.removeEventListener("metadataUpdate", this._onMetadataUpdate) + document.removeEventListener("canonicalMetadataUpdate", this._onCanonicalMetadataUpdate) + } + + _bindEventListeners() { + this._onTrackPlayed = this._handleTrackPlayed.bind(this) + this._onTrackPaused = this._handleTrackPaused.bind(this) + this._onMetadataUpdate = this._handleMetadataUpdate.bind(this) + this._onCanonicalMetadataUpdate = this._handleCanonicalMetadataUpdate.bind(this) + + document.addEventListener("trackPlayed", this._onTrackPlayed) + document.addEventListener("trackPaused", this._onTrackPaused) + document.addEventListener("metadataUpdate", this._onMetadataUpdate) + document.addEventListener("canonicalMetadataUpdate", this._onCanonicalMetadataUpdate) + } + + _setupAudioElement() { + const audio = this.querySelector("#radio-player") + if (!audio) return + + audio.volume = this._volume + + audio.addEventListener("loadstart", () => { + if (this._playButtonPressed) { + this._playerState = audio.readyState === 0 ? "loading" : "seeking" + this._render() + } + this.querySelector(".seek")?.classList.add("seeking") + }) + audio.addEventListener("pause", () => { + this._playerState = "paused" + this._render() + }) + audio.addEventListener("playing", () => { + this._playerState = "playing" + this._render() + }) + audio.addEventListener("timeupdate", () => { + if (audio.duration) { + this._playTimePercentage = (100 / audio.duration) * audio.currentTime + if (this._playingPodcast) { + this._playTime = audio.currentTime + this._duration = audio.duration + } + this._renderProgress() + } + }) + audio.addEventListener("canplay", () => { + this._duration = audio.duration + this.querySelector(".seek-bar-wrapper")?.classList.remove("seeking") + this.querySelector(".seek")?.classList.remove("seeking") + this._render() + }) + } + + _getIcecastHost() { + return this.dataset.icecastHost || "https://streampusher-relay.club" + } + + _handleTrackPlayed(event) { + const { track_id, title, cdnUrl } = event.detail + const audio = this.querySelector("#radio-player") + if (!audio) return + + if (String(this._podcastTrackId) !== String(track_id)) { + this._title = title + this._podcastTrackId = String(track_id) + this._setPageTitle() + this._playingPodcast = true + this._playTime = 0.0 + this._playTimePercentage = 0.0 + audio.src = cdnUrl + if (audio.readyState === 0) { + this._playerState = "loading" + } + } + audio.play().catch(e => console.error("Audio play failed", e)) + this._render() + } + + _handleTrackPaused() { + const audio = this.querySelector("#radio-player") + if (!audio) return + audio.pause() + this._playButtonPressed = false + this._playerState = "paused" + this._render() + } + + _handleMetadataUpdate(event) { + if (!this._playingPodcast) { + this._title = event.detail.title || "" + } + this._setPageTitle() + this._render() + } + + _handleCanonicalMetadataUpdate(event) { + this._episodeId = event.detail.episode_id || "" + this._showSeriesId = event.detail.show_series_id || "" + this._render() + } + + _setPageTitle() { + document.title = this._title ? `DATAFRUITS.FM - ${this._title}` : "DATAFRUITS.FM" + } + + _setRadioTitle() { + const metaTitle = document.querySelector('meta[name="now-playing-title"]') + if (metaTitle) { + this._title = metaTitle.getAttribute("content") || "" + } + this._setPageTitle() + } + + _play() { + const audio = this.querySelector("#radio-player") + if (!audio) return + + if (!this._playingPodcast) { + audio.src = `${this._getIcecastHost()}/datafruits.mp3` + } + if (audio.readyState === 0) { + this._playerState = "loading" + } + audio.play().catch(e => console.error("Audio play failed", e)) + this._playButtonPressed = true + this._render() + } + + _pause() { + const audio = this.querySelector("#radio-player") + if (!audio) return + audio.pause() + this._playButtonPressed = false + this._playerState = "paused" + document.dispatchEvent(new CustomEvent("trackPaused", { + detail: { track_id: this._podcastTrackId } + })) + this._render() + } + + _mute() { + const audio = this.querySelector("#radio-player") + if (audio) audio.muted = true + this._muted = true + this._oldVolume = this._volume + this._volume = 0.0 + localStorage.setItem("datafruits-volume", "0") + this._render() + } + + _unmute() { + const audio = this.querySelector("#radio-player") + if (audio) audio.muted = false + this._muted = false + this._volume = this._oldVolume + localStorage.setItem("datafruits-volume", String(this._volume)) + this._render() + } + + _playLiveStream() { + const audio = this.querySelector("#radio-player") + if (!audio) return + audio.pause() + this._playingPodcast = false + this._setRadioTitle() + audio.src = `${this._getIcecastHost()}/datafruits.mp3` + audio.play().catch(e => console.error("Audio play failed", e)) + this._render() + } + + _seek(percentage) { + const audio = this.querySelector("#radio-player") + if (!audio) return + audio.currentTime = audio.duration * (percentage / 100) + } + + _setVolume(value) { + this._volume = parseFloat(value) + const audio = this.querySelector("#radio-player") + if (audio) audio.volume = this._volume + localStorage.setItem("datafruits-volume", String(this._volume)) + } + + _formatTime(time) { + const hours = Math.floor(time / 3600) + const minutes = Math.floor((time % 3600) / 60) + const seconds = Math.floor(time % 60) + return [hours, minutes, seconds].map(n => String(n).padStart(2, "0")).join(":") + } + + get _formattedPlayTime() { + if (this._playTime) { + return `${this._formatTime(this._playTime)} / ${this._formatTime(this._duration)}` + } + return "..." + } + + get _isLive() { + return this._title && this._title.startsWith("LIVE") + } + + get _currentShowPresent() { + return this._episodeId && this._showSeriesId + } + + get _volumeString() { + return `${Math.floor(this._volume * 100)}%` + } + + _renderProgress() { + const seekBar = this.querySelector(".seek-bar") + if (seekBar) seekBar.value = this._playTimePercentage + + const timeDisplay = this.querySelector(".play-time-display") + if (timeDisplay) timeDisplay.textContent = this._formattedPlayTime + } + + _render() { + const controls = this.querySelector(".player-controls") + if (!controls) return + + this._renderLoadingState(controls) + this._renderVolumeControl() + this._renderNowPlaying() + this._renderSeekBar() + this._renderPlayTime() + this._renderReturnToLive() + } + + _renderLoadingState(controls) { + const loadingEl = controls.querySelector("#loading-stream") + const playEl = controls.querySelector("#play-stream") + const pauseEl = controls.querySelector("#pause-stream") + + if (loadingEl) loadingEl.classList.toggle("hidden", this._playerState !== "loading") + if (playEl) playEl.classList.toggle("hidden", this._playerState !== "paused") + if (pauseEl) pauseEl.classList.toggle("hidden", this._playerState !== "playing") + } + + _renderVolumeControl() { + const muteBtn = this.querySelector(".jp-mute") + const unmuteBtn = this.querySelector(".jp-unmute") + if (muteBtn) muteBtn.classList.toggle("hidden", this._muted) + if (unmuteBtn) unmuteBtn.classList.toggle("hidden", !this._muted) + + const volumeInput = this.querySelector(".volume-control") + if (volumeInput) { + volumeInput.value = this._volume + volumeInput.parentElement?.classList.toggle("hidden", !this._showingVolumeControl) + } + + const volumeStr = this.querySelector(".volume-string") + if (volumeStr) volumeStr.textContent = this._volumeString + } + + _renderNowPlaying() { + const titleEl = this.querySelector(".jp-title") + if (titleEl) titleEl.textContent = this._title || "" + } + + _renderSeekBar() { + const seekSection = this.querySelector(".seek") + if (seekSection) { + seekSection.classList.toggle("hidden", !this._playingPodcast) + } + } + + _renderPlayTime() { + const timeEl = this.querySelector(".play-time-display") + if (timeEl) { + timeEl.classList.toggle("hidden", !(this._playingPodcast && this._playerState === "playing")) + timeEl.textContent = this._formattedPlayTime + } + } + + _renderReturnToLive() { + const returnBtn = this.querySelector(".return-to-live") + if (returnBtn) returnBtn.classList.toggle("hidden", !this._playingPodcast) + } + + _attachClickHandlers() { + // Play / Pause + this.querySelector("#play-stream")?.addEventListener("click", e => { + e.preventDefault() + this._play() + }) + this.querySelector("#pause-stream")?.addEventListener("click", e => { + e.preventDefault() + this._pause() + }) + + // Mute / Unmute + this.querySelector(".jp-mute")?.addEventListener("click", e => { + e.preventDefault() + this._mute() + }) + this.querySelector(".jp-unmute")?.addEventListener("click", e => { + e.preventDefault() + this._unmute() + }) + + // Volume + this.querySelector(".volume-control")?.addEventListener("input", e => { + this._setVolume(e.target.value) + }) + + // Volume control show/hide + const volumeBtn = this.querySelector("#volume-control") + volumeBtn?.addEventListener("mouseenter", () => { + this._showingVolumeControl = true + this._render() + }) + volumeBtn?.addEventListener("mouseleave", () => { + if (this._debounceVolumeHideTimer) clearTimeout(this._debounceVolumeHideTimer) + this._debounceVolumeHideTimer = setTimeout(() => { + this._showingVolumeControl = false + this._render() + }, 3000) + }) + + // Seek + this.querySelector(".seek-bar")?.addEventListener("input", e => { + this._seek(e.target.value) + }) + + // Return to live + this.querySelector(".return-to-live")?.addEventListener("click", e => { + e.preventDefault() + this._playLiveStream() + }) + } +} + +if (!customElements.get("datafruits-player")) { + customElements.define("datafruits-player", DatafruitsPlayerElement) +} diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 000000000..e5fa67424 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/home_controller.js b/app/javascript/controllers/home_controller.js new file mode 100644 index 000000000..1de95eb7c --- /dev/null +++ b/app/javascript/controllers/home_controller.js @@ -0,0 +1,101 @@ +import { Controller } from "@hotwired/stimulus" + +// Handles the main home page interactions: +// - Mobile/desktop navigation menu toggles +// - User menu dropdown +// - Login modal +// - Donate modal +export default class extends Controller { + static targets = ["mobileMenu", "submenu", "userMenu", "loginModal", "donateModal"] + static values = { + menuOpen: { type: Boolean, default: false }, + submenuOpen: { type: Boolean, default: false }, + userMenuOpen: { type: Boolean, default: false }, + loginModalOpen: { type: Boolean, default: false }, + donateModalOpen: { type: Boolean, default: false } + } + + connect() { + this.debounceTimer = null + } + + disconnect() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + } + + toggleMenu() { + this.menuOpenValue = !this.menuOpenValue + if (this.hasMobileMenuTarget) { + this.mobileMenuTarget.classList.toggle("hidden", !this.menuOpenValue) + } + } + + toggleSubMenu() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + this.debounceTimer = setTimeout(() => { + this.submenuOpenValue = !this.submenuOpenValue + if (this.hasSubmenuTarget) { + this.submenuTarget.classList.toggle("hidden", !this.submenuOpenValue) + } + }, 500) + } + + toggleUserMenu() { + this.userMenuOpenValue = !this.userMenuOpenValue + if (this.hasUserMenuTarget) { + this.userMenuTarget.classList.toggle("hidden", !this.userMenuOpenValue) + } + } + + toggleLoginModal() { + this.loginModalOpenValue = !this.loginModalOpenValue + if (this.hasLoginModalTarget) { + this.loginModalTarget.classList.toggle("hidden", !this.loginModalOpenValue) + } + } + + toggleDonateModal() { + this.donateModalOpenValue = !this.donateModalOpenValue + if (this.hasDonateModalTarget) { + this.donateModalTarget.classList.toggle("hidden", !this.donateModalOpenValue) + } + } + + // Close all open menus and modals when Escape key is pressed + closeAll() { + if (this.menuOpenValue) { + this.menuOpenValue = false + if (this.hasMobileMenuTarget) { + this.mobileMenuTarget.classList.add("hidden") + } + } + if (this.submenuOpenValue) { + this.submenuOpenValue = false + if (this.hasSubmenuTarget) { + this.submenuTarget.classList.add("hidden") + } + } + if (this.userMenuOpenValue) { + this.userMenuOpenValue = false + if (this.hasUserMenuTarget) { + this.userMenuTarget.classList.add("hidden") + } + } + if (this.loginModalOpenValue) { + this.loginModalOpenValue = false + if (this.hasLoginModalTarget) { + this.loginModalTarget.classList.add("hidden") + } + } + if (this.donateModalOpenValue) { + this.donateModalOpenValue = false + if (this.hasDonateModalTarget) { + this.donateModalTarget.classList.add("hidden") + } + } + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 000000000..1156bf836 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/views/landing/index.html.erb b/app/views/landing/index.html.erb new file mode 100644 index 000000000..5e055c459 --- /dev/null +++ b/app/views/landing/index.html.erb @@ -0,0 +1,102 @@ +<%# + Home layout converted from datafruits Ember app (app/templates/home.hbs). + + Ember services mapped to Rails equivalents: + - session service → user_signed_in? / current_user (Devise) + - currentUser service → current_user + - fastboot service → not needed (Rails renders server-side) + - siteSettings service → @site_settings instance variable / ENV + - metadata service → ActionCable Turbo Stream (real-time title updates) + - chat service → ActionCable channels (separate concern) + - videoStream service → JavaScript-only (handled in datafruits-player web component) + - intl service → Rails I18n (t() helper) + + Ember components mapped to: + - PcNav → _pc_nav partial (with Stimulus home controller) + - SpNav → _sp_nav partial + - RandomBannerAd → _random_banner_ad partial + - UserMenu → _user_menu partial + - OurVersionNotifier → _version_notifier partial + - DatafruitsPlayer → web component + _datafruits_player partial + - DjDonateModal → _dj_donate_modal partial (inside player partial) + - HackButton → _hack_button partial (inside player partial) + - AudioVisualizer → web component + - AddDatafruit → _add_datafruit partial (Turbo Frame) + - DatafruitsVisuals → inline div#datafruits-visuals (JS-driven) + - Notifications → inline div#notifications-container + - LoginModal → _login_modal partial (Stimulus-toggled) +%> +
+
+ + Skip to content + + + <%# PC navigation (desktop) - Ember PcNav component %> + <%= render 'shared/pc_nav' %> + + <%# Mobile navigation - Ember SpNav component %> + <%= render 'shared/sp_nav' %> +
+ + <%# Random banner ad shown on mobile - Ember RandomBannerAd component %> + <%= render 'shared/random_banner_ad', css_class: 'block lg:hidden' %> + + <%# User dropdown menu - Ember UserMenu component %> + <%= render 'shared/user_menu' %> + + <%# Version update notifier - Ember OurVersionNotifier component %> + <%= render 'shared/version_notifier' %> + +
+ <%# Main content area - equivalent to Ember {{outlet}} %> + <%= yield %> +
+ + <%# Audio player - Ember DatafruitsPlayer component converted to a web component %> + <%= render 'shared/datafruits_player' %> + + <%# Audio visualizer - rendered conditionally based on site settings %> + <% if @site_settings&.dig(:audio_visualizer_on) %> +
+ + + +
+ <% end %> + + <%# Bottom bar - Ember AddDatafruit component %> +
+ <%= render 'shared/add_datafruit' %> +
+
+ +<%# Background visuals overlay - Ember DatafruitsVisuals component %> +
+
+ +<%# Non-fastboot components (always rendered in Rails since we're always server-side) %> +
+ +<%# April Fools easter eggs %> +<% if @april_fools %> + + :bgs: + + + :bgs: + +<% end %> + +<%# Login modal - shown/hidden by Stimulus home controller %> +<%= render 'shared/login_modal' %> + +
diff --git a/app/views/layouts/hotwire.html.erb b/app/views/layouts/hotwire.html.erb new file mode 100644 index 000000000..a8c18f369 --- /dev/null +++ b/app/views/layouts/hotwire.html.erb @@ -0,0 +1,23 @@ + + + + + + DATAFRUITS.FM + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%# Now-playing metadata for the player web component %> + + + + + + <%= stylesheet_link_tag "home", media: "all" %> + <%= javascript_importmap_tags %> + + + + <%= yield %> + + diff --git a/app/views/shared/_add_datafruit.html.erb b/app/views/shared/_add_datafruit.html.erb new file mode 100644 index 000000000..ca8dc57ac --- /dev/null +++ b/app/views/shared/_add_datafruit.html.erb @@ -0,0 +1,16 @@ +<%# + Add Datafruit partial - converted from Ember AddDatafruit component (app/components/add-datafruit.hbs). + The Ember component loaded microtexts (short user messages) via the API and displayed them + in a marquee. It also allowed authenticated users to submit new microtexts. + + In Rails: + - The microtext list is loaded via a Turbo Frame (with lazy loading from /api/microtexts) + - Submission uses a Turbo Stream response to update the frame + - The marquee display is kept for visual parity with the Ember app +%> +<%= turbo_frame_tag "add-datafruit", src: "/api/microtexts/current", loading: :lazy do %> +
+ <%# Placeholder while Turbo Frame loads %> + ♪ ♪ ♪ +
+<% end %> diff --git a/app/views/shared/_datafruits_player.html.erb b/app/views/shared/_datafruits_player.html.erb new file mode 100644 index 000000000..33c423dbc --- /dev/null +++ b/app/views/shared/_datafruits_player.html.erb @@ -0,0 +1,135 @@ +<%# + Datafruits Player partial - converts Ember DatafruitsPlayer component to: + 1. Server-rendered HTML structure (ERB) + 2. custom element (Web Component) for client-side behavior + + The Web Component (app/javascript/components/datafruits-player.js) handles: + - Audio playback (play/pause/seek/volume) + - State management (playing/loading/paused) + - Podcast vs. live stream mode + - Metadata title updates via DOM events + + Ember services replaced: + - metadata service → CustomEvent "metadataUpdate" from ActionCable/Turbo Stream + - eventBus service → native DOM CustomEvents + - fastboot service → not needed + - videoStream service → optional integration via CustomEvent "liveVideoAudio" +%> + + + +
+
+ <%# Return to live stream button - shown only when playing a podcast %> + + + <%# Loading indicator - shown while stream is buffering %> + + + <%# Pause/Stop button - shown when playing %> + + + <%# Play button - shown when paused (default state) %> + + + <%# Volume control %> + +
+ + <%# Now playing title %> +
+ +
+ + <%# Podcast play time display - only shown when playing a podcast %> + + + <%# Slot for additional items (donate button, hack button) %> +
+ <%= render 'shared/dj_donate_modal', css_class: 'hidden md:block mr-2' %> + <%= render 'shared/hack_button', css_class: 'hidden md:block mr-2' %> +
+
+ + <%# Podcast seek bar - only shown when playing a podcast episode %> + +
diff --git a/app/views/shared/_dj_donate_modal.html.erb b/app/views/shared/_dj_donate_modal.html.erb new file mode 100644 index 000000000..421d01acf --- /dev/null +++ b/app/views/shared/_dj_donate_modal.html.erb @@ -0,0 +1,36 @@ +<%# + DJ Donate Modal partial - converted from Ember DjDonateModal component. + Shows a donation button that opens a modal with donation links. +%> +<% css_class = local_assigns.fetch(:css_class, '') %> +
+ + + +
diff --git a/app/views/shared/_hack_button.html.erb b/app/views/shared/_hack_button.html.erb new file mode 100644 index 000000000..7d10f7ec0 --- /dev/null +++ b/app/views/shared/_hack_button.html.erb @@ -0,0 +1,14 @@ +<%# + Hack Button partial - converted from Ember HackButton component (app/components/hack-button.hbs). + Simple link to the datafruits GitHub organization. +%> +<% css_class = local_assigns.fetch(:css_class, '') %> + + Hack + diff --git a/app/views/shared/_login_modal.html.erb b/app/views/shared/_login_modal.html.erb new file mode 100644 index 000000000..d80d4676f --- /dev/null +++ b/app/views/shared/_login_modal.html.erb @@ -0,0 +1,42 @@ +<%# + Login modal partial - converted from Ember LoginModal component. + Wrapped in a Turbo Frame so it can be shown/hidden without a full page reload. + The Stimulus home controller handles showing/hiding the frame. +%> + diff --git a/app/views/shared/_pc_nav.html.erb b/app/views/shared/_pc_nav.html.erb new file mode 100644 index 000000000..84abfc66f --- /dev/null +++ b/app/views/shared/_pc_nav.html.erb @@ -0,0 +1,210 @@ +<%# + PC navigation partial - converted from Ember PcNav component (app/components/pc-nav.hbs). + + Ember services replaced: + - session.isAuthenticated → user_signed_in? + - currentUser.user → current_user + - fastboot.isFastBoot → not needed (always false in Rails) + - intl (t helper) → Rails t() helper with keys matching the Ember intl translations + + Ember actions/toggles handled by the Stimulus home controller. +%> + + +<%# Shown only on desktop - Ember RandomBannerAd component with hidden lg:block %> +<%= render 'shared/random_banner_ad', css_class: 'hidden lg:block mt-4' %> + +<% if user_signed_in? %> + <%# Authenticated desktop nav with user info %> + +<% else %> + <%# Unauthenticated - show sign up / login buttons + logo %> + + +<% end %> diff --git a/app/views/shared/_random_banner_ad.html.erb b/app/views/shared/_random_banner_ad.html.erb new file mode 100644 index 000000000..3dc5863ed --- /dev/null +++ b/app/views/shared/_random_banner_ad.html.erb @@ -0,0 +1,13 @@ +<%# + Random banner ad partial - converted from Ember RandomBannerAd component. + Renders a placeholder banner ad area. In production this would load from + the API or be replaced by a Turbo Frame that updates with a live ad. + The css_class local variable maps to the Ember component's class attribute. +%> +<% css_class = local_assigns.fetch(:css_class, '') %> +
+ <%# Turbo frame for dynamic ad loading %> + <%= turbo_frame_tag "banner-ad", src: "/api/banner_ad", loading: :lazy do %> + <%# Placeholder while loading %> + <% end %> +
diff --git a/app/views/shared/_sp_nav.html.erb b/app/views/shared/_sp_nav.html.erb new file mode 100644 index 000000000..d9beeaf1e --- /dev/null +++ b/app/views/shared/_sp_nav.html.erb @@ -0,0 +1,44 @@ +<%# + Mobile navigation partial - converted from Ember SpNav component (app/components/sp-nav.hbs). + Shown on small screens, hidden on desktop. + Stimulus home controller manages the menu open/close state. +%> +
+ + DATAFRUITS.FM + + +
+ +<%# Mobile menu dropdown - hidden by default, toggled by Stimulus home controller %> + diff --git a/app/views/shared/_user_menu.html.erb b/app/views/shared/_user_menu.html.erb new file mode 100644 index 000000000..412648631 --- /dev/null +++ b/app/views/shared/_user_menu.html.erb @@ -0,0 +1,18 @@ +<%# + User menu partial - converted from Ember UserMenu component (app/components/user-menu.hbs). + Shown/hidden by the Stimulus home controller via data-home-target="userMenu". + Only shown when the user is authenticated. +%> +<% if user_signed_in? %> + +<% end %> diff --git a/app/views/shared/_version_notifier.html.erb b/app/views/shared/_version_notifier.html.erb new file mode 100644 index 000000000..76aad3ed5 --- /dev/null +++ b/app/views/shared/_version_notifier.html.erb @@ -0,0 +1,11 @@ +<%# + Version notifier partial - converted from Ember OurVersionNotifier component. + In the Ember app this checked for app updates and prompted the user to refresh. + In the Rails/Hotwire app, Turbo handles page updates automatically. + We keep a hidden container that can be shown via a Turbo Stream broadcast + when a new deployment is detected. +%> + diff --git a/config/application.rb b/config/application.rb index 63b65176b..074f30787 100644 --- a/config/application.rb +++ b/config/application.rb @@ -11,9 +11,9 @@ require "action_mailer/railtie" # require "action_mailbox/engine" #require "action_text/engine" -#require "action_view/railtie" -#require "action_cable/engine" -# require "sprockets/railtie" +require "action_view/railtie" +require "action_cable/engine" +require "sprockets/railtie" #require "rails/test_unit/railtie" diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 000000000..95b455951 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,8 @@ +# Pin npm packages by running ./bin/importmap + +pin "application", preload: true +pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true +pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true +pin_all_from "app/javascript/controllers", under: "controllers" +pin_all_from "app/javascript/components", under: "components" diff --git a/spec/controllers/landing_controller_spec.rb b/spec/controllers/landing_controller_spec.rb index d96be9cdd..d203a1b71 100644 --- a/spec/controllers/landing_controller_spec.rb +++ b/spec/controllers/landing_controller_spec.rb @@ -1,5 +1,43 @@ require 'rails_helper' RSpec.describe LandingController, :type => :controller do + describe "GET #index" do + before do + allow(ScheduledShow).to receive(:current).and_return(double(first: nil)) + end + it "returns a successful response" do + get :index + expect(response).to be_successful + end + + it "renders the index template" do + get :index + expect(response).to render_template(:index) + end + + it "uses the hotwire layout" do + get :index + expect(response).to render_template(layout: "hotwire") + end + + it "sets @april_fools based on the current date" do + travel_to Date.new(2024, 4, 1) do + get :index + expect(assigns(:april_fools)).to eq(true) + end + end + + it "sets @april_fools to false on non-April Fools dates" do + travel_to Date.new(2024, 3, 15) do + get :index + expect(assigns(:april_fools)).to eq(false) + end + end + + it "initializes @site_settings" do + get :index + expect(assigns(:site_settings)).to eq({}) + end + end end From b1595cc6b9d5ccfddc9557cb3b9d04065dad798e Mon Sep 17 00:00:00 2001 From: Tony Miller Date: Wed, 8 Apr 2026 09:48:44 -0700 Subject: [PATCH 3/3] fix serving app --- app/assets/config/manifest.js | 3 +++ app/assets/images/.keep | 0 config/application.rb | 1 + 3 files changed, 4 insertions(+) create mode 100644 app/assets/config/manifest.js create mode 100644 app/assets/images/.keep diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 000000000..4c9ab805c --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_tree ../../javascript .js diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/config/application.rb b/config/application.rb index 074f30787..f78edf57a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,6 +24,7 @@ module StreamPusher class Application < Rails::Application config.api_only = true + config.assets.paths << Rails.root.join('app', 'javascript') # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded.