diff --git a/.dockerignore b/.dockerignore index 516b7b24..f6211800 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,14 +12,12 @@ /public/uploads/ /test /images -/config/nginx/nginx.conf -/docker +/docker/dev_entrypoint.sh +/.github yarn-error.log -web.Dockerfile Dockerfile -docker-compose.development.yml -docker-compose.test.yml +docker-compose.dev.yml docker-compose.yml .DS_Store *.swp diff --git a/.eslintrc.js b/.eslintrc.js index 22a230ff..313ea556 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,7 @@ module.exports = { "Event": true, "App": true, "Turbolinks": true, - "CustomEvent": true + "CustomEvent": true, + "IntersectionObserver": true } }; diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 00000000..a1534153 --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,48 @@ +name: Release Build + +on: + push: + tags: + - v* + +env: + DOCKER_BUILDKIT: 1 + +jobs: + create_release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Create release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + draft: false + prerelease: false + + build: + needs: create_release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Get version + run: | + # Strip git ref prefix from version + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + # Strip "v" prefix from tag name + [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') + echo "::set-env name=VERSION::$VERSION" + - name: Build and push image + uses: docker/build-push-action@v1.1.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: blackcandy/blackcandy + tags: ${{ env.VERSION }}, latest + target: production diff --git a/.gitignore b/.gitignore index f543b6a6..0be33c1f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,6 @@ /public/packs-test /public/uploads /node_modules -/config/nginx/nginx.conf .DS_Store *.swp diff --git a/.travis.yml b/.travis.yml index 9d59dc91..f1a7ff26 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,37 @@ -language: minimal +dist: bionic + +language: ruby + +rvm: + - 2.6.6 + +addons: + postgresql: "11" services: - - docker + - redis-server + +cache: + bundler: true + yarn: true + +env: + global: + - DB_HOST=localhost + - DB_USER=postgres + - REDIS_DATA_URL=redis://localhost:6379/0 + - REDIS_SIDEKIQ_URL=redis://localhost:6379/1 + +before_install: + - nvm install 12.15.0 + - sudo apt-get update + - sudo apt-get -y install imagemagick libtag1-dev ffmpeg + - yarn install before_script: - - make test_setup + - RAILS_ENV=test bundle exec rake db:setup script: - - make test_run - - make test_run_lint - - make test_run_brakeman + - RAILS_ENV=test bundle exec rails test + - bundle exec rails lint:all + - bundle exec brakeman diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab07720..b7ee0cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +### 1.1.0 + - enhancements + - use pg full text search to replace pgroonga + - update webpacker to 5.0 + - squash docker image size + - use intersection observer api to replace scrollmagic on infinite scroll + + - bug fixes + - fix issue for can not set right theme when user first login + - fix can not play song when use http range to send file on dev environment + + ### 1.0.5 - enhancements - update Rails to 6.0.2 diff --git a/Dockerfile b/Dockerfile index b269c424..2c439197 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,25 @@ -FROM node:12.14.1-slim as node -FROM ruby:2.6.0-slim as base +FROM ruby:2.6.6-alpine AS base ENV LANG C.UTF-8 -MAINTAINER Aidewoode https://github.com/aidewoode +LABEL maintainer="Aidewoode@github.com/aidewoode" -COPY --from=node /usr/local/bin/node /usr/local/bin/node -COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules -COPY --from=node /usr/local/bin/npm /usr/local/bin/npm -COPY --from=node /opt/yarn-* /opt/yarn - -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential git vim libpq-dev imagemagick libtag1-dev ffmpeg \ - && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ - && ln -s /usr/local/bin/node /usr/local/bin/nodejs \ - && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \ - && ln -s /opt/yarn/bin/yarnpkg /usr/local/bin/yarnpkg +RUN apk add --no-cache \ + tzdata \ + postgresql-dev \ + git \ + nodejs \ + yarn \ + imagemagick \ + taglib-dev \ + ffmpeg WORKDIR /app +FROM base AS dev + +RUN apk add --no-cache build-base + FROM base AS production ENV RAILS_ENV production @@ -26,10 +27,19 @@ ENV NODE_ENV production ADD . /app -RUN bundle install --without development test && yarn +RUN apk add --no-cache --virtual .build-deps build-base \ + && bundle install --without development test \ + && rm -rf /usr/local/bundle/cache/*.gem \ + && apk del --no-network .build-deps + + +RUN bundle exec rails assets:precompile SECRET_KEY_BASE=fake_secure_for_compile \ + && yarn cache clean \ + && rm -rf node_modules tmp/cache/* /tmp/* -RUN bundle exec rails assets:precompile SECRET_KEY_BASE=fake_secure_for_compile +RUN apk add --no-cache nginx \ + && cp config/nginx/nginx.conf /etc/nginx/nginx.conf EXPOSE 3000 -CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] +CMD ["docker/production_start.sh"] diff --git a/Gemfile b/Gemfile index 5dd30431..863ddcee 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,7 @@ gem 'pg', '~> 1.1.3' # Use Puma as the app server gem 'puma', '~> 4.3.1' # Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker -gem 'webpacker', '~> 4.2.0' +gem 'webpacker', '~> 5.1.1' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder gem 'jbuilder', '~> 2.9.1' # Use ActiveModel has_secure_password @@ -34,6 +34,8 @@ gem 'carrierwave', '~> 2.0' gem 'httparty', '~> 0.17.0' # For browser detection gem 'browser', '~> 2.6.1', require: 'browser/browser' +# For PostgreSQL's full text search +gem 'pg_search', '~> 2.3.2' # Use Capistrano for deployment # gem 'capistrano-rails', group: :development diff --git a/Gemfile.lock b/Gemfile.lock index f81c25ae..459d21d9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -85,11 +85,11 @@ GEM mimemagic (>= 0.3.0) mini_mime (>= 0.1.3) childprocess (3.0.0) - concurrent-ruby (1.1.5) + concurrent-ruby (1.1.6) connection_pool (2.2.2) crack (0.4.3) safe_yaml (~> 1.0.0) - crass (1.0.5) + crass (1.0.6) database_cleaner (1.7.0) erubi (1.9.0) ffi (1.10.0) @@ -99,7 +99,7 @@ GEM httparty (0.17.0) mime-types (~> 3.0) multi_xml (>= 0.5.2) - i18n (1.7.0) + i18n (1.8.2) concurrent-ruby (~> 1.0) image_processing (1.9.3) mini_magick (>= 4.9.5, < 5) @@ -111,7 +111,7 @@ GEM rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) ruby_dep (~> 1.2) - loofah (2.4.0) + loofah (2.5.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.1) @@ -119,7 +119,7 @@ GEM marcel (0.3.3) mimemagic (~> 0.3.2) memory_profiler (0.9.13) - method_source (0.9.2) + method_source (1.0.0) mime-types (3.2.2) mime-types-data (~> 3.2015) mime-types-data (3.2019.0331) @@ -127,22 +127,25 @@ GEM mini_magick (4.9.5) mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.13.0) + minitest (5.14.0) msgpack (1.3.1) multi_xml (0.6.0) nio4r (2.5.2) - nokogiri (1.10.7) + nokogiri (1.10.9) mini_portile2 (~> 2.4.0) pagy (3.5.0) parallel (1.17.0) parser (2.6.2.1) ast (~> 2.4.0) pg (1.1.4) + pg_search (2.3.2) + activerecord (>= 5.2) + activesupport (>= 5.2) powerpack (0.1.2) public_suffix (3.0.3) puma (4.3.1) nio4r (~> 2.0) - rack (2.0.8) + rack (2.2.2) rack-protection (2.0.7) rack rack-proxy (0.6.5) @@ -205,6 +208,7 @@ GEM selenium-webdriver (3.142.6) childprocess (>= 0.5, < 4.0) rubyzip (>= 1.2.2) + semantic_range (2.3.0) sidekiq (6.0.0) connection_pool (>= 2.2.2) rack (>= 2.0.0) @@ -228,7 +232,7 @@ GEM turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) - tzinfo (1.2.5) + tzinfo (1.2.7) thread_safe (~> 0.1) unicode-display_width (1.4.1) uniform_notifier (1.12.1) @@ -245,16 +249,17 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webpacker (4.2.0) - activesupport (>= 4.2) + webpacker (5.1.1) + activesupport (>= 5.2) rack-proxy (>= 0.6.1) - railties (>= 4.2) + railties (>= 5.2) + semantic_range (>= 2.3.0) websocket-driver (0.7.1) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.4) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.2.2) + zeitwerk (2.3.0) PLATFORMS ruby @@ -275,6 +280,7 @@ DEPENDENCIES memory_profiler (~> 0.9.13) pagy (~> 3.5.0) pg (~> 1.1.3) + pg_search (~> 2.3.2) puma (~> 4.3.1) rails (~> 6.0.2.1) rails-settings-cached (~> 2.1.0) @@ -290,7 +296,7 @@ DEPENDENCIES web-console (>= 3.3.0) webdrivers (~> 4.1.3) webmock (~> 3.6.2) - webpacker (~> 4.2.0) + webpacker (~> 5.1.1) BUNDLED WITH 1.17.2 diff --git a/Makefile b/Makefile index 70afe05b..f39c3941 100644 --- a/Makefile +++ b/Makefile @@ -1,73 +1,24 @@ -DEV_APP_COMMAND = docker-compose -f docker-compose.development.yml run --rm app -TEST_APP_COMMAND = docker-compose -f docker-compose.test.yml run --rm test_app -PRODUCTION_APP_COMMAND = docker-compose run --rm app -DOCKER_LOGIN_COMMAND = docker login -u $(DOCKER_USER) -p $(DOCKER_PASS) +APP_COMMAND = docker-compose -f docker-compose.dev.yml run --rm app dev_run: - @docker-compose -f docker-compose.development.yml up --build + @docker-compose -f docker-compose.dev.yml up --build dev_stop: - @docker-compose -f docker-compose.development.yml down + @docker-compose -f docker-compose.dev.yml down dev_setup: - @$(DEV_APP_COMMAND) bundle - @$(DEV_APP_COMMAND) yarn - @$(DEV_APP_COMMAND) rails db:setup + @$(APP_COMMAND) bundle + @$(APP_COMMAND) yarn + @$(APP_COMMAND) rails db:setup dev_shell: - @$(DEV_APP_COMMAND) bash + @$(APP_COMMAND) sh test_run: - @$(TEST_APP_COMMAND) rails test + @$(APP_COMMAND) rails test RAILS_ENV=test -test_run_lint: - @$(TEST_APP_COMMAND) rails lint:all +lint_run: + @$(APP_COMMAND) rails lint:all -test_run_brakeman: - @$(TEST_APP_COMMAND) brakeman - -test_shell: - @$(TEST_APP_COMMAND) bash - -test_setup: - @$(TEST_APP_COMMAND) bundle install --without development - @$(TEST_APP_COMMAND) yarn - @$(TEST_APP_COMMAND) rails db:setup - -production_setup: - @$(PRODUCTION_APP_COMMAND) rails db:setup - @$(PRODUCTION_APP_COMMAND) rails db:seed - -production_run: - docker/build_nginx_conf.sh - @docker-compose up -d - -production_stop: - @docker-compose down - -production_restart: - @make production_stop - @make production_run - -production_set_ssl: - docker/set_ssl.sh - -production_update: - @docker pull blackcandy/blackcandy:$$(cat VERSION) - @docker pull blackcandy/web:$$(cat VERSION) - @$(PRODUCTION_APP_COMMAND) rails db:migrate - @make production_restart - -build: - @docker build -t blackcandy/blackcandy . - @docker tag blackcandy/blackcandy blackcandy/blackcandy:$$(cat VERSION) - @$(DOCKER_LOGIN_COMMAND) - @docker push blackcandy/blackcandy:$$(cat VERSION) - @docker push blackcandy/blackcandy:latest - -build_web: - @docker build -f web.Dockerfile -t blackcandy/web . - @docker tag blackcandy/web blackcandy/web:$$(cat VERSION) - @$(DOCKER_LOGIN_COMMAND) - @docker push blackcandy/web:$$(cat VERSION) - @docker push blackcandy/web:latest +brakeman_run: + @$(APP_COMMAND) brakeman diff --git a/README.md b/README.md index 5b5556c0..9cc6a8b7 100644 --- a/README.md +++ b/README.md @@ -21,21 +21,11 @@ Black candy support mp3, m4a, ogg, opus, flac and wav formats now. ## Installation -Black candy has built [docker images](https://hub.docker.com/r/blackcandy/blackcandy). +Black candy has built [docker images](https://hub.docker.com/r/blackcandy/blackcandy). You can use docker compose to run all services. -First, you need clone this project to your server. +First, you should create `docker-compose.yml` file for black candy. -``` -$ git clone https://github.com/aidewoode/black_candy.git -``` - -And checkout to latest version. - -``` -$ git checkout v1.0.5 -``` - -Notice, the git tag you checkout must match with the version that blackcandy docker image you use, otherwise will cause some unexpected issue. +Here is the example [docker-compose.yml](https://raw.githubusercontent.com/aidewoode/black_candy/v1.1.0/docker-compose.yml) you can use. You can also change the `docker-compose.yml` for your own needs. Second, set `BLACK_CANDY_MEDIA_PATH` and `BLACK_CANDY_SECRET_KEY_BASE` environment variable on your sever and point `BLACK_CANDY_MEDIA_PATH` to the readable directory on your server to store your music files. @@ -45,75 +35,28 @@ $ export BLACK_CANDY_MEDIA_PATH="/example_media_path" $ export BLACK_CANDY_SECRET_KEY_BASE="your_secret_key" ``` -Then, you should setup database - -```shell -$ make production_setup -``` - Finally run: ```shell $ docker-compose up -d - -# or - -$ make production_run ``` That's all. You can use initial admin user to login (email: admin@admin.com, password: foobar). -You can also change `docker-compose.yml` for your own needs. - -## Setup SSL - -Black candy can use Let’s Encrypt to get certificate. If you want to enable ssl, you sould make sure your server IP associate with your domain on DNS first. - -Then you need set `BLACK_CANDY_DOMAIN_NAME` and `BLACK_CANDY_DOMAIN_EMAIL` environment variable on your sever. - -```shell -$ export BLACK_CANDY_DOMAIN_NAME="example.com" -$ export BLACK_CANDY_DOMAIN_EMAIL="youremail@email.com" -``` - -Then set ssl certificate - -```shell -$ make production_set_ssl -``` - -After set ssl certificate successfully, you should set `BLACK_CANDY_USE_SSL` environment variable to true. - -```shell -$ export BLACK_CANDY_USE_SSL="true" -``` - -Finally, restart Black candy - -```shell -$ make production_restart -``` - ## Update -Pull new code from remote - -```shell -$ git pull origin master -``` - -And checkout to new version +Pull new image from remote ```shell -$ git checkout v1.0.5 +$ docker pull blackcandy/blackcandy ``` -Finally run: +Restart services: ```shell -$ make production_update +$ docker-compose restart ``` ## Development @@ -138,17 +81,11 @@ $ make dev_shell ## Test ```shell -# Setup test environment -$ make test_setup - # Runing test $ make test_run # Runing lint -$ make test_run_lint - -# Into test shell -$ make test_shell +$ make lint_run ``` ## Integrations diff --git a/VERSION b/VERSION deleted file mode 100644 index 90a27f9c..00000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.0.5 diff --git a/app/controllers/stream_controller.rb b/app/controllers/stream_controller.rb index 8d3df8c5..d3cf00d1 100644 --- a/app/controllers/stream_controller.rb +++ b/app/controllers/stream_controller.rb @@ -1,19 +1,15 @@ # frozen_string_literal: true class StreamController < ApplicationController - include ActionController::Live - before_action :require_login - before_action :find_song + before_action :find_stream before_action :set_header def new - stream = Stream.new(@song) - - if need_transcode? stream - send_stream stream + if need_transcode? @stream + redirect_to new_transcoded_stream_path(song_id: params[:song_id]) else - send_local_file stream.file_path + send_local_file @stream.file_path end end @@ -29,27 +25,13 @@ def need_transcode?(stream) def set_header if nginx_senfile? response.headers['X-Media-Path'] = Setting.media_path - response.headers['X-Accel-Redirect'] = File.join('/private_media', @song.file_path.sub(Setting.media_path, '')) - end - end - - # Similar to send_file in rails, but let response_body to be a stream object. - # The instance of Stream can respond to each() method. So the download can be streamed, - # instead of read whole data into memory. - def send_stream(stream) - response.headers['Content-Type'] = Mime[Stream::TRANSCODE_FORMAT] - - stream.each do |data| - response.stream.write data + response.headers['X-Accel-Redirect'] = File.join('/private_media', @stream.file_path.sub(Setting.media_path, '')) end - rescue ActionController::Live::ClientDisconnected - logger.info "[#{Time.now.utc}] Stream closed" - ensure - response.stream.close end - def find_song - @song = Song.find(params[:song_id]) + def find_stream + song = Song.find(params[:song_id]) + @stream = Stream.new(song) end def nginx_senfile? diff --git a/app/controllers/transcoded_stream_controller.rb b/app/controllers/transcoded_stream_controller.rb new file mode 100644 index 00000000..38ee0500 --- /dev/null +++ b/app/controllers/transcoded_stream_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class TranscodedStreamController < StreamController + include ActionController::Live + + # Similar to send_file in rails, but let response_body to be a stream object. + # The instance of Stream can respond to each() method. So the download can be streamed, + # instead of read whole data into memory. + def new + response.headers['Content-Type'] = Mime[Stream::TRANSCODE_FORMAT] + + @stream.each do |data| + response.stream.write data + end + rescue ActionController::Live::ClientDisconnected + logger.info "[#{Time.now.utc}] Stream closed" + ensure + response.stream.close + end +end diff --git a/app/frontend/javascripts/app.js b/app/frontend/javascripts/app.js index 467e45b7..81a0a047 100644 --- a/app/frontend/javascripts/app.js +++ b/app/frontend/javascripts/app.js @@ -1,5 +1,3 @@ -import Noty from 'noty'; - export default { renderContent(selector, content) { document.querySelector(selector).innerHTML = content; @@ -19,18 +17,6 @@ export default { element.dispatchEvent(new CustomEvent(type, { detail: data })); }, - showNotification(text, type) { - const types = ['success', 'error']; - - new Noty({ - text, - type: types.includes(type) ? type : 'success', - timeout: 2500, - progressBar: false, - container: '#js-flash' - }).show(); - }, - playlistElement(playlistId) { const playlistElement = document.querySelector("#js-playlist-content [data-controller='playlist-songs']"); diff --git a/app/frontend/javascripts/controllers/flash_controller.js b/app/frontend/javascripts/controllers/flash_controller.js index d26f6383..88a298a7 100644 --- a/app/frontend/javascripts/controllers/flash_controller.js +++ b/app/frontend/javascripts/controllers/flash_controller.js @@ -1,10 +1,15 @@ import { Controller } from 'stimulus'; export default class extends Controller { - initialize() { - const type = this.data.get('type'); + connect() { + setTimeout(this._removeFlash.bind(this), this.data.get('timeout')); + } + + _removeFlash() { + this.element.addEventListener('animationend', function removeFlashElement() { + this.remove(); + }, { once: true }); - App.showNotification(this.element.textContent, type); - this.element.remove(); + this.element.classList.add('flash__body--close'); } } diff --git a/app/frontend/javascripts/controllers/infinite_scroll_controller.js b/app/frontend/javascripts/controllers/infinite_scroll_controller.js index 35eba8b7..d1ebab30 100644 --- a/app/frontend/javascripts/controllers/infinite_scroll_controller.js +++ b/app/frontend/javascripts/controllers/infinite_scroll_controller.js @@ -1,6 +1,5 @@ import { Controller } from 'stimulus'; import { ajax } from '@rails/ujs'; -import ScrollMagic from 'scrollmagic'; export default class extends Controller { static targets = ['trigger'] @@ -8,48 +7,41 @@ export default class extends Controller { connect() { if (!this.hasNextPage) { return; } - const scene = this.createScene(); - this.bindNextPageEvent(scene); + this.observer = new IntersectionObserver(this._handleNextPageLoad.bind(this), { + root: document.querySelector(this.data.get('container')), + rootMargin: '0px', + threshold: 1.0 + }); + + this.observer.observe(this.triggerTarget); } disconnect() { - if (this.scrollController) { - this.scrollController.destroy(true); + if (this.observer) { + this.observer.disconnect(); } } - createScene() { - this.scrollController = new ScrollMagic.Controller({ - container: this.data.get('container') || window - }); - - return new ScrollMagic.Scene({ - triggerElement: this.triggerTarget, - triggerHook: 'onEnter' - }).addTo(this.scrollController); - } - - bindNextPageEvent(scene) { - let ajaxRequest; - - scene.on('enter', () => { - if (ajaxRequest) { - // Abort previous ajax request. - ajaxRequest.abort(); - } + _handleNextPageLoad(entries) { + entries.forEach((entry) => { + if (!entry.intersectionRatio == 1) { return; } if (!this.hasNextPage) { this.triggerTarget.classList.add('hidden'); + return; } - if (this.isTriggerHidden && !this.hasNextPage) { return; } + if (this.ajaxRequest) { + // Abort previous ajax request. + this.ajaxRequest.abort(); + } ajax({ url: this.nextUrl, type: 'get', dataType: 'script', - beforeSend(xhr) { - ajaxRequest = xhr; + beforeSend: (xhr) => { + this.ajaxRequest = xhr; return true; } }); @@ -63,8 +55,4 @@ export default class extends Controller { get nextUrl() { return this.data.get('nextUrl'); } - - get isTriggerHidden() { - return this.triggerTarget.offsetParent == null; - } } diff --git a/app/frontend/javascripts/controllers/search_controller.js b/app/frontend/javascripts/controllers/search_controller.js index 0bf5f6df..4ffd2117 100644 --- a/app/frontend/javascripts/controllers/search_controller.js +++ b/app/frontend/javascripts/controllers/search_controller.js @@ -1,32 +1,40 @@ import { Controller } from 'stimulus'; -import { ajax } from '@rails/ujs'; export default class extends Controller { - static targets = ['loader']; + static targets = ['loader', 'input']; AVAILABLE_RESOURCES = ['albums', 'artists', 'songs']; - SEARCH_TIMEOUT = 600 + connect() { + this.inputTarget.value = this.inputTarget.getAttribute('value'); + } + + disconnect() { + this.loaderTarget.classList.add('hidden'); + this.inputTarget.value = ''; + } + + query(event) { + if (event.key != 'Enter') { return; } - query({ target }) { this.loaderTarget.classList.remove('hidden'); - if (this.searchTimeout) { - clearTimeout(this.searchTimeout); - } - - this.searchTimeout = setTimeout(() => { - const queryUrl = this.AVAILABLE_RESOURCES.includes(this.resource) ? `/${this.resource}` : '/albums'; - - ajax({ - url: `${queryUrl}?query=${target.value.trim()}`, - type: 'get', - dataType: 'script', - success: () => { - this.loaderTarget.classList.add('hidden'); - } - }); - }, this.SEARCH_TIMEOUT); + const baseUrl = this.AVAILABLE_RESOURCES.includes(this.resource) ? `/${this.resource}` : '/albums'; + const query = event.target.value.trim(); + const queryUrl = `${baseUrl}${query ? `?query=${query}` : ''}`; + + Turbolinks.visit(queryUrl); + this._focusInput(); + } + + _focusInput() { + document.addEventListener('turbolinks:load', () => { + const searchElement = document.querySelector('#js-search-input'); + const searchValueLength = searchElement.value.length; + + searchElement.focus(); + searchElement.setSelectionRange(searchValueLength, searchValueLength); + }, { once: true }); } get resource() { diff --git a/app/frontend/javascripts/controllers/theme_controller.js b/app/frontend/javascripts/controllers/theme_controller.js index 62f27786..ba83ee4d 100644 --- a/app/frontend/javascripts/controllers/theme_controller.js +++ b/app/frontend/javascripts/controllers/theme_controller.js @@ -17,9 +17,10 @@ export default class extends Controller { if (this.colorSchemeQuery) { this.colorSchemeQuery.removeListener(this._matchTheme); } const theme = this.data.get('name'); + const oneYearFromNow = new Date(Date.now() + 365 * 864e5).toUTCString(); // set theme cookie to track theme when user didn't login - document.cookie = `theme=${theme};path=/`; + document.cookie = `theme=${theme};path=/;samesite=lax;expires=${oneYearFromNow}`; switch (theme) { case 'dark': diff --git a/app/frontend/packs/application.css b/app/frontend/packs/application.css new file mode 100644 index 00000000..e1be53c9 --- /dev/null +++ b/app/frontend/packs/application.css @@ -0,0 +1,2 @@ +@import 'normalize.css'; +@import '../stylesheets/application.css'; diff --git a/app/frontend/packs/application.js b/app/frontend/packs/application.js index faf0ede0..c0681f7e 100644 --- a/app/frontend/packs/application.js +++ b/app/frontend/packs/application.js @@ -17,9 +17,6 @@ import Player from '../javascripts/player'; import 'core-js/stable'; import 'regenerator-runtime/runtime'; -import 'normalize.css'; -import '../stylesheets/application.css'; - require.context('../images', true); RailsUjs.start(); diff --git a/app/frontend/stylesheets/application.css b/app/frontend/stylesheets/application.css index 65f66227..d9fc3f73 100644 --- a/app/frontend/stylesheets/application.css +++ b/app/frontend/stylesheets/application.css @@ -23,6 +23,7 @@ @import 'components/avatar.css'; @import 'components/notice.css'; @import 'components/box.css'; +@import 'components/flash.css'; @import 'pages/layout.css'; @import 'pages/playlist.css'; @@ -30,4 +31,3 @@ @import 'pages/songs.css'; @import 'responsive.css'; -@import 'lib/noty.css'; diff --git a/app/frontend/stylesheets/components/flash.css b/app/frontend/stylesheets/components/flash.css new file mode 100644 index 00000000..916e56ee --- /dev/null +++ b/app/frontend/stylesheets/components/flash.css @@ -0,0 +1,36 @@ +.flash { + position: absolute; + margin: 0; + padding: 0; + z-index: 9999999; + backface-visibility: hidden; + filter: blur(0); + max-width: 90%; + top: 5%; + left: 50%; + width: 325px; + transform: translate(calc(-50% - 0.5px)) translateZ(0) scale(1, 1); +} + +.flash__body { + padding: 8px; + font-size: 14px; + text-align: center; + border-radius: var(--flash-border-radius); + animation-duration: 0.25s; + animation-name: fadeInDown; +} + +.flash__body--close { + animation-name: fadeOutUp; +} + +.flash__body--error { + background-color: var(--flash-error-bg-color); + color: var(--white); +} + +.flash__body--success { + background-color: var(--flash-success-bg-color); + color: var(--white); +} diff --git a/app/frontend/stylesheets/lib/noty.css b/app/frontend/stylesheets/lib/noty.css deleted file mode 100644 index f72f100e..00000000 --- a/app/frontend/stylesheets/lib/noty.css +++ /dev/null @@ -1,78 +0,0 @@ -/* Style for noty notification library */ - -#js-flash { - position: absolute; - margin: 0; - padding: 0; - z-index: 9999999; - backface-visibility: hidden; - filter: blur(0); - max-width: 90%; - top: 5%; - left: 50%; - width: 325px; - transform: translate(calc(-50% - 0.5px)) translateZ(0) scale(1, 1); -} - -.noty_bar { - transform: translate(0, 0) scale(1, 1); - margin: 4px 0; - overflow: hidden; - border-radius: var(--noty-bar-border-radius); - position: relative; - opacity: 0.9; -} - -.noty_bar .noty_body { - padding: 8px; - font-size: 14px; - text-align: center; -} - -.noty_type__error { - background-color: var(--noty-error-bg-color); - color: var(--white); -} - -.noty_type__success { - background-color: var(--noty-success-bg-color); - color: var(--white); -} - -.noty_effects_open { - opacity: 0; - transform: translate(50%); - animation: noty_anim_in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); - animation-fill-mode: forwards; - animation-name: fadeInDown; -} - -.noty_effects_close { - animation: noty_anim_out 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); - animation-fill-mode: forwards; - animation-name: fadeOutUp; -} - -.noty_fix_effects_height { - animation: noty_anim_height 75ms ease-out; -} - -@keyframes noty_anim_in { - 100% { - transform: translate(0); - opacity: 1; - } -} - -@keyframes noty_anim_out { - 100% { - transform: translate(50%); - opacity: 0; - } -} - -@keyframes noty_anim_height { - 100% { - height: 0; - } -} diff --git a/app/frontend/stylesheets/settings.css b/app/frontend/stylesheets/settings.css index 7f711097..ff8fcaf9 100644 --- a/app/frontend/stylesheets/settings.css +++ b/app/frontend/stylesheets/settings.css @@ -19,8 +19,8 @@ /* Form */ --form-input-border-radius: var(--border-radius-md); - /* Noty flash message */ - --noty-bar-border-radius: var(--border-radius-md); + /* Flash message */ + --flash-border-radius: var(--border-radius-md); /* Input */ --input-border-radius: var(--border-radius-md); diff --git a/app/frontend/stylesheets/theme.css b/app/frontend/stylesheets/theme.css index 83d00a7b..1caab683 100644 --- a/app/frontend/stylesheets/theme.css +++ b/app/frontend/stylesheets/theme.css @@ -39,9 +39,9 @@ html { --btn-color: var(--white); --btn-primary-bg-color: var(--purple); - /* Noty flash message */ - --noty-success-bg-color: var(--green); - --noty-error-bg-color: var(--red); + /* Flash message */ + --flash-success-bg-color: var(--green); + --flash-error-bg-color: var(--red); /* Tab */ --tab-color: var(--black); @@ -146,9 +146,9 @@ html[data-color-scheme='dark'] { --btn-color: var(--white); --btn-primary-bg-color: var(--purple); - /* Noty flash message */ - --noty-success-bg-color: var(--green); - --noty-error-bg-color: var(--red); + /* Flash message */ + --flash-success-bg-color: var(--green); + --flash-error-bg-color: var(--red); /* Tab */ --tab-color: var(--white); diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d704c975..f12628e1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -47,10 +47,6 @@ def render_playlist(html) render partial: 'shared/playlist.js.erb', locals: { html: html } end - def render_main_content(html) - render partial: 'shared/main.js.erb', locals: { html: html } - end - def format_duration(sec) time = Time.at(sec) sec > 1.hour ? time.strftime('%T') : time.strftime('%M:%S') diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index a69d6553..ba533dc7 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -3,24 +3,29 @@ module Searchable extend ActiveSupport::Concern + included do + include PgSearch::Model + end + class_methods do def search_by(attr, options = {}) - define_singleton_method :search do |query| - return self unless query.present? + associations = Array(options[:associations]) + associated_against = Hash[associations.map { |association| [association, attr] }] - associations = Array(options[:associations]).map(&:to_sym) - - if associations.blank? - where(sanitize_sql_for_conditions ["#{attr} &@ ?", query]) - else - associations_query = associations.map do |association| - "#{association.to_s.pluralize}.#{attr} &@ ?" - end.join(' OR ') + search_options = {}.tap do |option| + option[:against] = attr + option[:using] = { + tsearch: { prefix: true }, + trigram: {} + } + option[:associated_against] = associated_against unless associations.blank? + end - query_conditions = sanitize_sql_for_conditions ["#{self.table_name}.#{attr} &@ ? OR #{associations_query}", *Array.new(associations.length + 1, query)] + pg_search_scope "search_by_#{attr}", search_options - joins(associations).where(query_conditions) - end + define_singleton_method :search do |query| + return self unless query.present? + send("search_by_#{attr}", query) end end end diff --git a/app/models/user.rb b/app/models/user.rb index 776569fe..4e4fb34f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,6 +2,7 @@ class User < ApplicationRecord AVAILABLE_THEME_OPTIONS = %w(dark light auto) + DEFAULT_THEME = 'dark' include ScopedSetting @@ -17,7 +18,7 @@ class User < ApplicationRecord has_secure_password - scoped_field :theme, default: 'dark', available_options: AVAILABLE_THEME_OPTIONS + scoped_field :theme, default: DEFAULT_THEME, available_options: AVAILABLE_THEME_OPTIONS def update_settings(settings) settings.each do |key, value| diff --git a/app/views/albums/index.js.erb b/app/views/albums/index.js.erb index e91a3c45..5759173b 100644 --- a/app/views/albums/index.js.erb +++ b/app/views/albums/index.js.erb @@ -1,5 +1 @@ -<% if @pagy.page == 1 %> - <%= render_main_content(render template: 'albums/index.html.erb') %> -<% else %> - App.appedNextPageContentTo('#js-albums-content', '<%= j(render partial: 'album', collection: @albums, cached: true) %>', '<%= pagy_next_url(@pagy) %>') -<% end %> +App.appedNextPageContentTo('#js-albums-content', '<%= j(render partial: 'album', collection: @albums, cached: true) %>', '<%= pagy_next_url(@pagy) %>') diff --git a/app/views/artists/index.js.erb b/app/views/artists/index.js.erb index b1fa5be9..fd94c13f 100644 --- a/app/views/artists/index.js.erb +++ b/app/views/artists/index.js.erb @@ -1,5 +1 @@ -<% if @pagy.page == 1 %> - <%= render_main_content(render template: 'artists/index.html.erb') %> -<% else %> - App.appedNextPageContentTo('#js-artists-content', '<%= j render(partial: 'artist', collection: @artists, cached: true) %>', '<%= pagy_next_url(@pagy) %>') -<% end %> +App.appedNextPageContentTo('#js-artists-content', '<%= j render(partial: 'artist', collection: @artists, cached: true) %>', '<%= pagy_next_url(@pagy) %>') diff --git a/app/views/dialog/playlists/index.js.erb b/app/views/dialog/playlists/index.js.erb index 3b31ee42..82fa842f 100644 --- a/app/views/dialog/playlists/index.js.erb +++ b/app/views/dialog/playlists/index.js.erb @@ -1,5 +1,5 @@ <% if @pagy.page == 1 %> App.renderContent('#js-dialog-content', '<%= j(render 'index') %>'); <% else %> - App.appedNextPageContentTo('#js-dialog-playlist', '<%= j(render partial: 'playlist', collection: @playlists, cached: true ) %>', '<%= pagy_next_url(@pagy) %>') + App.appedNextPageContentTo('#js-dialog-playlists', '<%= j(render partial: 'playlist', collection: @playlists, cached: true ) %>', '<%= pagy_next_url(@pagy) %>') <% end %> diff --git a/app/views/layouts/_main.html.erb b/app/views/layouts/_main.html.erb index 8429d930..15c91ef6 100644 --- a/app/views/layouts/_main.html.erb +++ b/app/views/layouts/_main.html.erb @@ -1,12 +1,12 @@