pifi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/INSTALL.md +63 -0
- data/LICENSE +674 -0
- data/README.md +140 -0
- data/bin/pifi +24 -0
- data/config.ru +4 -0
- data/docs/icon/license.pdf +737 -1
- data/lib/pifi.rb +2 -0
- data/lib/pifi/controllers/application_controller.rb +16 -0
- data/lib/pifi/controllers/index_controller.rb +33 -0
- data/lib/pifi/controllers/player_controller.rb +40 -0
- data/lib/pifi/lib/config_getter.rb +44 -0
- data/lib/pifi/lib/lang_chooser.rb +27 -0
- data/lib/pifi/lib/player.rb +156 -0
- data/lib/pifi/lib/streams_getter.rb +27 -0
- data/lib/pifi/lib/utils.rb +8 -0
- data/lib/pifi/public/android-chrome-192x192.png +0 -0
- data/lib/pifi/public/android-chrome-384x384.png +0 -0
- data/lib/pifi/public/apple-touch-icon.png +0 -0
- data/lib/pifi/public/browserconfig.xml +9 -0
- data/lib/pifi/public/css/app.css +143 -0
- data/lib/pifi/public/favicon-16x16.png +0 -0
- data/lib/pifi/public/favicon-32x32.png +0 -0
- data/lib/pifi/public/favicon.ico +0 -0
- data/lib/pifi/public/js/app.js +298 -0
- data/lib/pifi/public/js/lang/en-us.js +19 -0
- data/lib/pifi/public/js/lang/fr-fr.js +19 -0
- data/lib/pifi/public/js/lang/nl-nl.js +19 -0
- data/lib/pifi/public/js/lang/pt-br.js +19 -0
- data/lib/pifi/public/mstile-144x144.png +0 -0
- data/lib/pifi/public/mstile-150x150.png +0 -0
- data/lib/pifi/public/mstile-310x150.png +0 -0
- data/lib/pifi/public/mstile-310x310.png +0 -0
- data/lib/pifi/public/mstile-70x70.png +0 -0
- data/lib/pifi/public/safari-pinned-tab.svg +61 -0
- data/lib/pifi/public/site.webmanifest +19 -0
- data/lib/pifi/public/vendor/css/bootstrap-theme.css +587 -0
- data/lib/pifi/public/vendor/css/bootstrap-theme.css.map +1 -0
- data/lib/pifi/public/vendor/css/bootstrap-theme.min.css +6 -0
- data/lib/pifi/public/vendor/css/bootstrap-theme.min.css.map +1 -0
- data/lib/pifi/public/vendor/css/bootstrap.css +6757 -0
- data/lib/pifi/public/vendor/css/bootstrap.css.map +1 -0
- data/lib/pifi/public/vendor/css/bootstrap.min.css +6 -0
- data/lib/pifi/public/vendor/css/bootstrap.min.css.map +1 -0
- data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.eot +0 -0
- data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.svg +288 -0
- data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.ttf +0 -0
- data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.woff +0 -0
- data/lib/pifi/public/vendor/fonts/glyphicons-halflings-regular.woff2 +0 -0
- data/lib/pifi/public/vendor/js/bootstrap.js +2377 -0
- data/lib/pifi/public/vendor/js/bootstrap.min.js +7 -0
- data/lib/pifi/public/vendor/js/jquery.min.js +4 -0
- data/lib/pifi/public/vendor/js/npm.js +13 -0
- data/lib/pifi/views/index.erb +91 -0
- data/lib/pifi/views/layout.erb +35 -0
- metadata +169 -0
data/lib/pifi.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "pifi/lib/config_getter"
|
|
2
|
+
require "pifi/lib/streams_getter"
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
|
|
5
|
+
module PiFi
|
|
6
|
+
class ApplicationController < Sinatra::Base
|
|
7
|
+
set ConfigGetter.new.config
|
|
8
|
+
set :streams, StreamsGetter.new(settings.streams_file, settings.streamsp_file).streams
|
|
9
|
+
|
|
10
|
+
set :root, File.expand_path("../../", __FILE__)
|
|
11
|
+
|
|
12
|
+
configure :production do
|
|
13
|
+
set :static, settings.serve_static
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "pifi/controllers/application_controller"
|
|
2
|
+
require "pifi/lib/lang_chooser"
|
|
3
|
+
|
|
4
|
+
module PiFi
|
|
5
|
+
class IndexController < ApplicationController
|
|
6
|
+
def title
|
|
7
|
+
title = "PiFi Radio"
|
|
8
|
+
settings.production? ? title : "[#{settings.environment.capitalize}] #{title}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def special_ip?
|
|
12
|
+
# Try to get remote IP if behind reverse-proxy
|
|
13
|
+
ip = env["HTTP_X_FORWARDED_FOR"] || request.ip
|
|
14
|
+
settings.special_ips.include?(ip)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def streams_set
|
|
18
|
+
special_ip? ? settings.streams[:all] : settings.streams[:pub]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def lang
|
|
22
|
+
LangChooser.new(env["HTTP_ACCEPT_LANGUAGE"]).lang
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def play_local?
|
|
26
|
+
settings.play_local
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
get "/" do
|
|
30
|
+
erb :index
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "pifi/controllers/application_controller"
|
|
2
|
+
require "pifi/lib/player"
|
|
3
|
+
|
|
4
|
+
module PiFi
|
|
5
|
+
class PlayerController < ApplicationController
|
|
6
|
+
ALLOWED_METHODS=["play", "stop", "change_vol", "play_radios", "play_urls", "play_random"]
|
|
7
|
+
|
|
8
|
+
def player
|
|
9
|
+
@@player
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
@@player = Player.new(settings.streams[:all],
|
|
13
|
+
host = settings.mpd_host,
|
|
14
|
+
port = settings.mpd_port,
|
|
15
|
+
password = settings.mpd_password)
|
|
16
|
+
|
|
17
|
+
get "/" do
|
|
18
|
+
content_type :json
|
|
19
|
+
cache_control :no_cache
|
|
20
|
+
player.state.to_json
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
post "/" do
|
|
24
|
+
content_type :text
|
|
25
|
+
|
|
26
|
+
method = params[:method]
|
|
27
|
+
args = params[:params]
|
|
28
|
+
halt 400, "Invalid method" unless ALLOWED_METHODS.include?(method)
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
result = player.public_send(method, *args)
|
|
32
|
+
rescue ArgumentError, MPD::NotFound => e
|
|
33
|
+
halt 400, e.message.delete_prefix("[] ")
|
|
34
|
+
rescue MPD::PermissionError, MPD::IncorrectPassword => e
|
|
35
|
+
halt 403, e.message.delete_prefix("[] ")
|
|
36
|
+
end
|
|
37
|
+
result.to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "pifi/lib/utils"
|
|
2
|
+
|
|
3
|
+
module PiFi
|
|
4
|
+
class ConfigGetter
|
|
5
|
+
include Utils
|
|
6
|
+
|
|
7
|
+
PATH = "/etc/pifi.json"
|
|
8
|
+
DEFAULT_KEYS = {
|
|
9
|
+
"mpd_host" => "127.0.0.1",
|
|
10
|
+
"mpd_port" => "6600",
|
|
11
|
+
"mpd_password" => "",
|
|
12
|
+
"streams_file" => "/etc/pifi_streams.json",
|
|
13
|
+
"streamsp_file" => "",
|
|
14
|
+
"special_ips" => "",
|
|
15
|
+
"play_local" => false,
|
|
16
|
+
"serve_static" => true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def config
|
|
20
|
+
@config ||= parse_config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def parse_config
|
|
26
|
+
if File.exists?(PATH)
|
|
27
|
+
warn "Config found at ${PATH}."
|
|
28
|
+
config = file_to_hash(PATH)
|
|
29
|
+
else
|
|
30
|
+
warn "Config not found. Using defaults."
|
|
31
|
+
config = {}
|
|
32
|
+
end
|
|
33
|
+
config = DEFAULT_KEYS.merge(config)
|
|
34
|
+
check_errors(config)
|
|
35
|
+
|
|
36
|
+
config
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def check_errors(config)
|
|
40
|
+
invalid = config.keys.reject { |key| DEFAULT_KEYS.include?(key) }
|
|
41
|
+
warn "Invalid keys in config file: #{invalid}" unless invalid.empty?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module PiFi
|
|
2
|
+
class LangChooser
|
|
3
|
+
DEFAULT = "en-us"
|
|
4
|
+
LANG_DIR = "app/public/js/lang/*.js"
|
|
5
|
+
|
|
6
|
+
def initialize(http_accept_language)
|
|
7
|
+
@http_accept = http_accept_language
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def lang
|
|
11
|
+
lang = accept.find { |e| avail.include?(e) }
|
|
12
|
+
lang || DEFAULT
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def avail
|
|
19
|
+
@@avail ||= Dir.glob(LANG_DIR).map { |file| File.basename(file, ".*") }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def accept
|
|
23
|
+
return [] if @http_accept.nil?
|
|
24
|
+
@http_accept.split(";")[0].split(",").map(&:downcase)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
require "ruby-mpd"
|
|
2
|
+
|
|
3
|
+
module PiFi
|
|
4
|
+
class Player
|
|
5
|
+
class VolNaError < StandardError
|
|
6
|
+
def message
|
|
7
|
+
"Volume is not available"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
CROSSFADE = 5
|
|
12
|
+
DEFAULT_TITLE_LOCAL = "Music"
|
|
13
|
+
DEFAULT_TITLE_STREAM = "Streaming"
|
|
14
|
+
DEFAULT_HOST = "localhost"
|
|
15
|
+
DEFAULT_PORT = "6600"
|
|
16
|
+
|
|
17
|
+
def initialize(streams, host=DEFAULT_HOST, port=DEFAULT_PORT, password=nil)
|
|
18
|
+
@streams = streams
|
|
19
|
+
@mpd = MPD.new(host, port, { callbacks: true })
|
|
20
|
+
@mpd.connect
|
|
21
|
+
@mpd.password(password) unless password.to_s.empty?
|
|
22
|
+
|
|
23
|
+
define_callbacks
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def state
|
|
27
|
+
{ playing: @playing,
|
|
28
|
+
title: @title,
|
|
29
|
+
artist: @artist,
|
|
30
|
+
local: @local,
|
|
31
|
+
elapsed: @elapsed,
|
|
32
|
+
length: @length,
|
|
33
|
+
vol: @vol,
|
|
34
|
+
con_mpd: @con_mpd }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def play
|
|
38
|
+
# Assigning is quicker than callback
|
|
39
|
+
@playing = true if @mpd.play
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stop
|
|
43
|
+
# Assigning is quicker than callback
|
|
44
|
+
@playing = false if @mpd.stop
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def change_vol(delta)
|
|
48
|
+
raise ArgumentError, "Invalid argument" unless delta =~ /^[+-]\d{1,2}$/
|
|
49
|
+
# We get @vol=-1 when PulseAudio sink is closed
|
|
50
|
+
raise VolNaError if @vol < 0
|
|
51
|
+
|
|
52
|
+
@mpd.send_command("volume", delta);
|
|
53
|
+
# This is more up-to-date than @vol
|
|
54
|
+
@mpd.volume
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def play_radios(*names)
|
|
58
|
+
raise ArgumentError, "Argument required" if names.empty?
|
|
59
|
+
|
|
60
|
+
urls = []
|
|
61
|
+
names.each do |name|
|
|
62
|
+
url = @streams[name]
|
|
63
|
+
# nil: key not found; empty: it's a radio category
|
|
64
|
+
raise ArgumentError, "Invalid radio name: #{name}" if url.nil? || url.empty?
|
|
65
|
+
|
|
66
|
+
urls << url
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
play_urls(*urls)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def play_urls(*urls)
|
|
73
|
+
raise ArgumentError, "Argument required" if urls.empty?
|
|
74
|
+
raise ArgumentError, "Empty url given" if urls.any?(&:empty?)
|
|
75
|
+
|
|
76
|
+
@mpd.clear
|
|
77
|
+
urls.each { |url| @mpd.add(url) }
|
|
78
|
+
@mpd.random=(false)
|
|
79
|
+
@mpd.play
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def play_random
|
|
83
|
+
if @playing && @local
|
|
84
|
+
@mpd.next
|
|
85
|
+
else
|
|
86
|
+
@mpd.clear
|
|
87
|
+
@mpd.add("/")
|
|
88
|
+
@mpd.random=(true)
|
|
89
|
+
@mpd.crossfade=(CROSSFADE)
|
|
90
|
+
@mpd.play
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def define_callbacks
|
|
98
|
+
@mpd.on(:state, &method(:set_state))
|
|
99
|
+
@mpd.on(:time, &method(:set_time))
|
|
100
|
+
@mpd.on(:song, &method(:set_song))
|
|
101
|
+
@mpd.on(:volume, &method(:set_vol))
|
|
102
|
+
@mpd.on(:connection, &method(:set_con_mpd))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def set_state(state)
|
|
106
|
+
@playing = (state == :play)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def set_time(elapsed, length)
|
|
110
|
+
@elapsed = elapsed
|
|
111
|
+
@length = length
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def set_song(*args)
|
|
115
|
+
# Sometimes we receive no arguments, instead of MPD::Song object
|
|
116
|
+
if args.length == 0
|
|
117
|
+
@local = false
|
|
118
|
+
@title = ""
|
|
119
|
+
@artist = ""
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
song = args[0]
|
|
124
|
+
if song.file.include?("://")
|
|
125
|
+
set_song_stream(song)
|
|
126
|
+
else
|
|
127
|
+
set_song_local(song)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def set_song_local(song)
|
|
132
|
+
@local = true
|
|
133
|
+
if song.artist.nil? || song.title.nil?
|
|
134
|
+
@title = DEFAULT_TITLE_LOCAL
|
|
135
|
+
@artist = ""
|
|
136
|
+
else
|
|
137
|
+
@title = song.title
|
|
138
|
+
@artist = song.artist
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def set_song_stream(song)
|
|
143
|
+
@local = false
|
|
144
|
+
@title = @streams.key(song.file) || DEFAULT_TITLE_STREAM
|
|
145
|
+
@artist = ""
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def set_vol(vol)
|
|
149
|
+
@vol = vol
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def set_con_mpd(con_mpd)
|
|
153
|
+
@con_mpd = con_mpd
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "pifi/lib/utils"
|
|
2
|
+
|
|
3
|
+
module PiFi
|
|
4
|
+
class StreamsGetter
|
|
5
|
+
include Utils
|
|
6
|
+
|
|
7
|
+
def initialize(path_pub, path_priv)
|
|
8
|
+
@path_pub = path_pub
|
|
9
|
+
@path_priv = path_priv
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def streams
|
|
13
|
+
@streams ||= parse_streams
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def parse_streams
|
|
20
|
+
pub = file_to_hash(@path_pub)
|
|
21
|
+
priv = @path_priv.empty? ? {} : file_to_hash(@path_priv)
|
|
22
|
+
all = pub.merge(priv)
|
|
23
|
+
|
|
24
|
+
{pub: pub, all: all}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
margin-top: 25px;
|
|
3
|
+
margin-bottom: 30px;
|
|
4
|
+
margin-right: auto;
|
|
5
|
+
margin-left: auto;
|
|
6
|
+
max-width: 350px;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Hide everything */
|
|
10
|
+
.view, #osd {
|
|
11
|
+
display: none;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Don't change buttons styles after clicked. */
|
|
15
|
+
.btn:focus {
|
|
16
|
+
background-position: 0;
|
|
17
|
+
border-color: #ccc;
|
|
18
|
+
outline: none;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* player */
|
|
22
|
+
#player, #alert {
|
|
23
|
+
max-width: 320px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#state {
|
|
27
|
+
height: 93px;
|
|
28
|
+
margin-bottom: 10px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#progress {
|
|
32
|
+
float: right;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#state-top-line {
|
|
36
|
+
margin-bottom: 0px; /* we want the progress bar to be close */
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#progress-bar {
|
|
40
|
+
margin-top: 2px;
|
|
41
|
+
height: 2px;
|
|
42
|
+
width: 50%;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#title {
|
|
46
|
+
margin-top: 6px; /* 10px - 2px (progress bar) - 2px (progress bar top margin) */
|
|
47
|
+
margin-bottom: 0px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#artist {
|
|
51
|
+
margin-top: 0px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Trim string and add suspension points if needed */
|
|
55
|
+
#title, #artist {
|
|
56
|
+
white-space: nowrap;
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
text-overflow: ellipsis;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#btn-ps {
|
|
62
|
+
margin-right: 30px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#player-bottom {
|
|
66
|
+
margin-top: 120px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#osd {
|
|
70
|
+
margin-top: 90px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#btn-random {
|
|
74
|
+
float: left;
|
|
75
|
+
width: 120px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Only float right if btn-random is present */
|
|
79
|
+
#btn-random ~ #btn-radios {
|
|
80
|
+
float: right;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#btn-radios {
|
|
84
|
+
width: 120px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
/* radios list */
|
|
89
|
+
#radios-welcome {
|
|
90
|
+
margin-bottom: 40px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#radios-list {
|
|
94
|
+
box-shadow: none;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.list-group-item {
|
|
98
|
+
border-style: none;
|
|
99
|
+
padding-bottom: 7px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.radios-group-title {
|
|
103
|
+
margin-top: 40px;
|
|
104
|
+
padding-top: 10px;
|
|
105
|
+
padding-bottom: 10px;
|
|
106
|
+
padding-left: 15px;
|
|
107
|
+
border-width: 0px 0px 1px 0px; /* just bottom border */
|
|
108
|
+
border-style: solid;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#insert {
|
|
112
|
+
margin-top: 45px;
|
|
113
|
+
margin-bottom: 130px;
|
|
114
|
+
padding-left: 15px;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#radios-bottom {
|
|
118
|
+
position: fixed;
|
|
119
|
+
bottom: 0px;
|
|
120
|
+
background-color: white;
|
|
121
|
+
width: 350px;
|
|
122
|
+
left: 50%;
|
|
123
|
+
transform: translate(-50%, 0%);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#btn-player {
|
|
127
|
+
width: 150px;
|
|
128
|
+
margin-bottom: 20px;
|
|
129
|
+
margin-top: 30px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
/* Alerts */
|
|
134
|
+
#alert {
|
|
135
|
+
padding-top: 40%;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Trim string and add suspension points if needed */
|
|
139
|
+
#alert-text-more {
|
|
140
|
+
white-space: nowrap;
|
|
141
|
+
overflow: hidden;
|
|
142
|
+
text-overflow: ellipsis;
|
|
143
|
+
}
|