feat: migrate to React (#2265)

This commit is contained in:
Nico Miguelino
2025-05-26 21:04:19 -07:00
committed by GitHub
parent b762fc7037
commit 51e4511bba
81 changed files with 7385 additions and 9780 deletions

View File

@@ -16,12 +16,33 @@ indent_style = space
indent_size = 2
max_line_length = 100
[*.{js,mjs,jsx}]
indent_style = space
indent_size = 2
max_line_length = 80
# CSS files
[*.{css,scss,sass,less}]
indent_style = space
indent_size = 2
max_line_length = 80
[*.html]
indent_style = space
indent_size = 2
max_line_length = 120
[package.json]
indent_style = space
indent_size = 2
[**.json]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = true
# Python files
[*.py]
indent_style = space

View File

@@ -1,11 +1,11 @@
name: Run JavaScript and CoffeeScript Linter
name: Run JavaScript Linter and Formatter
on:
push:
branches: [ master ]
paths:
- '**/*.js'
- '**/*.coffee'
- '**/*.jsx'
- '**/*.mjs'
- '.github/workflows/javascript-lint.yaml'
pull_request:
@@ -33,3 +33,6 @@ jobs:
- name: Run ESLint
run: npm run lint
- name: Run Prettier
run: npm run format:check

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
# Ignore artifacts:
build

25
.prettierrc Normal file
View File

@@ -0,0 +1,25 @@
{
"overrides": [
{
"files": ["*.js", "*.jsx", "*.mjs"],
"options": {
"singleQuote": true,
"jsxSingleQuote": false,
"semi": false,
"trailingComma": "all"
}
},
{
"files": ["*.scss"],
"options": {
"singleQuote": true
}
},
{
"files": ["*.css"],
"options": {
"singleQuote": true
}
}
]
}

View File

View File

View File

@@ -0,0 +1,100 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from anthias_app.models import Asset
class Command(BaseCommand):
help = 'Seeds the database with sample web assets'
def add_arguments(self, parser):
parser.add_argument(
'--clear',
action='store_true',
help='Clear existing assets before seeding',
)
def handle(self, *args, **options):
if options['clear']:
self.stdout.write('Clearing existing assets...')
Asset.objects.all().delete()
self.stdout.write('Creating sample web assets...')
# Create some sample web assets
assets = [
{
'name': 'Google Homepage',
'uri': 'https://www.google.com',
'mimetype': 'text/html',
'is_enabled': True,
'start_date': timezone.now(),
'end_date': timezone.now() + timedelta(days=7),
'play_order': 0,
'duration': 10,
},
{
'name': 'GitHub Homepage',
'uri': 'https://github.com',
'mimetype': 'text/html',
'is_enabled': True,
'start_date': timezone.now(),
'end_date': timezone.now() + timedelta(days=14),
'play_order': 1,
'duration': 10,
},
{
'name': 'Django Documentation',
'uri': 'https://docs.djangoproject.com',
'mimetype': 'text/html',
'is_enabled': True,
'start_date': timezone.now() - timedelta(days=1),
'end_date': timezone.now() + timedelta(days=21),
'play_order': 2,
'duration': 10,
},
{
'name': 'Vue.js Homepage',
'uri': 'https://vuejs.org',
'mimetype': 'text/html',
'is_enabled': True,
'start_date': timezone.now() - timedelta(days=1),
'end_date': timezone.now() + timedelta(days=21),
'play_order': 3,
'duration': 10,
},
{
'name': 'React Homepage',
'uri': 'https://reactjs.org',
'mimetype': 'text/html',
'is_enabled': True,
'start_date': timezone.now() - timedelta(days=1),
'end_date': timezone.now() + timedelta(days=21),
'play_order': 4,
'duration': 10,
},
{
'name': 'Angular Homepage',
'uri': 'https://angular.io',
'mimetype': 'text/html',
'is_enabled': True,
'start_date': timezone.now() - timedelta(days=1),
'end_date': timezone.now() + timedelta(days=21),
'play_order': 5,
'duration': 10,
}
]
for asset_data in assets:
Asset.objects.create(**asset_data)
self.stdout.write(
self.style.SUCCESS(f'Created web asset: {asset_data["name"]}')
)
self.stdout.write(
self.style.SUCCESS(
'Successfully seeded the database with web assets'
)
)

View File

@@ -1,13 +1,10 @@
from django.urls import path
from django.urls import path, re_path
from . import views
app_name = 'anthias_app'
urlpatterns = [
path('', views.index, name='index'),
path('settings', views.settings_page, name='settings'),
path('system-info', views.system_info, name='system_info'),
path('integrations', views.integrations, name='integrations'),
path('splash-page', views.splash_page, name='splash_page'),
re_path(r'^(?!api/).*$', views.react, name='react'),
]

View File

@@ -1,39 +1,14 @@
import ipaddress
from datetime import timedelta
from os import (
getenv,
statvfs,
)
from platform import machine
from urllib.parse import urlparse
import psutil
from django.views.decorators.http import require_http_methods
from hurry.filesize import size
from lib import (
device_helper,
diagnostics,
)
from lib.auth import authorized
from lib.utils import (
connect_to_redis,
get_node_ip,
get_node_mac_address,
is_balena_app,
is_demo_node,
is_docker,
)
from settings import (
CONFIGURABLE_SETTINGS,
DEFAULTS,
ZmqPublisher,
settings,
)
from .helpers import (
add_default_assets,
remove_default_assets,
template,
)
@@ -41,229 +16,8 @@ r = connect_to_redis()
@authorized
@require_http_methods(["GET"])
def index(request):
player_name = settings['player_name']
my_ip = urlparse(request.build_absolute_uri()).hostname
is_demo = is_demo_node()
balena_device_uuid = getenv("BALENA_DEVICE_UUID", None)
ws_addresses = []
if settings['use_ssl']:
ws_addresses.append('wss://' + my_ip + '/ws/')
else:
ws_addresses.append('ws://' + my_ip + '/ws/')
if balena_device_uuid:
ws_addresses.append(
'wss://{}.balena-devices.com/ws/'.format(balena_device_uuid)
)
return template(request, 'index.html', {
'ws_addresses': ws_addresses,
'player_name': player_name,
'is_demo': is_demo,
'is_balena': is_balena_app(),
})
@authorized
@require_http_methods(["GET", "POST"])
def settings_page(request):
context = {'flash': None}
if request.method == 'POST':
try:
current_pass = request.POST.get('current-password', '')
auth_backend = request.POST.get('auth_backend', '')
if (
auth_backend != settings['auth_backend']
and settings['auth_backend']
):
if not current_pass:
raise ValueError(
"Must supply current password to change "
"authentication method"
)
if not settings.auth.check_password(current_pass):
raise ValueError("Incorrect current password.")
prev_auth_backend = settings['auth_backend']
if not current_pass and prev_auth_backend:
current_pass_correct = None
else:
current_pass_correct = (
settings
.auth_backends[prev_auth_backend]
.check_password(current_pass)
)
next_auth_backend = settings.auth_backends[auth_backend]
next_auth_backend.update_settings(request, current_pass_correct)
settings['auth_backend'] = auth_backend
for field, default in list(CONFIGURABLE_SETTINGS.items()):
value = request.POST.get(field, default)
if (
not value
and field in [
'default_duration',
'default_streaming_duration',
]
):
value = str(0)
if isinstance(default, bool):
value = value == 'on'
if field == 'default_assets' and settings[field] != value:
if value:
add_default_assets()
else:
remove_default_assets()
settings[field] = value
settings.save()
publisher = ZmqPublisher.get_instance()
publisher.send_to_viewer('reload')
context['flash'] = {
'class': "success",
'message': "Settings were successfully saved.",
}
except ValueError as e:
context['flash'] = {'class': "danger", 'message': e}
except IOError as e:
context['flash'] = {'class': "danger", 'message': e}
except OSError as e:
context['flash'] = {'class': "danger", 'message': e}
else:
settings.load()
for field, _default in list(DEFAULTS['viewer'].items()):
context[field] = settings[field]
auth_backends = []
for backend in settings.auth_backends_list:
if backend.template:
html, ctx = backend.template
context.update(ctx)
else:
html = None
auth_backends.append({
'name': backend.name,
'text': backend.display_name,
'template': html,
'selected': (
'selected'
if settings['auth_backend'] == backend.name
else ''
),
})
ip_addresses = get_node_ip().split()
context.update({
'user': settings['user'],
'need_current_password': bool(settings['auth_backend']),
'is_balena': is_balena_app(),
'is_docker': is_docker(),
'auth_backend': settings['auth_backend'],
'auth_backends': auth_backends,
'ip_addresses': ip_addresses,
'host_user': getenv('HOST_USER'),
'device_type': getenv('DEVICE_TYPE')
})
return template(request, 'settings.html', context)
@authorized
@require_http_methods(["GET"])
def system_info(request):
loadavg = diagnostics.get_load_avg()['15 min']
display_power = r.get('display_power')
# Calculate disk space
slash = statvfs("/")
free_space = size(slash.f_bavail * slash.f_frsize)
# Memory
virtual_memory = psutil.virtual_memory()
memory = {
'total': virtual_memory.total >> 20,
'used': virtual_memory.used >> 20,
'free': virtual_memory.free >> 20,
'shared': virtual_memory.shared >> 20,
'buff': virtual_memory.buffers >> 20,
'available': virtual_memory.available >> 20
}
# Get uptime
system_uptime = timedelta(seconds=diagnostics.get_uptime())
# Player name for title
player_name = settings['player_name']
device_model = device_helper.parse_cpu_info().get('model')
if device_model is None and machine() == 'x86_64':
device_model = 'Generic x86_64 Device'
git_branch = diagnostics.get_git_branch()
git_short_hash = diagnostics.get_git_short_hash()
anthias_commit_link = None
if git_branch == 'master':
anthias_commit_link = (
'https://github.com/Screenly/Anthias'
f'/commit/{git_short_hash}'
)
anthias_version = '{}@{}'.format(
git_branch,
git_short_hash,
)
context = {
'player_name': player_name,
'loadavg': loadavg,
'free_space': free_space,
'uptime': {
'days': system_uptime.days,
'hours': round(system_uptime.seconds / 3600, 2),
},
'memory': memory,
'display_power': display_power,
'device_model': device_model,
'anthias_version': anthias_version,
'anthias_commit_link': anthias_commit_link,
'mac_address': get_node_mac_address(),
'is_balena': is_balena_app(),
}
return template(request, 'system-info.html', context)
@authorized
@require_http_methods(["GET"])
def integrations(request):
context = {
'player_name': settings['player_name'],
'is_balena': is_balena_app(),
}
if context['is_balena']:
context.update({
'balena_device_id': getenv('BALENA_DEVICE_UUID'),
'balena_app_id': getenv('BALENA_APP_ID'),
'balena_app_name': getenv('BALENA_APP_NAME'),
'balena_supervisor_version': getenv('BALENA_SUPERVISOR_VERSION'),
'balena_host_os_version': getenv('BALENA_HOST_OS_VERSION'),
'balena_device_name_at_init': getenv('BALENA_DEVICE_NAME_AT_INIT'),
})
return template(request, 'integrations.html', context)
def react(request):
return template(request, 'react.html', {})
@require_http_methods(["GET"])

View File

@@ -16,8 +16,8 @@ COPY package.json \
/app/
RUN npm install
COPY ./static/js/*.coffee /app/static/js/
COPY ./static/sass/*.scss /app/static/sass/
COPY ./static/src/ /app/static/src/
RUN npm run build
{% endif %}

View File

@@ -123,7 +123,7 @@ $ docker compose -f docker-compose.dev.yml exec anthias-server \
npm run dev
```
Making changes to the CoffeeScript or SCSS files will automatically trigger a recompilation,
Making changes to the JavaScript, JSX, or SCSS files will automatically trigger a recompilation,
generating the corresponding JavaScript and CSS files.
### Closing the transpiler

View File

@@ -1,3 +1,5 @@
import reactPlugin from 'eslint-plugin-react';
export default [
{
ignores: [
@@ -23,14 +25,40 @@ export default [
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true
}
}
},
plugins: {
react: reactPlugin
},
settings: {
react: {
version: 'detect'
}
},
rules: {
'semi': ['error', 'always'],
'semi': ['error', 'never'],
'quotes': ['error', 'single'],
'indent': ['error', 2],
'no-unused-vars': 'warn',
'no-console': 'warn',
'no-debugger': 'warn'
'no-unused-vars': 'error',
'no-console': 'error',
'no-debugger': 'warn',
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
'react/jsx-no-duplicate-props': 'error',
'react/jsx-key': 'warn',
'react/jsx-max-props-per-line': ['warn', { maximum: 1, when: 'multiline' }],
'react/jsx-first-prop-new-line': ['warn', 'multiline'],
'react/jsx-closing-bracket-location': ['warn', 'line-aligned'],
'react/jsx-tag-spacing': ['warn', {
closingSlash: 'never',
beforeSelfClosing: 'always',
afterOpening: 'never',
beforeClosing: 'never'
}]
}
}
];
];

3684
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -4,24 +4,42 @@
"scripts": {
"dev": "webpack --watch --config webpack.dev.js",
"build": "webpack --config webpack.prod.js",
"lint": "coffeelint static/js/*.coffee && eslint ."
"lint": "eslint static/src",
"format:check": "prettier --check static/src",
"format:fix": "prettier --write static/src"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@reduxjs/toolkit": "^2.2.1",
"bootstrap": "^4.3.1",
"css-toggle-switch": "^4.1.0"
"css-toggle-switch": "^4.1.0",
"jquery": "^3.7.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.4.0",
"react-redux": "^9.1.0",
"react-router": "^7.0.2",
"sweetalert2": "^11.18.0"
},
"devDependencies": {
"@coffeelint/cli": "^5.2.11",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/preset-react": "^7.26.3",
"babel-loader": "^9.2.1",
"classnames": "^2.5.1",
"coffee-loader": "^0.9.0",
"coffeescript": "^1.12.7",
"css-loader": "^7.1.2",
"eslint": "^8.56.0",
"eslint-plugin-react": "^7.33.2",
"mini-css-extract-plugin": "^2.9.2",
"prettier": "3.5.3",
"sass": "^1.75.0",
"sass-loader": "^16.0.3",
"webpack": "^5.96.1",
"webpack-cli": "^5.1.4",
"webpack-merge": "^6.0.1",
"eslint": "^8.56.0",
"eslint-plugin-react": "^7.33.2"
"webpack-merge": "^6.0.1"
}
}

View File

@@ -1,676 +0,0 @@
### anthias ui ###
import '../sass/anthias.scss'
$().ready ->
$('#subsribe-form-container').popover content: get_template 'subscribe-form'
API = (window.Anthias ||= {}) # exports
dateSettings = {}
if use24HourClock
dateSettings.time = "HH:mm"
dateSettings.fullTime = "HH:mm:ss"
dateSettings.showMeridian = false
else
dateSettings.time = "hh:mm A"
dateSettings.fullTime = "hh:mm:ss A"
dateSettings.showMeridian = true
dateSettings.date = dateFormat.toUpperCase()
dateSettings.datepickerFormat = dateFormat
dateSettings.fullDate = "#{dateSettings.date} #{dateSettings.fullTime}"
API.date_to = date_to = (d) ->
# Cross-browser UTC to localtime conversion
dd = moment.utc(d).local()
string: -> dd.format dateSettings.fullDate
date: -> dd.format dateSettings.date
time: -> dd.format dateSettings.time
now = -> new Date()
get_template = (name) -> _.template ($ "##{name}-template").html()
delay = (wait, fn) -> _.delay fn, wait
mimetypes = [ [('jpe jpg jpeg png pnm gif bmp'.split ' '), 'image']
[('avi mkv mov mpg mpeg mp4 ts flv'.split ' '), 'video']]
viduris = ('rtsp rtmp'.split ' ')
domains = [ [('www.youtube.com youtu.be'.split ' '), 'youtube_asset']]
getMimetype = (filename) ->
scheme = (_.first filename.split ':').toLowerCase()
match = scheme in viduris
if match
return 'streaming'
domain = (_.first ((_.last filename.split '//').toLowerCase()).split '/')
mt = _.find domains, (mt) -> domain in mt[0]
if mt and domain in mt[0]
return mt[1]
ext = (_.last filename.split '.').toLowerCase()
mt = _.find mimetypes, (mt) -> ext in mt[0]
if mt
return mt[1]
durationSecondsToHumanReadable = (secs) ->
durationString = ""
secInt = parseInt(secs)
if ((hours = Math.floor(secInt / 3600)) > 0)
durationString += hours + " hours "
if ((minutes = Math.floor(secInt / 60) % 60) > 0)
durationString += minutes + " min "
if ((seconds = (secInt % 60)) > 0)
durationString += seconds + " sec"
return durationString
url_test = (v) ->
urlPattern = /(http|https|rtsp|rtmp):\/\/[\w-]+(\.?[\w-]+)+([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])?/
urlPattern.test v
get_filename = (v) -> (v.replace /[\/\\\s]+$/g, '').replace /^.*[\\\/]/g, ''
truncate_str = (v) -> v.replace /(.{100})..+/, "$1..."
insertWbr = (v) -> (v.replace /\//g, '/<wbr>').replace /\&/g, '&amp;<wbr>'
# Tell Backbone to send its saves as JSON-encoded.
Backbone.emulateJSON = off
# Models
API.Asset = class Asset extends Backbone.Model
idAttribute: "asset_id"
fields: 'name mimetype uri start_date end_date duration skip_asset_check'.split ' '
defaults: ->
name: ''
mimetype: 'webpage'
uri: ''
is_active: 1
start_date: ''
end_date: ''
duration: defaultDuration
is_enabled: 0
is_processing: 0
nocache: 0
play_order: 0
skip_asset_check: 0
active: =>
if @get('is_enabled') and @get('start_date') and @get('end_date')
at = now()
start_date = new Date(@get('start_date'))
end_date = new Date(@get('end_date'))
return start_date <= at <= end_date
else
return false
backup: =>
@backup_attributes = @toJSON()
rollback: =>
if @backup_attributes
@set @backup_attributes
@backup_attributes = undefined
old_name: =>
if @backup_attributes
return @backup_attributes.name
API.Assets = class Assets extends Backbone.Collection
url: "/api/v2/assets"
model: Asset
comparator: 'play_order'
# Views
API.View = {}
API.View.AddAssetView = class AddAssetView extends Backbone.View
$f: (field) => @$ "[name='#{field}']" # get field element
$fv: (field, val...) => (@$f field).val val... # get or set filed value
initialize: (oprions) =>
($ 'body').append @$el.html get_template 'asset-modal'
(@$el.children ":first").modal()
(@$ '.cancel').val 'Back to Assets'
deadlines = start: now(), end: (moment().add 'days', 30).toDate()
for own tag, deadline of deadlines
d = date_to deadline
@.$fv "#{tag}_date_date", d.date()
@.$fv "#{tag}_date_time", d.time()
no
viewmodel:(model) =>
for which in ['start', 'end']
date = (@$fv "#{which}_date_date") + " " + (@$fv "#{which}_date_time")
@$fv "#{which}_date", (moment date, dateSettings.fullDate).toDate().toISOString()
for field in model.fields when not (@$f field).prop 'disabled'
model.set field, (@$fv field), silent:yes
events:
'change': 'change'
'click #save-asset': 'save'
'click .cancel': 'cancel'
'hidden.bs.modal': 'destroyFileUploadWidget'
'click .tabnav-uri': 'clickTabNavUri'
'click .tabnav-file_upload': 'clickTabNavUpload'
'change .is_enabled-skip_asset_check_checkbox': 'toggleSkipAssetCheck'
'keyup [name=uri]': 'change'
save: (e) =>
if ((@$fv 'uri') == '')
return no
if (@$ '#tab-uri').hasClass 'active'
model = new Asset {}, {collection: API.assets}
@$fv 'mimetype', ''
@updateUriMimetype()
@viewmodel model
model.set {name: model.get 'uri'}, silent:yes
save = model.save()
(@$ 'input').prop 'disabled', on
save.done (data) =>
model.id = data.asset_id
(@$el.children ":first").modal 'hide'
_.extend model.attributes, data
model.collection.add model
save.fail =>
(@$ 'input').prop 'disabled', off
model.destroy()
no
toggleSkipAssetCheck: (e) =>
@$fv 'skip_asset_check', if parseInt((@$fv 'skip_asset_check')) == 1 then 0 else 1
change_mimetype: =>
if (@$fv 'mimetype') == "video"
@$fv 'duration', 0
else if (@$fv 'mimetype') == "streaming"
@$fv 'duration', defaultStreamingDuration
else
@$fv 'duration', defaultDuration
clickTabNavUpload: (e) =>
if not (@$ '#tab-file_upload').hasClass 'active'
(@$ 'ul.nav-tabs li').removeClass 'active show'
(@$ '.tab-pane').removeClass 'active'
(@$ '.tabnav-file_upload').addClass 'active show'
(@$ '#tab-file_upload').addClass 'active'
(@$ '.uri').hide()
(@$ '.skip_asset_check_checkbox').hide()
(@$ '#save-asset').hide()
that = this
(@$ "[name='file_upload']").fileupload
autoUpload: false
sequentialUploads: true
maxChunkSize: 5000000 #5 MB
url: 'api/v2/file_asset'
progressall: (e, data) => if data.loaded and data.total
(@$ '.progress .bar').css 'width', "#{data.loaded / data.total * 100}%"
add: (e, data) ->
(that.$ '.status').hide()
(that.$ '.progress').show()
model = new Asset {}, {collection: API.assets}
filename = data['files'][0]['name']
that.$fv 'name', filename
that.updateFileUploadMimetype(filename)
that.viewmodel model
data.submit()
.success (data) ->
model.set {uri: data.uri, ext: data.ext}, silent:yes
save = model.save()
save.done (data) ->
model.id = data.asset_id
_.extend model.attributes, data
model.collection.add model
save.fail ->
model.destroy()
.error ->
model.destroy()
stop: (e) ->
(that.$ '.progress').hide()
(that.$ '.progress .bar').css 'width', "0"
done: (e, data) ->
(that.$ '.status').show()
(that.$ '.status').html 'Upload completed.'
setTimeout ->
(that.$ '.status').fadeOut('slow')
, 5000
no
clickTabNavUri: (e) => # TODO: clean
if not (@$ '#tab-uri').hasClass 'active'
(@$ "[name='file_upload']").fileupload 'destroy'
(@$ 'ul.nav-tabs li').removeClass 'active show'
(@$ '.tab-pane').removeClass 'active'
(@$ '.tabnav-uri').addClass 'active show'
(@$ '#tab-uri').addClass 'active'
(@$ '#save-asset').show()
(@$ '.uri').show()
(@$ '.skip_asset_check_checkbox').show()
(@$ '.status').hide()
(@$f 'uri').focus()
updateUriMimetype: => @updateMimetype @$fv 'uri'
updateFileUploadMimetype: (filename) => @updateMimetype filename
updateMimetype: (filename) =>
mt = getMimetype filename
@$fv 'mimetype', if mt then mt else new Asset().defaults()['mimetype']
@change_mimetype()
change: (e) =>
@_change ||= _.throttle (=>
@validate()
yes), 500
@_change arguments...
validate: (e) =>
that = this
validators =
uri: (v) ->
if v
if ((that.$ '#tab-uri').hasClass 'active') and not url_test v
'please enter a valid URL'
errors = ([field, v] for field, fn of validators when v = fn (@$fv field))
(@$ ".form-group .help-inline.invalid-feedback").remove()
(@$ ".form-group .form-control").removeClass 'is-invalid'
(@$ '[type=submit]').prop 'disabled', no
for [field, v] in errors
(@$ '[type=submit]').prop 'disabled', yes
(@$ ".form-group.#{field} .form-control").addClass 'is-invalid'
(@$ ".form-group.#{field} .controls").append \
$ ("<span class='help-inline invalid-feedback'>#{v}</span>")
cancel: (e) =>
(@$el.children ":first").modal 'hide'
destroyFileUploadWidget: (e) =>
if (@$ '#tab-file_upload').hasClass 'active'
(@$ "[name='file_upload']").fileupload 'destroy'
API.View.EditAssetView = class EditAssetView extends Backbone.View
$f: (field) => @$ "[name='#{field}']" # get field element
$fv: (field, val...) => (@$f field).val val... # get or set filed value
initialize: (options) =>
($ 'body').append @$el.html get_template 'asset-modal'
(@$ 'input.time').timepicker
minuteStep: 5, showInputs: yes, disableFocus: yes, showMeridian: dateSettings.showMeridian
(@$ 'input[name="nocache"]').prop 'checked', @model.get 'nocache'
(@$ '.modal-header .close').remove()
(@$el.children ":first").modal()
@model.backup()
@model.bind 'change', @render
@render()
@validate()
no
render: () =>
@undelegateEvents()
(@$ f).attr 'disabled', on for f in 'mimetype uri file_upload'.split ' '
(@$ '#modalLabel').text "Edit Asset"
(@$ '.asset-location').hide(); (@$ '.uri').hide(); (@$ '.skip_asset_check_checkbox').hide()
(@$ '.asset-location.edit').show()
(@$ '.mime-select').prop('disabled', 'true')
if (@model.get 'mimetype') == 'video'
(@$f 'duration').prop 'disabled', on
for field in @model.fields
if (@$fv field) != @model.get field
@$fv field, @model.get field
(@$ '.uri-text').html insertWbr truncate_str (@model.get 'uri')
for which in ['start', 'end']
d = date_to @model.get "#{which}_date"
@$fv "#{which}_date_date", d.date()
(@$f "#{which}_date_date").datepicker autoclose: yes, format: dateSettings.datepickerFormat
(@$f "#{which}_date_date").datepicker 'setValue', d.date()
@$fv "#{which}_date_time", d.time()
@displayAdvanced()
@delegateEvents()
no
viewmodel: =>
for which in ['start', 'end']
date = (@$fv "#{which}_date_date") + " " + (@$fv "#{which}_date_time")
@$fv "#{which}_date", (moment date, dateSettings.fullDate).toDate().toISOString()
for field in @model.fields when not (@$f field).prop 'disabled'
@model.set field, (@$fv field), silent:yes
events:
'click #save-asset': 'save'
'click .cancel': 'cancel'
'change': 'change'
'keyup': 'change'
'click .advanced-toggle': 'toggleAdvanced'
changeLoopTimes: =>
current_date = new Date()
end_date = new Date()
switch @$('#loop_times').val()
when "day"
@setLoopDateTime (date_to current_date), (date_to end_date.setDate(current_date.getDate() + 1))
when "week"
@setLoopDateTime (date_to current_date), (date_to end_date.setDate(current_date.getDate() + 7))
when "month"
@setLoopDateTime (date_to current_date), (date_to end_date.setMonth(current_date.getMonth() + 1))
when "year"
@setLoopDateTime (date_to current_date), (date_to end_date.setFullYear(current_date.getFullYear() + 1))
when "forever"
@setLoopDateTime (date_to current_date), (date_to end_date.setFullYear(9999))
when "manual"
@setDisabledDatepicker(false)
(@$ "#manul_date").show()
return
else
return
@setDisabledDatepicker(true)
(@$ "#manul_date").hide()
save: (e) =>
@viewmodel()
save = null
@model.set 'nocache', if (@$ 'input[name="nocache"]').prop 'checked' then 1 else 0
if not @model.get 'name'
if @model.old_name()
@model.set {name: @model.old_name()}, silent:yes
else if getMimetype @model.get 'uri'
@model.set {name: get_filename @model.get 'uri'}, silent:yes
else
@model.set {name: @model.get 'uri'}, silent:yes
save = @model.save()
(@$ 'input, select').prop 'disabled', on
save.done (data) =>
@model.id = data.asset_id
@collection.add @model if not @model.collection
(@$el.children ":first").modal 'hide'
_.extend @model.attributes, data
save.fail =>
(@$ '.progress').hide()
(@$ 'input, select').prop 'disabled', off
no
change: (e) =>
@_change ||= _.throttle (=>
@changeLoopTimes()
@viewmodel()
@model.trigger 'change'
@validate(e)
yes), 500
@_change arguments...
validate: (e) =>
that = this
validators =
duration: (v) =>
if ('video' isnt @model.get 'mimetype') and (not (_.isNumber v*1 ) or v*1 < 1)
'Please enter a valid number.'
end_date: (v) =>
unless (new Date @$fv 'start_date') < (new Date @$fv 'end_date')
if $(e?.target)?.attr("name") == "start_date_date"
start_date = new Date @$fv 'start_date'
end_date = new Date(start_date.getTime() + Math.max(parseInt(@$fv 'duration'), 60) * 1000)
@setLoopDateTime (date_to start_date), (date_to end_date)
return
'End date should be after start date.'
errors = ([field, v] for field, fn of validators when v = fn (@$fv field))
(@$ ".form-group .help-inline.invalid-feedback").remove()
(@$ ".form-group .form-control").removeClass 'is-invalid'
(@$ '[type=submit]').prop 'disabled', no
for [field, v] in errors
(@$ '[type=submit]').prop 'disabled', yes
(@$ ".form-group.#{field} .form-control").addClass 'is-invalid'
(@$ ".form-group.#{field} .controls").append \
$ ("<span class='help-inline invalid-feedback'>#{v}</span>")
cancel: (e) =>
@model.rollback()
(@$el.children ":first").modal 'hide'
toggleAdvanced: =>
(@$ '.fa-play').toggleClass 'rotated'
(@$ '.fa-play').toggleClass 'unrotated'
(@$ '.collapse-advanced').collapse 'toggle'
displayAdvanced: =>
img = 'image' is @$fv 'mimetype'
edit = url_test @model.get 'uri'
has_nocache = img and edit
(@$ '.advanced-accordion').toggle has_nocache is on
setLoopDateTime: (start_date, end_date) =>
@$fv "start_date_date", start_date.date()
(@$f "start_date_date").datepicker autoclose: yes, format: dateSettings.datepickerFormat
(@$f "start_date_date").datepicker 'setDate', moment(start_date.date(), dateSettings.date).toDate()
@$fv "start_date_time", start_date.time()
@$fv "end_date_date", end_date.date()
(@$f "end_date_date").datepicker autoclose: yes, format: dateSettings.datepickerFormat
(@$f "end_date_date").datepicker 'setDate', moment(end_date.date(), dateSettings.date).toDate()
@$fv "end_date_time", end_date.time()
(@$ ".form-group .help-inline.invalid-feedback").remove()
(@$ ".form-group .form-control").removeClass 'is-invalid'
(@$ '[type=submit]').prop 'disabled', no
setDisabledDatepicker: (b) =>
for which in ['start', 'end']
(@$f "#{which}_date_date").attr 'disabled', b
(@$f "#{which}_date_time").attr 'disabled', b
API.View.AssetRowView = class AssetRowView extends Backbone.View
tagName: "tr"
initialize: (options) =>
@template = get_template 'asset-row'
render: =>
@$el.html @template _.extend json = @model.toJSON(),
name: insertWbr truncate_str json.name # word break urls at slashes
duration: durationSecondsToHumanReadable(json.duration)
start_date: (date_to json.start_date).string()
end_date: (date_to json.end_date).string()
@$el.prop 'id', @model.get 'asset_id'
(@$ ".delete-asset-button").popover content: get_template 'confirm-delete'
(@$ ".toggle input").prop "checked", @model.get 'is_enabled'
(@$ ".asset-icon").addClass switch @model.get "mimetype"
when "video" then "fas fa-video"
when "streaming" then "fas fa-video"
when "image" then "far fa-image"
when "webpage" then "fas fa-globe-americas"
else ""
if (@model.get "is_processing") == 1
(@$ 'input, button').prop 'disabled', on
(@$ ".asset-toggle").html get_template 'processing-message'
@el
events:
'change .is_enabled-toggle input': 'toggleIsEnabled'
'click .download-asset-button': 'download'
'click .edit-asset-button': 'edit'
'click .delete-asset-button': 'showPopover'
toggleIsEnabled: (e) =>
val = (1 + @model.get 'is_enabled') % 2
@model.set is_enabled: val
@setEnabled off
save = @model.save()
save.done => @setEnabled on
save.fail =>
@model.set @model.previousAttributes(), silent:yes # revert changes
@setEnabled on
@render()
yes
setEnabled: (enabled) => if enabled
@$el.removeClass 'warning'
@delegateEvents()
(@$ 'input, button').prop 'disabled', off
else
@hidePopover()
@undelegateEvents()
@$el.addClass 'warning'
(@$ 'input, button').prop 'disabled', on
download: (e) =>
$.get '/api/v2/assets/' + @model.id + '/content', (result) ->
switch result['type']
when 'url'
window.open(result['url'])
when 'file'
content = base64js.toByteArray(result['content'])
mimetype = result['mimetype']
fn = result['filename']
blob = new Blob([content], {type: mimetype})
url = URL.createObjectURL(blob)
a = document.createElement('a')
document.body.appendChild(a)
a.download = fn
a.href = url
a.click()
URL.revokeObjectURL(url)
a.remove()
no
edit: (e) =>
new EditAssetView model: @model
no
delete: (e) =>
@hidePopover()
if (xhr = @model.destroy()) is not false
xhr.done => @remove()
else
@remove()
no
showPopover: =>
if not ($ '.popover').length
(@$ ".delete-asset-button").popover 'show'
($ '.confirm-delete').click @delete
($ window).one 'click', @hidePopover
no
hidePopover: =>
(@$ ".delete-asset-button").popover 'hide'
no
API.View.AssetsView = class AssetsView extends Backbone.View
initialize: (options) =>
@collection.bind event, @render for event in ('reset add remove sync'.split ' ')
@sorted = (@$ '#active-assets').sortable
containment: 'parent'
axis: 'y'
helper: 'clone'
update: @update_order
update_order: =>
active = (@$ '#active-assets').sortable 'toArray'
@collection.get(id).set('play_order', i) for id, i in active
@collection.get(el.id).set('play_order', active.length) for el in (@$ '#inactive-assets tr').toArray()
$.post '/api/v2/assets/order', ids: ((@$ '#active-assets').sortable 'toArray').join ','
render: =>
@collection.sort()
(@$ "##{which}-assets").html '' for which in ['active', 'inactive']
@collection.each (model) =>
which = if model.active() then 'active' else 'inactive'
(@$ "##{which}-assets").append (new AssetRowView model: model).render()
for which in ['active', 'inactive']
if (@$ "##{which}-assets tr").length == 0
(@$ "##{which}-assets-section .table-assets-help-text").show()
else
(@$ "##{which}-assets-section .table-assets-help-text").hide()
for which in ['inactive', 'active']
@$(".#{which}-table thead").toggle !!(@$("##{which}-assets tr").length)
@update_order()
@el
API.App = class App extends Backbone.View
initialize: =>
($ window).ajaxError (e,r) ->
($ '#request-error').html (get_template 'request-error')()
if (j = $.parseJSON r.responseText) and (err = j.error)
($ '#request-error .msg').text 'Server Error: ' + err
($ '#request-error').show()
setTimeout ->
($ '#request-error').fadeOut('slow')
, 5000
($ window).ajaxSuccess (event, request, settings) ->
if (settings.url == new Assets().url) and (settings.type == 'POST')
($ '#request-error').html (get_template 'request-success')()
($ '#request-error .msg').text 'Asset has been successfully uploaded.'
($ '#request-error').show()
setTimeout ->
($ '#request-error').fadeOut('slow')
, 5000
(API.assets = new Assets()).fetch()
API.assetsView = new AssetsView
collection: API.assets
el: @$ '#assets'
for address in wsAddresses
try
ws = new WebSocket address
ws.onmessage = (x) ->
x.data.text().then (assetId) ->
model = API.assets.get(assetId)
if model
save = model.fetch()
catch error
no
events:
'click .add-asset-button': 'add',
'click #previous-asset-button': 'previous',
'click #next-asset-button': 'next'
add: (e) ->
new AddAssetView
no
previous: (e) ->
$.get '/api/v2/assets/control/previous'
next: (e) ->
$.get '/api/v2/assets/control/next'

View File

@@ -1,42 +0,0 @@
// Backbone.js 0.9.10
// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc.
// Backbone may be freely distributed under the MIT license.
// For all details and documentation:
// http://backbonejs.org
(function(){var n=this,B=n.Backbone,h=[],C=h.push,u=h.slice,D=h.splice,g;g="undefined"!==typeof exports?exports:n.Backbone={};g.VERSION="0.9.10";var f=n._;!f&&"undefined"!==typeof require&&(f=require("underscore"));g.$=n.jQuery||n.Zepto||n.ender;g.noConflict=function(){n.Backbone=B;return this};g.emulateHTTP=!1;g.emulateJSON=!1;var v=/\s+/,q=function(a,b,c,d){if(!c)return!0;if("object"===typeof c)for(var e in c)a[b].apply(a,[e,c[e]].concat(d));else if(v.test(c)){c=c.split(v);e=0;for(var f=c.length;e<
f;e++)a[b].apply(a,[c[e]].concat(d))}else return!0},w=function(a,b){var c,d=-1,e=a.length;switch(b.length){case 0:for(;++d<e;)(c=a[d]).callback.call(c.ctx);break;case 1:for(;++d<e;)(c=a[d]).callback.call(c.ctx,b[0]);break;case 2:for(;++d<e;)(c=a[d]).callback.call(c.ctx,b[0],b[1]);break;case 3:for(;++d<e;)(c=a[d]).callback.call(c.ctx,b[0],b[1],b[2]);break;default:for(;++d<e;)(c=a[d]).callback.apply(c.ctx,b)}},h=g.Events={on:function(a,b,c){if(!q(this,"on",a,[b,c])||!b)return this;this._events||(this._events=
{});(this._events[a]||(this._events[a]=[])).push({callback:b,context:c,ctx:c||this});return this},once:function(a,b,c){if(!q(this,"once",a,[b,c])||!b)return this;var d=this,e=f.once(function(){d.off(a,e);b.apply(this,arguments)});e._callback=b;this.on(a,e,c);return this},off:function(a,b,c){var d,e,t,g,j,l,k,h;if(!this._events||!q(this,"off",a,[b,c]))return this;if(!a&&!b&&!c)return this._events={},this;g=a?[a]:f.keys(this._events);j=0;for(l=g.length;j<l;j++)if(a=g[j],d=this._events[a]){t=[];if(b||
c){k=0;for(h=d.length;k<h;k++)e=d[k],(b&&b!==e.callback&&b!==e.callback._callback||c&&c!==e.context)&&t.push(e)}this._events[a]=t}return this},trigger:function(a){if(!this._events)return this;var b=u.call(arguments,1);if(!q(this,"trigger",a,b))return this;var c=this._events[a],d=this._events.all;c&&w(c,b);d&&w(d,arguments);return this},listenTo:function(a,b,c){var d=this._listeners||(this._listeners={}),e=a._listenerId||(a._listenerId=f.uniqueId("l"));d[e]=a;a.on(b,"object"===typeof b?this:c,this);
return this},stopListening:function(a,b,c){var d=this._listeners;if(d){if(a)a.off(b,"object"===typeof b?this:c,this),!b&&!c&&delete d[a._listenerId];else{"object"===typeof b&&(c=this);for(var e in d)d[e].off(b,c,this);this._listeners={}}return this}}};h.bind=h.on;h.unbind=h.off;f.extend(g,h);var r=g.Model=function(a,b){var c,d=a||{};this.cid=f.uniqueId("c");this.attributes={};b&&b.collection&&(this.collection=b.collection);b&&b.parse&&(d=this.parse(d,b)||{});if(c=f.result(this,"defaults"))d=f.defaults({},
d,c);this.set(d,b);this.changed={};this.initialize.apply(this,arguments)};f.extend(r.prototype,h,{changed:null,idAttribute:"id",initialize:function(){},toJSON:function(){return f.clone(this.attributes)},sync:function(){return g.sync.apply(this,arguments)},get:function(a){return this.attributes[a]},escape:function(a){return f.escape(this.get(a))},has:function(a){return null!=this.get(a)},set:function(a,b,c){var d,e,g,p,j,l,k;if(null==a)return this;"object"===typeof a?(e=a,c=b):(e={})[a]=b;c||(c={});
if(!this._validate(e,c))return!1;g=c.unset;p=c.silent;a=[];j=this._changing;this._changing=!0;j||(this._previousAttributes=f.clone(this.attributes),this.changed={});k=this.attributes;l=this._previousAttributes;this.idAttribute in e&&(this.id=e[this.idAttribute]);for(d in e)b=e[d],f.isEqual(k[d],b)||a.push(d),f.isEqual(l[d],b)?delete this.changed[d]:this.changed[d]=b,g?delete k[d]:k[d]=b;if(!p){a.length&&(this._pending=!0);b=0;for(d=a.length;b<d;b++)this.trigger("change:"+a[b],this,k[a[b]],c)}if(j)return this;
if(!p)for(;this._pending;)this._pending=!1,this.trigger("change",this,c);this._changing=this._pending=!1;return this},unset:function(a,b){return this.set(a,void 0,f.extend({},b,{unset:!0}))},clear:function(a){var b={},c;for(c in this.attributes)b[c]=void 0;return this.set(b,f.extend({},a,{unset:!0}))},hasChanged:function(a){return null==a?!f.isEmpty(this.changed):f.has(this.changed,a)},changedAttributes:function(a){if(!a)return this.hasChanged()?f.clone(this.changed):!1;var b,c=!1,d=this._changing?
this._previousAttributes:this.attributes,e;for(e in a)if(!f.isEqual(d[e],b=a[e]))(c||(c={}))[e]=b;return c},previous:function(a){return null==a||!this._previousAttributes?null:this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)},fetch:function(a){a=a?f.clone(a):{};void 0===a.parse&&(a.parse=!0);var b=a.success;a.success=function(a,d,e){if(!a.set(a.parse(d,e),e))return!1;b&&b(a,d,e)};return this.sync("read",this,a)},save:function(a,b,c){var d,e,g=this.attributes;
null==a||"object"===typeof a?(d=a,c=b):(d={})[a]=b;if(d&&(!c||!c.wait)&&!this.set(d,c))return!1;c=f.extend({validate:!0},c);if(!this._validate(d,c))return!1;d&&c.wait&&(this.attributes=f.extend({},g,d));void 0===c.parse&&(c.parse=!0);e=c.success;c.success=function(a,b,c){a.attributes=g;var k=a.parse(b,c);c.wait&&(k=f.extend(d||{},k));if(f.isObject(k)&&!a.set(k,c))return!1;e&&e(a,b,c)};a=this.isNew()?"create":c.patch?"patch":"update";"patch"===a&&(c.attrs=d);a=this.sync(a,this,c);d&&c.wait&&(this.attributes=
g);return a},destroy:function(a){a=a?f.clone(a):{};var b=this,c=a.success,d=function(){b.trigger("destroy",b,b.collection,a)};a.success=function(a,b,e){(e.wait||a.isNew())&&d();c&&c(a,b,e)};if(this.isNew())return a.success(this,null,a),!1;var e=this.sync("delete",this,a);a.wait||d();return e},url:function(){var a=f.result(this,"urlRoot")||f.result(this.collection,"url")||x();return this.isNew()?a:a+("/"===a.charAt(a.length-1)?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this.attributes)},
isNew:function(){return null==this.id},isValid:function(a){return!this.validate||!this.validate(this.attributes,a)},_validate:function(a,b){if(!b.validate||!this.validate)return!0;a=f.extend({},this.attributes,a);var c=this.validationError=this.validate(a,b)||null;if(!c)return!0;this.trigger("invalid",this,c,b||{});return!1}});var s=g.Collection=function(a,b){b||(b={});b.model&&(this.model=b.model);void 0!==b.comparator&&(this.comparator=b.comparator);this.models=[];this._reset();this.initialize.apply(this,
arguments);a&&this.reset(a,f.extend({silent:!0},b))};f.extend(s.prototype,h,{model:r,initialize:function(){},toJSON:function(a){return this.map(function(b){return b.toJSON(a)})},sync:function(){return g.sync.apply(this,arguments)},add:function(a,b){a=f.isArray(a)?a.slice():[a];b||(b={});var c,d,e,g,p,j,l,k,h,m;l=[];k=b.at;h=this.comparator&&null==k&&!1!=b.sort;m=f.isString(this.comparator)?this.comparator:null;c=0;for(d=a.length;c<d;c++)(e=this._prepareModel(g=a[c],b))?(p=this.get(e))?b.merge&&(p.set(g===
e?e.attributes:g,b),h&&(!j&&p.hasChanged(m))&&(j=!0)):(l.push(e),e.on("all",this._onModelEvent,this),this._byId[e.cid]=e,null!=e.id&&(this._byId[e.id]=e)):this.trigger("invalid",this,g,b);l.length&&(h&&(j=!0),this.length+=l.length,null!=k?D.apply(this.models,[k,0].concat(l)):C.apply(this.models,l));j&&this.sort({silent:!0});if(b.silent)return this;c=0;for(d=l.length;c<d;c++)(e=l[c]).trigger("add",e,this,b);j&&this.trigger("sort",this,b);return this},remove:function(a,b){a=f.isArray(a)?a.slice():[a];
b||(b={});var c,d,e,g;c=0;for(d=a.length;c<d;c++)if(g=this.get(a[c]))delete this._byId[g.id],delete this._byId[g.cid],e=this.indexOf(g),this.models.splice(e,1),this.length--,b.silent||(b.index=e,g.trigger("remove",g,this,b)),this._removeReference(g);return this},push:function(a,b){a=this._prepareModel(a,b);this.add(a,f.extend({at:this.length},b));return a},pop:function(a){var b=this.at(this.length-1);this.remove(b,a);return b},unshift:function(a,b){a=this._prepareModel(a,b);this.add(a,f.extend({at:0},
b));return a},shift:function(a){var b=this.at(0);this.remove(b,a);return b},slice:function(a,b){return this.models.slice(a,b)},get:function(a){if(null!=a)return this._idAttr||(this._idAttr=this.model.prototype.idAttribute),this._byId[a.id||a.cid||a[this._idAttr]||a]},at:function(a){return this.models[a]},where:function(a){return f.isEmpty(a)?[]:this.filter(function(b){for(var c in a)if(a[c]!==b.get(c))return!1;return!0})},sort:function(a){if(!this.comparator)throw Error("Cannot sort a set without a comparator");
a||(a={});f.isString(this.comparator)||1===this.comparator.length?this.models=this.sortBy(this.comparator,this):this.models.sort(f.bind(this.comparator,this));a.silent||this.trigger("sort",this,a);return this},pluck:function(a){return f.invoke(this.models,"get",a)},update:function(a,b){b=f.extend({add:!0,merge:!0,remove:!0},b);b.parse&&(a=this.parse(a,b));var c,d,e,g,h=[],j=[],l={};f.isArray(a)||(a=a?[a]:[]);if(b.add&&!b.remove)return this.add(a,b);d=0;for(e=a.length;d<e;d++)c=a[d],g=this.get(c),
b.remove&&g&&(l[g.cid]=!0),(b.add&&!g||b.merge&&g)&&h.push(c);if(b.remove){d=0;for(e=this.models.length;d<e;d++)c=this.models[d],l[c.cid]||j.push(c)}j.length&&this.remove(j,b);h.length&&this.add(h,b);return this},reset:function(a,b){b||(b={});b.parse&&(a=this.parse(a,b));for(var c=0,d=this.models.length;c<d;c++)this._removeReference(this.models[c]);b.previousModels=this.models.slice();this._reset();a&&this.add(a,f.extend({silent:!0},b));b.silent||this.trigger("reset",this,b);return this},fetch:function(a){a=
a?f.clone(a):{};void 0===a.parse&&(a.parse=!0);var b=a.success;a.success=function(a,d,e){a[e.update?"update":"reset"](d,e);b&&b(a,d,e)};return this.sync("read",this,a)},create:function(a,b){b=b?f.clone(b):{};if(!(a=this._prepareModel(a,b)))return!1;b.wait||this.add(a,b);var c=this,d=b.success;b.success=function(a,b,f){f.wait&&c.add(a,f);d&&d(a,b,f)};a.save(null,b);return a},parse:function(a){return a},clone:function(){return new this.constructor(this.models)},_reset:function(){this.length=0;this.models.length=
0;this._byId={}},_prepareModel:function(a,b){if(a instanceof r)return a.collection||(a.collection=this),a;b||(b={});b.collection=this;var c=new this.model(a,b);return!c._validate(a,b)?!1:c},_removeReference:function(a){this===a.collection&&delete a.collection;a.off("all",this._onModelEvent,this)},_onModelEvent:function(a,b,c,d){("add"===a||"remove"===a)&&c!==this||("destroy"===a&&this.remove(b,d),b&&a==="change:"+b.idAttribute&&(delete this._byId[b.previous(b.idAttribute)],null!=b.id&&(this._byId[b.id]=
b)),this.trigger.apply(this,arguments))},sortedIndex:function(a,b,c){b||(b=this.comparator);var d=f.isFunction(b)?b:function(a){return a.get(b)};return f.sortedIndex(this.models,a,d,c)}});f.each("forEach each map collect reduce foldl inject reduceRight foldr find detect filter select reject every all some any include contains invoke max min toArray size first head take initial rest tail drop last without indexOf shuffle lastIndexOf isEmpty chain".split(" "),function(a){s.prototype[a]=function(){var b=
u.call(arguments);b.unshift(this.models);return f[a].apply(f,b)}});f.each(["groupBy","countBy","sortBy"],function(a){s.prototype[a]=function(b,c){var d=f.isFunction(b)?b:function(a){return a.get(b)};return f[a](this.models,d,c)}});var y=g.Router=function(a){a||(a={});a.routes&&(this.routes=a.routes);this._bindRoutes();this.initialize.apply(this,arguments)},E=/\((.*?)\)/g,F=/(\(\?)?:\w+/g,G=/\*\w+/g,H=/[\-{}\[\]+?.,\\\^$|#\s]/g;f.extend(y.prototype,h,{initialize:function(){},route:function(a,b,c){f.isRegExp(a)||
(a=this._routeToRegExp(a));c||(c=this[b]);g.history.route(a,f.bind(function(d){d=this._extractParameters(a,d);c&&c.apply(this,d);this.trigger.apply(this,["route:"+b].concat(d));this.trigger("route",b,d);g.history.trigger("route",this,b,d)},this));return this},navigate:function(a,b){g.history.navigate(a,b);return this},_bindRoutes:function(){if(this.routes)for(var a,b=f.keys(this.routes);null!=(a=b.pop());)this.route(a,this.routes[a])},_routeToRegExp:function(a){a=a.replace(H,"\\$&").replace(E,"(?:$1)?").replace(F,
function(a,c){return c?a:"([^/]+)"}).replace(G,"(.*?)");return RegExp("^"+a+"$")},_extractParameters:function(a,b){return a.exec(b).slice(1)}});var m=g.History=function(){this.handlers=[];f.bindAll(this,"checkUrl");"undefined"!==typeof window&&(this.location=window.location,this.history=window.history)},z=/^[#\/]|\s+$/g,I=/^\/+|\/+$/g,J=/msie [\w.]+/,K=/\/$/;m.started=!1;f.extend(m.prototype,h,{interval:50,getHash:function(a){return(a=(a||this).location.href.match(/#(.*)$/))?a[1]:""},getFragment:function(a,
b){if(null==a)if(this._hasPushState||!this._wantsHashChange||b){a=this.location.pathname;var c=this.root.replace(K,"");a.indexOf(c)||(a=a.substr(c.length))}else a=this.getHash();return a.replace(z,"")},start:function(a){if(m.started)throw Error("Backbone.history has already been started");m.started=!0;this.options=f.extend({},{root:"/"},this.options,a);this.root=this.options.root;this._wantsHashChange=!1!==this.options.hashChange;this._wantsPushState=!!this.options.pushState;this._hasPushState=!(!this.options.pushState||
!this.history||!this.history.pushState);a=this.getFragment();var b=document.documentMode,b=J.exec(navigator.userAgent.toLowerCase())&&(!b||7>=b);this.root=("/"+this.root+"/").replace(I,"/");b&&this._wantsHashChange&&(this.iframe=g.$('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo("body")[0].contentWindow,this.navigate(a));if(this._hasPushState)g.$(window).on("popstate",this.checkUrl);else if(this._wantsHashChange&&"onhashchange"in window&&!b)g.$(window).on("hashchange",this.checkUrl);
else this._wantsHashChange&&(this._checkUrlInterval=setInterval(this.checkUrl,this.interval));this.fragment=a;a=this.location;b=a.pathname.replace(/[^\/]$/,"$&/")===this.root;if(this._wantsHashChange&&this._wantsPushState&&!this._hasPushState&&!b)return this.fragment=this.getFragment(null,!0),this.location.replace(this.root+this.location.search+"#"+this.fragment),!0;this._wantsPushState&&(this._hasPushState&&b&&a.hash)&&(this.fragment=this.getHash().replace(z,""),this.history.replaceState({},document.title,
this.root+this.fragment+a.search));if(!this.options.silent)return this.loadUrl()},stop:function(){g.$(window).off("popstate",this.checkUrl).off("hashchange",this.checkUrl);clearInterval(this._checkUrlInterval);m.started=!1},route:function(a,b){this.handlers.unshift({route:a,callback:b})},checkUrl:function(){var a=this.getFragment();a===this.fragment&&this.iframe&&(a=this.getFragment(this.getHash(this.iframe)));if(a===this.fragment)return!1;this.iframe&&this.navigate(a);this.loadUrl()||this.loadUrl(this.getHash())},
loadUrl:function(a){var b=this.fragment=this.getFragment(a);return f.any(this.handlers,function(a){if(a.route.test(b))return a.callback(b),!0})},navigate:function(a,b){if(!m.started)return!1;if(!b||!0===b)b={trigger:b};a=this.getFragment(a||"");if(this.fragment!==a){this.fragment=a;var c=this.root+a;if(this._hasPushState)this.history[b.replace?"replaceState":"pushState"]({},document.title,c);else if(this._wantsHashChange)this._updateHash(this.location,a,b.replace),this.iframe&&a!==this.getFragment(this.getHash(this.iframe))&&
(b.replace||this.iframe.document.open().close(),this._updateHash(this.iframe.location,a,b.replace));else return this.location.assign(c);b.trigger&&this.loadUrl(a)}},_updateHash:function(a,b,c){c?(c=a.href.replace(/(javascript:|#).*$/,""),a.replace(c+"#"+b)):a.hash="#"+b}});g.history=new m;var A=g.View=function(a){this.cid=f.uniqueId("view");this._configure(a||{});this._ensureElement();this.initialize.apply(this,arguments);this.delegateEvents()},L=/^(\S+)\s*(.*)$/,M="model collection el id attributes className tagName events".split(" ");
f.extend(A.prototype,h,{tagName:"div",$:function(a){return this.$el.find(a)},initialize:function(){},render:function(){return this},remove:function(){this.$el.remove();this.stopListening();return this},setElement:function(a,b){this.$el&&this.undelegateEvents();this.$el=a instanceof g.$?a:g.$(a);this.el=this.$el[0];!1!==b&&this.delegateEvents();return this},delegateEvents:function(a){if(a||(a=f.result(this,"events"))){this.undelegateEvents();for(var b in a){var c=a[b];f.isFunction(c)||(c=this[a[b]]);
if(!c)throw Error('Method "'+a[b]+'" does not exist');var d=b.match(L),e=d[1],d=d[2],c=f.bind(c,this),e=e+(".delegateEvents"+this.cid);if(""===d)this.$el.on(e,c);else this.$el.on(e,d,c)}}},undelegateEvents:function(){this.$el.off(".delegateEvents"+this.cid)},_configure:function(a){this.options&&(a=f.extend({},f.result(this,"options"),a));f.extend(this,f.pick(a,M));this.options=a},_ensureElement:function(){if(this.el)this.setElement(f.result(this,"el"),!1);else{var a=f.extend({},f.result(this,"attributes"));
this.id&&(a.id=f.result(this,"id"));this.className&&(a["class"]=f.result(this,"className"));a=g.$("<"+f.result(this,"tagName")+">").attr(a);this.setElement(a,!1)}}});var N={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};g.sync=function(a,b,c){var d=N[a];f.defaults(c||(c={}),{emulateHTTP:g.emulateHTTP,emulateJSON:g.emulateJSON});var e={type:d,dataType:"json"};c.url||(e.url=f.result(b,"url")||x());if(null==c.data&&b&&("create"===a||"update"===a||"patch"===a))e.contentType="application/json",
e.data=JSON.stringify(c.attrs||b.toJSON(c));c.emulateJSON&&(e.contentType="application/x-www-form-urlencoded",e.data=e.data?{model:e.data}:{});if(c.emulateHTTP&&("PUT"===d||"DELETE"===d||"PATCH"===d)){e.type="POST";c.emulateJSON&&(e.data._method=d);var h=c.beforeSend;c.beforeSend=function(a){a.setRequestHeader("X-HTTP-Method-Override",d);if(h)return h.apply(this,arguments)}}"GET"!==e.type&&!c.emulateJSON&&(e.processData=!1);var m=c.success;c.success=function(a){m&&m(b,a,c);b.trigger("sync",b,a,c)};
var j=c.error;c.error=function(a){j&&j(b,a,c);b.trigger("error",b,a,c)};a=c.xhr=g.ajax(f.extend(e,c));b.trigger("request",b,a,c);return a};g.ajax=function(){return g.$.ajax.apply(g.$,arguments)};r.extend=s.extend=y.extend=A.extend=m.extend=function(a,b){var c=this,d;d=a&&f.has(a,"constructor")?a.constructor:function(){return c.apply(this,arguments)};f.extend(d,c,b);var e=function(){this.constructor=d};e.prototype=c.prototype;d.prototype=new e;a&&f.extend(d.prototype,a);d.__super__=c.prototype;return d};
var x=function(){throw Error('A "url" property or function must be specified');}}).call(this);

View File

@@ -1 +0,0 @@
(function(r){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=r()}else if(typeof define==="function"&&define.amd){define([],r)}else{var e;if(typeof window!=="undefined"){e=window}else if(typeof global!=="undefined"){e=global}else if(typeof self!=="undefined"){e=self}else{e=this}e.base64js=r()}})(function(){var r,e,n;return function(){function r(e,n,t){function o(f,i){if(!n[f]){if(!e[f]){var u="function"==typeof require&&require;if(!i&&u)return u(f,!0);if(a)return a(f,!0);var v=new Error("Cannot find module '"+f+"'");throw v.code="MODULE_NOT_FOUND",v}var d=n[f]={exports:{}};e[f][0].call(d.exports,function(r){var n=e[f][1][r];return o(n||r)},d,d.exports,r,e,n,t)}return n[f].exports}for(var a="function"==typeof require&&require,f=0;f<t.length;f++)o(t[f]);return o}return r}()({"/":[function(r,e,n){"use strict";n.byteLength=d;n.toByteArray=h;n.fromByteArray=p;var t=[];var o=[];var a=typeof Uint8Array!=="undefined"?Uint8Array:Array;var f="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for(var i=0,u=f.length;i<u;++i){t[i]=f[i];o[f.charCodeAt(i)]=i}o["-".charCodeAt(0)]=62;o["_".charCodeAt(0)]=63;function v(r){var e=r.length;if(e%4>0){throw new Error("Invalid string. Length must be a multiple of 4")}var n=r.indexOf("=");if(n===-1)n=e;var t=n===e?0:4-n%4;return[n,t]}function d(r){var e=v(r);var n=e[0];var t=e[1];return(n+t)*3/4-t}function c(r,e,n){return(e+n)*3/4-n}function h(r){var e;var n=v(r);var t=n[0];var f=n[1];var i=new a(c(r,t,f));var u=0;var d=f>0?t-4:t;for(var h=0;h<d;h+=4){e=o[r.charCodeAt(h)]<<18|o[r.charCodeAt(h+1)]<<12|o[r.charCodeAt(h+2)]<<6|o[r.charCodeAt(h+3)];i[u++]=e>>16&255;i[u++]=e>>8&255;i[u++]=e&255}if(f===2){e=o[r.charCodeAt(h)]<<2|o[r.charCodeAt(h+1)]>>4;i[u++]=e&255}if(f===1){e=o[r.charCodeAt(h)]<<10|o[r.charCodeAt(h+1)]<<4|o[r.charCodeAt(h+2)]>>2;i[u++]=e>>8&255;i[u++]=e&255}return i}function s(r){return t[r>>18&63]+t[r>>12&63]+t[r>>6&63]+t[r&63]}function l(r,e,n){var t;var o=[];for(var a=e;a<n;a+=3){t=(r[a]<<16&16711680)+(r[a+1]<<8&65280)+(r[a+2]&255);o.push(s(t))}return o.join("")}function p(r){var e;var n=r.length;var o=n%3;var a=[];var f=16383;for(var i=0,u=n-o;i<u;i+=f){a.push(l(r,i,i+f>u?u:i+f))}if(o===1){e=r[n-1];a.push(t[e>>2]+t[e<<4&63]+"==")}else if(o===2){e=(r[n-2]<<8)+r[n-1];a.push(t[e>>10]+t[e>>4&63]+t[e<<2&63]+"=")}return a.join("")}},{}]},{},[])("/")});

View File

@@ -1,960 +0,0 @@
/* =========================================================
* bootstrap-datepicker.js
* http://www.eyecon.ro/bootstrap-datepicker
* =========================================================
* Copyright 2012 Stefan Petre
* Improvements by Andrew Rowls
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ========================================================= */
!function( $ ) {
function UTCDate(){
return new Date(Date.UTC.apply(Date, arguments));
}
function UTCToday(){
var today = new Date();
return UTCDate(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
}
// Picker object
var Datepicker = function(element, options) {
var that = this;
this.element = $(element);
this.language = options.language||this.element.data('date-language')||"en";
this.language = this.language in dates ? this.language : "en";
this.isRTL = dates[this.language].rtl||false;
this.format = DPGlobal.parseFormat(options.format||this.element.data('date-format')||'mm/dd/yyyy');
this.isInline = false;
this.isInput = this.element.is('input');
this.component = this.element.is('.date') ? this.element.find('.add-on') : false;
this.hasInput = this.component && this.element.find('input').length;
if(this.component && this.component.length === 0)
this.component = false;
this._attachEvents();
this.forceParse = true;
if ('forceParse' in options) {
this.forceParse = options.forceParse;
} else if ('dateForceParse' in this.element.data()) {
this.forceParse = this.element.data('date-force-parse');
}
this.picker = $(DPGlobal.template)
.appendTo(this.isInline ? this.element : 'body')
.on({
click: $.proxy(this.click, this),
mousedown: $.proxy(this.mousedown, this)
});
if(this.isInline) {
this.picker.addClass('datepicker-inline');
} else {
this.picker.addClass('datepicker-dropdown dropdown-menu');
}
if (this.isRTL){
this.picker.addClass('datepicker-rtl');
this.picker.find('.prev i, .next i')
.toggleClass('fa-arrow-left fa-arrow-right');
}
$(document).on('mousedown', function (e) {
// Clicked outside the datepicker, hide it
if ($(e.target).closest('.datepicker').length === 0) {
that.hide();
}
});
this.autoclose = false;
if ('autoclose' in options) {
this.autoclose = options.autoclose;
} else if ('dateAutoclose' in this.element.data()) {
this.autoclose = this.element.data('date-autoclose');
}
this.keyboardNavigation = true;
if ('keyboardNavigation' in options) {
this.keyboardNavigation = options.keyboardNavigation;
} else if ('dateKeyboardNavigation' in this.element.data()) {
this.keyboardNavigation = this.element.data('date-keyboard-navigation');
}
this.viewMode = this.startViewMode = 0;
switch(options.startView || this.element.data('date-start-view')){
case 2:
case 'decade':
this.viewMode = this.startViewMode = 2;
break;
case 1:
case 'year':
this.viewMode = this.startViewMode = 1;
break;
}
this.todayBtn = (options.todayBtn||this.element.data('date-today-btn')||false);
this.todayHighlight = (options.todayHighlight||this.element.data('date-today-highlight')||false);
this.weekStart = ((options.weekStart||this.element.data('date-weekstart')||dates[this.language].weekStart||0) % 7);
this.weekEnd = ((this.weekStart + 6) % 7);
this.startDate = -Infinity;
this.endDate = Infinity;
this.daysOfWeekDisabled = [];
this.setStartDate(options.startDate||this.element.data('date-startdate'));
this.setEndDate(options.endDate||this.element.data('date-enddate'));
this.setDaysOfWeekDisabled(options.daysOfWeekDisabled||this.element.data('date-days-of-week-disabled'));
this.fillDow();
this.fillMonths();
this.update();
this.showMode();
if(this.isInline) {
this.show();
}
};
Datepicker.prototype = {
constructor: Datepicker,
_events: [],
_attachEvents: function(){
this._detachEvents();
if (this.isInput) { // single input
this._events = [
[this.element, {
focus: $.proxy(this.show, this),
keyup: $.proxy(this.update, this),
keydown: $.proxy(this.keydown, this)
}]
];
}
else if (this.component && this.hasInput){ // component: input + button
this._events = [
// For components that are not readonly, allow keyboard nav
[this.element.find('input'), {
focus: $.proxy(this.show, this),
keyup: $.proxy(this.update, this),
keydown: $.proxy(this.keydown, this)
}],
[this.component, {
click: $.proxy(this.show, this)
}]
];
}
else if (this.element.is('div')) { // inline datepicker
this.isInline = true;
}
else {
this._events = [
[this.element, {
click: $.proxy(this.show, this)
}]
];
}
for (var i=0, el, ev; i<this._events.length; i++){
el = this._events[i][0];
ev = this._events[i][1];
el.on(ev);
}
},
_detachEvents: function(){
for (var i=0, el, ev; i<this._events.length; i++){
el = this._events[i][0];
ev = this._events[i][1];
el.off(ev);
}
this._events = [];
},
show: function(e) {
this.picker.show();
this.height = this.component ? this.component.outerHeight() : this.element.outerHeight();
this.update();
this.place();
$(window).on('resize', $.proxy(this.place, this));
if (e ) {
e.stopPropagation();
e.preventDefault();
}
this.element.trigger({
type: 'show',
date: this.date
});
},
hide: function(e){
if(this.isInline) return;
this.picker.hide();
$(window).off('resize', this.place);
this.viewMode = this.startViewMode;
this.showMode();
if (!this.isInput) {
$(document).off('mousedown', this.hide);
}
if (
this.forceParse &&
(
this.isInput && this.element.val() ||
this.hasInput && this.element.find('input').val()
)
)
this.setValue();
this.element.trigger({
type: 'hide',
date: this.date
});
},
remove: function() {
this._detachEvents();
this.picker.remove();
delete this.element.data().datepicker;
},
getDate: function() {
var d = this.getUTCDate();
return new Date(d.getTime() + (d.getTimezoneOffset()*60000));
},
getUTCDate: function() {
return this.date;
},
setDate: function(d) {
this.setUTCDate(new Date(d.getTime() - (d.getTimezoneOffset()*60000)));
},
setUTCDate: function(d) {
this.date = d;
this.setValue();
},
setValue: function() {
var formatted = this.getFormattedDate();
if (!this.isInput) {
if (this.component){
this.element.find('input').prop('value', formatted);
}
this.element.data('date', formatted);
} else {
this.element.prop('value', formatted);
}
},
getFormattedDate: function(format) {
if(format == undefined) format = this.format;
return DPGlobal.formatDate(this.date, format, this.language);
},
setStartDate: function(startDate){
this.startDate = startDate||-Infinity;
if (this.startDate !== -Infinity) {
this.startDate = DPGlobal.parseDate(this.startDate, this.format, this.language);
}
this.update();
this.updateNavArrows();
},
setEndDate: function(endDate){
this.endDate = endDate||Infinity;
if (this.endDate !== Infinity) {
this.endDate = DPGlobal.parseDate(this.endDate, this.format, this.language);
}
this.update();
this.updateNavArrows();
},
setDaysOfWeekDisabled: function(daysOfWeekDisabled){
this.daysOfWeekDisabled = daysOfWeekDisabled||[];
if (!$.isArray(this.daysOfWeekDisabled)) {
this.daysOfWeekDisabled = this.daysOfWeekDisabled.split(/,\s*/);
}
this.daysOfWeekDisabled = $.map(this.daysOfWeekDisabled, function (d) {
return parseInt(d, 10);
});
this.update();
this.updateNavArrows();
},
place: function(){
if(this.isInline) return;
var zIndex = parseInt(this.element.parents().filter(function() {
return $(this).css('z-index') != 'auto';
}).first().css('z-index'))+10;
var offset = this.component ? this.component.offset() : this.element.offset();
this.picker.css({
top: offset.top + this.height,
left: offset.left,
zIndex: zIndex
});
},
update: function(){
var date, fromArgs = false;
if(arguments && arguments.length && (typeof arguments[0] === 'string' || arguments[0] instanceof Date)) {
date = arguments[0];
fromArgs = true;
} else {
date = this.isInput ? this.element.prop('value') : this.element.data('date') || this.element.find('input').prop('value');
}
this.date = DPGlobal.parseDate(date, this.format, this.language);
if(fromArgs) this.setValue();
var oldViewDate = this.viewDate;
if (this.date < this.startDate) {
this.viewDate = new Date(this.startDate);
} else if (this.date > this.endDate) {
this.viewDate = new Date(this.endDate);
} else {
this.viewDate = new Date(this.date);
}
if (oldViewDate && oldViewDate.getTime() != this.viewDate.getTime()){
this.element.trigger({
type: 'changeDate',
date: this.viewDate
});
}
this.fill();
},
fillDow: function(){
var dowCnt = this.weekStart,
html = '<tr>';
while (dowCnt < this.weekStart + 7) {
html += '<th class="dow">'+dates[this.language].daysMin[(dowCnt++)%7]+'</th>';
}
html += '</tr>';
this.picker.find('.datepicker-days thead').append(html);
},
fillMonths: function(){
var html = '',
i = 0;
while (i < 12) {
html += '<span class="month">'+dates[this.language].monthsShort[i++]+'</span>';
}
this.picker.find('.datepicker-months td').html(html);
},
fill: function() {
var d = new Date(this.viewDate),
year = d.getUTCFullYear(),
month = d.getUTCMonth(),
startYear = this.startDate !== -Infinity ? this.startDate.getUTCFullYear() : -Infinity,
startMonth = this.startDate !== -Infinity ? this.startDate.getUTCMonth() : -Infinity,
endYear = this.endDate !== Infinity ? this.endDate.getUTCFullYear() : Infinity,
endMonth = this.endDate !== Infinity ? this.endDate.getUTCMonth() : Infinity,
currentDate = this.date && this.date.valueOf(),
today = new Date();
this.picker.find('.datepicker-days thead th:eq(1)')
.text(dates[this.language].months[month]+' '+year);
this.picker.find('tfoot th.today')
.text(dates[this.language].today)
.toggle(this.todayBtn !== false);
this.updateNavArrows();
this.fillMonths();
var prevMonth = UTCDate(year, month-1, 28,0,0,0,0),
day = DPGlobal.getDaysInMonth(prevMonth.getUTCFullYear(), prevMonth.getUTCMonth());
prevMonth.setUTCDate(day);
prevMonth.setUTCDate(day - (prevMonth.getUTCDay() - this.weekStart + 7)%7);
var nextMonth = new Date(prevMonth);
nextMonth.setUTCDate(nextMonth.getUTCDate() + 42);
nextMonth = nextMonth.valueOf();
var html = [];
var clsName;
while(prevMonth.valueOf() < nextMonth) {
if (prevMonth.getUTCDay() == this.weekStart) {
html.push('<tr>');
}
clsName = '';
if (prevMonth.getUTCFullYear() < year || (prevMonth.getUTCFullYear() == year && prevMonth.getUTCMonth() < month)) {
clsName += ' old';
} else if (prevMonth.getUTCFullYear() > year || (prevMonth.getUTCFullYear() == year && prevMonth.getUTCMonth() > month)) {
clsName += ' new';
}
// Compare internal UTC date with local today, not UTC today
if (this.todayHighlight &&
prevMonth.getUTCFullYear() == today.getFullYear() &&
prevMonth.getUTCMonth() == today.getMonth() &&
prevMonth.getUTCDate() == today.getDate()) {
clsName += ' today';
}
if (currentDate && prevMonth.valueOf() == currentDate) {
clsName += ' active';
}
if (prevMonth.valueOf() < this.startDate || prevMonth.valueOf() > this.endDate ||
$.inArray(prevMonth.getUTCDay(), this.daysOfWeekDisabled) !== -1) {
clsName += ' disabled';
}
html.push('<td class="day'+clsName+'">'+prevMonth.getUTCDate() + '</td>');
if (prevMonth.getUTCDay() == this.weekEnd) {
html.push('</tr>');
}
prevMonth.setUTCDate(prevMonth.getUTCDate()+1);
}
this.picker.find('.datepicker-days tbody').empty().append(html.join(''));
var currentYear = this.date && this.date.getUTCFullYear();
var months = this.picker.find('.datepicker-months')
.find('th:eq(1)')
.text(year)
.end()
.find('span').removeClass('active');
if (currentYear && currentYear == year) {
months.eq(this.date.getUTCMonth()).addClass('active');
}
if (year < startYear || year > endYear) {
months.addClass('disabled');
}
if (year == startYear) {
months.slice(0, startMonth).addClass('disabled');
}
if (year == endYear) {
months.slice(endMonth+1).addClass('disabled');
}
html = '';
year = parseInt(year/10, 10) * 10;
var yearCont = this.picker.find('.datepicker-years')
.find('th:eq(1)')
.text(year + '-' + (year + 9))
.end()
.find('td');
year -= 1;
for (var i = -1; i < 11; i++) {
html += '<span class="year'+(i == -1 || i == 10 ? ' old' : '')+(currentYear == year ? ' active' : '')+(year < startYear || year > endYear ? ' disabled' : '')+'">'+year+'</span>';
year += 1;
}
yearCont.html(html);
},
updateNavArrows: function() {
var d = new Date(this.viewDate),
year = d.getUTCFullYear(),
month = d.getUTCMonth();
switch (this.viewMode) {
case 0:
if (this.startDate !== -Infinity && year <= this.startDate.getUTCFullYear() && month <= this.startDate.getUTCMonth()) {
this.picker.find('.prev').css({visibility: 'hidden'});
} else {
this.picker.find('.prev').css({visibility: 'visible'});
}
if (this.endDate !== Infinity && year >= this.endDate.getUTCFullYear() && month >= this.endDate.getUTCMonth()) {
this.picker.find('.next').css({visibility: 'hidden'});
} else {
this.picker.find('.next').css({visibility: 'visible'});
}
break;
case 1:
case 2:
if (this.startDate !== -Infinity && year <= this.startDate.getUTCFullYear()) {
this.picker.find('.prev').css({visibility: 'hidden'});
} else {
this.picker.find('.prev').css({visibility: 'visible'});
}
if (this.endDate !== Infinity && year >= this.endDate.getUTCFullYear()) {
this.picker.find('.next').css({visibility: 'hidden'});
} else {
this.picker.find('.next').css({visibility: 'visible'});
}
break;
}
},
click: function(e) {
e.stopPropagation();
e.preventDefault();
var target = $(e.target).closest('span, td, th');
if (target.length == 1) {
switch(target[0].nodeName.toLowerCase()) {
case 'th':
switch(target[0].className) {
case 'switch':
this.showMode(1);
break;
case 'prev':
case 'next':
var dir = DPGlobal.modes[this.viewMode].navStep * (target[0].className == 'prev' ? -1 : 1);
switch(this.viewMode){
case 0:
this.viewDate = this.moveMonth(this.viewDate, dir);
break;
case 1:
case 2:
this.viewDate = this.moveYear(this.viewDate, dir);
break;
}
this.fill();
break;
case 'today':
var date = new Date();
date = UTCDate(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0);
this.showMode(-2);
var which = this.todayBtn == 'linked' ? null : 'view';
this._setDate(date, which);
break;
}
break;
case 'span':
if (!target.is('.disabled')) {
this.viewDate.setUTCDate(1);
if (target.is('.month')) {
var month = target.parent().find('span').index(target);
this.viewDate.setUTCMonth(month);
this.element.trigger({
type: 'changeMonth',
date: this.viewDate
});
} else {
var year = parseInt(target.text(), 10)||0;
this.viewDate.setUTCFullYear(year);
this.element.trigger({
type: 'changeYear',
date: this.viewDate
});
}
this.showMode(-1);
this.fill();
}
break;
case 'td':
if (target.is('.day') && !target.is('.disabled')){
var day = parseInt(target.text(), 10)||1;
var year = this.viewDate.getUTCFullYear(),
month = this.viewDate.getUTCMonth();
if (target.is('.old')) {
if (month === 0) {
month = 11;
year -= 1;
} else {
month -= 1;
}
} else if (target.is('.new')) {
if (month == 11) {
month = 0;
year += 1;
} else {
month += 1;
}
}
this._setDate(UTCDate(year, month, day,0,0,0,0));
}
break;
}
}
},
_setDate: function(date, which){
if (!which || which == 'date')
this.date = date;
if (!which || which == 'view')
this.viewDate = date;
this.fill();
this.setValue();
this.element.trigger({
type: 'changeDate',
date: this.date
});
var element;
if (this.isInput) {
element = this.element;
} else if (this.component){
element = this.element.find('input');
}
if (element) {
element.change();
if (this.autoclose && (!which || which == 'date')) {
this.hide();
}
}
},
moveMonth: function(date, dir){
if (!dir) return date;
var new_date = new Date(date.valueOf()),
day = new_date.getUTCDate(),
month = new_date.getUTCMonth(),
mag = Math.abs(dir),
new_month, test;
dir = dir > 0 ? 1 : -1;
if (mag == 1){
test = dir == -1
// If going back one month, make sure month is not current month
// (eg, Mar 31 -> Feb 31 == Feb 28, not Mar 02)
? function(){ return new_date.getUTCMonth() == month; }
// If going forward one month, make sure month is as expected
// (eg, Jan 31 -> Feb 31 == Feb 28, not Mar 02)
: function(){ return new_date.getUTCMonth() != new_month; };
new_month = month + dir;
new_date.setUTCMonth(new_month);
// Dec -> Jan (12) or Jan -> Dec (-1) -- limit expected date to 0-11
if (new_month < 0 || new_month > 11)
new_month = (new_month + 12) % 12;
} else {
// For magnitudes >1, move one month at a time...
for (var i=0; i<mag; i++)
// ...which might decrease the day (eg, Jan 31 to Feb 28, etc)...
new_date = this.moveMonth(new_date, dir);
// ...then reset the day, keeping it in the new month
new_month = new_date.getUTCMonth();
new_date.setUTCDate(day);
test = function(){ return new_month != new_date.getUTCMonth(); };
}
// Common date-resetting loop -- if date is beyond end of month, make it
// end of month
while (test()){
new_date.setUTCDate(--day);
new_date.setUTCMonth(new_month);
}
return new_date;
},
moveYear: function(date, dir){
return this.moveMonth(date, dir*12);
},
dateWithinRange: function(date){
return date >= this.startDate && date <= this.endDate;
},
keydown: function(e){
if (this.picker.is(':not(:visible)')){
if (e.keyCode == 27) // allow escape to hide and re-show picker
this.show();
return;
}
var dateChanged = false,
dir, day, month,
newDate, newViewDate;
switch(e.keyCode){
case 27: // escape
this.hide();
e.preventDefault();
break;
case 37: // left
case 39: // right
if (!this.keyboardNavigation) break;
dir = e.keyCode == 37 ? -1 : 1;
if (e.ctrlKey){
newDate = this.moveYear(this.date, dir);
newViewDate = this.moveYear(this.viewDate, dir);
} else if (e.shiftKey){
newDate = this.moveMonth(this.date, dir);
newViewDate = this.moveMonth(this.viewDate, dir);
} else {
newDate = new Date(this.date);
newDate.setUTCDate(this.date.getUTCDate() + dir);
newViewDate = new Date(this.viewDate);
newViewDate.setUTCDate(this.viewDate.getUTCDate() + dir);
}
if (this.dateWithinRange(newDate)){
this.date = newDate;
this.viewDate = newViewDate;
this.setValue();
this.update();
e.preventDefault();
dateChanged = true;
}
break;
case 38: // up
case 40: // down
if (!this.keyboardNavigation) break;
dir = e.keyCode == 38 ? -1 : 1;
if (e.ctrlKey){
newDate = this.moveYear(this.date, dir);
newViewDate = this.moveYear(this.viewDate, dir);
} else if (e.shiftKey){
newDate = this.moveMonth(this.date, dir);
newViewDate = this.moveMonth(this.viewDate, dir);
} else {
newDate = new Date(this.date);
newDate.setUTCDate(this.date.getUTCDate() + dir * 7);
newViewDate = new Date(this.viewDate);
newViewDate.setUTCDate(this.viewDate.getUTCDate() + dir * 7);
}
if (this.dateWithinRange(newDate)){
this.date = newDate;
this.viewDate = newViewDate;
this.setValue();
this.update();
e.preventDefault();
dateChanged = true;
}
break;
case 13: // enter
this.hide();
e.preventDefault();
break;
case 9: // tab
this.hide();
break;
}
if (dateChanged){
this.element.trigger({
type: 'changeDate',
date: this.date
});
var element;
if (this.isInput) {
element = this.element;
} else if (this.component){
element = this.element.find('input');
}
if (element) {
element.change();
}
}
},
showMode: function(dir) {
if (dir) {
this.viewMode = Math.max(0, Math.min(2, this.viewMode + dir));
}
/*
vitalets: fixing bug of very special conditions:
jquery 1.7.1 + webkit + show inline datepicker in bootstrap popover.
Method show() does not set display css correctly and datepicker is not shown.
Changed to .css('display', 'block') solve the problem.
See https://github.com/vitalets/x-editable/issues/37
In jquery 1.7.2+ everything works fine.
*/
//this.picker.find('>div').hide().filter('.datepicker-'+DPGlobal.modes[this.viewMode].clsName).show();
this.picker.find('>div').hide().filter('.datepicker-'+DPGlobal.modes[this.viewMode].clsName).css('display', 'block');
this.updateNavArrows();
}
};
$.fn.datepicker = function ( option ) {
var args = Array.apply(null, arguments);
args.shift();
return this.each(function () {
var $this = $(this),
data = $this.data('datepicker'),
options = typeof option == 'object' && option;
if (!data) {
$this.data('datepicker', (data = new Datepicker(this, $.extend({}, $.fn.datepicker.defaults,options))));
}
if (typeof option == 'string' && typeof data[option] == 'function') {
data[option].apply(data, args);
}
});
};
$.fn.datepicker.defaults = {
};
$.fn.datepicker.Constructor = Datepicker;
var dates = $.fn.datepicker.dates = {
en: {
days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
daysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
daysMin: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
today: "Today"
}
};
var DPGlobal = {
modes: [
{
clsName: 'days',
navFnc: 'Month',
navStep: 1
},
{
clsName: 'months',
navFnc: 'FullYear',
navStep: 1
},
{
clsName: 'years',
navFnc: 'FullYear',
navStep: 10
}],
isLeapYear: function (year) {
return (((year % 4 === 0) && (year % 100 !== 0)) || (year % 400 === 0))
},
getDaysInMonth: function (year, month) {
return [31, (DPGlobal.isLeapYear(year) ? 29 : 28), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month]
},
validParts: /dd?|DD?|mm?|MM?|yy(?:yy)?/g,
nonpunctuation: /[^ -\/:-@\[\u3400-\u9fff-`{-~\t\n\r]+/g,
parseFormat: function(format){
// IE treats \0 as a string end in inputs (truncating the value),
// so it's a bad format delimiter, anyway
var separators = format.replace(this.validParts, '\0').split('\0'),
parts = format.match(this.validParts);
if (!separators || !separators.length || !parts || parts.length == 0){
throw new Error("Invalid date format.");
}
return {separators: separators, parts: parts};
},
parseDate: function(date, format, language) {
if (date instanceof Date) return date;
if (/^[-+]\d+[dmwy]([\s,]+[-+]\d+[dmwy])*$/.test(date)) {
var part_re = /([-+]\d+)([dmwy])/,
parts = date.match(/([-+]\d+)([dmwy])/g),
part, dir;
date = new Date();
for (var i=0; i<parts.length; i++) {
part = part_re.exec(parts[i]);
dir = parseInt(part[1]);
switch(part[2]){
case 'd':
date.setUTCDate(date.getUTCDate() + dir);
break;
case 'm':
date = Datepicker.prototype.moveMonth.call(Datepicker.prototype, date, dir);
break;
case 'w':
date.setUTCDate(date.getUTCDate() + dir * 7);
break;
case 'y':
date = Datepicker.prototype.moveYear.call(Datepicker.prototype, date, dir);
break;
}
}
return UTCDate(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
}
var parts = date && date.match(this.nonpunctuation) || [],
date = new Date(),
parsed = {},
setters_order = ['yyyy', 'yy', 'M', 'MM', 'm', 'mm', 'd', 'dd'],
setters_map = {
yyyy: function(d,v){ return d.setUTCFullYear(v); },
yy: function(d,v){ return d.setUTCFullYear(2000+v); },
m: function(d,v){
v -= 1;
while (v<0) v += 12;
v %= 12;
d.setUTCMonth(v);
while (d.getUTCMonth() != v)
d.setUTCDate(d.getUTCDate()-1);
return d;
},
d: function(d,v){ return d.setUTCDate(v); }
},
val, filtered, part;
setters_map['M'] = setters_map['MM'] = setters_map['mm'] = setters_map['m'];
setters_map['dd'] = setters_map['d'];
date = UTCDate(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0);
var fparts = format.parts.slice();
// Remove noop parts
if (parts.length != fparts.length) {
fparts = $(fparts).filter(function(i,p){
return $.inArray(p, setters_order) !== -1;
}).toArray();
}
// Process remainder
if (parts.length == fparts.length) {
for (var i=0, cnt = fparts.length; i < cnt; i++) {
val = parseInt(parts[i], 10);
part = fparts[i];
if (isNaN(val)) {
switch(part) {
case 'MM':
filtered = $(dates[language].months).filter(function(){
var m = this.slice(0, parts[i].length),
p = parts[i].slice(0, m.length);
return m == p;
});
val = $.inArray(filtered[0], dates[language].months) + 1;
break;
case 'M':
filtered = $(dates[language].monthsShort).filter(function(){
var m = this.slice(0, parts[i].length),
p = parts[i].slice(0, m.length);
return m == p;
});
val = $.inArray(filtered[0], dates[language].monthsShort) + 1;
break;
}
}
parsed[part] = val;
}
for (var i=0, s; i<setters_order.length; i++){
s = setters_order[i];
if (s in parsed && !isNaN(parsed[s]))
setters_map[s](date, parsed[s])
}
}
return date;
},
formatDate: function(date, format, language){
var val = {
d: date.getUTCDate(),
D: dates[language].daysShort[date.getUTCDay()],
DD: dates[language].days[date.getUTCDay()],
m: date.getUTCMonth() + 1,
M: dates[language].monthsShort[date.getUTCMonth()],
MM: dates[language].months[date.getUTCMonth()],
yy: date.getUTCFullYear().toString().substring(2),
yyyy: date.getUTCFullYear()
};
val.dd = (val.d < 10 ? '0' : '') + val.d;
val.mm = (val.m < 10 ? '0' : '') + val.m;
var date = [],
seps = $.extend([], format.separators);
for (var i=0, cnt = format.parts.length; i < cnt; i++) {
if (seps.length)
date.push(seps.shift())
date.push(val[format.parts[i]]);
}
return date.join('');
},
headTemplate: '<thead>'+
'<tr>'+
'<th class="prev"><i class="fas fa-arrow-left"/></th>'+
'<th colspan="5" class="switch"></th>'+
'<th class="next"><i class="fas fa-arrow-right"/></th>'+
'</tr>'+
'</thead>',
contTemplate: '<tbody><tr><td colspan="7"></td></tr></tbody>',
footTemplate: '<tfoot><tr><th colspan="7" class="today"></th></tr></tfoot>'
};
DPGlobal.template = '<div class="datepicker">'+
'<div class="datepicker-days">'+
'<table class=" table-condensed">'+
DPGlobal.headTemplate+
'<tbody></tbody>'+
DPGlobal.footTemplate+
'</table>'+
'</div>'+
'<div class="datepicker-months">'+
'<table class="table-condensed">'+
DPGlobal.headTemplate+
DPGlobal.contTemplate+
DPGlobal.footTemplate+
'</table>'+
'</div>'+
'<div class="datepicker-years">'+
'<table class="table-condensed">'+
DPGlobal.headTemplate+
DPGlobal.contTemplate+
DPGlobal.footTemplate+
'</table>'+
'</div>'+
'</div>';
$.fn.datepicker.DPGlobal = DPGlobal;
}( window.jQuery );

View File

@@ -1,804 +0,0 @@
/* =========================================================
* bootstrap-timepicker.js
* http://www.github.com/jdewit/bootstrap-timepicker
* =========================================================
* Copyright 2012
*
* Created By:
* Joris de Wit @joris_dewit
*
* Contributions By:
* Gilbert @mindeavor
* Koen Punt info@koenpunt.nl
* Nek
* Chris Martin
* Dominic Barnes contact@dominicbarnes.us
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ========================================================= */
!function($) {
"use strict"; // jshint ;_;
/* TIMEPICKER PUBLIC CLASS DEFINITION
* ================================== */
var Timepicker = function(element, options) {
this.$element = $(element);
this.options = $.extend({}, $.fn.timepicker.defaults, options, this.$element.data());
this.minuteStep = this.options.minuteStep || this.minuteStep;
this.secondStep = this.options.secondStep || this.secondStep;
this.showMeridian = this.options.showMeridian || this.showMeridian;
this.showSeconds = this.options.showSeconds || this.showSeconds;
this.showInputs = this.options.showInputs || this.showInputs;
this.disableFocus = this.options.disableFocus || this.disableFocus;
this.template = this.options.template || this.template;
this.modalBackdrop = this.options.modalBackdrop || this.modalBackdrop;
this.defaultTime = this.options.defaultTime || this.defaultTime;
this.open = false;
this.init();
};
Timepicker.prototype = {
constructor: Timepicker
, init: function () {
if (this.$element.parent().hasClass('input-append')) {
this.$element.parent('.input-append').find('.add-on').on('click', $.proxy(this.showWidget, this));
this.$element.on({
focus: $.proxy(this.highlightUnit, this),
click: $.proxy(this.highlightUnit, this),
keypress: $.proxy(this.elementKeypress, this),
blur: $.proxy(this.blurElement, this)
});
} else {
if (this.template) {
this.$element.on({
focus: $.proxy(this.showWidget, this),
click: $.proxy(this.showWidget, this),
blur: $.proxy(this.blurElement, this)
});
} else {
this.$element.on({
focus: $.proxy(this.highlightUnit, this),
click: $.proxy(this.highlightUnit, this),
keypress: $.proxy(this.elementKeypress, this),
blur: $.proxy(this.blurElement, this)
});
}
}
this.$widget = $(this.getTemplate()).appendTo('body');
this.$widget.on('click', $.proxy(this.widgetClick, this));
if (this.showInputs) {
this.$widget.find('input').on({
click: function() { this.select(); },
keypress: $.proxy(this.widgetKeypress, this),
change: $.proxy(this.updateFromWidgetInputs, this)
});
}
this.setDefaultTime(this.defaultTime);
}
, showWidget: function(e) {
e.stopPropagation();
e.preventDefault();
if (this.open) {
return;
}
this.$element.trigger('show');
if (this.disableFocus) {
this.$element.blur();
}
var pos = $.extend({}, this.$element.offset(), {
height: this.$element[0].offsetHeight
});
this.updateFromElementVal();
$('html')
.trigger('click.timepicker.data-api')
.one('click.timepicker.data-api', $.proxy(this.hideWidget, this));
if (this.template === 'modal') {
this.$widget.modal('show').on('hidden', $.proxy(this.hideWidget, this));
} else {
this.$widget.css({
top: pos.top + pos.height
, left: pos.left
})
if (!this.open) {
this.$widget.addClass('open');
}
}
this.open = true;
this.$element.trigger('shown');
}
, hideWidget: function(){
this.$element.trigger('hide');
if (this.template === 'modal') {
this.$widget.modal('hide');
} else {
this.$widget.removeClass('open');
}
this.open = false;
this.$element.trigger('hidden');
}
, widgetClick: function(e) {
e.stopPropagation();
e.preventDefault();
var action = $(e.target).closest('a').data('action');
if (action) {
this[action]();
this.update();
}
}
, widgetKeypress: function(e) {
var input = $(e.target).closest('input').attr('name');
switch (e.keyCode) {
case 9: //tab
if (this.showMeridian) {
if (input == 'meridian') {
this.hideWidget();
}
} else {
if (this.showSeconds) {
if (input == 'second') {
this.hideWidget();
}
} else {
if (input == 'minute') {
this.hideWidget();
}
}
}
break;
case 27: // escape
this.hideWidget();
break;
case 38: // up arrow
switch (input) {
case 'hour':
this.incrementHour();
break;
case 'minute':
this.incrementMinute();
break;
case 'second':
this.incrementSecond();
break;
case 'meridian':
this.toggleMeridian();
break;
}
this.update();
break;
case 40: // down arrow
switch (input) {
case 'hour':
this.decrementHour();
break;
case 'minute':
this.decrementMinute();
break;
case 'second':
this.decrementSecond();
break;
case 'meridian':
this.toggleMeridian();
break;
}
this.update();
break;
}
}
, elementKeypress: function(e) {
var input = this.$element.get(0);
switch (e.keyCode) {
case 0: //input
break;
case 9: //tab
this.updateFromElementVal();
if (this.showMeridian) {
if (this.highlightedUnit != 'meridian') {
e.preventDefault();
this.highlightNextUnit();
}
} else {
if (this.showSeconds) {
if (this.highlightedUnit != 'second') {
e.preventDefault();
this.highlightNextUnit();
}
} else {
if (this.highlightedUnit != 'minute') {
e.preventDefault();
this.highlightNextUnit();
}
}
}
break;
case 27: // escape
this.updateFromElementVal();
break;
case 37: // left arrow
this.updateFromElementVal();
this.highlightPrevUnit();
break;
case 38: // up arrow
switch (this.highlightedUnit) {
case 'hour':
this.incrementHour();
break;
case 'minute':
this.incrementMinute();
break;
case 'second':
this.incrementSecond();
break;
case 'meridian':
this.toggleMeridian();
break;
}
this.updateElement();
break;
case 39: // right arrow
this.updateFromElementVal();
this.highlightNextUnit();
break;
case 40: // down arrow
switch (this.highlightedUnit) {
case 'hour':
this.decrementHour();
break;
case 'minute':
this.decrementMinute();
break;
case 'second':
this.decrementSecond();
break;
case 'meridian':
this.toggleMeridian();
break;
}
this.updateElement();
break;
}
if (e.keyCode !== 0 && e.keyCode !== 8 && e.keyCode !== 9 && e.keyCode !== 46) {
e.preventDefault();
}
}
, setValues: function(time) {
if (this.showMeridian) {
var arr = time.split(' ');
var timeArray = arr[0].split(':');
this.meridian = arr[1];
} else {
var timeArray = time.split(':');
}
this.hour = parseInt(timeArray[0], 10);
this.minute = parseInt(timeArray[1], 10);
this.second = parseInt(timeArray[2], 10);
if (isNaN(this.hour)) {
this.hour = 0;
}
if (isNaN(this.minute)) {
this.minute = 0;
}
if (this.showMeridian) {
if (this.hour > 12) {
this.hour = 12;
} else if (this.hour < 1) {
this.hour = 1;
}
if (this.meridian == 'am' || this.meridian == 'a') {
this.meridian = 'AM';
} else if (this.meridian == 'pm' || this.meridian == 'p') {
this.meridian = 'PM';
}
if (this.meridian != 'AM' && this.meridian != 'PM') {
this.meridian = 'AM';
}
} else {
if (this.hour >= 24) {
this.hour = 23;
} else if (this.hour < 0) {
this.hour = 0;
}
}
if (this.minute < 0) {
this.minute = 0;
} else if (this.minute >= 60) {
this.minute = 59;
}
if (this.showSeconds) {
if (isNaN(this.second)) {
this.second = 0;
} else if (this.second < 0) {
this.second = 0;
} else if (this.second >= 60) {
this.second = 59;
}
}
if ( this.$element.val() != '' )
this.updateElement();
this.updateWidget();
}
, setMeridian: function(meridian) {
if (meridian == 'a' || meridian == 'am' || meridian == 'AM' ) {
this.meridian = 'AM';
} else if (meridian == 'p' || meridian == 'pm' || meridian == 'PM' ) {
this.meridian = 'PM';
} else {
this.updateWidget();
}
this.updateElement();
}
, setDefaultTime: function(defaultTime){
if (defaultTime) {
if (defaultTime === 'current') {
var dTime = new Date();
var hours = dTime.getHours();
var minutes = Math.floor(dTime.getMinutes() / this.minuteStep) * this.minuteStep;
var seconds = Math.floor(dTime.getSeconds() / this.secondStep) * this.secondStep;
var meridian = "AM";
if (this.showMeridian) {
if (hours === 0) {
hours = 12;
} else if (hours >= 12) {
if (hours > 12) {
hours = hours - 12;
}
meridian = "PM";
} else {
meridian = "AM";
}
}
this.hour = hours;
this.minute = minutes;
this.second = seconds;
this.meridian = meridian;
} else if (defaultTime === 'value') {
this.setValues(this.$element.val());
} else {
this.setValues(defaultTime);
}
if ( this.$element.val() != '' )
this.updateElement();
this.updateWidget();
this.updateElement();
} else {
this.hour = 0;
this.minute = 0;
this.second = 0;
}
}
, formatTime: function(hour, minute, second, meridian) {
hour = hour < 10 ? '0' + hour : hour;
minute = minute < 10 ? '0' + minute : minute;
second = second < 10 ? '0' + second : second;
return hour + ':' + minute + (this.showSeconds ? ':' + second : '') + (this.showMeridian ? ' ' + meridian : '');
}
, getTime: function() {
return this.formatTime(this.hour, this.minute, this.second, this.meridian);
}
, setTime: function(time) {
this.setValues(time);
this.update();
}
, update: function() {
this.updateElement();
this.updateWidget();
}
, blurElement: function() {
this.highlightedUnit = undefined;
this.updateFromElementVal();
}
, updateElement: function() {
var time = this.getTime();
this.$element.val(time).change();
switch (this.highlightedUnit) {
case 'hour':
this.highlightHour();
break;
case 'minute':
this.highlightMinute();
break;
case 'second':
this.highlightSecond();
break;
case 'meridian':
this.highlightMeridian();
break;
}
}
, updateWidget: function() {
if (this.showInputs) {
this.$widget.find('input.bootstrap-timepicker-hour').val(this.hour < 10 ? '0' + this.hour : this.hour);
this.$widget.find('input.bootstrap-timepicker-minute').val(this.minute < 10 ? '0' + this.minute : this.minute);
if (this.showSeconds) {
this.$widget.find('input.bootstrap-timepicker-second').val(this.second < 10 ? '0' + this.second : this.second);
}
if (this.showMeridian) {
this.$widget.find('input.bootstrap-timepicker-meridian').val(this.meridian);
}
} else {
this.$widget.find('span.bootstrap-timepicker-hour').text(this.hour);
this.$widget.find('span.bootstrap-timepicker-minute').text(this.minute < 10 ? '0' + this.minute : this.minute);
if (this.showSeconds) {
this.$widget.find('span.bootstrap-timepicker-second').text(this.second < 10 ? '0' + this.second : this.second);
}
if (this.showMeridian) {
this.$widget.find('span.bootstrap-timepicker-meridian').text(this.meridian);
}
}
}
, updateFromElementVal: function (e) {
var time = this.$element.val();
if (time) {
this.setValues(time);
this.updateWidget();
}
}
, updateFromWidgetInputs: function () {
var time = $('input.bootstrap-timepicker-hour', this.$widget).val() + ':' +
$('input.bootstrap-timepicker-minute', this.$widget).val() +
(this.showSeconds ?
':' + $('input.bootstrap-timepicker-second', this.$widget).val()
: '') +
(this.showMeridian ?
' ' + $('input.bootstrap-timepicker-meridian', this.$widget).val()
: '');
this.setValues(time);
}
, getCursorPosition: function() {
var input = this.$element.get(0);
if ('selectionStart' in input) {
// Standard-compliant browsers
return input.selectionStart;
} else if (document.selection) {
// IE fix
input.focus();
var sel = document.selection.createRange();
var selLen = document.selection.createRange().text.length;
sel.moveStart('character', - input.value.length);
return sel.text.length - selLen;
}
}
, highlightUnit: function () {
var input = this.$element.get(0);
this.position = this.getCursorPosition();
if (this.position >= 0 && this.position <= 2) {
this.highlightHour();
} else if (this.position >= 3 && this.position <= 5) {
this.highlightMinute();
} else if (this.position >= 6 && this.position <= 8) {
if (this.showSeconds) {
this.highlightSecond();
} else {
this.highlightMeridian();
}
} else if (this.position >= 9 && this.position <= 11) {
this.highlightMeridian();
}
}
, highlightNextUnit: function() {
switch (this.highlightedUnit) {
case 'hour':
this.highlightMinute();
break;
case 'minute':
if (this.showSeconds) {
this.highlightSecond();
} else {
this.highlightMeridian();
}
break;
case 'second':
this.highlightMeridian();
break;
case 'meridian':
this.highlightHour();
break;
}
}
, highlightPrevUnit: function() {
switch (this.highlightedUnit) {
case 'hour':
this.highlightMeridian();
break;
case 'minute':
this.highlightHour();
break;
case 'second':
this.highlightMinute();
break;
case 'meridian':
if (this.showSeconds) {
this.highlightSecond();
} else {
this.highlightMinute();
}
break;
}
}
, highlightHour: function() {
this.highlightedUnit = 'hour';
this.$element.get(0).setSelectionRange(0,2);
}
, highlightMinute: function() {
this.highlightedUnit = 'minute';
this.$element.get(0).setSelectionRange(3,5);
}
, highlightSecond: function() {
this.highlightedUnit = 'second';
this.$element.get(0).setSelectionRange(6,8);
}
, highlightMeridian: function() {
this.highlightedUnit = 'meridian';
if (this.showSeconds) {
this.$element.get(0).setSelectionRange(9,11);
} else {
this.$element.get(0).setSelectionRange(6,8);
}
}
, incrementHour: function() {
if (this.showMeridian) {
if (this.hour === 11) {
this.toggleMeridian();
} else if (this.hour === 12) {
return this.hour = 1;
}
}
if (this.hour === 23) {
return this.hour = 0;
}
this.hour = this.hour + 1;
}
, decrementHour: function() {
if (this.showMeridian) {
if (this.hour === 1) {
return this.hour = 12;
}
else if (this.hour === 12) {
this.toggleMeridian();
}
}
if (this.hour === 0) {
return this.hour = 23;
}
this.hour = this.hour - 1;
}
, incrementMinute: function() {
var newVal = this.minute + this.minuteStep - (this.minute % this.minuteStep);
if (newVal > 59) {
this.incrementHour();
this.minute = newVal - 60;
} else {
this.minute = newVal;
}
}
, decrementMinute: function() {
var newVal = this.minute - this.minuteStep;
if (newVal < 0) {
this.decrementHour();
this.minute = newVal + 60;
} else {
this.minute = newVal;
}
}
, incrementSecond: function() {
var newVal = this.second + this.secondStep - (this.second % this.secondStep);
if (newVal > 59) {
this.incrementMinute();
this.second = newVal - 60;
} else {
this.second = newVal;
}
}
, decrementSecond: function() {
var newVal = this.second - this.secondStep;
if (newVal < 0) {
this.decrementMinute();
this.second = newVal + 60;
} else {
this.second = newVal;
}
}
, toggleMeridian: function() {
this.meridian = this.meridian === 'AM' ? 'PM' : 'AM';
this.update();
}
, getTemplate: function() {
if (this.options.templates[this.options.template]) {
return this.options.templates[this.options.template];
}
if (this.showInputs) {
var hourTemplate = '<input type="text" name="hour" class="bootstrap-timepicker-hour" maxlength="2"/>';
var minuteTemplate = '<input type="text" name="minute" class="bootstrap-timepicker-minute" maxlength="2"/>';
var secondTemplate = '<input type="text" name="second" class="bootstrap-timepicker-second" maxlength="2"/>';
var meridianTemplate = '<input type="text" name="meridian" class="bootstrap-timepicker-meridian" maxlength="2"/>';
} else {
var hourTemplate = '<span class="bootstrap-timepicker-hour"></span>';
var minuteTemplate = '<span class="bootstrap-timepicker-minute"></span>';
var secondTemplate = '<span class="bootstrap-timepicker-second"></span>';
var meridianTemplate = '<span class="bootstrap-timepicker-meridian"></span>';
}
var templateContent = '<table class="'+ (this.showSeconds ? 'show-seconds' : '') +' '+ (this.showMeridian ? 'show-meridian' : '') +'">'+
'<tr>'+
'<td><a href="#" data-action="incrementHour"><i class="fas fa-chevron-up"></i></a></td>'+
'<td class="separator">&nbsp;</td>'+
'<td><a href="#" data-action="incrementMinute"><i class="fas fa-chevron-up"></i></a></td>'+
(this.showSeconds ?
'<td class="separator">&nbsp;</td>'+
'<td><a href="#" data-action="incrementSecond"><i class="fas fa-chevron-up"></i></a></td>'
: '') +
(this.showMeridian ?
'<td class="separator">&nbsp;</td>'+
'<td class="meridian-column"><a href="#" data-action="toggleMeridian"><i class="fas fa-chevron-up"></i></a></td>'
: '') +
'</tr>'+
'<tr>'+
'<td>'+ hourTemplate +'</td> '+
'<td class="separator">:</td>'+
'<td>'+ minuteTemplate +'</td> '+
(this.showSeconds ?
'<td class="separator">:</td>'+
'<td>'+ secondTemplate +'</td>'
: '') +
(this.showMeridian ?
'<td class="separator">&nbsp;</td>'+
'<td>'+ meridianTemplate +'</td>'
: '') +
'</tr>'+
'<tr>'+
'<td><a href="#" data-action="decrementHour"><i class="fas fa-chevron-down"></i></a></td>'+
'<td class="separator"></td>'+
'<td><a href="#" data-action="decrementMinute"><i class="fas fa-chevron-down"></i></a></td>'+
(this.showSeconds ?
'<td class="separator">&nbsp;</td>'+
'<td><a href="#" data-action="decrementSecond"><i class="fas fa-chevron-down"></i></a></td>'
: '') +
(this.showMeridian ?
'<td class="separator">&nbsp;</td>'+
'<td><a href="#" data-action="toggleMeridian"><i class="fas fa-chevron-down"></i></a></td>'
: '') +
'</tr>'+
'</table>';
var template;
switch(this.options.template) {
case 'modal':
template = '<div class="bootstrap-timepicker modal hide fade in" style="top: 30%; margin-top: 0; width: 200px; margin-left: -100px;" data-backdrop="'+ (this.modalBackdrop ? 'true' : 'false') +'">'+
'<div class="modal-header">'+
'<a href="#" class="close" data-dismiss="modal">×</a>'+
'<h3>Pick a Time</h3>'+
'</div>'+
'<div class="modal-content">'+
templateContent +
'</div>'+
'<div class="modal-footer">'+
'<a href="#" class="btn btn-primary" data-dismiss="modal">Ok</a>'+
'</div>'+
'</div>';
break;
case 'dropdown':
template = '<div class="bootstrap-timepicker dropdown-menu">'+
templateContent +
'</div>';
break;
}
return template;
}
};
/* TIMEPICKER PLUGIN DEFINITION
* =========================== */
$.fn.timepicker = function (option) {
return this.each(function () {
var $this = $(this)
, data = $this.data('timepicker')
, options = typeof option == 'object' && option;
if (!data) {
$this.data('timepicker', (data = new Timepicker(this, options)));
}
if (typeof option == 'string') {
data[option]();
}
})
}
$.fn.timepicker.defaults = {
minuteStep: 15
, secondStep: 15
, disableFocus: false
, defaultTime: 'current'
, showSeconds: false
, showInputs: true
, showMeridian: true
, template: 'dropdown'
, modalBackdrop: false
, templates: {} // set custom templates
}
$.fn.timepicker.Constructor = Timepicker
}(window.jQuery);

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,185 +0,0 @@
/*
* jQuery Iframe Transport Plugin 1.6.1
* https://github.com/blueimp/jQuery-File-Upload
*
* Copyright 2011, Sebastian Tschan
* https://blueimp.net
*
* Licensed under the MIT license:
* http://www.opensource.org/licenses/MIT
*/
/*jslint unparam: true, nomen: true */
/*global define, window, document */
(function (factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// Register as an anonymous AMD module:
define(['jquery'], factory);
} else {
// Browser globals:
factory(window.jQuery);
}
}(function ($) {
'use strict';
// Helper variable to create unique names for the transport iframes:
var counter = 0;
// The iframe transport accepts three additional options:
// options.fileInput: a jQuery collection of file input fields
// options.paramName: the parameter name for the file form data,
// overrides the name property of the file input field(s),
// can be a string or an array of strings.
// options.formData: an array of objects with name and value properties,
// equivalent to the return data of .serializeArray(), e.g.:
// [{name: 'a', value: 1}, {name: 'b', value: 2}]
$.ajaxTransport('iframe', function (options) {
if (options.async) {
var form,
iframe,
addParamChar;
return {
send: function (_, completeCallback) {
form = $('<form style="display:none;"></form>');
form.attr('accept-charset', options.formAcceptCharset);
addParamChar = /\?/.test(options.url) ? '&' : '?';
// XDomainRequest only supports GET and POST:
if (options.type === 'DELETE') {
options.url = options.url + addParamChar + '_method=DELETE';
options.type = 'POST';
} else if (options.type === 'PUT') {
options.url = options.url + addParamChar + '_method=PUT';
options.type = 'POST';
} else if (options.type === 'PATCH') {
options.url = options.url + addParamChar + '_method=PATCH';
options.type = 'POST';
}
// javascript:false as initial iframe src
// prevents warning popups on HTTPS in IE6.
// IE versions below IE8 cannot set the name property of
// elements that have already been added to the DOM,
// so we set the name along with the iframe HTML markup:
iframe = $(
'<iframe src="javascript:false;" name="iframe-transport-' +
(counter += 1) + '"></iframe>'
).bind('load', function () {
var fileInputClones,
paramNames = $.isArray(options.paramName) ?
options.paramName : [options.paramName];
iframe
.unbind('load')
.bind('load', function () {
var response;
// Wrap in a try/catch block to catch exceptions thrown
// when trying to access cross-domain iframe contents:
try {
response = iframe.contents();
// Google Chrome and Firefox do not throw an
// exception when calling iframe.contents() on
// cross-domain requests, so we unify the response:
if (!response.length || !response[0].firstChild) {
throw new Error();
}
} catch (e) {
response = undefined;
}
// The complete callback returns the
// iframe content document as response object:
completeCallback(
200,
'success',
{'iframe': response}
);
// Fix for IE endless progress bar activity bug
// (happens on form submits to iframe targets):
$('<iframe src="javascript:false;"></iframe>')
.appendTo(form);
form.remove();
});
form
.prop('target', iframe.prop('name'))
.prop('action', options.url)
.prop('method', options.type);
if (options.formData) {
$.each(options.formData, function (index, field) {
$('<input type="hidden"/>')
.prop('name', field.name)
.val(field.value)
.appendTo(form);
});
}
if (options.fileInput && options.fileInput.length &&
options.type === 'POST') {
fileInputClones = options.fileInput.clone();
// Insert a clone for each file input field:
options.fileInput.after(function (index) {
return fileInputClones[index];
});
if (options.paramName) {
options.fileInput.each(function (index) {
$(this).prop(
'name',
paramNames[index] || options.paramName
);
});
}
// Appending the file input fields to the hidden form
// removes them from their original location:
form
.append(options.fileInput)
.prop('enctype', 'multipart/form-data')
// enctype must be set as encoding for IE:
.prop('encoding', 'multipart/form-data');
}
form.submit();
// Insert the file input fields at their original location
// by replacing the clones with the originals:
if (fileInputClones && fileInputClones.length) {
options.fileInput.each(function (index, input) {
var clone = $(fileInputClones[index]);
$(input).prop('name', clone.prop('name'));
clone.replaceWith(input);
});
}
});
form.append(iframe).appendTo(document.body);
},
abort: function () {
if (iframe) {
// javascript:false as iframe src aborts the request
// and prevents warning popups on HTTPS in IE6.
// concat is used to avoid the "Script URL" JSLint error:
iframe
.unbind('load')
.prop('src', 'javascript'.concat(':false;'));
}
if (form) {
form.remove();
}
}
};
}
});
// The iframe transport returns the iframe content document as response.
// The following adds converters from iframe to text, json, html, and script:
$.ajaxSetup({
converters: {
'iframe text': function (iframe) {
return iframe && $(iframe[0].body).text();
},
'iframe json': function (iframe) {
return iframe && $.parseJSON($(iframe[0].body).text());
},
'iframe html': function (iframe) {
return iframe && $(iframe[0].body).html();
},
'iframe script': function (iframe) {
return iframe && $.globalEval($(iframe[0].body).text());
}
}
});
}));

View File

@@ -1,5 +0,0 @@
jQuery(function() {
Anthias.app = new Anthias.App({
el: $('body')
});
});

View File

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,120 +0,0 @@
import '../sass/anthias.scss'
$().ready ->
$("#request-error .close").click (e) ->
$("#request-error .alert").hide()
$("#btn-backup").click (e) ->
btnText = $("#btn-backup").text()
$("#btn-backup").text "Preparing archive..."
$("#btn-upload").prop "disabled", yes
$("#btn-backup").prop "disabled", yes
$.ajax({
method: "POST"
url: "/api/v2/backup"
timeout: 1800 * 1000
})
.done (data, e) ->
if (data)
window.location = "/static_with_mime/" + data + "?mime=application/x-tgz"
.fail (data, e) ->
$("#request-error .alert").addClass "alert-danger"
$("#request-error .alert").removeClass "alert-success"
$("#request-error .alert").show()
if (data.responseText != "") and (j = $.parseJSON data.responseText) and (err = j.error)
($ "#request-error .msg").text "Server Error: " + err
else
($ "#request-error .msg").text "The operation failed. Please reload the page and try again."
.always (data, e) ->
$("#btn-backup").text btnText
$("#btn-upload").prop "disabled", no
$("#btn-backup").prop "disabled", no
$("#btn-upload").click (e) ->
e.preventDefault()
$("[name='backup_upload']").click()
$("[name='backup_upload']").fileupload
url: "/api/v2/recover"
progressall: (e, data) -> if data.loaded and data.total
valuenow = data.loaded/data.total*100
$(".progress .bar").css "width", valuenow + "%"
$(".progress .bar").text "Uploading: " + Math.floor(valuenow) + "%"
add: (e, data) ->
$("#btn-upload").hide()
$("#btn-backup").hide()
$(".progress").show()
data.submit()
done: (e, data) ->
if (data.jqXHR.responseText != "") and (message = $.parseJSON data.jqXHR.responseText)
$("#request-error .alert").show()
$("#request-error .alert").addClass "alert-success"
$("#request-error .alert").removeClass "alert-danger"
($ "#request-error .msg").text message
fail: (e, data) ->
$("#request-error .alert").show()
$("#request-error .alert").addClass "alert-danger"
$("#request-error .alert").removeClass "alert-success"
if (data.jqXHR.responseText != "") and (j = $.parseJSON data.jqXHR.responseText) and (err = j.error)
($ "#request-error .msg").text "Server Error: " + err
else
($ "#request-error .msg").text "The operation failed. Please reload the page and try again."
always: (e, data) ->
$(".progress").hide()
$("#btn-upload").show()
$("#btn-backup").show()
$("#btn-reboot-system").click (e) ->
if confirm "Are you sure you want to reboot your device?"
$.post "/api/v2/reboot"
.done (e) ->
($ "#request-error .alert").show()
($ "#request-error .alert").addClass "alert-success"
($ "#request-error .alert").removeClass "alert-danger"
($ "#request-error .msg").text "Reboot has started successfully."
.fail (data, e) ->
($ "#request-error .alert").show()
($ "#request-error .alert").addClass "alert-danger"
($ "#request-error .alert").removeClass "alert-success"
if (data.responseText != "") and (j = $.parseJSON data.responseText) and (err = j.error)
($ "#request-error .msg").text "Server Error: " + err
else
($ "#request-error .msg").text "The operation failed. Please reload the page and try again."
$("#btn-shutdown-system").click (e) ->
if confirm "Are you sure you want to shutdown your device?"
$.post "/api/v2/shutdown"
.done (e) ->
($ "#request-error .alert").show()
($ "#request-error .alert").addClass "alert-success"
($ "#request-error .alert").removeClass "alert-danger"
($ "#request-error .msg").text """
Device shutdown has started successfully.
Soon you will be able to unplug the power from your Raspberry Pi.
"""
.fail (data, e) ->
($ "#request-error .alert").show()
($ "#request-error .alert").addClass "alert-danger"
($ "#request-error .alert").removeClass "alert-success"
if (data.responseText != "") and (j = $.parseJSON data.responseText) and (err = j.error)
($ "#request-error .msg").text "Server Error: " + err
else
($ "#request-error .msg").text "The operation failed. Please reload the page and try again."
toggle_chunk = () ->
$("[id^=auth_chunk]").hide()
$.each $('#auth_backend option'), (e, t) ->
$('#auth_backend-'+t.value).toggle $('#auth_backend').val() == t.value
$('#auth_backend').change (e) ->
toggle_chunk()
toggle_chunk()

View File

File diff suppressed because one or more lines are too long

View File

@@ -504,11 +504,11 @@ input[name="file_upload"] {
}
.asset_row_btns {
padding: 6px 8px 10px 8px !important;
padding: 12px 8px 10px 8px !important;
white-space: nowrap;
button {
padding: 3px 10px;
padding: 7px 10px;
}
}
@@ -555,3 +555,23 @@ label.toggle {
-webkit-appearance: none;
}
}
.placeholder {
display: inline-block;
width: 100%;
height: 1em;
background-color: #dedede;
border-radius: 4px;
animation: placeholderGlow 0.85s ease-in-out infinite alternate;
}
@keyframes placeholderGlow {
from {
opacity: 0.5;
filter: brightness(1);
}
to {
opacity: 1;
filter: brightness(1.1);
}
}

View File

@@ -1,681 +0,0 @@
jasmine.HtmlReporterHelpers = {};
jasmine.HtmlReporterHelpers.createDom = function(type, attrs, childrenVarArgs) {
var el = document.createElement(type);
for (var i = 2; i < arguments.length; i++) {
var child = arguments[i];
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else {
if (child) {
el.appendChild(child);
}
}
}
for (var attr in attrs) {
if (attr == "className") {
el[attr] = attrs[attr];
} else {
el.setAttribute(attr, attrs[attr]);
}
}
return el;
};
jasmine.HtmlReporterHelpers.getSpecStatus = function(child) {
var results = child.results();
var status = results.passed() ? 'passed' : 'failed';
if (results.skipped) {
status = 'skipped';
}
return status;
};
jasmine.HtmlReporterHelpers.appendToSummary = function(child, childElement) {
var parentDiv = this.dom.summary;
var parentSuite = (typeof child.parentSuite == 'undefined') ? 'suite' : 'parentSuite';
var parent = child[parentSuite];
if (parent) {
if (typeof this.views.suites[parent.id] == 'undefined') {
this.views.suites[parent.id] = new jasmine.HtmlReporter.SuiteView(parent, this.dom, this.views);
}
parentDiv = this.views.suites[parent.id].element;
}
parentDiv.appendChild(childElement);
};
jasmine.HtmlReporterHelpers.addHelpers = function(ctor) {
for(var fn in jasmine.HtmlReporterHelpers) {
ctor.prototype[fn] = jasmine.HtmlReporterHelpers[fn];
}
};
jasmine.HtmlReporter = function(_doc) {
var self = this;
var doc = _doc || window.document;
var reporterView;
var dom = {};
// Jasmine Reporter Public Interface
self.logRunningSpecs = false;
self.reportRunnerStarting = function(runner) {
var specs = runner.specs() || [];
if (specs.length == 0) {
return;
}
createReporterDom(runner.env.versionString());
doc.body.appendChild(dom.reporter);
setExceptionHandling();
reporterView = new jasmine.HtmlReporter.ReporterView(dom);
reporterView.addSpecs(specs, self.specFilter);
};
self.reportRunnerResults = function(runner) {
reporterView && reporterView.complete();
};
self.reportSuiteResults = function(suite) {
reporterView.suiteComplete(suite);
};
self.reportSpecStarting = function(spec) {
if (self.logRunningSpecs) {
self.log('>> Jasmine Running ' + spec.suite.description + ' ' + spec.description + '...');
}
};
self.reportSpecResults = function(spec) {
reporterView.specComplete(spec);
};
self.log = function() {
var console = jasmine.getGlobal().console;
if (console && console.log) {
if (console.log.apply) {
console.log.apply(console, arguments);
} else {
console.log(arguments); // ie fix: console.log.apply doesn't exist on ie
}
}
};
self.specFilter = function(spec) {
if (!focusedSpecName()) {
return true;
}
return spec.getFullName().indexOf(focusedSpecName()) === 0;
};
return self;
function focusedSpecName() {
var specName;
(function memoizeFocusedSpec() {
if (specName) {
return;
}
var paramMap = [];
var params = jasmine.HtmlReporter.parameters(doc);
for (var i = 0; i < params.length; i++) {
var p = params[i].split('=');
paramMap[decodeURIComponent(p[0])] = decodeURIComponent(p[1]);
}
specName = paramMap.spec;
})();
return specName;
}
function createReporterDom(version) {
dom.reporter = self.createDom('div', { id: 'HTMLReporter', className: 'jasmine_reporter' },
dom.banner = self.createDom('div', { className: 'banner' },
self.createDom('span', { className: 'title' }, "Jasmine "),
self.createDom('span', { className: 'version' }, version)),
dom.symbolSummary = self.createDom('ul', {className: 'symbolSummary'}),
dom.alert = self.createDom('div', {className: 'alert'},
self.createDom('span', { className: 'exceptions' },
self.createDom('label', { className: 'label', 'for': 'no_try_catch' }, 'No try/catch'),
self.createDom('input', { id: 'no_try_catch', type: 'checkbox' }))),
dom.results = self.createDom('div', {className: 'results'},
dom.summary = self.createDom('div', { className: 'summary' }),
dom.details = self.createDom('div', { id: 'details' }))
);
}
function noTryCatch() {
return window.location.search.match(/catch=false/);
}
function searchWithCatch() {
var params = jasmine.HtmlReporter.parameters(window.document);
var removed = false;
var i = 0;
while (!removed && i < params.length) {
if (params[i].match(/catch=/)) {
params.splice(i, 1);
removed = true;
}
i++;
}
if (jasmine.CATCH_EXCEPTIONS) {
params.push("catch=false");
}
return params.join("&");
}
function setExceptionHandling() {
var chxCatch = document.getElementById('no_try_catch');
if (noTryCatch()) {
chxCatch.setAttribute('checked', true);
jasmine.CATCH_EXCEPTIONS = false;
}
chxCatch.onclick = function() {
window.location.search = searchWithCatch();
};
}
};
jasmine.HtmlReporter.parameters = function(doc) {
var paramStr = doc.location.search.substring(1);
var params = [];
if (paramStr.length > 0) {
params = paramStr.split('&');
}
return params;
}
jasmine.HtmlReporter.sectionLink = function(sectionName) {
var link = '?';
var params = [];
if (sectionName) {
params.push('spec=' + encodeURIComponent(sectionName));
}
if (!jasmine.CATCH_EXCEPTIONS) {
params.push("catch=false");
}
if (params.length > 0) {
link += params.join("&");
}
return link;
};
jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter);
jasmine.HtmlReporter.ReporterView = function(dom) {
this.startedAt = new Date();
this.runningSpecCount = 0;
this.completeSpecCount = 0;
this.passedCount = 0;
this.failedCount = 0;
this.skippedCount = 0;
this.createResultsMenu = function() {
this.resultsMenu = this.createDom('span', {className: 'resultsMenu bar'},
this.summaryMenuItem = this.createDom('a', {className: 'summaryMenuItem', href: "#"}, '0 specs'),
' | ',
this.detailsMenuItem = this.createDom('a', {className: 'detailsMenuItem', href: "#"}, '0 failing'));
this.summaryMenuItem.onclick = function() {
dom.reporter.className = dom.reporter.className.replace(/ showDetails/g, '');
};
this.detailsMenuItem.onclick = function() {
showDetails();
};
};
this.addSpecs = function(specs, specFilter) {
this.totalSpecCount = specs.length;
this.views = {
specs: {},
suites: {}
};
for (var i = 0; i < specs.length; i++) {
var spec = specs[i];
this.views.specs[spec.id] = new jasmine.HtmlReporter.SpecView(spec, dom, this.views);
if (specFilter(spec)) {
this.runningSpecCount++;
}
}
};
this.specComplete = function(spec) {
this.completeSpecCount++;
if (isUndefined(this.views.specs[spec.id])) {
this.views.specs[spec.id] = new jasmine.HtmlReporter.SpecView(spec, dom);
}
var specView = this.views.specs[spec.id];
switch (specView.status()) {
case 'passed':
this.passedCount++;
break;
case 'failed':
this.failedCount++;
break;
case 'skipped':
this.skippedCount++;
break;
}
specView.refresh();
this.refresh();
};
this.suiteComplete = function(suite) {
var suiteView = this.views.suites[suite.id];
if (isUndefined(suiteView)) {
return;
}
suiteView.refresh();
};
this.refresh = function() {
if (isUndefined(this.resultsMenu)) {
this.createResultsMenu();
}
// currently running UI
if (isUndefined(this.runningAlert)) {
this.runningAlert = this.createDom('a', { href: jasmine.HtmlReporter.sectionLink(), className: "runningAlert bar" });
dom.alert.appendChild(this.runningAlert);
}
this.runningAlert.innerHTML = "Running " + this.completeSpecCount + " of " + specPluralizedFor(this.totalSpecCount);
// skipped specs UI
if (isUndefined(this.skippedAlert)) {
this.skippedAlert = this.createDom('a', { href: jasmine.HtmlReporter.sectionLink(), className: "skippedAlert bar" });
}
this.skippedAlert.innerHTML = "Skipping " + this.skippedCount + " of " + specPluralizedFor(this.totalSpecCount) + " - run all";
if (this.skippedCount === 1 && isDefined(dom.alert)) {
dom.alert.appendChild(this.skippedAlert);
}
// passing specs UI
if (isUndefined(this.passedAlert)) {
this.passedAlert = this.createDom('span', { href: jasmine.HtmlReporter.sectionLink(), className: "passingAlert bar" });
}
this.passedAlert.innerHTML = "Passing " + specPluralizedFor(this.passedCount);
// failing specs UI
if (isUndefined(this.failedAlert)) {
this.failedAlert = this.createDom('span', {href: "?", className: "failingAlert bar"});
}
this.failedAlert.innerHTML = "Failing " + specPluralizedFor(this.failedCount);
if (this.failedCount === 1 && isDefined(dom.alert)) {
dom.alert.appendChild(this.failedAlert);
dom.alert.appendChild(this.resultsMenu);
}
// summary info
this.summaryMenuItem.innerHTML = "" + specPluralizedFor(this.runningSpecCount);
this.detailsMenuItem.innerHTML = "" + this.failedCount + " failing";
};
this.complete = function() {
dom.alert.removeChild(this.runningAlert);
this.skippedAlert.innerHTML = "Ran " + this.runningSpecCount + " of " + specPluralizedFor(this.totalSpecCount) + " - run all";
if (this.failedCount === 0) {
dom.alert.appendChild(this.createDom('span', {className: 'passingAlert bar'}, "Passing " + specPluralizedFor(this.passedCount)));
} else {
showDetails();
}
dom.banner.appendChild(this.createDom('span', {className: 'duration'}, "finished in " + ((new Date().getTime() - this.startedAt.getTime()) / 1000) + "s"));
};
return this;
function showDetails() {
if (dom.reporter.className.search(/showDetails/) === -1) {
dom.reporter.className += " showDetails";
}
}
function isUndefined(obj) {
return typeof obj === 'undefined';
}
function isDefined(obj) {
return !isUndefined(obj);
}
function specPluralizedFor(count) {
var str = count + " spec";
if (count > 1) {
str += "s"
}
return str;
}
};
jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter.ReporterView);
jasmine.HtmlReporter.SpecView = function(spec, dom, views) {
this.spec = spec;
this.dom = dom;
this.views = views;
this.symbol = this.createDom('li', { className: 'pending' });
this.dom.symbolSummary.appendChild(this.symbol);
this.summary = this.createDom('div', { className: 'specSummary' },
this.createDom('a', {
className: 'description',
href: jasmine.HtmlReporter.sectionLink(this.spec.getFullName()),
title: this.spec.getFullName()
}, this.spec.description)
);
this.detail = this.createDom('div', { className: 'specDetail' },
this.createDom('a', {
className: 'description',
href: '?spec=' + encodeURIComponent(this.spec.getFullName()),
title: this.spec.getFullName()
}, this.spec.getFullName())
);
};
jasmine.HtmlReporter.SpecView.prototype.status = function() {
return this.getSpecStatus(this.spec);
};
jasmine.HtmlReporter.SpecView.prototype.refresh = function() {
this.symbol.className = this.status();
switch (this.status()) {
case 'skipped':
break;
case 'passed':
this.appendSummaryToSuiteDiv();
break;
case 'failed':
this.appendSummaryToSuiteDiv();
this.appendFailureDetail();
break;
}
};
jasmine.HtmlReporter.SpecView.prototype.appendSummaryToSuiteDiv = function() {
this.summary.className += ' ' + this.status();
this.appendToSummary(this.spec, this.summary);
};
jasmine.HtmlReporter.SpecView.prototype.appendFailureDetail = function() {
this.detail.className += ' ' + this.status();
var resultItems = this.spec.results().getItems();
var messagesDiv = this.createDom('div', { className: 'messages' });
for (var i = 0; i < resultItems.length; i++) {
var result = resultItems[i];
if (result.type == 'log') {
messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage log'}, result.toString()));
} else if (result.type == 'expect' && result.passed && !result.passed()) {
messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage fail'}, result.message));
if (result.trace.stack) {
messagesDiv.appendChild(this.createDom('div', {className: 'stackTrace'}, result.trace.stack));
}
}
}
if (messagesDiv.childNodes.length > 0) {
this.detail.appendChild(messagesDiv);
this.dom.details.appendChild(this.detail);
}
};
jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter.SpecView);jasmine.HtmlReporter.SuiteView = function(suite, dom, views) {
this.suite = suite;
this.dom = dom;
this.views = views;
this.element = this.createDom('div', { className: 'suite' },
this.createDom('a', { className: 'description', href: jasmine.HtmlReporter.sectionLink(this.suite.getFullName()) }, this.suite.description)
);
this.appendToSummary(this.suite, this.element);
};
jasmine.HtmlReporter.SuiteView.prototype.status = function() {
return this.getSpecStatus(this.suite);
};
jasmine.HtmlReporter.SuiteView.prototype.refresh = function() {
this.element.className += " " + this.status();
};
jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter.SuiteView);
/* @deprecated Use jasmine.HtmlReporter instead
*/
jasmine.TrivialReporter = function(doc) {
this.document = doc || document;
this.suiteDivs = {};
this.logRunningSpecs = false;
};
jasmine.TrivialReporter.prototype.createDom = function(type, attrs, childrenVarArgs) {
var el = document.createElement(type);
for (var i = 2; i < arguments.length; i++) {
var child = arguments[i];
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else {
if (child) { el.appendChild(child); }
}
}
for (var attr in attrs) {
if (attr == "className") {
el[attr] = attrs[attr];
} else {
el.setAttribute(attr, attrs[attr]);
}
}
return el;
};
jasmine.TrivialReporter.prototype.reportRunnerStarting = function(runner) {
var showPassed, showSkipped;
this.outerDiv = this.createDom('div', { id: 'TrivialReporter', className: 'jasmine_reporter' },
this.createDom('div', { className: 'banner' },
this.createDom('div', { className: 'logo' },
this.createDom('span', { className: 'title' }, "Jasmine"),
this.createDom('span', { className: 'version' }, runner.env.versionString())),
this.createDom('div', { className: 'options' },
"Show ",
showPassed = this.createDom('input', { id: "__jasmine_TrivialReporter_showPassed__", type: 'checkbox' }),
this.createDom('label', { "for": "__jasmine_TrivialReporter_showPassed__" }, " passed "),
showSkipped = this.createDom('input', { id: "__jasmine_TrivialReporter_showSkipped__", type: 'checkbox' }),
this.createDom('label', { "for": "__jasmine_TrivialReporter_showSkipped__" }, " skipped")
)
),
this.runnerDiv = this.createDom('div', { className: 'runner running' },
this.createDom('a', { className: 'run_spec', href: '?' }, "run all"),
this.runnerMessageSpan = this.createDom('span', {}, "Running..."),
this.finishedAtSpan = this.createDom('span', { className: 'finished-at' }, ""))
);
this.document.body.appendChild(this.outerDiv);
var suites = runner.suites();
for (var i = 0; i < suites.length; i++) {
var suite = suites[i];
var suiteDiv = this.createDom('div', { className: 'suite' },
this.createDom('a', { className: 'run_spec', href: '?spec=' + encodeURIComponent(suite.getFullName()) }, "run"),
this.createDom('a', { className: 'description', href: '?spec=' + encodeURIComponent(suite.getFullName()) }, suite.description));
this.suiteDivs[suite.id] = suiteDiv;
var parentDiv = this.outerDiv;
if (suite.parentSuite) {
parentDiv = this.suiteDivs[suite.parentSuite.id];
}
parentDiv.appendChild(suiteDiv);
}
this.startedAt = new Date();
var self = this;
showPassed.onclick = function(evt) {
if (showPassed.checked) {
self.outerDiv.className += ' show-passed';
} else {
self.outerDiv.className = self.outerDiv.className.replace(/ show-passed/, '');
}
};
showSkipped.onclick = function(evt) {
if (showSkipped.checked) {
self.outerDiv.className += ' show-skipped';
} else {
self.outerDiv.className = self.outerDiv.className.replace(/ show-skipped/, '');
}
};
};
jasmine.TrivialReporter.prototype.reportRunnerResults = function(runner) {
var results = runner.results();
var className = (results.failedCount > 0) ? "runner failed" : "runner passed";
this.runnerDiv.setAttribute("class", className);
//do it twice for IE
this.runnerDiv.setAttribute("className", className);
var specs = runner.specs();
var specCount = 0;
for (var i = 0; i < specs.length; i++) {
if (this.specFilter(specs[i])) {
specCount++;
}
}
var message = "" + specCount + " spec" + (specCount == 1 ? "" : "s" ) + ", " + results.failedCount + " failure" + ((results.failedCount == 1) ? "" : "s");
message += " in " + ((new Date().getTime() - this.startedAt.getTime()) / 1000) + "s";
this.runnerMessageSpan.replaceChild(this.createDom('a', { className: 'description', href: '?'}, message), this.runnerMessageSpan.firstChild);
this.finishedAtSpan.appendChild(document.createTextNode("Finished at " + new Date().toString()));
};
jasmine.TrivialReporter.prototype.reportSuiteResults = function(suite) {
var results = suite.results();
var status = results.passed() ? 'passed' : 'failed';
if (results.totalCount === 0) { // todo: change this to check results.skipped
status = 'skipped';
}
this.suiteDivs[suite.id].className += " " + status;
};
jasmine.TrivialReporter.prototype.reportSpecStarting = function(spec) {
if (this.logRunningSpecs) {
this.log('>> Jasmine Running ' + spec.suite.description + ' ' + spec.description + '...');
}
};
jasmine.TrivialReporter.prototype.reportSpecResults = function(spec) {
var results = spec.results();
var status = results.passed() ? 'passed' : 'failed';
if (results.skipped) {
status = 'skipped';
}
var specDiv = this.createDom('div', { className: 'spec ' + status },
this.createDom('a', { className: 'run_spec', href: '?spec=' + encodeURIComponent(spec.getFullName()) }, "run"),
this.createDom('a', {
className: 'description',
href: '?spec=' + encodeURIComponent(spec.getFullName()),
title: spec.getFullName()
}, spec.description));
var resultItems = results.getItems();
var messagesDiv = this.createDom('div', { className: 'messages' });
for (var i = 0; i < resultItems.length; i++) {
var result = resultItems[i];
if (result.type == 'log') {
messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage log'}, result.toString()));
} else if (result.type == 'expect' && result.passed && !result.passed()) {
messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage fail'}, result.message));
if (result.trace.stack) {
messagesDiv.appendChild(this.createDom('div', {className: 'stackTrace'}, result.trace.stack));
}
}
}
if (messagesDiv.childNodes.length > 0) {
specDiv.appendChild(messagesDiv);
}
this.suiteDivs[spec.suite.id].appendChild(specDiv);
};
jasmine.TrivialReporter.prototype.log = function() {
var console = jasmine.getGlobal().console;
if (console && console.log) {
if (console.log.apply) {
console.log.apply(console, arguments);
} else {
console.log(arguments); // ie fix: console.log.apply doesn't exist on ie
}
}
};
jasmine.TrivialReporter.prototype.getLocation = function() {
return this.document.location;
};
jasmine.TrivialReporter.prototype.specFilter = function(spec) {
var paramMap = {};
var params = this.getLocation().search.substring(1).split('&');
for (var i = 0; i < params.length; i++) {
var p = params[i].split('=');
paramMap[decodeURIComponent(p[0])] = decodeURIComponent(p[1]);
}
if (!paramMap.spec) {
return true;
}
return spec.getFullName().indexOf(paramMap.spec) === 0;
};

View File

@@ -1,82 +0,0 @@
body { background-color: #eeeeee; padding: 0; margin: 5px; overflow-y: scroll; }
#HTMLReporter { font-size: 11px; font-family: Monaco, "Lucida Console", monospace; line-height: 14px; color: #333333; }
#HTMLReporter a { text-decoration: none; }
#HTMLReporter a:hover { text-decoration: underline; }
#HTMLReporter p, #HTMLReporter h1, #HTMLReporter h2, #HTMLReporter h3, #HTMLReporter h4, #HTMLReporter h5, #HTMLReporter h6 { margin: 0; line-height: 14px; }
#HTMLReporter .banner, #HTMLReporter .symbolSummary, #HTMLReporter .summary, #HTMLReporter .resultMessage, #HTMLReporter .specDetail .description, #HTMLReporter .alert .bar, #HTMLReporter .stackTrace { padding-left: 9px; padding-right: 9px; }
#HTMLReporter #jasmine_content { position: fixed; right: 100%; }
#HTMLReporter .version { color: #aaaaaa; }
#HTMLReporter .banner { margin-top: 14px; }
#HTMLReporter .duration { color: #aaaaaa; float: right; }
#HTMLReporter .symbolSummary { overflow: hidden; *zoom: 1; margin: 14px 0; }
#HTMLReporter .symbolSummary li { display: block; float: left; height: 7px; width: 14px; margin-bottom: 7px; font-size: 16px; }
#HTMLReporter .symbolSummary li.passed { font-size: 14px; }
#HTMLReporter .symbolSummary li.passed:before { color: #5e7d00; content: "\02022"; }
#HTMLReporter .symbolSummary li.failed { line-height: 9px; }
#HTMLReporter .symbolSummary li.failed:before { color: #b03911; content: "x"; font-weight: bold; margin-left: -1px; }
#HTMLReporter .symbolSummary li.skipped { font-size: 14px; }
#HTMLReporter .symbolSummary li.skipped:before { color: #bababa; content: "\02022"; }
#HTMLReporter .symbolSummary li.pending { line-height: 11px; }
#HTMLReporter .symbolSummary li.pending:before { color: #aaaaaa; content: "-"; }
#HTMLReporter .exceptions { color: #fff; float: right; margin-top: 5px; margin-right: 5px; }
#HTMLReporter .bar { line-height: 28px; font-size: 14px; display: block; color: #eee; }
#HTMLReporter .runningAlert { background-color: #666666; }
#HTMLReporter .skippedAlert { background-color: #aaaaaa; }
#HTMLReporter .skippedAlert:first-child { background-color: #333333; }
#HTMLReporter .skippedAlert:hover { text-decoration: none; color: white; text-decoration: underline; }
#HTMLReporter .passingAlert { background-color: #a6b779; }
#HTMLReporter .passingAlert:first-child { background-color: #5e7d00; }
#HTMLReporter .failingAlert { background-color: #cf867e; }
#HTMLReporter .failingAlert:first-child { background-color: #b03911; }
#HTMLReporter .results { margin-top: 14px; }
#HTMLReporter #details { display: none; }
#HTMLReporter .resultsMenu, #HTMLReporter .resultsMenu a { background-color: #fff; color: #333333; }
#HTMLReporter.showDetails .summaryMenuItem { font-weight: normal; text-decoration: inherit; }
#HTMLReporter.showDetails .summaryMenuItem:hover { text-decoration: underline; }
#HTMLReporter.showDetails .detailsMenuItem { font-weight: bold; text-decoration: underline; }
#HTMLReporter.showDetails .summary { display: none; }
#HTMLReporter.showDetails #details { display: block; }
#HTMLReporter .summaryMenuItem { font-weight: bold; text-decoration: underline; }
#HTMLReporter .summary { margin-top: 14px; }
#HTMLReporter .summary .suite .suite, #HTMLReporter .summary .specSummary { margin-left: 14px; }
#HTMLReporter .summary .specSummary.passed a { color: #5e7d00; }
#HTMLReporter .summary .specSummary.failed a { color: #b03911; }
#HTMLReporter .description + .suite { margin-top: 0; }
#HTMLReporter .suite { margin-top: 14px; }
#HTMLReporter .suite a { color: #333333; }
#HTMLReporter #details .specDetail { margin-bottom: 28px; }
#HTMLReporter #details .specDetail .description { display: block; color: white; background-color: #b03911; }
#HTMLReporter .resultMessage { padding-top: 14px; color: #333333; }
#HTMLReporter .resultMessage span.result { display: block; }
#HTMLReporter .stackTrace { margin: 5px 0 0 0; max-height: 224px; overflow: auto; line-height: 18px; color: #666666; border: 1px solid #ddd; background: white; white-space: pre; }
#TrivialReporter { padding: 8px 13px; position: absolute; top: 0; bottom: 0; left: 0; right: 0; overflow-y: scroll; background-color: white; font-family: "Helvetica Neue Light", "Lucida Grande", "Calibri", "Arial", sans-serif; /*.resultMessage {*/ /*white-space: pre;*/ /*}*/ }
#TrivialReporter a:visited, #TrivialReporter a { color: #303; }
#TrivialReporter a:hover, #TrivialReporter a:active { color: blue; }
#TrivialReporter .run_spec { float: right; padding-right: 5px; font-size: .8em; text-decoration: none; }
#TrivialReporter .banner { color: #303; background-color: #fef; padding: 5px; }
#TrivialReporter .logo { float: left; font-size: 1.1em; padding-left: 5px; }
#TrivialReporter .logo .version { font-size: .6em; padding-left: 1em; }
#TrivialReporter .runner.running { background-color: yellow; }
#TrivialReporter .options { text-align: right; font-size: .8em; }
#TrivialReporter .suite { border: 1px outset gray; margin: 5px 0; padding-left: 1em; }
#TrivialReporter .suite .suite { margin: 5px; }
#TrivialReporter .suite.passed { background-color: #dfd; }
#TrivialReporter .suite.failed { background-color: #fdd; }
#TrivialReporter .spec { margin: 5px; padding-left: 1em; clear: both; }
#TrivialReporter .spec.failed, #TrivialReporter .spec.passed, #TrivialReporter .spec.skipped { padding-bottom: 5px; border: 1px solid gray; }
#TrivialReporter .spec.failed { background-color: #fbb; border-color: red; }
#TrivialReporter .spec.passed { background-color: #bfb; border-color: green; }
#TrivialReporter .spec.skipped { background-color: #bbb; }
#TrivialReporter .messages { border-left: 1px dashed gray; padding-left: 1em; padding-right: 1em; }
#TrivialReporter .passed { background-color: #cfc; display: none; }
#TrivialReporter .failed { background-color: #fbb; }
#TrivialReporter .skipped { color: #777; background-color: #eee; display: none; }
#TrivialReporter .resultMessage span.result { display: block; line-height: 2em; color: black; }
#TrivialReporter .resultMessage .mismatch { color: black; }
#TrivialReporter .stackTrace { white-space: pre; font-size: .8em; margin-left: 10px; max-height: 5em; overflow: auto; border: 1px inset red; padding: 1em; background: #eef; }
#TrivialReporter .finished-at { padding-left: 1em; font-size: .6em; }
#TrivialReporter.show-passed .passed, #TrivialReporter.show-skipped .skipped { display: block; }
#TrivialReporter #jasmine_content { position: fixed; right: 100%; }
#TrivialReporter .runner { border: 1px solid gray; display: block; margin: 5px 0; padding: 2px 0 2px 10px; }

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,146 +0,0 @@
(function() {
if (! jasmine) {
throw new Exception("jasmine library does not exist in global namespace!");
}
/**
* Basic reporter that outputs spec results to the browser console.
* Useful if you need to test an html page and don't want the TrivialReporter
* markup mucking things up.
*
* Usage:
*
* jasmine.getEnv().addReporter(new jasmine.ConsoleReporter());
* jasmine.getEnv().execute();
*/
var ConsoleReporter = function() {
this.started = false;
this.finished = false;
};
ConsoleReporter.prototype = {
reportRunnerResults: function(runner) {
if (this.hasGroupedConsole()) {
var suites = runner.suites();
startGroup(runner.results(), 'tests');
for (var i=0; i<suites.length; i++) {
if (!suites[i].parentSuite) {
suiteResults(suites[i]);
}
}
console.groupEnd();
}
else {
var dur = (new Date()).getTime() - this.start_time;
var failed = this.executed_specs - this.passed_specs;
var spec_str = this.executed_specs + (this.executed_specs === 1 ? " spec, " : " specs, ");
var fail_str = failed + (failed === 1 ? " failure in " : " failures in ");
this.log("Runner Finished.");
this.log(spec_str + fail_str + (dur/1000) + "s.");
}
this.finished = true;
if(window.inPhantom)
confirm(failed === 0 ? 0 : 1)
},
hasGroupedConsole: function() {
return false;
},
reportRunnerStarting: function(runner) {
this.started = true;
if (!this.hasGroupedConsole()) {
this.start_time = (new Date()).getTime();
this.executed_specs = 0;
this.passed_specs = 0;
this.log("Runner Started.");
}
},
reportSpecResults: function(spec) {
if (!this.hasGroupedConsole()) {
var resultText = "Failed.";
if (spec.results().skipped ) {
resultText = 'Skipped.';
} else if (spec.results().passed()) {
this.passed_specs++;
resultText = "Passed.";
}
this.log(resultText);
}
},
reportSpecStarting: function(spec) {
if (!this.hasGroupedConsole()) {
this.executed_specs++;
this.log(spec.suite.description + ' : ' + spec.description + ' ... ');
}
},
reportSuiteResults: function(suite) {
if (!this.hasGroupedConsole()) {
var results = suite.results();
this.log(suite.description + ": " + results.passedCount + " of " + results.totalCount + " passed.");
}
},
log: function(str) {
var console = jasmine.getGlobal().console;
if (console && console.log) {
console.log(str);
}
}
};
function suiteResults(suite) {
var results = suite.results();
startGroup(results, suite.description);
var specs = suite.specs();
for (var i in specs) {
if (specs.hasOwnProperty(i)) {
specResults(specs[i]);
}
}
var suites = suite.suites();
for (var j in suites) {
if (suites.hasOwnProperty(j)) {
suiteResults(suites[j]);
}
}
console.groupEnd();
}
function specResults(spec) {
var results = spec.results();
startGroup(results, spec.description);
var items = results.getItems();
for (var k in items) {
if (items.hasOwnProperty(k)) {
itemResults(items[k]);
}
}
console.groupEnd();
}
function itemResults(item) {
if (item.passed && !item.passed()) {
console.warn({actual:item.actual,expected: item.expected});
item.trace.message = item.matcherName;
console.error(item.trace);
} else {
console.info('Passed');
}
}
function startGroup(results, description) {
var consoleFunc = (results.passed() && console.groupCollapsed) ? 'groupCollapsed' : 'group';
console[consoleFunc](description + ' (' + results.passedCount + '/' + results.totalCount + ' passed, ' + results.failedCount + ' failures)');
}
// export public
jasmine.ConsoleReporter = ConsoleReporter;
})();

View File

@@ -1,24 +0,0 @@
var webPage = require('webpage');
var page = webPage.create();
var url = 'http://localhost:8081/static/spec/runner.html';
page.onConsoleMessage = function(msg, lineNum, sourceId) {
console.log(msg);
};
page.onInitialized = function() {
page.evaluate(function() {
window.inPhantom = true;
});
};
page.open(url, function (status) {
if(status !== 'success') {
console.log('cannot load page ' + url);
phantom.exit(1);
}
});
page.onConfirm = function(status) {
phantom.exit(status);
};

View File

@@ -1,62 +0,0 @@
<!DOCTYPE HTML>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<title>Jasmine Spec Runner</title>
<link rel="stylesheet" type="text/css" href="jasmine/jasmine.css">
<script src="jasmine/jasmine.js"></script>
<script src="jasmine/jasmine-html.js"></script>
<script src="jasmine/phantom-console-reporter.js"></script>
<!-- include source files here... -->
<script>
var defaultDuration = 10;
var use24HourClock = false;
var dateFormat = "mm/dd/yyyy";
</script>
<script src="/static/js/underscore-1.4.3.min.js"></script>
<script src="/static/js/jquery-1.9.1.min.js"></script>
<script src="/static/js/jquery.iframe-transport.js"></script>
<script src="/static/js/backbone-0.9.10.min.js"></script>
<script src="/static/js/jquery-ui-1.10.1.custom.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<script src="/static/js/bootstrap-datepicker.js"></script>
<script src="/static/js/bootstrap-timepicker.js"></script>
<script src="/static/js/moment.js"></script>
<script src="../dist/js/anthias.js"></script>
</head>
<body>
<script type="text/javascript">
(function() {
var jasmineEnv = jasmine.getEnv();
window.jenv = jasmineEnv;
jasmineEnv.updateInterval = 2000;
var trivialReporter = new jasmine.TrivialReporter();
jasmineEnv.addReporter(trivialReporter);
jasmineEnv.addReporter(new jasmine.ConsoleReporter());
jasmineEnv.specFilter = function(spec) {
return trivialReporter.specFilter(spec);
};
var currentWindowOnload = window.onload;
window.onload = function() {
if (currentWindowOnload) {
currentWindowOnload();
}
jasmineEnv.execute();
};
})();
</script>
<script src="screenly-spec.js"></script>
</body>
</html>

View File

@@ -1,12 +0,0 @@
#!/usr/bin/env bash
for i in {1..10}; do
timeout 7 phantomjs static/spec/phantom-runner.js
last=$?
if [[ "$last" -ne "124" ]]; then
exit $last
fi
echo "Timeout reach. Retrying."
done
echo "Max retries reached."
exit 1

View File

@@ -1,148 +0,0 @@
// Generated by CoffeeScript 1.12.5
(function() {
describe("Anthias Open Source", function() {
it("should have a Anthias object at its root", function() {
return expect(Anthias).toBeDefined();
});
describe("date_to", function() {
var a_date, test_date;
test_date = new Date(2014, 5, 6, 14, 20, 0, 0);
a_date = Anthias.date_to(test_date);
it("should format date and time as 'MM/DD/YYYY hh:mm:ss A'", function() {
return expect(a_date.string()).toBe('06/06/2014 02:20:00 PM');
});
it("should format date as 'MM/a_date/YYYY'", function() {
return expect(a_date.date()).toBe('06/06/2014');
});
return it("should format date as 'hh:mm:ss A'", function() {
return expect(a_date.time()).toBe('02:20 PM');
});
});
describe("Models", function() {
return describe("Asset model", function() {
var asset, end_date, start_date;
it("should exist", function() {
return expect(Anthias.Asset).toBeDefined();
});
start_date = new Date(2014, 4, 6, 14, 20, 0, 0);
end_date = new Date();
end_date.setMonth(end_date.getMonth() + 2);
asset = new Anthias.Asset({
asset_id: 2,
duration: "8",
end_date: end_date,
is_enabled: true,
mimetype: 'webpage',
name: 'Test',
start_date: start_date,
uri: 'https://anthias.screenly.io'
});
it("should be active if enabled and date is in range", function() {
return expect(asset.active()).toBe(true);
});
it("should be inactive if disabled and date is in range", function() {
asset.set('is_enabled', false);
return expect(asset.active()).toBe(false);
});
it("should be inactive if enabled and date is out of range", function() {
asset.set('is_enabled', true);
asset.set('start_date', asset.get('end_date'));
return expect(asset.active()).toBe(false);
});
it("should rollback to backup data if it exists", function() {
asset.set('start_date', start_date);
asset.set('end_date', end_date);
asset.backup();
asset.set({
is_enabled: false,
name: "Test 2",
start_date: new Date(2011, 4, 6, 14, 20, 0, 0),
end_date: new Date(2011, 4, 6, 14, 20, 0, 0),
uri: "http://www.wireload.net"
});
asset.rollback();
expect(asset.get('is_enabled')).toBe(true);
expect(asset.get('name')).toBe('Test');
expect(asset.get('start_date')).toBe(start_date);
return expect(asset.get('uri')).toBe("https://anthias.screenly.io");
});
return it("should erase backup date after rollback", function() {
asset.set({
is_enabled: false,
name: "Test 2",
start_date: new Date(2011, 4, 6, 14, 20, 0, 0),
end_date: new Date(2011, 4, 6, 14, 20, 0, 0),
uri: "http://www.wireload.net"
});
asset.rollback();
expect(asset.get('is_enabled')).toBe(false);
expect(asset.get('name')).toBe('Test 2');
expect(asset.get('start_date').toISOString()).toBe((new Date(2011, 4, 6, 14, 20, 0, 0)).toISOString());
return expect(asset.get('uri')).toBe("http://www.wireload.net");
});
});
});
describe("Collections", function() {
return describe("Assets", function() {
it("should exist", function() {
return expect(Anthias.Assets).toBeDefined();
});
it("should use the Asset model", function() {
var assets;
assets = new Anthias.Assets();
return expect(assets.model).toBe(Anthias.Asset);
});
return it("should keep play order of assets", function() {
var asset1, asset2, asset3, assets;
assets = new Anthias.Assets();
asset1 = new Anthias.Asset({
asset_id: 1,
is_enabled: true,
name: 'AAA',
uri: 'https://anthias.screenly.io',
play_order: 2
});
asset2 = new Anthias.Asset({
asset_id: 2,
is_enabled: true,
name: 'BBB',
uri: 'https://anthias.screenly.io',
play_order: 1
});
asset3 = new Anthias.Asset({
asset_id: 3,
is_enabled: true,
name: 'CCC',
uri: 'https://anthias.screenly.io',
play_order: 0
});
assets.add([asset1, asset2, asset3]);
expect(assets.at(0)).toBe(asset3);
expect(assets.at(1)).toBe(asset2);
expect(assets.at(2)).toBe(asset1);
asset1.set('play_order', 0);
asset3.set('play_order', 2);
assets.sort();
expect(assets.at(0)).toBe(asset1);
expect(assets.at(1)).toBe(asset2);
return expect(assets.at(2)).toBe(asset3);
});
});
});
return describe("Views", function() {
it("should have AddAssetView", function() {
return expect(Anthias.View.AddAssetView).toBeDefined();
});
it("should have EditAssetView", function() {
return expect(Anthias.View.EditAssetView).toBeDefined();
});
it("should have AssetRowView", function() {
return expect(Anthias.View.AssetRowView).toBeDefined();
});
return it("should have AssetsView", function() {
return expect(Anthias.View.AssetsView).toBeDefined();
});
});
});
}).call(this);

View File

@@ -0,0 +1,126 @@
import { useSelector, useDispatch } from 'react-redux'
import {
selectActiveAssets,
updateAssetOrder,
fetchAssets,
} from '@/store/assets'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { SortableAssetRow } from '@/components/sortable-asset-row'
import { useState, useEffect } from 'react'
export const ActiveAssetsTable = ({ onEditAsset }) => {
const dispatch = useDispatch()
const activeAssets = useSelector(selectActiveAssets)
const [items, setItems] = useState(activeAssets)
useEffect(() => {
setItems(activeAssets)
}, [activeAssets])
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const handleDragEnd = async (event) => {
const { active, over } = event
if (!over || active.id === over.id) {
return
}
const oldIndex = items.findIndex(
(asset) => asset.asset_id.toString() === active.id,
)
const newIndex = items.findIndex(
(asset) => asset.asset_id.toString() === over.id,
)
const newItems = arrayMove(items, oldIndex, newIndex)
setItems(newItems)
const activeIds = newItems.map((asset) => asset.asset_id)
try {
await dispatch(updateAssetOrder(activeIds.join(','))).unwrap()
dispatch(fetchAssets())
} catch (error) {
setItems(activeAssets)
}
}
return (
<>
<table className="table">
<thead className="table-borderless">
<tr>
<th className="font-weight-normal asset_row_name">Name</th>
<th className="font-weight-normal" style={{ width: '21%' }}>
Start
</th>
<th className="font-weight-normal" style={{ width: '21%' }}>
End
</th>
<th className="font-weight-normal" style={{ width: '13%' }}>
Duration
</th>
<th className="font-weight-normal" style={{ width: '7%' }}>
Activity
</th>
<th className="font-weight-normal" style={{ width: '13%' }}></th>
</tr>
</thead>
</table>
<div className="mb-1"></div>
<table className="table">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<tbody id="active-assets" className="table-borderless">
<SortableContext
items={items.map((a) => a.asset_id.toString())}
strategy={verticalListSortingStrategy}
>
{items.map((asset) => (
<SortableAssetRow
key={asset.asset_id}
id={asset.asset_id.toString()}
name={asset.name}
startDate={asset.start_date}
endDate={asset.end_date}
duration={asset.duration}
isEnabled={asset.is_enabled}
assetId={asset.asset_id}
isProcessing={asset.is_processing}
uri={asset.uri}
mimetype={asset.mimetype}
nocache={asset.nocache}
skipAssetCheck={asset.skip_asset_check}
onEditAsset={onEditAsset}
showDragHandle={true}
/>
))}
</SortableContext>
</tbody>
</DndContext>
</table>
</>
)
}

View File

@@ -0,0 +1,5 @@
import { AssetModal } from './asset-modal'
export const AddAssetModal = (props) => {
return <AssetModal {...props} />
}

View File

@@ -0,0 +1,14 @@
export const Alert = ({ message }) => {
return (
<div id="request-error" className="navbar navbar fixed-top">
<div className="container">
<div className="alert">
<button className="close" type="button">
&times;
</button>
<span className="msg">{message}</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { Routes, Route } from 'react-router'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { fetchDeviceSettings } from '@/store/assets/asset-modal-slice'
import { Integrations } from '@/components/integrations'
import { Navbar } from '@/components/navbar'
import { ScheduleOverview } from '@/components/home'
import { Settings } from '@/components/settings'
import { SystemInfo } from '@/components/system-info'
import { Footer } from '@/components/footer'
export const App = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(fetchDeviceSettings())
}, [dispatch])
return (
<>
<Navbar />
<Routes>
<Route path="/" element={<ScheduleOverview />} />
<Route path="/integrations" element={<Integrations />} />
<Route path="/settings" element={<Settings />} />
<Route path="/system-info" element={<SystemInfo />} />
</Routes>
<div className="container mt-4">
<div className="row">
<div className="col-6 small text-white">
<span>
Want to get more out of your digital signage?
<a
className="brand"
href="https://www.screenly.io/?utm_source=Anthias&utm_medium=root-page&utm_campaign=UI"
target="_blank"
rel="noopener noreferrer"
>
{' '}
<strong>Try Screenly</strong>.
</a>
</span>
</div>
</div>
</div>
<Footer />
</>
)
}

View File

@@ -0,0 +1,87 @@
import React from 'react'
import classNames from 'classnames'
/**
* File upload tab component for the asset modal
* @param {Object} props - Component props
* @param {Object} props.fileInputRef - Reference to the file input
* @param {Object} props.dropZoneRef - Reference to the drop zone
* @param {Function} props.handleFileSelect - File select handler
* @param {Function} props.handleFileDrop - File drop handler
* @param {Function} props.handleDragOver - Drag over handler
* @param {Function} props.handleDragEnter - Drag enter handler
* @param {Function} props.handleDragLeave - Drag leave handler
* @param {boolean} props.isSubmitting - Whether the form is submitting
* @param {number} props.uploadProgress - Upload progress
* @returns {JSX.Element} - File upload tab component
*/
export const FileUploadTab = ({
fileInputRef,
dropZoneRef,
handleFileSelect,
handleFileDrop,
handleDragOver,
handleDragEnter,
handleDragLeave,
isSubmitting,
uploadProgress,
}) => {
return (
<div
id="tab-file_upload"
className={classNames('tab-pane', {
active: true,
})}
>
<div className="control-group">
<div
className="filedrop"
ref={dropZoneRef}
onDrop={handleFileDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
>
<div className="upload-header">
<button
className="btn btn-primary"
onClick={() => fileInputRef.current.click()}
disabled={isSubmitting}
>
Add Files
</button>
<input
ref={fileInputRef}
name="file_upload"
type="file"
style={{ display: 'none' }}
onChange={handleFileSelect}
disabled={isSubmitting}
/>
<br />
or
</div>
<div>drop files here to upload</div>
</div>
</div>
<div
className="progress active"
style={{
marginTop: '1.25rem',
opacity: isSubmitting ? 1 : 0,
maxHeight: isSubmitting ? '20px' : '0',
overflow: 'hidden',
transition: 'opacity 0.3s ease-in-out, max-height 0.3s ease-in-out',
}}
>
<div
className="bar progress-bar-striped progress-bar progress-bar-animated"
style={{
width: `${uploadProgress}%`,
transition: 'width 0.3s ease-in-out',
}}
></div>
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
/**
* Utility functions for file upload handling
*/
/**
* Get the mimetype based on filename
* @param {string} filename - The name of the file
* @returns {string} - The mimetype of the file
*/
export const getMimetype = (filename) => {
const viduris = ['rtsp', 'rtmp']
const mimetypes = [
[['jpe', 'jpg', 'jpeg', 'png', 'pnm', 'gif', 'bmp'], 'image'],
[['avi', 'mkv', 'mov', 'mpg', 'mpeg', 'mp4', 'ts', 'flv'], 'video'],
]
const domains = [[['www.youtube.com', 'youtu.be'], 'youtube_asset']]
// Check if it's a streaming URL
const scheme = filename.split(':')[0].toLowerCase()
if (viduris.includes(scheme)) {
return 'streaming'
}
// Check if it's a domain-specific asset
try {
const domain = filename.split('//')[1].toLowerCase().split('/')[0]
for (const [domainList, type] of domains) {
if (domainList.includes(domain)) {
return type
}
}
} catch (e) {
// Invalid URL format
}
// Check file extension
try {
const ext = filename.split('.').pop().toLowerCase()
for (const [extList, type] of mimetypes) {
if (extList.includes(ext)) {
return type
}
}
} catch (e) {
// No extension found
}
// Default to webpage
return 'webpage'
}
/**
* Get the duration for a mimetype
* @param {string} mimetype - The mimetype of the file
* @param {number} defaultDuration - Default duration for webpage
* @param {number} defaultStreamingDuration - Default duration for streaming
* @returns {number} - The duration in seconds
*/
export const getDurationForMimetype = (
mimetype,
defaultDuration,
defaultStreamingDuration,
) => {
if (mimetype === 'video') {
return 0
} else if (mimetype === 'streaming') {
return defaultStreamingDuration
} else {
return defaultDuration
}
}
/**
* Get default dates for an asset
* @returns {Object} - Object containing start_date and end_date
*/
export const getDefaultDates = () => {
const now = new Date()
const endDate = new Date()
endDate.setDate(endDate.getDate() + 30) // 30 days from now
return {
start_date: now.toISOString(),
end_date: endDate.toISOString(),
}
}

View File

@@ -0,0 +1,193 @@
import React, { useEffect } from 'react'
import classNames from 'classnames'
import { useDispatch, useSelector } from 'react-redux'
import { setActiveTab, selectAssetModalState } from '@/store/assets'
import { useFileUpload } from './use-file-upload'
import { useAssetForm } from './use-asset-form'
import { useModalAnimation } from './use-modal-animation'
import { UriTab } from './uri-tab'
import { FileUploadTab } from './file-upload-tab'
/**
* Asset modal component
* @param {Object} props - Component props
* @param {boolean} props.isOpen - Whether the modal is open
* @param {Function} props.onClose - Callback function to call after closing
* @param {Function} props.onSave - Callback function to call after saving
* @param {Object} props.initialData - Initial data for the form
* @returns {JSX.Element|null} - Asset modal component
*/
export const AssetModal = ({ isOpen, onClose, onSave, initialData = {} }) => {
const dispatch = useDispatch()
const { activeTab, statusMessage, uploadProgress } = useSelector(
selectAssetModalState,
)
// Use custom hooks
const {
fileInputRef,
dropZoneRef,
handleFileSelect,
handleFileDrop,
handleDragOver,
handleDragEnter,
handleDragLeave,
} = useFileUpload()
const {
formData,
isValid,
errorMessage,
isSubmitting,
handleInputChange,
handleSubmit,
} = useAssetForm(onSave, onClose)
const { isVisible, modalRef, handleClose } = useModalAnimation(
isOpen,
onClose,
)
// Reset form data when modal is opened
useEffect(() => {
if (isOpen) {
// Form reset is handled by the useAssetForm hook
}
}, [isOpen, initialData])
if (!isOpen && !isVisible) return null
return (
<div
className={classNames('modal', {
show: isOpen,
fade: true,
'd-block': isOpen,
'modal-visible': isVisible,
})}
aria-hidden="true"
role="dialog"
tabIndex="-1"
style={{
display: isOpen ? 'block' : 'none',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
transition: 'opacity 0.3s ease-in-out',
opacity: isVisible ? 1 : 0,
}}
>
<div
className="modal-dialog"
role="document"
ref={modalRef}
style={{
transition: 'transform 0.3s ease-in-out',
transform: isVisible ? 'translate(0, 0)' : 'translate(0, -25%)',
}}
>
<div className="modal-content">
<div className="form-horizontal">
<div className="modal-header">
<h3 id="modalLabel">Add Asset</h3>
<button type="button" className="close" onClick={handleClose}>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="modal-body">
<div className="asset-location add">
<fieldset>
<div className="tabbable">
<ul className="nav nav-tabs" id="add-asset-nav-tabs">
<li
className={classNames(
'tabnav-uri nav-item text-center',
{ active: activeTab === 'uri' },
)}
>
<a
className="nav-link"
href="#"
onClick={() => dispatch(setActiveTab('uri'))}
>
URL
</a>
</li>
<li
className={classNames(
'tabnav-file_upload nav-item text-center',
{ active: activeTab === 'file_upload' },
)}
>
<a
className="nav-link upload-asset-tab"
href="#"
onClick={() => dispatch(setActiveTab('file_upload'))}
>
Upload
</a>
</li>
</ul>
<div className="tab-content px-4 pt-2 pb-4">
{activeTab === 'uri' ? (
<UriTab
formData={formData}
isValid={isValid}
errorMessage={errorMessage}
isSubmitting={isSubmitting}
handleInputChange={handleInputChange}
/>
) : (
<FileUploadTab
fileInputRef={fileInputRef}
dropZoneRef={dropZoneRef}
handleFileSelect={handleFileSelect}
handleFileDrop={handleFileDrop}
handleDragOver={handleDragOver}
handleDragEnter={handleDragEnter}
handleDragLeave={handleDragLeave}
isSubmitting={isSubmitting}
uploadProgress={uploadProgress}
/>
)}
</div>
</div>
</fieldset>
</div>
</div>
<div className="modal-footer">
<div
className="status"
style={{
display:
statusMessage && activeTab === 'file_upload'
? 'block'
: 'none',
}}
>
{statusMessage}
</div>
<button
className="btn btn-outline-primary btn-long cancel"
type="button"
onClick={handleClose}
disabled={isSubmitting}
>
Back to Assets
</button>
{activeTab === 'uri' && (
<button
id="save-asset"
className="btn btn-primary btn-long"
type="submit"
onClick={handleSubmit}
disabled={isSubmitting || !isValid}
>
Save
</button>
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import React from 'react'
import classNames from 'classnames'
/**
* URI tab component for the asset modal
* @param {Object} props - Component props
* @param {Object} props.formData - Form data
* @param {boolean} props.isValid - Whether the URI is valid
* @param {string} props.errorMessage - Error message
* @param {boolean} props.isSubmitting - Whether the form is submitting
* @param {Function} props.handleInputChange - Input change handler
* @returns {JSX.Element} - URI tab component
*/
export const UriTab = ({
formData,
isValid,
errorMessage,
isSubmitting,
handleInputChange,
}) => {
return (
<div
id="tab-uri"
className={classNames('tab-pane', {
active: true,
})}
>
<div className="form-group row uri">
<label className="col-4 col-form-label">Asset URL</label>
<div className="col-7 controls">
<input
className={classNames('form-control', {
'is-invalid': !isValid && formData.uri,
})}
name="uri"
value={formData.uri}
onChange={handleInputChange}
placeholder="Public URL to this asset's location"
type="text"
disabled={isSubmitting}
/>
{!isValid && formData.uri && (
<div className="invalid-feedback">{errorMessage}</div>
)}
</div>
</div>
<div className="form-group row skip_asset_check_checkbox">
<label className="col-4 small">Skip asset check</label>
<div className="col-7 is_enabled-skip_asset_check_checkbox checkbox">
<input
name="skipAssetCheck"
type="checkbox"
checked={formData.skipAssetCheck}
onChange={handleInputChange}
disabled={isSubmitting}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,128 @@
import { useDispatch, useSelector } from 'react-redux'
import {
updateFormData,
validateUrl,
setValid,
setErrorMessage,
resetForm,
saveAsset,
selectAssetModalState,
} from '@/store/assets'
import {
getMimetype,
getDurationForMimetype,
getDefaultDates,
} from './file-upload-utils'
/**
* Custom hook for asset form handling
* @param {Function} onSave - Callback function to call after successful save
* @param {Function} onClose - Callback function to call after closing
* @returns {Object} - Form handlers and state
*/
export const useAssetForm = (onSave, onClose) => {
const dispatch = useDispatch()
const {
activeTab,
formData,
isValid,
errorMessage,
statusMessage,
isSubmitting,
defaultDuration,
defaultStreamingDuration,
} = useSelector(selectAssetModalState)
/**
* Handle input change
* @param {Event} e - The input change event
*/
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target
dispatch(
updateFormData({
[name]: type === 'checkbox' ? checked : value,
}),
)
// Validate URL when it changes
if (name === 'uri' && activeTab === 'uri') {
dispatch(validateUrl(value))
}
}
/**
* Handle form submission
* @param {Event} e - The form submission event
*/
const handleSubmit = async (e) => {
e.preventDefault()
if (activeTab === 'uri') {
if (!formData.uri) {
dispatch(setErrorMessage('Please enter a URL'))
dispatch(setValid(false))
return
}
if (!isValid) {
return
}
// Determine mimetype based on URL
const mimetype = getMimetype(formData.uri)
// Get duration based on mimetype
const duration = getDurationForMimetype(
mimetype,
defaultDuration,
defaultStreamingDuration,
)
// Get default dates
const dates = getDefaultDates()
// Create asset data
const assetData = {
...formData,
mimetype,
name: formData.uri, // Use URI as name by default
is_active: 1,
is_enabled: 0,
is_processing: 0,
nocache: 0,
play_order: 0,
skip_asset_check: formData.skipAssetCheck ? 1 : 0,
duration,
...dates,
}
try {
// Save the asset
const savedAsset = await dispatch(saveAsset({ assetData })).unwrap()
// Call the onSave callback with the asset data
onSave(savedAsset)
// Reset form
dispatch(resetForm())
// Close the modal
onClose()
} catch (error) {
dispatch(setErrorMessage('Failed to save asset. Please try again.'))
}
}
}
return {
activeTab,
formData,
isValid,
errorMessage,
statusMessage,
isSubmitting,
handleInputChange,
handleSubmit,
}
}

View File

@@ -0,0 +1,140 @@
import { useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
uploadFile,
saveAsset,
setErrorMessage,
setStatusMessage,
setUploadProgress,
resetForm,
selectAssetModalState,
} from '@/store/assets'
/**
* Custom hook for file upload functionality
* @returns {Object} - File upload handlers and refs
*/
export const useFileUpload = () => {
const dispatch = useDispatch()
const { formData } = useSelector(selectAssetModalState)
const fileInputRef = useRef(null)
const dropZoneRef = useRef(null)
/**
* Handle file selection from input
* @param {Event} e - The file input change event
*/
const handleFileSelect = (e) => {
const file = e.target.files[0]
if (file) {
handleFileUpload(file)
}
}
/**
* Handle file drop
* @param {Event} e - The drop event
*/
const handleFileDrop = (e) => {
e.preventDefault()
e.stopPropagation()
const file = e.dataTransfer.files[0]
if (file) {
handleFileUpload(file)
}
}
/**
* Handle drag over event
* @param {Event} e - The drag over event
*/
const handleDragOver = (e) => {
e.preventDefault()
e.stopPropagation()
}
/**
* Handle drag enter event
* @param {Event} e - The drag enter event
*/
const handleDragEnter = (e) => {
e.preventDefault()
e.stopPropagation()
}
/**
* Handle drag leave event
* @param {Event} e - The drag leave event
*/
const handleDragLeave = (e) => {
e.preventDefault()
e.stopPropagation()
}
/**
* Main file upload function
* @param {File} file - The file to upload
*/
const handleFileUpload = async (file) => {
try {
// Upload the file
const result = await dispatch(
uploadFile({ file, skipAssetCheck: formData.skipAssetCheck }),
).unwrap()
// Create asset data
const assetData = {
uri: result.fileData.uri,
ext: result.fileData.ext,
name: file.name,
mimetype: result.mimetype,
is_active: 1,
is_enabled: 0,
is_processing: 0,
nocache: 0,
play_order: 0,
duration: result.duration,
skip_asset_check: formData.skipAssetCheck ? 1 : 0,
...result.dates,
}
// Save the asset
await dispatch(saveAsset({ assetData })).unwrap()
// Reset form and show success message
dispatch(resetForm())
dispatch(setStatusMessage('Upload completed.'))
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
// Hide status message after 5 seconds
setTimeout(() => {
dispatch(setStatusMessage(''))
}, 5000)
} catch (error) {
dispatch(setErrorMessage(`Upload failed: ${error.message}`))
dispatch(setUploadProgress(0))
// Reset the progress bar width directly
const progressBar = document.querySelector('.progress .bar')
if (progressBar) {
progressBar.style.width = '0%'
}
}
}
return {
fileInputRef,
dropZoneRef,
handleFileSelect,
handleFileDrop,
handleDragOver,
handleDragEnter,
handleDragLeave,
handleFileUpload,
}
}

View File

@@ -0,0 +1,61 @@
import { useState, useEffect, useRef } from 'react'
/**
* Custom hook for modal animation
* @param {boolean} isOpen - Whether the modal is open
* @param {Function} onClose - Callback function to call after closing
* @returns {Object} - Modal animation state and handlers
*/
export const useModalAnimation = (isOpen, onClose) => {
const [isVisible, setIsVisible] = useState(false)
const [isClosing] = useState(false)
const modalRef = useRef(null)
// Handle animation when modal opens/closes
useEffect(() => {
if (isOpen) {
// Small delay to ensure the DOM is updated before adding the visible class
setTimeout(() => {
setIsVisible(true)
}, 10)
} else {
setIsVisible(false)
}
}, [isOpen])
// Handle clicks outside the modal
useEffect(() => {
const handleClickOutside = (event) => {
if (modalRef.current && !modalRef.current.contains(event.target)) {
handleClose()
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
/**
* Handle modal close
*/
const handleClose = () => {
// Start the closing animation
setIsVisible(false)
// Wait for animation to complete before calling onClose
setTimeout(() => {
onClose()
}, 300)
}
return {
isVisible,
isClosing,
modalRef,
handleClose,
}
}

View File

@@ -0,0 +1,534 @@
import {
FaDownload,
FaPencilAlt,
FaTrashAlt,
FaGlobe,
FaImage,
FaVideo,
} from 'react-icons/fa'
import { GiHamburgerMenu } from 'react-icons/gi'
import Swal from 'sweetalert2'
import classNames from 'classnames'
import { useEffect, forwardRef, useState } from 'react'
import { useDispatch } from 'react-redux'
import { css } from '@/utils'
import { toggleAssetEnabled, fetchAssets } from '@/store/assets'
const tooltipStyles = css`
.tooltip {
opacity: 1;
transition: opacity 0s ease-in-out;
}
.tooltip.fade {
opacity: 0;
}
.tooltip.show {
opacity: 1;
}
.tooltip-inner {
background-color: #2c3e50;
color: #fff;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
max-width: 300px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.tooltip.bs-tooltip-top .arrow::before {
border-top-color: #2c3e50;
}
/* SweetAlert2 Custom Styles */
html.swal2-shown body.swal2-shown {
overflow-y: auto;
padding-right: 0;
}
.swal2-popup.swal2-modal {
font-size: 0.875rem;
border-radius: 0.5rem;
padding: 1.5rem;
}
.swal2-popup .swal2-title {
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
}
.swal2-popup .swal2-html-container {
font-size: 0.875rem;
color: #6c757d;
}
.swal2-popup .swal2-confirm {
background-color: #dc3545;
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 1.5rem;
border-radius: 0.375rem;
}
.swal2-popup .swal2-confirm:hover {
background-color: #c82333;
}
.swal2-popup .swal2-cancel {
background-color: #6c757d;
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 1.5rem;
border-radius: 0.375rem;
}
.swal2-popup .swal2-cancel:hover {
background-color: #5a6268;
}
.swal2-popup .swal2-actions {
gap: 0.1rem;
}
.swal2-popup .swal2-icon {
display: flex;
align-items: center;
justify-content: center;
margin: 1.5rem auto;
}
.swal2-popup .swal2-icon-content {
display: flex;
align-items: center;
justify-content: center;
}
`
const formatDuration = (seconds) => {
let durationString = ''
const secInt = parseInt(seconds)
const hours = Math.floor(secInt / 3600)
if (hours > 0) {
durationString += `${hours} hours `
}
const minutes = Math.floor(secInt / 60) % 60
if (minutes > 0) {
durationString += `${minutes} min `
}
const secs = secInt % 60
if (secs > 0) {
durationString += `${secs} sec`
}
return durationString
}
const formatDate = (date, dateFormat, use24HourClock = false) => {
if (!date) return ''
// Create a Date object from the input date string
const dateObj = new Date(date)
// Check if the date is valid
if (isNaN(dateObj.getTime())) return date
// Extract the separator from the format
const separator = dateFormat.includes('/')
? '/'
: dateFormat.includes('-')
? '-'
: dateFormat.includes('.')
? '.'
: '/'
// Extract the format parts from the dateFormat string
const formatParts = dateFormat.split(/[\/\-\.]/)
// Set up the date formatting options
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: !use24HourClock, // Use 12-hour format if use24HourClock is false
}
// Create a formatter with the specified options
const formatter = new Intl.DateTimeFormat('en-US', options)
// Format the date and get the parts
const formattedParts = formatter.formatToParts(dateObj)
// Extract the formatted values
const month = formattedParts.find((p) => p.type === 'month').value
const day = formattedParts.find((p) => p.type === 'day').value
const year = formattedParts.find((p) => p.type === 'year').value
const hour = formattedParts.find((p) => p.type === 'hour').value
const minute = formattedParts.find((p) => p.type === 'minute').value
const second = formattedParts.find((p) => p.type === 'second').value
// Get the period (AM/PM) if using 12-hour format
let period = ''
if (!use24HourClock) {
const periodPart = formattedParts.find((p) => p.type === 'dayPeriod')
if (periodPart) {
period = ` ${periodPart.value}`
}
}
// Build the date part according to the format
let datePart = ''
// Determine the order based on the format
if (formatParts[0].includes('mm')) {
datePart = `${month}${separator}${day}${separator}${year}`
} else if (formatParts[0].includes('dd')) {
datePart = `${day}${separator}${month}${separator}${year}`
} else if (formatParts[0].includes('yyyy')) {
datePart = `${year}${separator}${month}${separator}${day}`
} else {
// Default to mm/dd/yyyy if format is not recognized
datePart = `${month}${separator}${day}${separator}${year}`
}
// Add the time part with AM/PM if using 12-hour format
const timePart = `${hour}:${minute}:${second}${period}`
return `${datePart} ${timePart}`
}
const getMimetypeIcon = (mimetype) => {
if (mimetype.includes('image')) {
return (
<FaImage
className="mr-2 align-middle"
style={{ verticalAlign: 'middle' }}
/>
)
} else if (mimetype.includes('video')) {
return (
<FaVideo
className="mr-2 align-middle"
style={{ verticalAlign: 'middle' }}
/>
)
} else if (mimetype.includes('webpage')) {
return (
<FaGlobe
className="mr-2 align-middle"
style={{ verticalAlign: 'middle' }}
/>
)
} else {
return (
<FaGlobe
className="mr-2 align-middle"
style={{ verticalAlign: 'middle' }}
/>
)
}
}
export const AssetRow = forwardRef((props, ref) => {
const defaultDateFormat = 'mm/dd/yyyy'
const dispatch = useDispatch()
const [isDisabled, setIsDisabled] = useState(false)
const [dateFormat, setDateFormat] = useState(defaultDateFormat)
const [use24HourClock, setUse24HourClock] = useState(false)
useEffect(() => {
const fetchDateFormat = async () => {
try {
const response = await fetch('/api/v2/device_settings')
const data = await response.json()
setDateFormat(data.date_format)
setUse24HourClock(data.use_24_hour_clock)
} catch (error) {
setDateFormat(defaultDateFormat)
setUse24HourClock(false)
}
}
fetchDateFormat()
}, [])
const handleToggle = async () => {
const newValue = !props.isEnabled ? 1 : 0
setIsDisabled(true)
try {
await dispatch(
toggleAssetEnabled({ assetId: props.assetId, newValue }),
).unwrap()
dispatch(fetchAssets())
} catch (error) {
} finally {
setIsDisabled(false)
}
}
const handleDownload = async (event) => {
event.preventDefault()
const assetId = props.assetId
try {
const response = await fetch(`/api/v2/assets/${assetId}/content`)
const result = await response.json()
if (result.type === 'url') {
window.open(result.url)
} else if (result.type === 'file') {
// Convert base64 to byte array
const content = atob(result.content)
const bytes = new Uint8Array(content.length)
for (let i = 0; i < content.length; i++) {
bytes[i] = content.charCodeAt(i)
}
const mimetype = result.mimetype
const filename = result.filename
// Create blob and download
const blob = new Blob([bytes], { type: mimetype })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
document.body.appendChild(a)
a.download = filename
a.href = url
a.click()
// Clean up
URL.revokeObjectURL(url)
a.remove()
}
} catch (error) {}
}
const handleDelete = () => {
Swal.fire({
title: 'Are you sure?',
text: 'This action cannot be undone.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
reverseButtons: true,
confirmButtonColor: '#dc3545',
cancelButtonColor: '#6c757d',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
cancelButton: 'swal2-cancel',
actions: 'swal2-actions',
},
}).then(async (result) => {
if (result.isConfirmed) {
try {
// Disable the row while deleting
setIsDisabled(true)
// Make API call to delete the asset
const response = await fetch(`/api/v2/assets/${props.assetId}`, {
method: 'DELETE',
})
if (response.ok) {
// Refresh the assets list after successful deletion
dispatch(fetchAssets())
// Show success message
Swal.fire({
title: 'Deleted!',
text: 'Asset has been deleted.',
icon: 'success',
timer: 2000,
showConfirmButton: false,
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
},
})
} else {
// Show error message
Swal.fire({
title: 'Error!',
text: 'Failed to delete asset.',
icon: 'error',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
})
}
} catch (error) {
Swal.fire({
title: 'Error!',
text: 'Failed to delete asset.',
icon: 'error',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
})
} finally {
setIsDisabled(false)
}
}
})
}
const handleEdit = () => {
if (props.onEditAsset) {
props.onEditAsset({
id: props.assetId,
name: props.name,
start_date: props.startDate,
end_date: props.endDate,
duration: props.duration,
uri: props.uri,
mimetype: props.mimetype,
is_enabled: props.isEnabled,
nocache: props.nocache,
skip_asset_check: props.skipAssetCheck,
})
}
}
return (
<>
<style>{tooltipStyles}</style>
<tr
ref={ref}
style={props.style}
className={classNames({ warning: isDisabled })}
>
<td className={classNames('asset_row_name')}>
{props.showDragHandle && (
<span
{...props.dragHandleProps}
style={{
cursor: props.isDragging ? 'grabbing' : 'grab',
display: 'inline-block',
verticalAlign: 'middle',
}}
>
<GiHamburgerMenu
className="mr-3 align-middle"
style={{ verticalAlign: 'middle' }}
/>
</span>
)}
{getMimetypeIcon(props.mimetype)}
<span
data-toggle="tooltip"
data-placement="top"
title={props.name}
style={{ verticalAlign: 'middle' }}
>
{props.name}
</span>
</td>
<td
style={{ width: '21%', maxWidth: '200px' }}
className="text-truncate"
data-toggle="tooltip"
data-placement="top"
title={formatDate(props.startDate, dateFormat, use24HourClock)}
>
{formatDate(props.startDate, dateFormat, use24HourClock)}
</td>
<td
style={{ width: '21%', maxWidth: '200px' }}
className="text-truncate"
data-toggle="tooltip"
data-placement="top"
title={formatDate(props.endDate, dateFormat, use24HourClock)}
>
{formatDate(props.endDate, dateFormat, use24HourClock)}
</td>
<td
style={{ width: '13%', maxWidth: '150px' }}
className={classNames('text-truncate')}
data-toggle="tooltip"
data-placement="top"
title={formatDuration(props.duration)}
>
{formatDuration(props.duration)}
</td>
<td className={classNames('asset-toggle')} style={{ width: '7%' }}>
<label
className={classNames(
'is_enabled-toggle',
'toggle',
'switch-light',
'switch-material',
'small',
'm-0',
)}
>
<input
type="checkbox"
checked={props.isEnabled}
onChange={handleToggle}
disabled={isDisabled || props.isProcessing === 1}
/>
<span>
<span className="off"></span>
<span className="on"></span>
<a></a>
</span>
</label>
</td>
<td className={classNames('asset_row_btns')}>
<button
className={classNames(
'download-asset-button',
'btn',
'btn-outline-dark',
'mr-1',
'd-inline-flex',
'p-2',
)}
type="button"
disabled={isDisabled}
onClick={handleDownload}
>
<FaDownload />
</button>
<button
className={classNames(
'edit-asset-button',
'btn',
'btn-outline-dark',
'mr-1',
'd-inline-flex',
'p-2',
)}
type="button"
disabled={isDisabled}
onClick={handleEdit}
>
<FaPencilAlt />
</button>
<button
className={classNames(
'delete-asset-button',
'btn',
'btn-outline-dark',
'd-inline-flex',
'p-2',
)}
type="button"
onClick={handleDelete}
disabled={isDisabled}
>
<FaTrashAlt />
</button>
</td>
</tr>
</>
)
})

View File

@@ -0,0 +1,389 @@
import React, { useEffect, useState } from 'react'
import classNames from 'classnames'
import { useDispatch } from 'react-redux'
import { fetchAssets } from '@/store/assets'
/**
* Edit Asset Modal component
* @param {Object} props - Component props
* @param {boolean} props.isOpen - Whether the modal is open
* @param {Function} props.onClose - Callback function to call after closing
* @param {Object} props.asset - The asset to edit
* @returns {JSX.Element|null} - Edit Asset Modal component
*/
export const EditAssetModal = ({ isOpen, onClose, asset }) => {
const dispatch = useDispatch()
const [isVisible, setIsVisible] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: '',
start_date: '',
end_date: '',
duration: '',
mimetype: 'webpage',
nocache: false,
skip_asset_check: false,
})
const [loopTimes, setLoopTimes] = useState('manual')
const [startDateDate, setStartDateDate] = useState('')
const [startDateTime, setStartDateTime] = useState('')
const [endDateDate, setEndDateDate] = useState('')
const [endDateTime, setEndDateTime] = useState('')
// Initialize form data when asset changes
useEffect(() => {
if (asset) {
// Parse dates from UTC
const startDate = new Date(asset.start_date)
const endDate = new Date(asset.end_date)
// Format date and time parts in local timezone
const formatDatePart = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const formatTimePart = (date) => {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
setFormData({
name: asset.name || '',
start_date: asset.start_date || '',
end_date: asset.end_date || '',
duration: asset.duration || '',
mimetype: asset.mimetype || 'webpage',
nocache: asset.nocache || false,
skip_asset_check: asset.skip_asset_check || false,
})
setStartDateDate(formatDatePart(startDate))
setStartDateTime(formatTimePart(startDate))
setEndDateDate(formatDatePart(endDate))
setEndDateTime(formatTimePart(endDate))
}
}, [asset])
// Handle modal visibility
useEffect(() => {
if (isOpen) {
setIsVisible(true)
} else {
const timer = setTimeout(() => {
setIsVisible(false)
}, 300) // Match the transition duration
return () => clearTimeout(timer)
}
}, [isOpen])
const handleClose = () => {
setIsVisible(false)
setTimeout(() => {
onClose()
}, 300) // Match the transition duration
}
const handleModalClick = (e) => {
// Only close if clicking the modal backdrop (outside the modal content)
if (e.target === e.currentTarget) {
handleClose()
}
}
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value,
})
}
const handleLoopTimesChange = (e) => {
setLoopTimes(e.target.value)
}
const handleDateChange = (e, type) => {
const { value } = e.target
if (type === 'startDate') {
setStartDateDate(value)
} else if (type === 'startTime') {
setStartDateTime(value)
} else if (type === 'endDate') {
setEndDateDate(value)
} else if (type === 'endTime') {
setEndDateTime(value)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setIsSubmitting(true)
try {
// Combine date and time parts
const startDate = new Date(`${startDateDate}T${startDateTime}`)
const endDate = new Date(`${endDateDate}T${endDateTime}`)
// Prepare data for API
const updatedAsset = {
...formData,
start_date: startDate.toISOString(),
end_date: endDate.toISOString(),
asset_id: asset.id,
is_enabled: asset.is_enabled,
}
// Make API call to update asset
const response = await fetch(`/api/v2/assets/${asset.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedAsset),
})
if (!response.ok) {
throw new Error('Failed to update asset')
}
// Refresh assets list
dispatch(fetchAssets())
// Close modal
onClose()
} catch (error) {
} finally {
setIsSubmitting(false)
}
}
if (!isOpen && !isVisible) return null
return (
<div
className={classNames('modal', {
show: isOpen,
fade: true,
'd-block': isOpen,
'modal-visible': isVisible,
})}
aria-hidden="true"
role="dialog"
tabIndex="-1"
onClick={handleModalClick}
style={{
display: isOpen ? 'block' : 'none',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
transition: 'opacity 0.3s ease-in-out',
opacity: isVisible ? 1 : 0,
}}
>
<div
className="modal-dialog"
role="document"
style={{
transition: 'transform 0.3s ease-in-out',
transform: isVisible ? 'translate(0, 0)' : 'translate(0, -25%)',
}}
>
<div className="modal-content">
<div className="form-horizontal">
<div className="modal-header">
<h3 id="modalLabel">Edit Asset</h3>
<button type="button" className="close" onClick={handleClose}>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="modal-body">
<div className="asset-location edit">
<form id="edit-form" onSubmit={handleSubmit}>
<div className="form-group row name">
<label className="col-4 col-form-label">Name</label>
<div className="col-7">
<input
className="form-control"
name="name"
placeholder="Nickname for this asset"
type="text"
value={formData.name}
onChange={handleInputChange}
/>
</div>
</div>
<div className="form-group row">
<label className="col-4 col-form-label">
Asset Location
</label>
<div className="col-8 controls">
<div
className="uri-text first text-break"
style={{ wordBreak: 'break-all' }}
>
{asset?.uri || ''}
</div>
</div>
</div>
<div className="form-group row mimetype">
<label className="col-4 col-form-label">Asset Type</label>
<div className="col-4 controls">
<select
className="mime-select form-control"
name="mimetype"
value={formData.mimetype}
onChange={handleInputChange}
disabled={true}
>
<option value="webpage">Webpage</option>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="streaming">Streaming</option>
<option value="youtube_asset">YouTubeAsset</option>
</select>
</div>
</div>
<hr />
<div className="row form-group loop_date">
<label className="col-4 col-form-label">Play for</label>
<div className="controls col-7">
<select
className="form-control"
id="loop_times"
value={loopTimes}
onChange={handleLoopTimesChange}
>
<option value="day">1 Day</option>
<option value="week">1 Week</option>
<option value="month">1 Month</option>
<option value="year">1 Year</option>
<option value="forever">Forever</option>
<option value="manual">Manual</option>
</select>
</div>
</div>
<div id="manul_date">
<div className="form-group row start_date">
<label className="col-4 col-form-label">Start Date</label>
<div className="controls col-7">
<input
className="form-control date"
name="start_date_date"
type="date"
value={startDateDate}
onChange={(e) => handleDateChange(e, 'startDate')}
style={{ marginRight: '5px' }}
/>
<input
className="form-control time"
name="start_date_time"
type="time"
value={startDateTime}
onChange={(e) => handleDateChange(e, 'startTime')}
/>
</div>
</div>
<div className="form-group row end_date">
<label className="col-4 col-form-label">End Date</label>
<div className="controls col-7">
<input
className="form-control date"
name="end_date_date"
type="date"
value={endDateDate}
onChange={(e) => handleDateChange(e, 'endDate')}
style={{ marginRight: '5px' }}
/>
<input
className="form-control time"
name="end_date_time"
type="time"
value={endDateTime}
onChange={(e) => handleDateChange(e, 'endTime')}
/>
</div>
</div>
</div>
<div className="form-group row duration">
<label className="col-4 col-form-label">Duration</label>
<div className="col-7 controls">
<input
className="form-control"
name="duration"
type="number"
value={formData.duration}
onChange={handleInputChange}
/>
seconds &nbsp;
</div>
</div>
<div className="advanced-accordion accordion">
<div className="accordion-group">
<div className="accordion-heading">
<i className="fas fa-play unrotated"></i>
<a className="advanced-toggle" href="#">
Advanced
</a>
</div>
<div className="collapse-advanced accordion-body collapse">
<div className="accordion-inner">
<div className="form-group row">
<label className="col-4 col-form-label">
Disable cache
</label>
<div className="col-8 nocache controls justify-content-center align-self-center">
<label className="nocache-toggle toggle switch-light switch-ios small m-0">
<input
type="checkbox"
name="nocache"
checked={formData.nocache}
onChange={handleInputChange}
/>
<span>
<span></span>
<span></span>
<a></a>
</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<div className="modal-footer">
<div
className="float-left progress active"
style={{ display: 'none' }}
>
<div className="bar progress-bar-striped progress-bar progress-bar-animated"></div>
</div>
<button
className="btn btn-outline-primary btn-long cancel"
type="button"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
id="save-asset"
className="btn btn-primary btn-long"
type="submit"
onClick={handleSubmit}
disabled={isSubmitting}
>
Save
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
export const EmptyAssetMessage = ({ onAddAssetClick }) => {
return (
<div className="table-assets-help-text">
Currently there are no assets.
<a className="add-asset-button" href="#" onClick={onAddAssetClick}>
Add asset
</a>{' '}
now.
</div>
)
}

View File

@@ -0,0 +1,80 @@
export const Footer = () => {
return (
<footer id="footer">
<div className="container">
<div className="row">
<div id="screenly-logo" className="col-12 row m-2 ml-0 mr-0">
<div className="links offset-3 col-6 text-center justify-content-center align-self-center">
<a
href="/api/docs/"
target="_blank"
className="mr-4 small"
rel="noopener noreferrer"
>
API
</a>
<a
href="https://anthias.screenly.io/#faq?utm_source=Anthias&utm_medium=footer&utm_campaign=UI"
target="_blank"
className="mr-4 small"
rel="noopener noreferrer"
>
FAQ
</a>
<a
href="https://screenly.io/?utm_source=Anthias&utm_medium=footer&utm_campaign=UI"
target="_blank"
className="mr-4 small"
rel="noopener noreferrer"
>
Screenly.io
</a>
<a
href="https://forums.screenly.io/"
target="_blank"
className="mr-4 small"
rel="noopener noreferrer"
>
Support
</a>
</div>
<div
id="github-stars"
className="col-3 text-right justify-content-center align-self-center"
>
<a
href="https://github.com/Screenly/Anthias"
target="_blank"
rel="noopener noreferrer"
>
<img
alt="GitHub Repo stars"
src={(() => {
const url = new URL(
'https://img.shields.io/github/stars/Screenly/Anthias',
)
const params = new URLSearchParams({
style: 'for-the-badge',
labelColor: '#EBF0F4',
color: '#FFE11A',
logo: 'github',
logoColor: 'black',
})
url.search = params.toString()
return url.toString()
})()}
/>
</a>
</div>
</div>
</div>
</div>
<div className="copy pb-4">
<div className="container">
<div className="text-center p-2">&copy; Screenly, Inc.</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,259 @@
import classNames from 'classnames'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import $ from 'jquery'
import 'bootstrap/js/dist/tooltip'
import {
fetchAssets,
selectActiveAssets,
selectInactiveAssets,
} from '@/store/assets'
import { EmptyAssetMessage } from '@/components/empty-asset-message'
import { InactiveAssetsTable } from '@/components/inactive-assets'
import { ActiveAssetsTable } from '@/components/active-assets'
import { AddAssetModal } from '@/components/add-asset-modal'
import { EditAssetModal } from '@/components/edit-asset-modal'
const tooltipStyles = `
.tooltip {
opacity: 1 !important;
transition: opacity 0s ease-in-out !important;
}
.tooltip.fade {
opacity: 0;
}
.tooltip.show {
opacity: 1;
}
.tooltip-inner {
background-color: #2c3e50;
color: #fff;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
max-width: 300px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.tooltip.bs-tooltip-top .arrow::before {
border-top-color: #2c3e50;
}
`
export const ScheduleOverview = () => {
const dispatch = useDispatch()
const activeAssets = useSelector(selectActiveAssets)
const inactiveAssets = useSelector(selectInactiveAssets)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [assetToEdit, setAssetToEdit] = useState(null)
const [playerName, setPlayerName] = useState('')
const fetchPlayerName = async () => {
try {
const response = await fetch('/api/v2/device_settings')
const data = await response.json()
setPlayerName(data.player_name || '')
} catch {}
}
useEffect(() => {
document.title = 'Schedule Overview'
dispatch(fetchAssets())
fetchPlayerName()
}, [dispatch])
// Initialize tooltips
useEffect(() => {
const initializeTooltips = () => {
$('[data-toggle="tooltip"]').tooltip({
placement: 'top',
trigger: 'hover',
html: true,
delay: { show: 0, hide: 0 },
animation: true,
})
}
// Initial tooltip initialization
initializeTooltips()
// Reinitialize tooltips when assets change
const observer = new MutationObserver(() => {
initializeTooltips()
})
// Observe changes in both active and inactive sections
const activeSection = document.getElementById('active-assets-section')
const inactiveSection = document.getElementById('inactive-assets-section')
if (activeSection) {
observer.observe(activeSection, { childList: true, subtree: true })
}
if (inactiveSection) {
observer.observe(inactiveSection, { childList: true, subtree: true })
}
return () => {
observer.disconnect()
$('[data-toggle="tooltip"]').tooltip('dispose')
}
}, [activeAssets, inactiveAssets])
const handleAddAsset = (event) => {
event.preventDefault()
setIsModalOpen(true)
setAssetToEdit(null)
}
const handlePreviousAsset = async (event) => {
event.preventDefault()
await fetch('/api/v2/assets/control/previous')
}
const handleNextAsset = async (event) => {
event.preventDefault()
await fetch('/api/v2/assets/control/next')
}
const handleCloseModal = () => {
setIsModalOpen(false)
}
const handleSaveAsset = () => {
setIsModalOpen(false)
}
const handleEditAsset = (asset) => {
setAssetToEdit(asset)
setIsEditModalOpen(true)
}
const handleCloseEditModal = () => {
setIsEditModalOpen(false)
setAssetToEdit(null)
}
return (
<>
<style>{tooltipStyles}</style>
<div className="container pt-3 pb-3">
<div className="row">
<div className="col-12">
<h4 className="d-flex">
<b
className={classNames(
'justify-content-center',
'align-self-center',
'text-white',
)}
>
Schedule Overview
</b>
<div className="ml-auto">
<a
id="previous-asset-button"
className={classNames(
'btn',
'btn-long',
'btn-outline-primary',
'mr-1',
)}
href="#"
onClick={handlePreviousAsset}
>
<i className="fas fa-chevron-left pr-2"></i>
Previous Asset
</a>
<a
id="next-asset-button"
className={classNames(
'btn',
'btn-long',
'btn-outline-primary',
'mr-1',
)}
href="#"
onClick={handleNextAsset}
>
Next Asset
<i className="fas fa-chevron-right pl-2"></i>
</a>
<a
id="add-asset-button"
className={classNames(
'add-asset-button',
'btn',
'btn-long',
'btn-primary',
'mr-1',
)}
href="#"
onClick={handleAddAsset}
>
Add Asset
</a>
</div>
</h4>
{playerName && (
<span className="badge badge-primary px-3 py-2 rounded-pill mb-3">
<h6 className="my-0 text-center font-weight-bold">
{playerName}
</h6>
</span>
)}
</div>
</div>
</div>
<span id="assets">
<div className="container">
<div className="row content active-content px-2 pt-4">
<div className="col-12 mb-5">
<section id="active-assets-section">
<h5>
<b>Active assets</b>
</h5>
<ActiveAssetsTable onEditAsset={handleEditAsset} />
{activeAssets.length === 0 && (
<EmptyAssetMessage onAddAssetClick={handleAddAsset} />
)}
</section>
</div>
</div>
</div>
<div className="container mt-4">
<div className="row content inactive-content px-2 pt-4">
<div className="col-12 mb-5">
<section id="inactive-assets-section">
<h5>
<b>Inactive assets</b>
</h5>
<InactiveAssetsTable onEditAsset={handleEditAsset} />
{inactiveAssets.length === 0 && (
<EmptyAssetMessage onAddAssetClick={handleAddAsset} />
)}
</section>
</div>
</div>
</div>
</span>
<AddAssetModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onSave={handleSaveAsset}
initialData={assetToEdit}
/>
<EditAssetModal
isOpen={isEditModalOpen}
onClose={handleCloseEditModal}
asset={assetToEdit}
/>
</>
)
}

View File

@@ -0,0 +1,72 @@
import { useSelector } from 'react-redux'
import { selectInactiveAssets } from '@/store/assets'
import { AssetRow } from '@/components/asset-row'
export const InactiveAssetsTable = ({ onEditAsset }) => {
const inactiveAssets = useSelector(selectInactiveAssets)
return (
<>
<table className="table">
<thead className="table-borderless">
<tr>
<th className="text-secondary font-weight-normal asset_row_name">
Name
</th>
<th
className="text-secondary font-weight-normal"
style={{ width: '21%' }}
>
Start
</th>
<th
className="text-secondary font-weight-normal"
style={{ width: '21%' }}
>
End
</th>
<th
className="text-secondary font-weight-normal"
style={{ width: '13%' }}
>
Duration
</th>
<th
className="text-secondary font-weight-normal"
style={{ width: '7%' }}
>
Activity
</th>
<th
className="text-secondary font-weight-normal"
style={{ width: '13%' }}
></th>
</tr>
</thead>
</table>
<div className="mb-1"></div>
<table className="table">
<tbody id="inactive-assets" className="table-borderless">
{inactiveAssets.map((asset) => (
<AssetRow
key={asset.asset_id}
name={asset.name}
startDate={asset.start_date}
endDate={asset.end_date}
duration={asset.duration}
isEnabled={asset.is_enabled}
assetId={asset.asset_id}
isProcessing={asset.is_processing}
uri={asset.uri}
mimetype={asset.mimetype}
nocache={asset.nocache}
skipAssetCheck={asset.skip_asset_check}
onEditAsset={onEditAsset}
showDragHandle={false}
/>
))}
</tbody>
</table>
</>
)
}

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react'
export const Integrations = () => {
const [data, setData] = useState({
is_balena: false,
balena_device_id: '',
balena_app_id: '',
balena_app_name: '',
balena_supervisor_version: '',
balena_host_os_version: '',
balena_device_name_at_init: '',
})
useEffect(() => {
document.title = 'Integrations'
fetch('/api/v2/integrations')
.then((response) => response.json())
.then((data) => {
setData(data)
})
}, [])
return (
<div className="container">
<div className="row py-2">
<div className="col-12">
<h4 className="page-header text-white">
<b>Integrations</b>
</h4>
</div>
</div>
<div className="row content" style={{ minHeight: '60vh' }}>
{data.is_balena && (
<div id="balena-section" className="col-12">
<h4 className="page-header">
<b>Balena</b>
</h4>
<table className="table">
<thead className="table-borderless">
<tr>
<th className="text-secondary font-weight-normal" scope="col">
Option
</th>
<th className="text-secondary font-weight-normal" scope="col">
Value
</th>
<th className="text-secondary font-weight-normal" scope="col">
Description
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Device Name</th>
<td>{data.balena_device_name_at_init}</td>
<td>The name of the device on first initialisation.</td>
</tr>
<tr>
<th scope="row">Device UUID</th>
<td>{data.balena_device_id}</td>
<td>
The unique identification number for the device. This is
used to identify it on balena.
</td>
</tr>
<tr>
<th scope="row">App ID</th>
<td>{data.balena_app_id}</td>
<td>
ID number of the balena application the device is
associated.
</td>
</tr>
<tr>
<th scope="row">App Name</th>
<td>{data.balena_app_name}</td>
<td>
The name of the balena application the device is associated
with.
</td>
</tr>
{data.balena_supervisor_version && (
<tr>
<th scope="row">Supervisor Version</th>
<td>{data.balena_supervisor_version}</td>
<td>
The current version of the supervisor agent running on the
device.
</td>
</tr>
)}
<tr>
<th scope="row">Host OS Version</th>
<td>{data.balena_host_os_version}</td>
<td>The version of the host OS.</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react'
import {
FaArrowCircleDown,
FaRegClock,
FaCog,
FaPlusSquare,
FaTasks,
} from 'react-icons/fa'
import { Link, NavLink } from 'react-router'
export const Navbar = () => {
const [upToDate, setUpToDate] = useState(null)
const [isBalena, setIsBalena] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
try {
const [integrationsResponse, infoResponse] = await Promise.all([
fetch('/api/v2/integrations'),
fetch('/api/v2/info'),
])
const [integrationsData, infoData] = await Promise.all([
integrationsResponse.json(),
infoResponse.json(),
])
setIsBalena(integrationsData.is_balena)
setUpToDate(infoData.up_to_date)
} catch (error) {
setIsBalena(false)
setUpToDate(false)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [])
return (
<>
<div className="navbar navbar-header navbar-expand-lg fixed-top bg-dark">
<div className="container">
<NavLink to="/" className="brand">
<img src="/static/img/logo-full.svg" />
</NavLink>
<ul className="nav float-right">
{!isLoading && !upToDate && !isBalena && (
<li className="update-available">
<Link to="/settings#upgrade-section">
<span className="pr-1">
<FaArrowCircleDown />
</span>
Update Available
</Link>
</li>
)}
<li>
<NavLink to="/">
<span className="pr-1">
<FaRegClock />
</span>
Schedule Overview
</NavLink>
</li>
{isBalena && (
<li>
<NavLink to="/integrations">
<span className="pr-1">
<FaPlusSquare />
</span>
Integrations
</NavLink>
</li>
)}
<li>
<NavLink to="/settings">
<span className="pr-1">
<FaCog />
</span>
Settings
</NavLink>
</li>
<li className="divider-vertical"></li>
<li>
<NavLink to="/system-info">
<span className="pr-1">
<FaTasks />
</span>
System Info
</NavLink>
</li>
</ul>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,774 @@
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { fetchDeviceSettings } from '@/store/assets/asset-modal-slice'
import Swal from 'sweetalert2'
export const Settings = () => {
const dispatch = useDispatch()
const [settings, setSettings] = useState({
playerName: '',
defaultDuration: 0,
defaultStreamingDuration: 0,
audioOutput: 'hdmi',
dateFormat: 'mm/dd/yyyy',
authBackend: '',
currentPassword: '',
user: '',
password: '',
confirmPassword: '',
showSplash: false,
defaultAssets: false,
shufflePlaylist: false,
use24HourClock: false,
debugLogging: false,
})
const [isLoading, setIsLoading] = useState(false)
const [prevAuthBackend, setPrevAuthBackend] = useState('')
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const handleBackup = async () => {
const backupButton = document.getElementById('btn-backup')
const originalText = backupButton.textContent
backupButton.textContent = 'Preparing archive...'
backupButton.disabled = true
document.getElementById('btn-upload').disabled = true
try {
const response = await fetch('/api/v2/backup', {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to create backup')
}
const data = await response.json()
if (data) {
window.location = `/static_with_mime/${data}?mime=application/x-tgz`
}
} catch (err) {
await Swal.fire({
title: 'Error!',
text:
err.message ||
'The operation failed. Please reload the page and try again.',
icon: 'error',
confirmButtonColor: '#dc3545',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
})
} finally {
backupButton.textContent = originalText
backupButton.disabled = false
document.getElementById('btn-upload').disabled = false
}
}
const handleUpload = (e) => {
e.preventDefault()
const fileInput = document.querySelector('[name="backup_upload"]')
fileInput.value = '' // Reset the file input
fileInput.click()
}
const handleFileUpload = async (e) => {
const file = e.target.files[0]
if (!file) return
setIsUploading(true)
setUploadProgress(0)
document.getElementById('btn-upload').style.display = 'none'
document.getElementById('btn-backup').style.display = 'none'
document.querySelector('.progress').style.display = 'block'
const formData = new FormData()
formData.append('backup_upload', file)
try {
const response = await fetch('/api/v2/recover', {
method: 'POST',
body: formData,
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
)
setUploadProgress(percentCompleted)
document.querySelector('.progress .bar').style.width =
`${percentCompleted}%`
document.querySelector('.progress .bar').textContent =
`Uploading: ${percentCompleted}%`
},
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to upload backup')
}
if (data) {
await Swal.fire({
title: 'Success!',
text:
typeof data === 'string' ? data : 'Backup uploaded successfully',
icon: 'success',
timer: 2000,
showConfirmButton: false,
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
},
})
// Fetch updated settings after successful recovery
try {
const settingsResponse = await fetch('/api/v2/device_settings')
const settingsData = await settingsResponse.json()
setSettings((prev) => ({
...prev,
playerName: settingsData.player_name || '',
defaultDuration: settingsData.default_duration || 0,
defaultStreamingDuration:
settingsData.default_streaming_duration || 0,
audioOutput: settingsData.audio_output || 'hdmi',
dateFormat: settingsData.date_format || 'mm/dd/yyyy',
authBackend: settingsData.auth_backend || '',
user: settingsData.username || '',
showSplash: settingsData.show_splash || false,
defaultAssets: settingsData.default_assets || false,
shufflePlaylist: settingsData.shuffle_playlist || false,
use24HourClock: settingsData.use_24_hour_clock || false,
debugLogging: settingsData.debug_logging || false,
}))
setPrevAuthBackend(settingsData.auth_backend || '')
} catch (settingsErr) {}
}
} catch (err) {
await Swal.fire({
title: 'Error!',
text:
err.message ||
'The operation failed. Please reload the page and try again.',
icon: 'error',
confirmButtonColor: '#dc3545',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
})
} finally {
setIsUploading(false)
document.querySelector('.progress').style.display = 'none'
document.getElementById('btn-upload').style.display = 'inline-block'
document.getElementById('btn-backup').style.display = 'inline-block'
// Reset the file input
e.target.value = ''
}
}
const handleShutdown = async () => {
const result = await Swal.fire({
title: 'Are you sure?',
text: 'Are you sure you want to shutdown your device?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Shutdown',
cancelButtonText: 'Cancel',
reverseButtons: true,
confirmButtonColor: '#dc3545',
cancelButtonColor: '#6c757d',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
cancelButton: 'swal2-cancel',
actions: 'swal2-actions',
},
})
if (result.isConfirmed) {
try {
const response = await fetch('/api/v2/shutdown', {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to shutdown device')
}
await Swal.fire({
title: 'Success!',
text: 'Device shutdown has started successfully.\nSoon you will be able to unplug the power from your Raspberry Pi.',
icon: 'success',
timer: 2000,
showConfirmButton: false,
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
},
})
} catch (err) {
await Swal.fire({
title: 'Error!',
text:
err.message ||
'The operation failed. Please reload the page and try again.',
icon: 'error',
confirmButtonColor: '#dc3545',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
})
}
}
}
const handleReboot = async () => {
const result = await Swal.fire({
title: 'Are you sure?',
text: 'Are you sure you want to reboot your device?',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Reboot',
cancelButtonText: 'Cancel',
reverseButtons: true,
confirmButtonColor: '#dc3545',
cancelButtonColor: '#6c757d',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
cancelButton: 'swal2-cancel',
actions: 'swal2-actions',
},
})
if (result.isConfirmed) {
try {
const response = await fetch('/api/v2/reboot', {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to reboot device')
}
await Swal.fire({
title: 'Success!',
text: 'Reboot has started successfully.',
icon: 'success',
timer: 2000,
showConfirmButton: false,
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
},
})
} catch (err) {
await Swal.fire({
title: 'Error!',
text:
err.message ||
'The operation failed. Please reload the page and try again.',
icon: 'error',
confirmButtonColor: '#dc3545',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
})
}
}
}
useEffect(() => {
document.title = 'Settings'
// Load initial settings
fetch('/api/v2/device_settings')
.then((res) => res.json())
.then((data) => {
setSettings((prev) => ({
...prev,
playerName: data.player_name || '',
defaultDuration: data.default_duration || 0,
defaultStreamingDuration: data.default_streaming_duration || 0,
audioOutput: data.audio_output || 'hdmi',
dateFormat: data.date_format || 'mm/dd/yyyy',
authBackend: data.auth_backend || '',
user: data.username || '',
showSplash: data.show_splash || false,
defaultAssets: data.default_assets || false,
shufflePlaylist: data.shuffle_playlist || false,
use24HourClock: data.use_24_hour_clock || false,
debugLogging: data.debug_logging || false,
}))
setPrevAuthBackend(data.auth_backend || '')
})
.catch(() => {
setError('Failed to load settings. Please try again.')
})
}, [])
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target
if (name === 'authBackend') {
setPrevAuthBackend(settings.authBackend)
}
setSettings((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
try {
const response = await fetch('/api/v2/device_settings', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
player_name: settings.playerName,
default_duration: settings.defaultDuration,
default_streaming_duration: settings.defaultStreamingDuration,
audio_output: settings.audioOutput,
date_format: settings.dateFormat,
auth_backend: settings.authBackend,
current_password: settings.currentPassword,
username: settings.user,
password: settings.password,
password_2: settings.confirmPassword,
show_splash: settings.showSplash,
default_assets: settings.defaultAssets,
shuffle_playlist: settings.shufflePlaylist,
use_24_hour_clock: settings.use24HourClock,
debug_logging: settings.debugLogging,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to save settings')
}
await Swal.fire({
title: 'Success!',
text: 'Settings were successfully saved.',
icon: 'success',
timer: 2000,
showConfirmButton: false,
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
},
})
// Clear password after successful save
setSettings((prev) => ({ ...prev, currentPassword: '' }))
// Fetch updated device settings
dispatch(fetchDeviceSettings())
// Reset the form
e.target.reset()
} catch (err) {
await Swal.fire({
title: 'Error!',
text: err.message || 'Failed to save settings',
icon: 'error',
confirmButtonColor: '#dc3545',
customClass: {
popup: 'swal2-popup',
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
})
} finally {
setIsLoading(false)
}
}
return (
<div className="container">
<div className="row py-2">
<div className="col-12">
<h4 className="page-header text-white">
<b>Settings</b>
</h4>
</div>
</div>
<div className="row content px-3">
<div className="col-12 my-3">
<form onSubmit={handleSubmit} className="row">
<div className="form-group col-6 d-flex flex-column justify-content-between">
<div className="form-group">
<label className="small text-secondary">
<small>Player name</small>
</label>
<input
className="form-control"
name="playerName"
type="text"
value={settings.playerName}
onChange={handleInputChange}
/>
</div>
<div className="row">
<div className="form-group col-6">
<label className="small text-secondary">
<small>Default duration (seconds)</small>
</label>
<input
className="form-control"
name="defaultDuration"
type="number"
value={settings.defaultDuration}
onChange={handleInputChange}
/>
</div>
<div className="form-group col-6">
<label className="small text-secondary">
<small>Default streaming duration (seconds)</small>
</label>
<input
className="form-control"
name="defaultStreamingDuration"
type="number"
value={settings.defaultStreamingDuration}
onChange={handleInputChange}
/>
</div>
</div>
<div className="form-group">
<label className="small text-secondary">
<small>Audio output</small>
</label>
<select
className="form-control"
name="audioOutput"
value={settings.audioOutput}
onChange={handleInputChange}
>
<option value="hdmi">HDMI</option>
<option value="local">3.5mm jack</option>
</select>
</div>
<div className="form-group">
<label className="small text-secondary">
<small>Date format</small>
</label>
<select
className="form-control"
name="dateFormat"
value={settings.dateFormat}
onChange={handleInputChange}
>
<option value="mm/dd/yyyy">month/day/year</option>
<option value="dd/mm/yyyy">day/month/year</option>
<option value="yyyy/mm/dd">year/month/day</option>
<option value="mm-dd-yyyy">month-day-year</option>
<option value="dd-mm-yyyy">day-month-year</option>
<option value="yyyy-mm-dd">year-month-day</option>
<option value="mm.dd.yyyy">month.day.year</option>
<option value="dd.mm.yyyy">day.month.year</option>
<option value="yyyy.mm.dd">year.month.day</option>
</select>
</div>
<div className="form-group mb-0">
<label className="small text-secondary">
<small>Authentication</small>
</label>
<select
className="form-control"
id="auth_backend"
name="authBackend"
value={settings.authBackend}
onChange={handleInputChange}
>
<option value="">Disabled</option>
<option value="auth_basic">Basic</option>
</select>
</div>
{(settings.authBackend === 'auth_basic' ||
(settings.authBackend === '' &&
prevAuthBackend === 'auth_basic')) && (
<>
{prevAuthBackend === 'auth_basic' && (
<div className="form-group" id="curpassword_group">
<label className="small text-secondary">
<small>Current Password</small>
</label>
<input
className="form-control"
name="currentPassword"
type="password"
value={settings.currentPassword}
onChange={handleInputChange}
/>
</div>
)}
{settings.authBackend === 'auth_basic' && (
<>
<div className="form-group" id="user_group">
<label className="small text-secondary">
<small>User</small>
</label>
<input
className="form-control"
name="user"
type="text"
value={settings.user}
onChange={handleInputChange}
/>
</div>
<div className="row">
<div className="form-group col-6" id="password_group">
<label className="small text-secondary">
<small>Password</small>
</label>
<input
className="form-control"
name="password"
type="password"
value={settings.password}
onChange={handleInputChange}
/>
</div>
<div className="form-group col-6" id="password2_group">
<label className="small text-secondary">
<small>Confirm Password</small>
</label>
<input
className="form-control"
name="confirmPassword"
type="password"
value={settings.confirmPassword}
onChange={handleInputChange}
/>
</div>
</div>
</>
)}
</>
)}
</div>
<div className="form-group col-6 d-flex flex-column justify-content-start">
<div className="form-inline mt-4">
<label>Show splash screen</label>
<div className="ml-auto">
<label className="is_enabled-toggle toggle switch-light switch-material small m-0">
<input
name="showSplash"
type="checkbox"
checked={settings.showSplash}
onChange={handleInputChange}
/>
<span>
<span></span>
<span></span>
<a></a>
</span>
</label>
</div>
</div>
<div className="form-inline mt-4">
<label>Default assets</label>
<div className="ml-auto">
<label className="is_enabled-toggle toggle switch-light switch-material small m-0">
<input
name="defaultAssets"
type="checkbox"
checked={settings.defaultAssets}
onChange={handleInputChange}
/>
<span>
<span></span>
<span></span>
<a></a>
</span>
</label>
</div>
</div>
<div className="form-inline mt-4">
<label>Shuffle playlist</label>
<div className="ml-auto">
<label className="is_enabled-toggle toggle switch-light switch-material small m-0">
<input
name="shufflePlaylist"
type="checkbox"
checked={settings.shufflePlaylist}
onChange={handleInputChange}
/>
<span>
<span></span>
<span></span>
<a></a>
</span>
</label>
</div>
</div>
<div className="form-inline mt-4">
<label>Use 24-hour clock</label>
<div className="ml-auto">
<label className="is_enabled-toggle toggle switch-light switch-material small m-0">
<input
name="use24HourClock"
type="checkbox"
checked={settings.use24HourClock}
onChange={handleInputChange}
/>
<span>
<span></span>
<span></span>
<a></a>
</span>
</label>
</div>
</div>
<div className="form-inline mt-4">
<label>Debug logging</label>
<div className="ml-auto">
<label className="is_enabled-toggle toggle switch-light switch-material small m-0">
<input
name="debugLogging"
type="checkbox"
checked={settings.debugLogging}
onChange={handleInputChange}
/>
<span>
<span></span>
<span></span>
<a></a>
</span>
</label>
</div>
</div>
</div>
<div className="form-group col-12">
<div className="text-right">
<a className="btn btn-long btn-outline-primary mr-2" href="/">
Cancel
</a>
<button
className="btn btn-long btn-primary"
type="submit"
disabled={isLoading}
>
{isLoading ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</form>
</div>
</div>
{/* Backup Section */}
<div className="row py-2 mt-4">
<div className="col-12">
<h4 className="page-header text-white">
<b>Backup</b>
</h4>
</div>
</div>
<div className="row content px-3">
<div id="backup-section" className="col-12 my-3">
<div className="text-right">
<input
name="backup_upload"
style={{ display: 'none' }}
type="file"
onChange={handleFileUpload}
/>
<button
id="btn-backup"
className="btn btn-long btn-outline-primary mr-2"
onClick={handleBackup}
disabled={isUploading}
>
Get Backup
</button>
<button
id="btn-upload"
className="btn btn-primary"
type="button"
onClick={handleUpload}
disabled={isUploading}
>
{isUploading ? 'Uploading...' : 'Upload and Recover'}
</button>
</div>
<div
className="progress-bar progress-bar-striped progress active w-100"
style={{ display: isUploading ? 'block' : 'none' }}
>
<div className="bar" style={{ width: `${uploadProgress}%` }}></div>
</div>
</div>
</div>
{/* System Controls Section */}
<div className="row py-2 mt-4">
<div className="col-12">
<h4 className="page-header text-white">
<b>System Controls</b>
</h4>
</div>
</div>
<div className="row content px-3">
<div className="col-12 my-3">
<div className="text-right">
<button
className="btn btn-danger btn-long mr-2"
type="button"
onClick={handleReboot}
>
Reboot
</button>
<button
className="btn btn-danger btn-long mr-2"
type="button"
onClick={handleShutdown}
>
Shutdown
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { AssetRow } from './asset-row'
export const SortableAssetRow = (props) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.95 : 1,
zIndex: isDragging ? 99999 : 'auto',
position: isDragging ? 'relative' : 'static',
backgroundColor: isDragging ? 'rgba(255, 255, 255, 0.1)' : 'transparent',
}
return (
<AssetRow
{...props}
ref={setNodeRef}
style={style}
dragHandleProps={{ ...attributes, ...listeners }}
isDragging={isDragging}
/>
)
}

View File

@@ -0,0 +1,188 @@
import { useEffect, useState } from 'react'
const ANTHIAS_REPO_URL = 'https://github.com/Screenly/Anthias'
const AnthiasVersionValue = ({ version }) => {
const [commitLink, setCommitLink] = useState('')
useEffect(() => {
if (!version) {
return
}
const [gitBranch, gitCommit] = version ? version.split('@') : ['', '']
if (gitBranch === 'master') {
setCommitLink(`${ANTHIAS_REPO_URL}/commit/${gitCommit}`)
}
})
if (commitLink) {
return (
<a href={commitLink} rel="noopener" target="_blank" class="text-dark">
{version}
</a>
)
}
return <>{version}</>
}
const Skeleton = ({ children, isLoading }) => {
return isLoading ? (
<span className="placeholder placeholder-wave"></span>
) : (
children
)
}
export const SystemInfo = () => {
const [loadAverage, setLoadAverage] = useState('')
const [freeSpace, setFreeSpace] = useState('')
const [memory, setMemory] = useState({})
const [uptime, setUptime] = useState({})
const [displayPower, setDisplayPower] = useState(null)
const [deviceModel, setDeviceModel] = useState('')
const [anthiasVersion, setAnthiasVersion] = useState('')
const [macAddress, setMacAddress] = useState('')
const [isLoading, setIsLoading] = useState(false)
const initializeSystemInfo = async () => {
setIsLoading(true)
const response = await fetch('/api/v2/info', {
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
setIsLoading(false)
throw new Error('Failed to fetch system info')
}
const systemInfo = await response.json()
setIsLoading(false)
setLoadAverage(systemInfo.loadavg)
setFreeSpace(systemInfo.free_space)
setMemory(systemInfo.memory)
setUptime(systemInfo.uptime)
setDisplayPower(systemInfo.display_power)
setDeviceModel(systemInfo.device_model)
setAnthiasVersion(systemInfo.anthias_version)
setMacAddress(systemInfo.mac_address)
}
useEffect(() => {
document.title = 'System Info'
initializeSystemInfo()
}, [])
return (
<div className="container">
<div className="row py-2">
<div className="col-12">
<h4 className="page-header text-white">
<b>System Info</b>
</h4>
</div>
</div>
<div className="row content">
<div className="col-12">
<table className="table mb-5">
<thead className="table-borderless">
<tr>
<th
className="text-secondary font-weight-normal"
scope="col"
style={{ width: '20%' }}
>
Option
</th>
<th className="text-secondary font-weight-normal" scope="col">
Value
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Load Average</th>
<td>
<Skeleton isLoading={isLoading}>{loadAverage}</Skeleton>
</td>
</tr>
<tr>
<th scope="row">Free Space</th>
<td>
<Skeleton isLoading={isLoading}>{freeSpace}</Skeleton>
</td>
</tr>
<tr>
<th scope="row">Memory</th>
<td>
<Skeleton isLoading={isLoading}>
<div>
Total: <strong>{memory.total}</strong>
</div>
<div>
Used: <strong>{memory.used}</strong>
</div>
<div>
Free: <strong>{memory.free}</strong>
</div>
<div>
Shared: <strong>{memory.shared}</strong>
</div>
<div>
Buff: <strong>{memory.buff}</strong>
</div>
<div>
Available: <strong>{memory.available}</strong>
</div>
</Skeleton>
</td>
</tr>
<tr>
<th scope="row">Uptime</th>
<td>
<Skeleton isLoading={isLoading}>
{uptime.days} days and {uptime.hours} hours
</Skeleton>
</td>
</tr>
<tr>
<th scope="row">Display Power (CEC)</th>
<td>
<Skeleton isLoading={isLoading}>
{displayPower || 'None'}
</Skeleton>
</td>
</tr>
<tr>
<th scope="row">Device Model</th>
<td>
<Skeleton isLoading={isLoading}>{deviceModel}</Skeleton>
</td>
</tr>
<tr>
<th scope="row">Anthias Version</th>
<td>
<Skeleton isLoading={isLoading}>
<AnthiasVersionValue version={anthiasVersion} />
</Skeleton>
</td>
</tr>
<tr>
<th scope="row">MAC Address</th>
<td>
<Skeleton isLoading={isLoading}>{macAddress}</Skeleton>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
)
}

17
static/src/index.js Normal file
View File

@@ -0,0 +1,17 @@
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { Provider } from 'react-redux'
import { store } from './store'
import '@/sass/anthias.scss'
import { App } from '@/components/app'
const root = ReactDOM.createRoot(document.getElementById('app'))
root.render(
<BrowserRouter basename="/">
<Provider store={store}>
<App />
</Provider>
</BrowserRouter>,
)

View File

@@ -0,0 +1,335 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { addAsset } from './assets-list-slice'
// Async thunks for API operations
export const uploadFile = createAsyncThunk(
'assetModal/uploadFile',
async ({ file, skipAssetCheck }, { dispatch, getState, rejectWithValue }) => {
try {
const formData = new FormData()
formData.append('file_upload', file)
// Create XMLHttpRequest for progress tracking
const xhr = new XMLHttpRequest()
const uploadPromise = new Promise((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100)
dispatch(setUploadProgress(progress))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText)
resolve(response)
} catch (error) {
reject(new Error('Invalid JSON response'))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'))
})
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'))
})
})
// Start the upload
xhr.open('POST', '/api/v2/file_asset')
xhr.send(formData)
// Wait for upload to complete
const response = await uploadPromise
// Get mimetype and duration
const mimetype = getMimetype(file.name)
const state = getState()
const duration = getDurationForMimetype(
mimetype,
state.assetModal.defaultDuration,
state.assetModal.defaultStreamingDuration,
)
const dates = getDefaultDates()
return {
fileData: response,
filename: file.name,
skipAssetCheck,
mimetype,
duration,
dates,
}
} catch (error) {
return rejectWithValue(error.message)
}
},
)
export const saveAsset = createAsyncThunk(
'assetModal/saveAsset',
async ({ assetData }, { dispatch, rejectWithValue }) => {
try {
const response = await fetch('/api/v2/assets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(assetData),
})
if (!response.ok) {
return rejectWithValue('Failed to save asset')
}
const data = await response.json()
// Create the complete asset object with the response data
const completeAsset = {
...assetData,
asset_id: data.asset_id,
...data,
}
// Dispatch the addAsset action to update the assets list
dispatch(addAsset(completeAsset))
return completeAsset
} catch (error) {
return rejectWithValue(error.message)
}
},
)
export const fetchDeviceSettings = createAsyncThunk(
'assetModal/fetchDeviceSettings',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/v2/device_settings')
if (!response.ok) {
return rejectWithValue('Failed to fetch device settings')
}
const data = await response.json()
return {
defaultDuration: data.default_duration,
defaultStreamingDuration: data.default_streaming_duration,
}
} catch (error) {
return rejectWithValue(error.message)
}
},
)
// Helper functions
const getMimetype = (filename) => {
const viduris = ['rtsp', 'rtmp']
const mimetypes = [
[['jpe', 'jpg', 'jpeg', 'png', 'pnm', 'gif', 'bmp'], 'image'],
[['avi', 'mkv', 'mov', 'mpg', 'mpeg', 'mp4', 'ts', 'flv'], 'video'],
]
const domains = [[['www.youtube.com', 'youtu.be'], 'youtube_asset']]
// Check if it's a streaming URL
const scheme = filename.split(':')[0].toLowerCase()
if (viduris.includes(scheme)) {
return 'streaming'
}
// Check if it's a domain-specific asset
try {
const domain = filename.split('//')[1].toLowerCase().split('/')[0]
for (const [domainList, type] of domains) {
if (domainList.includes(domain)) {
return type
}
}
} catch (e) {
// Invalid URL format
}
// Check file extension
try {
const ext = filename.split('.').pop().toLowerCase()
for (const [extList, type] of mimetypes) {
if (extList.includes(ext)) {
return type
}
}
} catch (e) {
// No extension found
}
// Default to webpage
return 'webpage'
}
const getDurationForMimetype = (
mimetype,
defaultDuration,
defaultStreamingDuration,
) => {
if (mimetype === 'video') {
return 0
} else if (mimetype === 'streaming') {
return defaultStreamingDuration
} else {
return defaultDuration
}
}
const getDefaultDates = () => {
const now = new Date()
const endDate = new Date()
endDate.setDate(endDate.getDate() + 30) // 30 days from now
return {
start_date: now.toISOString(),
end_date: endDate.toISOString(),
}
}
// Slice definition
const assetModalSlice = createSlice({
name: 'assetModal',
initialState: {
activeTab: 'uri',
formData: {
uri: '',
skipAssetCheck: false,
},
isValid: true,
errorMessage: '',
statusMessage: '',
isSubmitting: false,
uploadProgress: 0,
defaultDuration: 10, // Default fallback value
defaultStreamingDuration: 300, // Default fallback value
},
reducers: {
setActiveTab: (state, action) => {
state.activeTab = action.payload
},
updateFormData: (state, action) => {
state.formData = { ...state.formData, ...action.payload }
},
setValid: (state, action) => {
state.isValid = action.payload
},
setErrorMessage: (state, action) => {
state.errorMessage = action.payload
},
setStatusMessage: (state, action) => {
state.statusMessage = action.payload
},
setUploadProgress: (state, action) => {
state.uploadProgress = action.payload
},
resetForm: (state) => {
state.formData = {
uri: '',
skipAssetCheck: false,
}
state.isValid = true
state.errorMessage = ''
state.statusMessage = ''
state.isSubmitting = false
state.uploadProgress = 0
},
validateUrl: (state, action) => {
const url = action.payload
if (!url) {
state.isValid = true
state.errorMessage = ''
return
}
const urlPattern =
/(http|https|rtsp|rtmp):\/\/[\w-]+(\.?[\w-]+)+([\w.,@?^=%&amp;:\/~+#-]*[\w@?^=%&amp;\/~+#-])?/
const isValidUrl = urlPattern.test(url)
state.isValid = isValidUrl
state.errorMessage = isValidUrl ? '' : 'Please enter a valid URL'
},
},
extraReducers: (builder) => {
builder
// Device settings
.addCase(fetchDeviceSettings.fulfilled, (state, action) => {
state.defaultDuration = action.payload.defaultDuration
state.defaultStreamingDuration = action.payload.defaultStreamingDuration
})
// Upload file
.addCase(uploadFile.pending, (state) => {
state.isSubmitting = true
state.statusMessage = ''
state.uploadProgress = 0
})
.addCase(uploadFile.fulfilled, (state, action) => {
const {
fileData,
filename,
skipAssetCheck,
mimetype,
duration,
dates,
} = action.payload
// Update form data with file name and other details
state.formData = {
...state.formData,
name: filename,
uri: fileData.uri,
skipAssetCheck,
mimetype,
duration,
dates,
}
state.statusMessage = 'Upload completed.'
state.isSubmitting = false
state.uploadProgress = 0
})
.addCase(uploadFile.rejected, (state, action) => {
state.errorMessage = `Upload failed: ${action.payload}`
state.isSubmitting = false
state.uploadProgress = 0
})
// Save asset
.addCase(saveAsset.pending, (state) => {
state.isSubmitting = true
})
.addCase(saveAsset.fulfilled, (state) => {
state.isSubmitting = false
state.statusMessage = 'Asset saved successfully.'
})
.addCase(saveAsset.rejected, (state, action) => {
state.errorMessage = `Failed to save asset: ${action.payload}`
state.isSubmitting = false
})
},
})
// Export actions
export const {
setActiveTab,
updateFormData,
setValid,
setErrorMessage,
setStatusMessage,
setUploadProgress,
resetForm,
validateUrl,
} = assetModalSlice.actions
// Export selectors
export const selectAssetModalState = (state) => state.assetModal
// Export reducer
export default assetModalSlice.reducer

View File

@@ -0,0 +1,49 @@
import { createSlice } from '@reduxjs/toolkit'
import {
fetchAssets,
updateAssetOrder,
toggleAssetEnabled,
} from '@/store/assets/assets-thunks'
const assetsSlice = createSlice({
name: 'assets',
initialState: {
items: [],
status: 'idle',
error: null,
},
reducers: {
addAsset: (state, action) => {
state.items.push(action.payload)
},
},
extraReducers: (builder) => {
builder
.addCase(fetchAssets.pending, (state) => {
state.status = 'loading'
})
.addCase(fetchAssets.fulfilled, (state, action) => {
state.status = 'succeeded'
state.items = action.payload
})
.addCase(fetchAssets.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message
})
.addCase(updateAssetOrder.fulfilled, (state) => {
state.status = 'succeeded'
})
.addCase(toggleAssetEnabled.fulfilled, (state, action) => {
const { assetId, newValue, playOrder } = action.payload
const asset = state.items.find((item) => item.asset_id === assetId)
if (asset) {
asset.is_enabled = newValue
asset.play_order = playOrder
}
})
},
})
export const { addAsset } = assetsSlice.actions
export default assetsSlice.reducer

View File

@@ -0,0 +1,7 @@
export const selectActiveAssets = (state) =>
state.assets.items
.filter((asset) => asset.is_active)
.sort((a, b) => a.play_order - b.play_order)
export const selectInactiveAssets = (state) =>
state.assets.items.filter((asset) => !asset.is_active)

View File

@@ -0,0 +1,66 @@
import { createAsyncThunk } from '@reduxjs/toolkit'
export const fetchAssets = createAsyncThunk('assets/fetchAssets', async () => {
const response = await fetch('/api/v2/assets')
const data = await response.json()
return data
})
export const updateAssetOrder = createAsyncThunk(
'assets/updateOrder',
async (orderedIds) => {
const response = await fetch('/api/v2/assets/order', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids: orderedIds }),
})
if (!response.ok) {
throw new Error('Failed to update order')
}
return orderedIds
},
)
export const toggleAssetEnabled = createAsyncThunk(
'assets/toggleEnabled',
async ({ assetId, newValue }, { dispatch, getState }) => {
// First, fetch the current assets to determine the next play_order
const response = await fetch('/api/v2/assets')
const assets = await response.json()
// Get the current active assets to determine the next play_order
const activeAssets = assets.filter((asset) => asset.is_active)
// If enabling the asset, set play_order to the next available position
// If disabling the asset, set play_order to 0
const playOrder = newValue === 1 ? activeAssets.length : 0
const updateResponse = await fetch(`/api/v2/assets/${assetId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
is_enabled: newValue,
play_order: playOrder,
}),
})
const activeAssetIds = getState()
.assets.items.filter((asset) => asset.is_active)
.sort((a, b) => a.play_order - b.play_order)
.map((asset) => asset.asset_id)
.concat(assetId)
await dispatch(updateAssetOrder(activeAssetIds.join(',')))
if (!updateResponse.ok) {
throw new Error('Failed to update asset')
}
// Return both the assetId and newValue for the reducer
return { assetId, newValue, playOrder }
},
)

View File

@@ -0,0 +1,48 @@
import assetsReducer from '@/store/assets/assets-list-slice'
import { addAsset } from '@/store/assets/assets-list-slice'
import {
fetchAssets,
updateAssetOrder,
toggleAssetEnabled,
} from '@/store/assets/assets-thunks'
import {
selectActiveAssets,
selectInactiveAssets,
} from '@/store/assets/assets-selectors'
import assetModalReducer from './asset-modal-slice'
import {
uploadFile,
saveAsset,
setActiveTab,
updateFormData,
setValid,
setErrorMessage,
setStatusMessage,
setUploadProgress,
resetForm,
validateUrl,
selectAssetModalState,
} from './asset-modal-slice'
export {
assetsReducer,
addAsset,
fetchAssets,
updateAssetOrder,
toggleAssetEnabled,
selectActiveAssets,
selectInactiveAssets,
// Asset Modal exports
assetModalReducer,
uploadFile,
saveAsset,
setActiveTab,
updateFormData,
setValid,
setErrorMessage,
setStatusMessage,
setUploadProgress,
resetForm,
validateUrl,
selectAssetModalState,
}

View File

@@ -0,0 +1,9 @@
import { configureStore } from '@reduxjs/toolkit'
import { assetsReducer, assetModalReducer } from '@/store/assets'
export const store = configureStore({
reducer: {
assets: assetsReducer,
assetModal: assetModalReducer,
},
})

11
static/src/utils.js Normal file
View File

@@ -0,0 +1,11 @@
/**
* CSS tagged template literal helper function
* @param {TemplateStringsArray} strings - The template literal strings
* @param {...any} values - The template literal values
* @returns {string} The processed CSS string
*/
export const css = (strings, ...values) => {
return strings.reduce((result, string, i) => {
return result + string + (values[i] || '')
}, '')
}

View File

@@ -1,14 +0,0 @@
<div class="form-group" id="user_group">
<label class="small text-secondary">User</label>
<input class="form-control" name="user" type="text" value="{{ user }}">
</div>
<div class="row">
<div class="form-group col-6" id="password_group">
<label class="small text-secondary">Password</label>
<input class="form-control" name="password" type="password" value="">
</div>
<div class="form-group col-6" id="password2_group">
<label class="small text-secondary">Confirm Password</label>
<input class="form-control" name="password2" type="password" value="">
</div>
</div>

View File

@@ -1,47 +0,0 @@
<!DOCTYPE html>
<html>
<head>
{% include "head.html" %}
{% block head %}
{% endblock %}
</head>
<body>
{% include 'header.html' %}
{% block content %}
{% endblock %}
<div class="container mt-4">
<div class="row">
<div class="col-6 small text-white">
<span>Want to get more out of your digital signage?
<a class="brand"
href="https://www.screenly.io/?utm_source=Anthias&amp;utm_medium=root-page&amp;utm_campaign=UI"
target="_blank">
<strong>Try Screenly</strong>.
</a></span>
</div>
<div class="col-6 text-right small">
<span>
<a href="javascript://" id="subsribe-form-container" data-html="true" data-trigger="focus"
data-placement="top">
<strong>Get the latest Screenly news directly in your mailbox.</strong>
</a>
</span>
</div>
</div>
</div>
{% include 'footer.html' %}
<script type="text/javascript">
$(".navbar .nav li a").removeClass('active');
$(".navbar .nav li a[href='" + window.location.pathname + "']").addClass('active');
</script>
</body>
</html>

View File

@@ -1,35 +0,0 @@
<footer id="footer">
<div class="container">
<div class="row">
<div id="screenly-logo" class="col-12 row m-2 ml-0 mr-0">
<div class="links offset-3 col-6 text-center justify-content-center align-self-center">
<a href="/api/docs/" target="_blank" class="mr-4 small">
API
</a>
<a href="https://anthias.screenly.io/#faq?utm_source=Anthias&amp;utm_medium=footer&amp;utm_campaign=UI" target="_blank" class="mr-4 small">
FAQ
</a>
<a href="http://screenly.io/?utm_source=Anthias&amp;utm_medium=footer&amp;utm_campaign=UI" target="_blank" class="mr-4 small">
Screenly.io
</a>
<a href="https://forums.screenly.io/" target="_blank" class="mr-4 small">
Support
</a>
</div>
<div id="github-stars" class="col-3 text-right justify-content-center align-self-center">
<a class="github-button" href="https://github.com/screenly/anthias" data-size="large" data-show-count="true" aria-label="Star screenly/anthias on GitHub">
Star
</a>
</div>
</div>
</div>
</div>
<div class="copy pb-4">
<div class="container">
<div class="text-center p-2">
&copy; Screenly, Inc.
</div>
</div>
</div>
</footer>

View File

@@ -1,57 +0,0 @@
{# vim: ft=htmldjango #}
{% load static %}
<meta charset="utf-8"/>
{% if context.player_name %}
<title>Anthias - {{ context.player_name }}</title>
{% else %}
<title>Anthias</title>
{% endif %}
<link href="/static/favicons/apple-touch-icon-57x57.png" rel="apple-touch-icon-precomposed" sizes="57x57"/>
<link href="/static/favicons/apple-touch-icon-114x114.png" rel="apple-touch-icon-precomposed" sizes="114x114"/>
<link href="/static/favicons/apple-touch-icon-72x72.png" rel="apple-touch-icon-precomposed" sizes="72x72"/>
<link href="/static/favicons/apple-touch-icon-144x144.png" rel="apple-touch-icon-precomposed" sizes="144x144"/>
<link href="/static/favicons/apple-touch-icon-60x60.png" rel="apple-touch-icon-precomposed" sizes="60x60"/>
<link href="/static/favicons/apple-touch-icon-120x120.png" rel="apple-touch-icon-precomposed" sizes="120x120"/>
<link href="/static/favicons/apple-touch-icon-76x76.png" rel="apple-touch-icon-precomposed" sizes="76x76"/>
<link href="/static/favicons/apple-touch-icon-152x152.png" rel="apple-touch-icon-precomposed" sizes="152x152"/>
<link href="/static/favicons/favicon-196x196.png" rel="icon" sizes="196x196" type="image/png"/>
<link href="/static/favicons/favicon-96x96.png" rel="icon" sizes="96x96" type="image/png"/>
<link href="/static/favicons/favicon-32x32.png" rel="icon" sizes="32x32" type="image/png"/>
<link href="/static/favicons/favicon-16x16.png" rel="icon" sizes="16x16" type="image/png"/>
<link href="/static/favicons/favicon-128.png" rel="icon" sizes="128x128" type="image/png"/>
<link href="https://fonts.googleapis.com/css?family=Plus Jakarta Sans" rel="stylesheet">
<link rel="icon" href="/static/favicons/favicon.ico">
<meta content="anthias;" name="application-name"/>
<meta content="#FFFFFF" name="msapplication-TileColor"/>
<meta content="/static/favicons/mstile-144x144.png" name="msapplication-TileImage"/>
<meta content="/static/favicons/mstile-70x70.png" name="msapplication-square70x70logo"/>
<meta content="/static/favicons/mstile-150x150.png" name="msapplication-square150x150logo"/>
<meta content="/static/favicons/mstile-310x150.png" name="msapplication-wide310x150logo"/>
<meta content="/static/favicons/mstile-310x310.png" name="msapplication-square310x310logo"/>
<link href="{% static 'dist/css/anthias.css' %}" type="text/css" rel="stylesheet"/>
<link href="{% static 'fontawesome/css/all.css' %}" rel="stylesheet"/>
<script async defer src="https://buttons.github.io/buttons.js"></script>
<script src="{% static 'js/jquery-3.7.1.min.js' %}"></script>
{% if context.is_demo %}
<!-- Global Site Tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-37846380-3"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'UA-37846380-3');
</script>
{% endif %}

View File

@@ -1,56 +0,0 @@
{# vim: ft=htmldjango #}
{% load static %}
<div id="request-error" class="navbar navbar fixed-top">
<div class="container">
<div class="alert" style="display:none">
<button class="close" type="button">&times;</button>
<span class="msg"></span>
</div>
</div>
</div>
<div class="navbar navbar-header navbar-expand-lg fixed-top bg-dark">
<div class="container">
<a class="brand" href="/">
<img src="{% static 'img/logo-full.svg' %}"/>
</a>
<ul class="nav float-right">
{% if not up_to_date and not is_balena %}
<li class="update-available">
<a href="/settings#upgrade-section">
<i class="fas fa-arrow-circle-down pr-1"></i>
Update Available
</a>
</li>
{% endif %}
<li>
<a href="/">
<i class="far fa-clock pr-1"></i>
Schedule Overview
</a>
</li>
{% if is_balena %}
<li>
<a href="/integrations">
<i class="far fa-plus-square pr-1"></i>
Integrations
</a>
</li>
{% endif %}
<li>
<a href="/settings">
<i class="fas fa-cog pr-1"></i>
Settings
</a>
</li>
<li class="divider-vertical"></li>
<li>
<a href="/system-info">
<i class="fas fa-tasks pr-1"></i>
System Info
</a>
</li>
</ul>
</div>
</div>

View File

@@ -1,386 +0,0 @@
{# vim: ft=htmldjango #}
{% extends "base.html" %}
{% load static %}
{% block head %}
<link href="{% static 'css/datepicker.css' %}" rel="stylesheet"/>
<link href="{% static 'css/timepicker.css' %}" rel="stylesheet"/>
<script src="{% static 'js/underscore-1.4.3.min.js' %}"></script>
<script src="{% static 'js/popper.min.js' %}"></script>
<script src="{% static 'js/jquery.iframe-transport.js' %}"></script>
<script src="{% static 'js/base64js.min.js' %}"></script>
<script src="{% static 'js/backbone-0.9.10.min.js' %}"></script> <!-- needs jquery -->
<script src="{% static 'js/jquery-ui-1.10.1.custom.min.js' %}"></script>
<script src="{% static 'js/jquery.fileupload.js' %}"></script>
<!-- needs jqueryui.widget -->
<script src="{% static 'js/bootstrap.min.js' %}"></script> <!-- needs jquery -->
<script src="{% static 'js/bootstrap-datepicker.js' %}"></script>
<script src="{% static 'js/bootstrap-timepicker.js' %}"></script>
<script src="{% static 'js/moment.js' %}"></script>
{{ ws_addresses|json_script:"ws_addresses" }};
<script type="text/javascript">
var dateFormat = "{{ date_format }}";
var defaultDuration = {{ default_duration }};
var defaultStreamingDuration = {{ default_streaming_duration }};
var use24HourClock = {% if use_24_hour_clock %} true; {% else %} false; {% endif %}
var wsAddresses = JSON.parse(document.getElementById('ws_addresses').textContent);
</script>
<script src="{% static 'dist/js/anthias.js' %}"></script>
<script src="{% static 'js/main.js' %}"></script>
<script id="asset-row-template" type="text/template">
<td class="asset_row_name">
<i class="fas fa-grip-vertical mr-2"></i>
<i class="asset-icon mr-2"></i>
<%= name %>
</td>
<td style="width:21%">
<%= start_date %>
</td>
<td style="width:21%">
<%= end_date %>
</td>
<td style="width:13%">
<%= duration %>
</td>
<td class="asset-toggle" style="width:7%">
<label class="is_enabled-toggle toggle switch-light switch-material small m-0">
<input type="checkbox"/>
<span>
<span class="off"></span>
<span class="on"></span>
<a></a>
</span>
</label>
</td>
<td class="asset_row_btns">
<button class="download-asset-button btn btn-outline-dark" type="button">
<i class="fas fa-download"></i>
</button>
<button class="edit-asset-button btn btn-outline-dark" type="button">
<i class="fas fa-pencil-alt"></i>
</button>
<button class="delete-asset-button btn btn-outline-dark" data-html="true" data-placement="left"
data-title="Are you sure?" data-trigger="manual" type="button">
<i class="far fa-trash-alt"></i>
</button>
</td>
</script>
<script id="confirm-delete-template" type="text/template">
<div class="popover-delete-content">
<div class="float-left">
<a class="confirm-delete btn btn-danger" href="#">Delete</a>
</div>
<div class="float-right">
<a class="cancel-delete btn btn-outline-dark" href="#">Cancel</a>
</div>
</div>
</script>
<script id="processing-message-template" type="text/template">
<label class="processing-message">Asset in processing</label>
</script>
<script id="request-error-template" type="text/template">
<div class="container">
<div class="alert alert-danger">
<button class="close" data-dismiss="alert" type="button">&times;</button>
<span class="msg">
The operation failed. Please reload the page and try again.
</span>
</div>
</div>
</script>
<script id="request-success-template" type="text/template">
<div class="container">
<div class="alert alert-success">
<button class="close" data-dismiss="alert" type="button">&times;</button>
<span class="msg"></span>
</div>
</div>
</script>
<script id="asset-modal-template" type="text/template">
<div class="modal hide fade" aria-hidden="true" aria-labelledby="myModalLabel" role="dialog" tabindex="-1">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="form-horizontal">
<div class="modal-header">
<h3 id="modalLabel">Add Asset</h3>
</div>
<div class="modal-body">
<div class="asset-location add">
<fieldset>
<div class="tabbable">
<ul class="nav nav-tabs" id="add-asset-nav-tabs">
<li class="tabnav-uri nav-item active show text-center">
<a class="nav-link" href="#tab-uri">URL</a>
</li>
<li class="tabnav-file_upload nav-item text-center">
<a class="nav-link" href="#tab-file_upload">Upload</a>
</li>
</ul>
<div class="tab-content px-4 pt-2 pb-4">
<div id="tab-uri" class="tab-pane active"></div>
<div id="tab-file_upload" class="tab-pane">
<div class="control-group">
<div class="filedrop">
<div class="upload-header">
<button class="btn btn-primary">Add Files</button>
<input name="file_upload" type="file"/>
<br/>
or
</div>
<div>
drop files here to upload
</div>
</div>
</div>
</div>
</div>
</div>
</fieldset>
</div>
<form id="add-form">
<div class="form-group row uri">
<label class="col-4 col-form-label">Asset URL</label>
<div class="col-7 controls">
<input class="form-control" name="uri"
placeholder="Public URL to this asset's location" type="text"/>
</div>
</div>
<div class="form-group row skip_asset_check_checkbox">
<label class="col-4 small">Skip asset check</label>
<div class="col-7 is_enabled-skip_asset_check_checkbox checkbox">
<input name="skip_asset_check" type="checkbox" value="0">
</div>
</div>
<div class="asset-location edit" style="display:none">
<div class="form-group row name">
<label class="col-4 col-form-label">Name</label>
<div class="col-7">
<input class="form-control" name="name"
placeholder="Nickname for this asset" type="text"/>
</div>
</div>
<div class="form-group row">
<label class="col-4 col-form-label">Asset Location</label>
<div class="col-8 controls">
<div class="uri-text first"></div>
</div>
</div>
<div class="form-group row mimetype">
<label class="col-4 col-form-label">Asset Type</label>
<div class="col-4 controls">
<select class="mime-select form-control" name="mimetype">
<option value="webpage">Webpage</option>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="streaming">Streaming</option>
<option value="youtube_asset">YouTubeAsset</option>
</select>
</div>
</div>
<hr/>
<div class="row form-group loop_date">
<label class="col-4 col-form-label">Play for</label>
<div class="controls col-7">
<select class="form-control" id="loop_times">
<option value="day">1 Day</option>
<option value="week">1 Week</option>
<option value="month">1 Month</option>
<option value="year">1 Year</option>
<option value="forever">Forever</option>
<option value="manual" selected="selected">Manual</option>
</select>
</div>
</div>
<div id="manul_date">
<div class="form-group row start_date">
<label class="col-4 col-form-label">Start Date</label>
<div class="controls col-7">
<input class="form-control date" name="start_date_date" type="text"
style="margin-right:5px"/>
<input class="form-control time" name="start_date_time" type="text"/>
</div>
<input name="start_date" type="hidden"/>
</div>
<div class="form-group row end_date">
<label class="col-4 col-form-label">End Date</label>
<div class="controls col-7">
<input class="form-control date" name="end_date_date" type="text"
style="margin-right:5px"/>
<input class="form-control time" name="end_date_time" type="text"/>
</div>
<input name="end_date" type="hidden"/>
</div>
</div>
<div class="form-group row duration">
<label class="col-4 col-form-label">Duration</label>
<div class="col-7 controls">
<input class="form-control" name="duration" type="number"/>
seconds
&nbsp;
</div>
</div>
<div class="advanced-accordion accordion">
<div class="accordion-group">
<div class="accordion-heading">
<i class="fas fa-play unrotated"></i>
<a class="advanced-toggle" href="#">Advanced</a>
</div>
<div class="collapse-advanced accordion-body collapse">
<div class="accordion-inner">
<div class="form-group row">
<label class="col-4 col-form-label">Disable cache</label>
<div class="col-8 nocache controls justify-content-center align-self-center">
<label class="nocache-toggle toggle switch-light switch-ios small m-0">
<input type="checkbox"/>
<span><span></span><span></span><a></a></span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<div class="status"></div>
<div class="float-left progress active" style="display:none">
<div class="bar progress-bar-striped progress-bar progress-bar-animated"></div>
</div>
<input class="btn btn-outline-primary btn-long cancel" type="button" value="Cancel"/>
<input id="save-asset" class="btn btn-primary btn-long" type="submit" value="Save"/>
</div>
</div>
</div>
</div>
</div>
</script>
<script id="subscribe-form-template" type="text/template">
<form class="validate"
action="//wireload.us1.list-manage.com/subscribe/post?u=adb4f752497b0d86b3b1b79d7&amp;id=5c47388076"
method="post" name="mc-embedded-subscribe-form" style="margin-bottom:5px" target="_blank">
<div class="mc-field-group form-group">
<label for="mce-EMAIL">
Email Address
<span class="asterisk">*</span>
</label>
<input id="mce-EMAIL" class="required email form-control" name="EMAIL" required="required"
type="email"/>
</div>
<div class="mc-field-group form-group">
<label for="mce-FNAME">First Name</label>
<input id="mce-FNAME" class="form-control" name="FNAME" type="text"/>
</div>
<div class="mc-field-group form-group">
<label for="mce-LNAME">Last Name</label>
<input id="mce-LNAME" class="form-control" name="LNAME" type="text"/>
</div>
<!-- real people should not fill this in and expect good things - do not remove this or risk form bot signups -->
<div hidden="true" style="position absolute; left -5000px;">
<input name="b_adb4f752497b0d86b3b1b79d7_5c47388076" tabindex="-1" type="text"/>
</div>
<input id="mc-embedded-subscribe" class="button btn btn-outline-dark" name="subscribe" type="submit"
value="Subscribe"/>
</form>
</script>
{% endblock %}
{% block content %}
<div class="container pt-3 pb-3">
<div class="row">
<div class="col-12">
<h4 class="d-flex">
<b class="justify-content-center align-self-center text-white">Schedule Overview</b>
<div class="ml-auto">
<a id="previous-asset-button" class="btn btn-long btn-outline-primary" href="#">
<i class="fas fa-chevron-left pr-2"></i>
Previous Asset
</a>
<a id="next-asset-button" class="btn btn-long btn-outline-primary" href="#">
Next Asset
<i class="fas fa-chevron-right pl-2"></i>
</a>
<a id="add-asset-button" class="add-asset-button btn btn-long btn-primary" href="#">
Add Asset
</a>
</div>
</h4>
{% if player_name %}
<h4 class="text-white">{{ player_name }}</h4>
{% endif %}
</div>
</div>
</div>
<span id="assets">
<div class="container">
<div class="row content active-content px-2 pt-4">
<div class="col-12 mb-5">
<section id="active-assets-section">
<h5>
<b>Active assets</b>
</h5>
<table class="table">
<thead class="table-borderless">
<tr>
<th class="font-weight-normal asset_row_name">Name</th>
<th class="font-weight-normal" style="width:21%">Start</th>
<th class="font-weight-normal" style="width:21%">End</th>
<th class="font-weight-normal" style="width:13%">Duration</th>
<th class="font-weight-normal" style="width:7%">Activity</th>
<th class="font-weight-normal" style="width:13%"></th>
</tr>
</thead>
<tbody id="active-assets"></tbody>
</table>
<div class="table-assets-help-text">Currently there are no assets. <a class="add-asset-button" href="#">Add asset</a> now.</div>
</section>
</div>
</div>
</div>
<div class="container mt-4">
<div class="row content inactive-content px-2 pt-4">
<div class="col-12 mb-5">
<section id="inactive-assets-section">
<h5>
<b>Inactive assets</b>
</h5>
<table class="table">
<thead class="table-borderless">
<tr>
<th class="text-secondary font-weight-normal asset_row_name">Name</th>
<th class="text-secondary font-weight-normal" style="width:21%">Start</th>
<th class="text-secondary font-weight-normal" style="width:21%">End</th>
<th class="text-secondary font-weight-normal" style="width:13%">Duration</th>
<th class="text-secondary font-weight-normal" style="width:7%">Activity</th>
<th class="text-secondary font-weight-normal" style="width:13%"></th>
</tr>
</thead>
<tbody id="inactive-assets"></tbody>
</table>
<div class="table-assets-help-text text-secondary">Currently there are no assets. <a class="add-asset-button" href="#">Add asset</a> now.</div>
</section>
</div>
</div>
</div>
</span>
{% endblock %}

View File

@@ -1,65 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<div class="row py-2">
<div class="col-12">
<h4 class="page-header text-white">
<b>Integrations</b>
</h4>
</div>
</div>
<div class="row content" style="min-height: 60vh;">
{% if is_balena %}
<div id="balena-section" class="col-12">
<h4 class="page-header">
<b>Balena</b>
</h4>
<table class="table">
<thead class="table-borderless">
<tr>
<th class="text-secondary font-weight-normal" scope="col">Option</th>
<th class="text-secondary font-weight-normal" scope="col">Value</th>
<th class="text-secondary font-weight-normal" scope="col">Description</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Device Name</th>
<td>{{ balena_device_name_at_init }}</td>
<td>The name of the device on first initialisation.</td>
</tr>
<tr>
<th scope="row">Device UUID</th>
<td>{{ balena_device_id }}</td>
<td>The unique identification number for the device. This is used to identify it on
balena.
</td>
</tr>
<tr>
<th scope="row">App ID</th>
<td>{{ balena_app_id }}</td>
<td>ID number of the balena application the device is associated.</td>
</tr>
<tr>
<th scope="row">App Name</th>
<td>{{ balena_app_name }}</td>
<td>The name of the balena application the device is associated with.</td>
</tr>
<tr>
<th scope="row">Supervisor Version</th>
<td>{{ balena_supervisor_version }}</td>
<td>The current version of the supervisor agent running on the device.</td>
</tr>
<tr>
<th scope="row">Host OS Version</th>
<td>{{ balena_host_os_version }}</td>
<td>The version of the host OS.</td>
</tr>
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{% endblock %}

44
templates/react.html Normal file
View File

@@ -0,0 +1,44 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
href="{% static 'dist/css/anthias.css' %}"
type="text/css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css?family=Plus Jakarta Sans"
rel="stylesheet"
>
<link href="/static/favicons/apple-touch-icon-57x57.png" rel="apple-touch-icon-precomposed" sizes="57x57"/>
<link href="/static/favicons/apple-touch-icon-114x114.png" rel="apple-touch-icon-precomposed" sizes="114x114"/>
<link href="/static/favicons/apple-touch-icon-72x72.png" rel="apple-touch-icon-precomposed" sizes="72x72"/>
<link href="/static/favicons/apple-touch-icon-144x144.png" rel="apple-touch-icon-precomposed" sizes="144x144"/>
<link href="/static/favicons/apple-touch-icon-60x60.png" rel="apple-touch-icon-precomposed" sizes="60x60"/>
<link href="/static/favicons/apple-touch-icon-120x120.png" rel="apple-touch-icon-precomposed" sizes="120x120"/>
<link href="/static/favicons/apple-touch-icon-76x76.png" rel="apple-touch-icon-precomposed" sizes="76x76"/>
<link href="/static/favicons/apple-touch-icon-152x152.png" rel="apple-touch-icon-precomposed" sizes="152x152"/>
<link href="/static/favicons/favicon-196x196.png" rel="icon" sizes="196x196" type="image/png"/>
<link href="/static/favicons/favicon-96x96.png" rel="icon" sizes="96x96" type="image/png"/>
<link href="/static/favicons/favicon-32x32.png" rel="icon" sizes="32x32" type="image/png"/>
<link href="/static/favicons/favicon-16x16.png" rel="icon" sizes="16x16" type="image/png"/>
<link href="/static/favicons/favicon-128.png" rel="icon" sizes="128x128" type="image/png"/>
<link href="https://fonts.googleapis.com/css?family=Plus Jakarta Sans" rel="stylesheet">
<link rel="icon" href="/static/favicons/favicon.ico">
<meta content="anthias;" name="application-name"/>
<meta content="#FFFFFF" name="msapplication-TileColor"/>
<meta content="/static/favicons/mstile-144x144.png" name="msapplication-TileImage"/>
<meta content="/static/favicons/mstile-70x70.png" name="msapplication-square70x70logo"/>
<meta content="/static/favicons/mstile-150x150.png" name="msapplication-square150x150logo"/>
<meta content="/static/favicons/mstile-310x150.png" name="msapplication-wide310x150logo"/>
<meta content="/static/favicons/mstile-310x310.png" name="msapplication-square310x310logo"/>
<title></title>
</head>
<body>
<div id="app"></div>
<script src="{% static 'dist/js/anthias.js' %}"></script>
</body>
</html>

View File

@@ -1,330 +0,0 @@
{# vim: ft=htmldjango #}
{% extends "base.html" %}
{% load static %}
{% block head %}
<script src="{% static 'js/popper.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script> <!-- needs jquery -->
<script src="{% static 'js/jquery-ui-1.10.1.custom.min.js' %}"></script>
<script src="{% static 'js/jquery.fileupload.js' %}"></script> <!-- needs jqueryui.widget -->
<script src="{% static 'js/bootstrap-datepicker.js' %}"></script>
<script src="{% static 'dist/js/settings.js' %}"></script>
{% endblock %}
{% block content %}
<div class="container"
>
<div class="row py-2">
<div class="col-12">
<h4 class="page-header text-white">
<b>Settings</b>
</h4>
</div>
</div>
<div class="row content px-3">
<div class="col-12 my-3">
{% if flash %}
<div class="alert alert-{{ flash.class }}">
{{ flash.message }}
</div>
{% endif %}
<form method="post" class="row">
{% csrf_token %}
<div class="form-group col-6 d-flex flex-column justify-content-between">
<div class="form-group">
<label class="small text-secondary">Player name</label>
<input class="form-control" name="player_name" type="text"
value="{{ player_name }}">
</div>
<div class="row">
<div class="form-group col-6">
<label class="small text-secondary">Default duration (seconds)</label>
<input class="form-control" name="default_duration" type="number"
value="{{ default_duration }}"/>
</div>
<div class="form-group col-6">
<label class="small text-secondary">Default streaming duration (seconds)</label>
<input class="form-control" name="default_streaming_duration" type="number"
value="{{ default_streaming_duration }}"/>
</div>
</div>
<div class="form-group">
<label class="small text-secondary">Audio output</label>
<select class="form-control" name="audio_output">
{% if audio_output == 'hdmi' %}
<option value="hdmi" selected="selected">HDMI</option>
{% if device_type != 'pi5' %}
<option value="local">3.5mm jack</option>
{% endif %}
{% else %}
<option value="hdmi">HDMI</option>
{% if device_type != 'pi5' %}
<option value="local" selected="selected">3.5mm jack</option>
{% endif %}
{% endif %}
</select>
</div>
<div class="form-group">
<label class="small text-secondary">Date format</label>
<select class="form-control" name="date_format">
<option value="mm/dd/yyyy"
{% if date_format == 'mm/dd/yyyy' %} selected="selected" {% endif %}>
month/day/year
</option>
<option value="dd/mm/yyyy"
{% if date_format == 'dd/mm/yyyy' %} selected="selected" {% endif %}>
day/month/year
</option>
<option value="yyyy/mm/dd"
{% if date_format == 'yyyy/mm/dd' %} selected="selected" {% endif %}>
year/month/day
</option>
<option value="mm-dd-yyyy"
{% if date_format == 'mm-dd-yyyy' %} selected="selected" {% endif %}>
month-day-year
</option>
<option value="dd-mm-yyyy"
{% if date_format == 'dd-mm-yyyy' %} selected="selected" {% endif %}>
day-month-year
</option>
<option value="yyyy-mm-dd"
{% if date_format == 'yyyy-mm-dd' %} selected="selected" {% endif %}>
year-month-day
</option>
<option value="mm.dd.yyyy"
{% if date_format == 'mm.dd.yyyy' %} selected="selected" {% endif %}>
month.day.year
</option>
<option value="dd.mm.yyyy"
{% if date_format == 'dd.mm.yyyy' %} selected="selected" {% endif %}>
day.month.year
</option>
<option value="yyyy.mm.dd"
{% if date_format == 'yyyy.mm.dd' %} selected="selected" {% endif %}>
year.month.day
</option>
</select>
</div>
<div class="form-group mb-0">
<label class="small text-secondary">Authentication</label>
<select class="form-control" id="auth_backend" name="auth_backend">
{% for opt in auth_backends %}
<option value="{{ opt.name }}" {{ opt.selected }}>{{ opt.text }}</option>
{% endfor %}
</select>
</div>
{% if need_current_password %}
<div class="form-group" id="curpassword_group">
<label class="small text-secondary">Current Password</label>
<input class="form-control" name="current-password" type="password" value="">
</div>
{% endif %}
{% for backend in auth_backends %}
{% if backend.template %}
<div id="auth_backend-{{ backend.name }}">
{% include backend.template %}
</div>
{% endif %}
{% endfor %}
</div>
<div class="form-group col-6 d-flex flex-column justify-content-start">
<div class="form-inline mt-4">
<label>Show splash screen</label>
<div class="ml-auto">
<label id="splash_checkbox"
class="is_enabled-toggle toggle switch-light switch-material small m-0">
{% if show_splash %}
<input name="show_splash" checked="checked" type="checkbox"/>
{% else %}
<input name="show_splash" type="checkbox"/>
{% endif %}
<span><span></span><span></span><a></a></span>
</label>
</div>
</div>
<div class="form-inline mt-4">
<label>Default assets</label>
<div class="ml-auto">
<label id="default_assets_checkbox"
class="is_enabled-toggle toggle switch-light switch-material small m-0">
{% if default_assets %}
<input name="default_assets" checked="checked" type="checkbox"/>
{% else %}
<input name="default_assets" type="checkbox"/>
{% endif %}
<span><span></span><span></span><a></a></span>
</label>
</div>
</div>
<div class="form-inline mt-4">
<label>Shuffle playlist</label>
<div class="ml-auto">
<label id="shuffle_checkbox"
class="is_enabled-toggle toggle switch-light switch-material small m-0">
{% if shuffle_playlist %}
<input name="shuffle_playlist" checked="checked" type="checkbox"/>
{% else %}
<input name="shuffle_playlist" type="checkbox"/>
{% endif %}
<span><span></span><span></span><a></a></span>
</label>
</div>
</div>
<div class="form-inline mt-4">
<label>Use 24-hour clock</label>
<div class="ml-auto">
<label id="use_24_hour_clock_checkbox"
class="is_enabled-toggle toggle switch-light switch-material small m-0">
{% if use_24_hour_clock %}
<input name="use_24_hour_clock" checked="checked" type="checkbox"/>
{% else %}
<input name="use_24_hour_clock" type="checkbox"/>
{% endif %}
<span><span></span><span></span><a></a></span>
</label>
</div>
</div>
<div class="form-inline mt-4">
<label>Debug logging</label>
<div class="ml-auto">
<label id="debug_checkbox"
class="is_enabled-toggle toggle switch-light switch-material small m-0">
{% if debug_logging %}
<input name="debug_logging" checked="checked" type="checkbox">
{% else %}
<input name="debug_logging" type="checkbox">
{% endif %}
<span><span></span><span></span><a></a></span>
</label>
</div>
</div>
</div>
<div class="form-group col-6 offset-6">
</div>
<div class="form-group col-12">
<div class="text-right">
<a class="btn btn-long btn-outline-primary" href="/">Cancel</a>
<input class="btn btn-long btn-primary" type="submit" value="Save Settings">
</div>
</div>
</form>
</div>
</div>
</div>
{% if not context.up_to_date and not context.is_balena and context.is_docker %}
<div class="container mt-4">
<div class="row py-2">
<div class="col-12">
<h4 class="page-header text-white">
<b>Upgrade Anthias</b>
</h4>
</div>
</div>
<div class="row content px-3">
<div id="upgrade-section" class="col-12 my-3">
<p>Do the following steps to upgrade Anthias:</p>
<ul>
<li>
Go to the <a href="#backup-section" class="text-danger">backup section</a> and click <em>Get Backup</em>.
</li>
<li>
Open up a terminal and SSH to this device using any of the following commands:
<ul>
{% for ip_address in context.ip_addresses %}
<li><code>ssh {{ context.host_user }}@{{ ip_address }}</code></li>
{% endfor %}
</ul>
</li>
<li>
Go to the project root directory &mdash; <code>cd ~/screenly</code>
</li>
<li>
Run the following upgrade script &mdash; <code>./bin/run_upgrade.sh</code>. The script is essentially a wrapper around
the install script, so it will prompt you with the same questions as when you first installed Anthias.
</li>
</ul>
</div>
</div>
</div>
{% endif %}
<div class="container mt-4">
<div class="row py-2">
<div class="col-12">
<h4 class="page-header text-white">
<b>Backup</b>
</h4>
</div>
</div>
<div class="row content px-3">
{# Backup #}
<div id="backup-section" class="col-12 my-3">
<div class="text-right">
<input name="backup_upload" style="display:none" type="file">
<button id="btn-backup" class="btn btn-long btn-outline-primary">Get Backup</button>
<button id="btn-upload" class="btn btn-primary" type="button">Upload and Recover
</button>
</div>
<div class="progress-bar progress-bar-striped progress active w-100" style="display:none">
<div class="bar"></div>
</div>
</div>
</div>
</div>
{% if not is_balena and not is_docker %}
{# Reset Wifi #}
<div class="container mt-4">
<div class="row py-2">
<div class="col-12">
<h4 class="page-header text-white">
<b>Reset Wi-Fi Config</b>
</h4>
</div>
</div>
<div class="row content px-3">
<div id="wifi-section" class="col-12 my-3">
<p>Run it, and if the next boot does not have a network connection, you will be prompted to select a
WiFi
network. <b>Warning:</b> After pressing, a reboot is required. Web interface will not be
available
until reboot.</p>
<div class="text-right">
<button id="btn-reset" class="btn btn-danger btn-long" type="button">Reset Wi-Fi</button>
</div>
</div>
</div>
</div>
{% endif %}
{# System controls #}
<div class="container mt-4">
<div class="row py-2">
<div class="col-12">
<h4 class="page-header text-white">
<b>System Controls</b>
</h4>
</div>
</div>
<div class="row content px-3">
<div id="system-controls-section" class="col-12 my-3">
<div class="text-right">
<button id="btn-reboot-system" class="btn btn-danger btn-long" type="button">Reboot</button>
<button id="btn-shutdown-system" class="btn btn-danger btn-long" type="button">Shutdown</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,80 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<div class="row py-2">
<div class="col-12">
<h4 class="page-header text-white">
<b>System Info</b>
</h4>
</div>
</div>
<div class="row content">
<div class="col-12">
<table class="table mb-5">
<thead class="table-borderless">
<tr>
<th class="text-secondary font-weight-normal" scope="col" style="width: 20%">Option</th>
<th class="text-secondary font-weight-normal" scope="col">Value</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Load Average</th>
<td>{{ loadavg }}</td>
</tr>
<tr>
<th scope="row">Free Space</th>
<td>{{ free_space }}</td>
</tr>
<tr>
<th scope="row">Memory</th>
<td>
Total: <strong>{{ memory.total }}</strong> /
Used: <strong>{{ memory.used }}</strong> /
Free: <strong>{{ memory.free }}</strong> /
Shared: <strong>{{ memory.shared }}</strong> /
Buff: <strong>{{ memory.buff }}</strong> /
Available: <strong>{{ memory.available }}</strong>
</td>
</tr>
<tr>
<th scope="row">Uptime</th>
<td>{{ uptime.days }} days and {{ uptime.hours }} hours</td>
</tr>
<tr>
<th scope="row">Display Power (CEC)</th>
<td>{{ display_power }}</td>
</tr>
<tr>
<th scope="row">Device Model</th>
<td>{{ device_model }}</td>
</tr>
<tr>
<th scope="row">Anthias Version</th>
<td>
{% if anthias_commit_link %}
<a
href="{{ anthias_commit_link }}"
rel="noopener"
target="_blank"
class="text-dark"
>
{{ anthias_version }}
</a>
{% else %}
{{ anthias_version }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">MAC Address</th>
<td>{{ mac_address }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -103,7 +103,7 @@ class WebTest(TestCase):
lambda field: field.fill('https://example.com'))
sleep(1)
wait_for_and_do(browser, '#add-form', lambda form: form.click())
wait_for_and_do(browser, '#tab-uri', lambda form: form.click())
sleep(1) # Wait for the new-asset panel animation.
wait_for_and_do(browser, '#save-asset', lambda btn: btn.click())
@@ -118,6 +118,7 @@ class WebTest(TestCase):
self.assertEqual(asset.mimetype, 'webpage')
self.assertEqual(asset.duration, settings['default_duration'])
@skip('migrate to React-based tests')
def test_edit_asset(self):
asset = Asset.objects.create(**asset_x)
@@ -132,10 +133,14 @@ class WebTest(TestCase):
lambda field: field.fill('333'))
sleep(1)
wait_for_and_do(browser, '#add-form', lambda form: form.click())
sleep(1)
wait_for_and_do(browser, '#edit-form', lambda form: form.click())
sleep(3)
wait_for_and_do(browser, '#save-asset', lambda btn: btn.click())
wait_for_and_do(
browser,
'.edit-asset-modal #save-asset',
lambda btn: btn.click()
)
sleep(3)
assets = Asset.objects.all()
@@ -154,7 +159,7 @@ class WebTest(TestCase):
sleep(1)
wait_for_and_do(
browser, 'a[href="#tab-file_upload"]', lambda tab: tab.click())
browser, '.nav-link.upload-asset-tab', lambda tab: tab.click())
wait_for_and_do(
browser, 'input[name="file_upload"]',
lambda input: input.fill(image_file))
@@ -181,7 +186,7 @@ class WebTest(TestCase):
sleep(1)
wait_for_and_do(
browser, 'a[href="#tab-file_upload"]',
browser, '.nav-link.upload-asset-tab',
lambda tab: tab.click())
wait_for_and_do(
browser, 'input[name="file_upload"]',
@@ -211,7 +216,7 @@ class WebTest(TestCase):
sleep(1)
wait_for_and_do(
browser, 'a[href="#tab-file_upload"]',
browser, '.nav-link.upload-asset-tab',
lambda tab: tab.click())
wait_for_and_do(
browser, 'input[name="file_upload"]',
@@ -265,6 +270,7 @@ class WebTest(TestCase):
self.assertEqual(
asset.duration, settings['default_streaming_duration'])
@skip('migrate to React-based tests')
def test_remove_asset(self):
Asset.objects.create(**asset_x)
@@ -311,6 +317,7 @@ class WebTest(TestCase):
asset = assets.first()
self.assertEqual(asset.is_enabled, 0)
@skip('migrate to React-based tests')
def test_reorder_asset(self):
Asset.objects.create(**{
**asset_x,

View File

@@ -1,36 +1,56 @@
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const path = require('path');
const path = require("path");
const webpack = require('webpack');
module.exports = {
entry: {
'anthias': './static/js/anthias.coffee',
'settings': './static/js/settings.coffee',
"anthias": "./static/src/index.js",
},
output: {
path: path.resolve(__dirname, 'static/dist'),
filename: 'js/[name].js',
path: path.resolve(__dirname, "static/dist"),
filename: "js/[name].js",
clean: true,
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/anthias.css'
})
filename: "css/anthias.css"
}),
new webpack.ProvidePlugin({
React: 'react'
}),
],
module: {
rules: [
{
test: /\.coffee$/,
use: ['coffee-loader']
test: /.(js|jsx|mjs)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
'@babel/preset-react'
]
}
}
},
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader'
"css-loader",
"sass-loader"
]
}
]
},
resolve: {
alias: {
'@/components': path.resolve(__dirname, 'static/src/components'),
'@/store': path.resolve(__dirname, 'static/src/store'),
'@/sass': path.resolve(__dirname, 'static/sass'),
'@/utils': path.resolve(__dirname, 'static/src/utils'),
},
extensions: ['.js', '.jsx']
}
};

View File

@@ -1,7 +1,11 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
devtool: 'source-map',
mode: 'development',
devtool: "source-map",
mode: "development",
devServer: {
contentBase: "./static/dist",
hot: true,
},
});