Merge commit '0b54c7e307109903515e64989202381cb5c523db' into stable
|
@ -37,12 +37,12 @@ jobs:
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.2'
|
php-version: '8.3'
|
||||||
extensions: intl, xdebug
|
extensions: intl, xdebug
|
||||||
tools: composer:v2, cs2pr
|
tools: composer:v2, cs2pr
|
||||||
|
|
||||||
- name: Cache PHP dependencies
|
- name: Cache PHP dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: vendor
|
path: vendor
|
||||||
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
|
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
|
||||||
|
@ -77,12 +77,12 @@ jobs:
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.2'
|
php-version: '8.3'
|
||||||
extensions: intl, xdebug
|
extensions: intl, xdebug
|
||||||
tools: composer:v2, cs2pr
|
tools: composer:v2, cs2pr
|
||||||
|
|
||||||
- name: Cache PHP dependencies
|
- name: Cache PHP dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: vendor
|
path: vendor
|
||||||
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
|
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
|
||||||
|
@ -135,7 +135,7 @@ jobs:
|
||||||
chmod 777 .gitinfo
|
chmod 777 .gitinfo
|
||||||
|
|
||||||
- name: Upload built static assets and translations
|
- name: Upload built static assets and translations
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: assets
|
name: assets
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
@ -158,7 +158,7 @@ jobs:
|
||||||
- uses: actions/checkout@master
|
- uses: actions/checkout@master
|
||||||
|
|
||||||
- name: Download built static assets from previous step
|
- name: Download built static assets from previous step
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: assets
|
name: assets
|
||||||
|
|
||||||
|
@ -166,13 +166,13 @@ jobs:
|
||||||
uses: depot/setup-action@v1
|
uses: depot/setup-action@v1
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
|
@ -180,7 +180,7 @@ jobs:
|
||||||
|
|
||||||
- name: Build Docker Metadata
|
- name: Build Docker Metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
azuracast/azuracast
|
azuracast/azuracast
|
||||||
|
|
46
CHANGELOG.md
|
@ -11,6 +11,52 @@ release channel, you can take advantage of these new features and fixes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# AzuraCast 0.19.5 (Feb 20, 2024)
|
||||||
|
|
||||||
|
## New Features/Changes
|
||||||
|
|
||||||
|
- **Redesigned Public Podcast Pages**: We've completely overhauled the public-facing podcasts pages to be a single
|
||||||
|
cohesive experience similar to our On-Demand streaming and other public pages.
|
||||||
|
- Podcasts and episodes are searchable, sortable and paginated, and the pages now use our built-in player for
|
||||||
|
playing back the podcasts themselves, ensuring uniform controls across browsers.
|
||||||
|
- Because it's built in our new frontend, you can also continue listening to your podcast episode as you navigate
|
||||||
|
around the podcast pages.
|
||||||
|
- The entire new podcast component is also now an embeddable widget in external pages.
|
||||||
|
|
||||||
|
- **Improved Listeners Report**: You can now search and sort through the listeners report, view several additional
|
||||||
|
fields supplying more information about your listeners, and filter your results to only show listeners with a certain
|
||||||
|
total connected time or using a certain kind of device.
|
||||||
|
|
||||||
|
- **Custom Bitrates**: When configuring Mount Points, Remote Relays, or broadcast recordings, you can now specify a
|
||||||
|
custom bitrate in kilobits per second (kbps) if you want to use a bitrate outside the default options.
|
||||||
|
|
||||||
|
- You can now submit a manual metadata update directly via the station profile page. This is useful in cases where the
|
||||||
|
metadata does not update correctly (i.e. from a live DJ).
|
||||||
|
|
||||||
|
## Code Quality/Technical Changes
|
||||||
|
|
||||||
|
- Our Docker image is now built directly on top of the official PHP image, which is powered by Debian Bookworm instead
|
||||||
|
of Ubuntu 22.04 (Jammy). For a majority of station operators, this change will not impact your station operations at
|
||||||
|
all, but if you specify custom packages to be installed on startup, you should make sure those packages exist in the
|
||||||
|
Debian repository as well.
|
||||||
|
|
||||||
|
- You can now specify a path when using the `azuracast:media:reprocess` command to only mark items starting with the
|
||||||
|
given (file or directory) path for reprocessing.
|
||||||
|
|
||||||
|
- The list of stations on the home dashboard is now paginated, searchable and sortable.
|
||||||
|
|
||||||
|
- The "Reorder" dialog for sequential playlists has been emphasized better and now has a "move to top" and "move to
|
||||||
|
bottom" button.
|
||||||
|
|
||||||
|
- The Upcoming Song Queue page automatically refreshes periodically.
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- We're continuing to work with Liquidsoap to resolve known issues relating to crackles and pops on crossfade
|
||||||
|
transitions and issues with metadata not updating on shorter tracks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# AzuraCast 0.19.4 (Jan 4, 2024)
|
# AzuraCast 0.19.4 (Jan 4, 2024)
|
||||||
|
|
||||||
## New Features/Changes
|
## New Features/Changes
|
||||||
|
|
19
Dockerfile
|
@ -1,7 +1,7 @@
|
||||||
#
|
#
|
||||||
# Golang dependencies build step
|
# Golang dependencies build step
|
||||||
#
|
#
|
||||||
FROM golang:1.21-bullseye AS go-dependencies
|
FROM golang:1.21-bookworm AS go-dependencies
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends openssl git
|
&& apt-get install -y --no-install-recommends openssl git
|
||||||
|
@ -10,7 +10,7 @@ RUN go install github.com/jwilder/dockerize@v0.6.1
|
||||||
|
|
||||||
RUN go install github.com/aptible/supercronic@v0.2.28
|
RUN go install github.com/aptible/supercronic@v0.2.28
|
||||||
|
|
||||||
RUN go install github.com/centrifugal/centrifugo/v5@v5.2.0
|
RUN go install github.com/centrifugal/centrifugo/v5@v5.2.2
|
||||||
|
|
||||||
#
|
#
|
||||||
# MariaDB dependencies build step
|
# MariaDB dependencies build step
|
||||||
|
@ -25,7 +25,7 @@ FROM ghcr.io/azuracast/azuracast.com:builtin AS docs
|
||||||
#
|
#
|
||||||
# Icecast-KH with AzuraCast customizations build step
|
# Icecast-KH with AzuraCast customizations build step
|
||||||
#
|
#
|
||||||
FROM ghcr.io/azuracast/icecast-kh-ac:latest AS icecast
|
FROM ghcr.io/azuracast/icecast-kh-ac:2024-02-13 AS icecast
|
||||||
|
|
||||||
#
|
#
|
||||||
# Roadrunner build step
|
# Roadrunner build step
|
||||||
|
@ -35,9 +35,16 @@ FROM ghcr.io/roadrunner-server/roadrunner:2023.3.8 AS roadrunner
|
||||||
#
|
#
|
||||||
# Final build image
|
# Final build image
|
||||||
#
|
#
|
||||||
FROM ubuntu:jammy AS pre-final
|
FROM php:8.3-fpm-bookworm AS pre-final
|
||||||
|
|
||||||
ENV TZ="UTC"
|
ENV TZ="UTC" \
|
||||||
|
LANGUAGE="en_US.UTF-8" \
|
||||||
|
LC_ALL="en_US.UTF-8" \
|
||||||
|
LANG="en_US.UTF-8" \
|
||||||
|
LC_TYPE="en_US.UTF-8"
|
||||||
|
|
||||||
|
# Add PHP extension installer tool
|
||||||
|
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
|
||||||
|
|
||||||
# Add Go dependencies
|
# Add Go dependencies
|
||||||
COPY --from=go-dependencies /go/bin/dockerize /usr/local/bin
|
COPY --from=go-dependencies /go/bin/dockerize /usr/local/bin
|
||||||
|
@ -47,8 +54,6 @@ COPY --from=go-dependencies /go/bin/centrifugo /usr/local/bin/centrifugo
|
||||||
# Add MariaDB dependencies
|
# Add MariaDB dependencies
|
||||||
COPY --from=mariadb /usr/local/bin/healthcheck.sh /usr/local/bin/db_healthcheck.sh
|
COPY --from=mariadb /usr/local/bin/healthcheck.sh /usr/local/bin/db_healthcheck.sh
|
||||||
COPY --from=mariadb /usr/local/bin/docker-entrypoint.sh /usr/local/bin/db_entrypoint.sh
|
COPY --from=mariadb /usr/local/bin/docker-entrypoint.sh /usr/local/bin/db_entrypoint.sh
|
||||||
COPY --from=mariadb /etc/apt/sources.list.d/mariadb.list /etc/apt/sources.list.d/mariadb.list
|
|
||||||
COPY --from=mariadb /etc/apt/trusted.gpg.d/mariadb.gpg /etc/apt/trusted.gpg.d/mariadb.gpg
|
|
||||||
|
|
||||||
# Add Icecast
|
# Add Icecast
|
||||||
COPY --from=icecast /usr/local/bin/icecast /usr/local/bin/icecast
|
COPY --from=icecast /usr/local/bin/icecast /usr/local/bin/icecast
|
||||||
|
|
|
@ -16,7 +16,7 @@ MYSQL_ROOT_PASSWORD=azur4c457
|
||||||
|
|
||||||
# Developer options.
|
# Developer options.
|
||||||
# Populate these!
|
# Populate these!
|
||||||
INIT_BASE_URL=http://azuracast.local
|
INIT_BASE_URL=https://azuracast.local
|
||||||
INIT_INSTANCE_NAME="local development"
|
INIT_INSTANCE_NAME="local development"
|
||||||
INIT_DEMO_API_KEY=
|
INIT_DEMO_API_KEY=
|
||||||
INIT_ADMIN_EMAIL=
|
INIT_ADMIN_EMAIL=
|
||||||
|
|
|
@ -12,7 +12,7 @@ $environment = App\AppFactory::buildEnvironment();
|
||||||
|
|
||||||
$console = new Symfony\Component\Console\Application(
|
$console = new Symfony\Component\Console\Application(
|
||||||
'AzuraCast installer',
|
'AzuraCast installer',
|
||||||
App\Version::FALLBACK_VERSION
|
App\Version::STABLE_VERSION
|
||||||
);
|
);
|
||||||
|
|
||||||
$installCommand = new App\Installer\Command\InstallCommand();
|
$installCommand = new App\Installer\Command\InstallCommand();
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.3",
|
||||||
"ext-PDO": "*",
|
"ext-PDO": "*",
|
||||||
"ext-curl": "*",
|
"ext-curl": "*",
|
||||||
"ext-ffi": "*",
|
"ext-ffi": "*",
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
"ext-xmlreader": "*",
|
"ext-xmlreader": "*",
|
||||||
"ext-xmlwriter": "*",
|
"ext-xmlwriter": "*",
|
||||||
"azuracast/nowplaying": "dev-main",
|
"azuracast/nowplaying": "dev-main",
|
||||||
"beberlei/doctrineextensions": "^1.2",
|
"beberlei/doctrineextensions": "^1.4",
|
||||||
"br33f/php-ga4-mp": "^0.1.2",
|
"br33f/php-ga4-mp": "^0.1.2",
|
||||||
"brick/math": "^0.11",
|
"brick/math": "^0.11",
|
||||||
"composer/ca-bundle": "^1.2",
|
"composer/ca-bundle": "^1.2",
|
||||||
|
@ -57,7 +57,7 @@
|
||||||
"mezzio/mezzio-session-cache": "^1.7",
|
"mezzio/mezzio-session-cache": "^1.7",
|
||||||
"monolog/monolog": "^3",
|
"monolog/monolog": "^3",
|
||||||
"myclabs/deep-copy": "^1.10",
|
"myclabs/deep-copy": "^1.10",
|
||||||
"nesbot/carbon": "^2.36",
|
"nesbot/carbon": "^3",
|
||||||
"pagerfanta/doctrine-collections-adapter": "^4",
|
"pagerfanta/doctrine-collections-adapter": "^4",
|
||||||
"pagerfanta/doctrine-orm-adapter": "^4",
|
"pagerfanta/doctrine-orm-adapter": "^4",
|
||||||
"php-di/php-di": "^7.0.1",
|
"php-di/php-di": "^7.0.1",
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Controller\Admin\IndexAction;
|
use App\Controller\AdminAction;
|
||||||
use App\Enums\GlobalPermissions;
|
use App\Enums\GlobalPermissions;
|
||||||
use App\Middleware;
|
use App\Middleware;
|
||||||
use Slim\Routing\RouteCollectorProxy;
|
use Slim\Routing\RouteCollectorProxy;
|
||||||
|
@ -33,11 +33,11 @@ return static function (RouteCollectorProxy $app) {
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($routes as $routeName => $routePath) {
|
foreach ($routes as $routeName => $routePath) {
|
||||||
$group->get($routePath, IndexAction::class)
|
$group->get($routePath, AdminAction::class)
|
||||||
->setName($routeName);
|
->setName($routeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
$group->get('/{routes:.+}', IndexAction::class);
|
$group->get('/{routes:.+}', AdminAction::class);
|
||||||
}
|
}
|
||||||
)->add(Middleware\Module\PanelLayout::class)
|
)->add(Middleware\Module\PanelLayout::class)
|
||||||
->add(Middleware\EnableView::class)
|
->add(Middleware\EnableView::class)
|
||||||
|
|
|
@ -68,31 +68,67 @@ return static function (RouteCollectorProxy $group) {
|
||||||
$group->get(
|
$group->get(
|
||||||
'/art/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.jpg]',
|
'/art/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.jpg]',
|
||||||
Controller\Api\Stations\Art\GetArtAction::class
|
Controller\Api\Stations\Art\GetArtAction::class
|
||||||
)->setName('api:stations:media:art');
|
)->setName('api:stations:media:art')
|
||||||
|
->add(new Middleware\Cache\SetStaticFileCache());
|
||||||
|
|
||||||
// Streamer Art
|
// Streamer Art
|
||||||
$group->get(
|
$group->get(
|
||||||
'/streamer/{id}/art[-{timestamp}.jpg]',
|
'/streamer/{id}/art[-{timestamp}.jpg]',
|
||||||
Controller\Api\Stations\Streamers\Art\GetArtAction::class
|
Controller\Api\Stations\Streamers\Art\GetArtAction::class
|
||||||
)->setName('api:stations:streamer:art');
|
)->setName('api:stations:streamer:art')
|
||||||
|
->add(new Middleware\Cache\SetStaticFileCache());
|
||||||
|
|
||||||
// Podcast and Episode Art
|
|
||||||
$group->group(
|
$group->group(
|
||||||
'/podcast/{podcast_id}',
|
'/public',
|
||||||
function (RouteCollectorProxy $group) {
|
function (RouteCollectorProxy $group) {
|
||||||
$group->get(
|
// Podcast Public Pages
|
||||||
'/art[-{timestamp}.jpg]',
|
$group->get('/podcasts', Controller\Api\Stations\Podcasts\ListPodcastsAction::class)
|
||||||
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
|
->setName('api:stations:public:podcasts');
|
||||||
)->setName('api:stations:podcast:art');
|
|
||||||
|
|
||||||
$group->get(
|
$group->group(
|
||||||
'/episode/{episode_id}/art[-{timestamp}.jpg]',
|
'/podcast/{podcast_id}',
|
||||||
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
|
function (RouteCollectorProxy $group) {
|
||||||
)->setName('api:stations:podcast:episode:art');
|
$group->get('', Controller\Api\Stations\Podcasts\GetPodcastAction::class)
|
||||||
|
->setName('api:stations:public:podcast');
|
||||||
|
|
||||||
|
$group->get(
|
||||||
|
'/art[-{timestamp}.jpg]',
|
||||||
|
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
|
||||||
|
)->setName('api:stations:public:podcast:art')
|
||||||
|
->add(new Middleware\Cache\SetStaticFileCache());
|
||||||
|
|
||||||
|
$group->get(
|
||||||
|
'/episodes',
|
||||||
|
Controller\Api\Stations\Podcasts\Episodes\ListEpisodesAction::class
|
||||||
|
)->setName('api:stations:public:podcast:episodes');
|
||||||
|
|
||||||
|
$group->group(
|
||||||
|
'/episode/{episode_id}',
|
||||||
|
function (RouteCollectorProxy $group) {
|
||||||
|
$group->get(
|
||||||
|
'',
|
||||||
|
Controller\Api\Stations\Podcasts\Episodes\GetEpisodeAction::class
|
||||||
|
)->setName('api:stations:public:podcast:episode')
|
||||||
|
->add(new Middleware\Cache\SetStaticFileCache());
|
||||||
|
|
||||||
|
$group->get(
|
||||||
|
'/art[-{timestamp}.jpg]',
|
||||||
|
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
|
||||||
|
)->setName('api:stations:public:podcast:episode:art')
|
||||||
|
->add(new Middleware\Cache\SetStaticFileCache());
|
||||||
|
|
||||||
|
$group->get(
|
||||||
|
'/download',
|
||||||
|
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
|
||||||
|
)->setName('api:stations:public:podcast:episode:download');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class)
|
||||||
|
->add(Middleware\GetAndRequirePodcast::class);
|
||||||
}
|
}
|
||||||
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
|
);
|
||||||
}
|
}
|
||||||
)->add(new Middleware\Cache\SetStaticFileCache())
|
)->add(Middleware\RequireStation::class)
|
||||||
->add(Middleware\RequireStation::class)
|
|
||||||
->add(Middleware\GetStation::class);
|
->add(Middleware\GetStation::class);
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,7 +19,7 @@ return static function (RouteCollectorProxy $group) {
|
||||||
->setName('api:stations:index')
|
->setName('api:stations:index')
|
||||||
->add(new Middleware\RateLimit('api', 5, 2));
|
->add(new Middleware\RateLimit('api', 5, 2));
|
||||||
|
|
||||||
$group->get('/nowplaying', Controller\Api\NowPlayingAction::class . ':getAction');
|
$group->get('/nowplaying', Controller\Api\NowPlayingAction::class);
|
||||||
|
|
||||||
$group->get('/schedule', Controller\Api\Stations\ScheduleAction::class)
|
$group->get('/schedule', Controller\Api\Stations\ScheduleAction::class)
|
||||||
->setName('api:stations:schedule');
|
->setName('api:stations:schedule');
|
||||||
|
@ -48,38 +48,7 @@ return static function (RouteCollectorProxy $group) {
|
||||||
->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand))
|
->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand))
|
||||||
->add(new Middleware\RateLimit('ondemand', 1, 2));
|
->add(new Middleware\RateLimit('ondemand', 1, 2));
|
||||||
|
|
||||||
// Podcast Public Pages
|
// NOTE: See ./api_public.php for podcast public pages.
|
||||||
$group->group(
|
|
||||||
'/podcast/{podcast_id}',
|
|
||||||
function (RouteCollectorProxy $group) {
|
|
||||||
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
|
|
||||||
->setName('api:stations:podcast');
|
|
||||||
|
|
||||||
// See ./api_public for podcast art.
|
|
||||||
|
|
||||||
$group->get(
|
|
||||||
'/episodes',
|
|
||||||
Controller\Api\Stations\PodcastEpisodesController::class . ':listAction'
|
|
||||||
)->setName('api:stations:podcast:episodes');
|
|
||||||
|
|
||||||
$group->group(
|
|
||||||
'/episode/{episode_id}',
|
|
||||||
function (RouteCollectorProxy $group) {
|
|
||||||
$group->get(
|
|
||||||
'',
|
|
||||||
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
|
|
||||||
)->setName('api:stations:podcast:episode');
|
|
||||||
|
|
||||||
$group->get(
|
|
||||||
'/download',
|
|
||||||
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
|
|
||||||
)->setName('api:stations:podcast:episode:download');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)->add(Middleware\RequirePublishedPodcastEpisodeMiddleware::class);
|
|
||||||
|
|
||||||
// NOTE: See ./api_public.php for media art public path.
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Authenticated Functions
|
* Authenticated Functions
|
||||||
|
@ -159,6 +128,9 @@ return static function (RouteCollectorProxy $group) {
|
||||||
$group->group(
|
$group->group(
|
||||||
'/podcast/{podcast_id}',
|
'/podcast/{podcast_id}',
|
||||||
function (RouteCollectorProxy $group) {
|
function (RouteCollectorProxy $group) {
|
||||||
|
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
|
||||||
|
->setName('api:stations:podcast');
|
||||||
|
|
||||||
$group->put('', Controller\Api\Stations\PodcastsController::class . ':editAction');
|
$group->put('', Controller\Api\Stations\PodcastsController::class . ':editAction');
|
||||||
|
|
||||||
$group->delete(
|
$group->delete(
|
||||||
|
@ -166,16 +138,26 @@ return static function (RouteCollectorProxy $group) {
|
||||||
Controller\Api\Stations\PodcastsController::class . ':deleteAction'
|
Controller\Api\Stations\PodcastsController::class . ':deleteAction'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$group->get(
|
||||||
|
'/art[-{timestamp}.jpg]',
|
||||||
|
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
|
||||||
|
)->setName('api:stations:podcast:art');
|
||||||
|
|
||||||
$group->post(
|
$group->post(
|
||||||
'/art',
|
'/art',
|
||||||
Controller\Api\Stations\Podcasts\Art\PostArtAction::class
|
Controller\Api\Stations\Podcasts\Art\PostArtAction::class
|
||||||
)->setName('api:stations:podcast:art-internal');
|
);
|
||||||
|
|
||||||
$group->delete(
|
$group->delete(
|
||||||
'/art',
|
'/art',
|
||||||
Controller\Api\Stations\Podcasts\Art\DeleteArtAction::class
|
Controller\Api\Stations\Podcasts\Art\DeleteArtAction::class
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$group->get(
|
||||||
|
'/episodes',
|
||||||
|
Controller\Api\Stations\PodcastEpisodesController::class . ':listAction'
|
||||||
|
)->setName('api:stations:podcast:episodes');
|
||||||
|
|
||||||
$group->post(
|
$group->post(
|
||||||
'/episodes',
|
'/episodes',
|
||||||
Controller\Api\Stations\PodcastEpisodesController::class . ':createAction'
|
Controller\Api\Stations\PodcastEpisodesController::class . ':createAction'
|
||||||
|
@ -194,6 +176,11 @@ return static function (RouteCollectorProxy $group) {
|
||||||
$group->group(
|
$group->group(
|
||||||
'/episode/{episode_id}',
|
'/episode/{episode_id}',
|
||||||
function (RouteCollectorProxy $group) {
|
function (RouteCollectorProxy $group) {
|
||||||
|
$group->get(
|
||||||
|
'',
|
||||||
|
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
|
||||||
|
)->setName('api:stations:podcast:episode');
|
||||||
|
|
||||||
$group->put(
|
$group->put(
|
||||||
'',
|
'',
|
||||||
Controller\Api\Stations\PodcastEpisodesController::class . ':editAction'
|
Controller\Api\Stations\PodcastEpisodesController::class . ':editAction'
|
||||||
|
@ -205,20 +192,30 @@ return static function (RouteCollectorProxy $group) {
|
||||||
. ':deleteAction'
|
. ':deleteAction'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$group->get(
|
||||||
|
'/art[-{timestamp}.jpg]',
|
||||||
|
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
|
||||||
|
)->setName('api:stations:podcast:episode:art');
|
||||||
|
|
||||||
$group->post(
|
$group->post(
|
||||||
'/art',
|
'/art',
|
||||||
Controller\Api\Stations\Podcasts\Episodes\Art\PostArtAction::class
|
Controller\Api\Stations\Podcasts\Episodes\Art\PostArtAction::class
|
||||||
)->setName('api:stations:podcast:episode:art-internal');
|
);
|
||||||
|
|
||||||
$group->delete(
|
$group->delete(
|
||||||
'/art',
|
'/art',
|
||||||
Controller\Api\Stations\Podcasts\Episodes\Art\DeleteArtAction::class
|
Controller\Api\Stations\Podcasts\Episodes\Art\DeleteArtAction::class
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$group->get(
|
||||||
|
'/media',
|
||||||
|
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
|
||||||
|
)->setName('api:stations:podcast:episode:media');
|
||||||
|
|
||||||
$group->post(
|
$group->post(
|
||||||
'/media',
|
'/media',
|
||||||
Controller\Api\Stations\Podcasts\Episodes\Media\PostMediaAction::class
|
Controller\Api\Stations\Podcasts\Episodes\Media\PostMediaAction::class
|
||||||
)->setName('api:stations:podcast:episode:media-internal');
|
);
|
||||||
|
|
||||||
$group->delete(
|
$group->delete(
|
||||||
'/media',
|
'/media',
|
||||||
|
@ -227,7 +224,7 @@ return static function (RouteCollectorProxy $group) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
)->add(Middleware\GetAndRequirePodcast::class);
|
||||||
}
|
}
|
||||||
)->add(new Middleware\Permissions(StationPermissions::Podcasts, true));
|
)->add(new Middleware\Permissions(StationPermissions::Podcasts, true));
|
||||||
|
|
||||||
|
|
|
@ -57,20 +57,16 @@ return static function (RouteCollectorProxy $app) {
|
||||||
$group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class)
|
$group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class)
|
||||||
->setName('public:schedule');
|
->setName('public:schedule');
|
||||||
|
|
||||||
$group->get('/podcasts', Controller\Frontend\PublicPages\PodcastsAction::class)
|
$routes = [
|
||||||
->setName('public:podcasts');
|
'public:podcasts' => '/podcasts',
|
||||||
|
'public:podcast' => '/podcast/{podcast_id}',
|
||||||
|
'public:podcast:episode' => '/podcast/{podcast_id}/episode/{episode_id}',
|
||||||
|
];
|
||||||
|
|
||||||
$group->get(
|
foreach ($routes as $routeName => $routePath) {
|
||||||
'/podcast/{podcast_id}/episodes',
|
$group->get($routePath, Controller\Frontend\PublicPages\PodcastsAction::class)
|
||||||
Controller\Frontend\PublicPages\PodcastEpisodesAction::class
|
->setName($routeName);
|
||||||
)
|
}
|
||||||
->setName('public:podcast:episodes');
|
|
||||||
|
|
||||||
$group->get(
|
|
||||||
'/podcast/{podcast_id}/episode/{episode_id}',
|
|
||||||
Controller\Frontend\PublicPages\PodcastEpisodeAction::class
|
|
||||||
)
|
|
||||||
->setName('public:podcast:episode');
|
|
||||||
|
|
||||||
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class)
|
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class)
|
||||||
->setName('public:podcast:feed');
|
->setName('public:podcast:feed');
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Controller\Stations\IndexAction;
|
use App\Controller\StationsAction;
|
||||||
use App\Enums\StationPermissions;
|
use App\Enums\StationPermissions;
|
||||||
use App\Middleware;
|
use App\Middleware;
|
||||||
use Slim\Routing\RouteCollectorProxy;
|
use Slim\Routing\RouteCollectorProxy;
|
||||||
|
@ -23,6 +23,7 @@ return static function (RouteCollectorProxy $app) {
|
||||||
'stations:logs' => '/logs',
|
'stations:logs' => '/logs',
|
||||||
'stations:playlists:index' => '/playlists',
|
'stations:playlists:index' => '/playlists',
|
||||||
'stations:podcasts:index' => '/podcasts',
|
'stations:podcasts:index' => '/podcasts',
|
||||||
|
'stations:podcast:episodes' => '/podcast/{podcast_id}',
|
||||||
'stations:mounts:index' => '/mounts',
|
'stations:mounts:index' => '/mounts',
|
||||||
'stations:profile:index' => '/profile',
|
'stations:profile:index' => '/profile',
|
||||||
'stations:profile:edit' => '/profile/edit',
|
'stations:profile:edit' => '/profile/edit',
|
||||||
|
@ -40,11 +41,11 @@ return static function (RouteCollectorProxy $app) {
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($routes as $routeName => $routePath) {
|
foreach ($routes as $routeName => $routePath) {
|
||||||
$group->get($routePath, IndexAction::class)
|
$group->get($routePath, StationsAction::class)
|
||||||
->setName($routeName);
|
->setName($routeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
$group->get('/{routes:.+}', IndexAction::class);
|
$group->get('/{routes:.+}', StationsAction::class);
|
||||||
}
|
}
|
||||||
)->add(Middleware\Module\PanelLayout::class)
|
)->add(Middleware\Module\PanelLayout::class)
|
||||||
->add(new Middleware\Permissions(StationPermissions::View, true))
|
->add(new Middleware\Permissions(StationPermissions::View, true))
|
||||||
|
|
|
@ -155,7 +155,7 @@ return [
|
||||||
$cacheInterface = new Symfony\Component\Cache\Adapter\ArrayAdapter();
|
$cacheInterface = new Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||||
} elseif ($redisFactory->isSupported()) {
|
} elseif ($redisFactory->isSupported()) {
|
||||||
$cacheInterface = new Symfony\Component\Cache\Adapter\RedisAdapter(
|
$cacheInterface = new Symfony\Component\Cache\Adapter\RedisAdapter(
|
||||||
$redisFactory->createInstance(),
|
$redisFactory->getInstance(),
|
||||||
marshaller: new Symfony\Component\Cache\Marshaller\DefaultMarshaller(
|
marshaller: new Symfony\Component\Cache\Marshaller\DefaultMarshaller(
|
||||||
$environment->isProduction() ? null : false
|
$environment->isProduction() ? null : false
|
||||||
)
|
)
|
||||||
|
@ -190,7 +190,7 @@ return [
|
||||||
Environment $environment,
|
Environment $environment,
|
||||||
App\Service\RedisFactory $redisFactory
|
App\Service\RedisFactory $redisFactory
|
||||||
) => ($redisFactory->isSupported())
|
) => ($redisFactory->isSupported())
|
||||||
? new Symfony\Component\Lock\Store\RedisStore($redisFactory->createInstance())
|
? new Symfony\Component\Lock\Store\RedisStore($redisFactory->getInstance())
|
||||||
: new Symfony\Component\Lock\Store\FlockStore($environment->getTempDirectory()),
|
: new Symfony\Component\Lock\Store\FlockStore($environment->getTempDirectory()),
|
||||||
|
|
||||||
// Console
|
// Console
|
||||||
|
@ -330,8 +330,13 @@ return [
|
||||||
// Register plugin-provided message queue receivers
|
// Register plugin-provided message queue receivers
|
||||||
$receivers = $plugins->registerMessageQueueReceivers($receivers);
|
$receivers = $plugins->registerMessageQueueReceivers($receivers);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var class-string $messageClass
|
||||||
|
* @var class-string $handlerClass
|
||||||
|
*/
|
||||||
foreach ($receivers as $messageClass => $handlerClass) {
|
foreach ($receivers as $messageClass => $handlerClass) {
|
||||||
$handlers[$messageClass][] = static function ($message) use ($handlerClass, $di) {
|
$handlers[$messageClass][] = static function ($message) use ($handlerClass, $di) {
|
||||||
|
/** @var callable $obj */
|
||||||
$obj = $di->get($handlerClass);
|
$obj = $di->get($handlerClass);
|
||||||
return $obj($message);
|
return $obj($message);
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,6 +8,9 @@ services:
|
||||||
- "127.0.0.1:3306:3306" # MariaDB
|
- "127.0.0.1:3306:3306" # MariaDB
|
||||||
- "127.0.0.1:6025:6025" # Centrifugo
|
- "127.0.0.1:6025:6025" # Centrifugo
|
||||||
- "127.0.0.1:6379:6379" # Redis
|
- "127.0.0.1:6379:6379" # Redis
|
||||||
|
environment:
|
||||||
|
VAR_DUMPER_FORMAT: server
|
||||||
|
VAR_DUMPER_SERVER: host.docker.internal:9912
|
||||||
volumes:
|
volumes:
|
||||||
- $PWD/util/local_ssl/default.crt:/var/azuracast/storage/acme/ssl.crt:ro
|
- $PWD/util/local_ssl/default.crt:/var/azuracast/storage/acme/ssl.crt:ro
|
||||||
- $PWD/util/local_ssl/default.key:/var/azuracast/storage/acme/ssl.key:ro
|
- $PWD/util/local_ssl/default.key:/var/azuracast/storage/acme/ssl.key:ro
|
||||||
|
|
10
docker.sh
|
@ -536,16 +536,8 @@ install-dev() {
|
||||||
.env --file .env set AZURACAST_PODMAN_MODE=true
|
.env --file .env set AZURACAST_PODMAN_MODE=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chmod 777 ./frontend/ ./web/ ./vendor/ \
|
|
||||||
./web/static/ ./web/static/api/ \
|
|
||||||
./web/static/dist/ ./web/static/img/
|
|
||||||
|
|
||||||
dc build
|
dc build
|
||||||
dc run --rm web -- azuracast_install "$@"
|
dc run --rm web -- azuracast_dev_install "$@"
|
||||||
|
|
||||||
dc -p azuracast_frontend -f docker-compose.frontend.yml build
|
|
||||||
dc -p azuracast_frontend -f docker-compose.frontend.yml run --rm frontend npm run build
|
|
||||||
|
|
||||||
dc up -d
|
dc up -d
|
||||||
exit
|
exit
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@ import * as url from 'url';
|
||||||
import {JSDOM} from "jsdom";
|
import {JSDOM} from "jsdom";
|
||||||
|
|
||||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
|
||||||
const outputPath = path.resolve(__dirname, './vue/components/Common/icons.ts');
|
const outputPath = path.resolve(__dirname, './src/components/Common/icons.ts');
|
||||||
const iconsPath = path.resolve(__dirname, './icons');
|
const iconsPath = path.resolve(__dirname, './src/icons');
|
||||||
|
|
||||||
const materialIconsViewBox = '0 -960 960 960';
|
const materialIconsViewBox = '0 -960 960 960';
|
||||||
const bootstrapIconsViewBox = '0 0 16 16';
|
const bootstrapIconsViewBox = '0 0 16 16';
|
||||||
|
@ -64,7 +64,7 @@ function genIconComponents() {
|
||||||
svgViewBox = `'${svgViewBox}'`;
|
svgViewBox = `'${svgViewBox}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const svgContents = svgInner.innerHTML.trim().replace(
|
const svgContents = svgInner.innerHTML.trim().replace("\n", "").replace(
|
||||||
' xmlns="http://www.w3.org/2000/svg"',
|
' xmlns="http://www.w3.org/2000/svg"',
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite",
|
"serve": "vite",
|
||||||
|
"generate-icons": "node genicons.mjs",
|
||||||
"generate-locales": "vue-gettext-extract",
|
"generate-locales": "vue-gettext-extract",
|
||||||
"generate-api": "swagger-typescript-api --path http://localhost/api/openapi.yml --output ./src/entities --name ApiInterfaces.ts --no-client"
|
"generate-api": "swagger-typescript-api --path http://localhost/api/openapi.yml --output ./src/entities --name ApiInterfaces.ts --no-client"
|
||||||
},
|
},
|
||||||
|
@ -42,7 +43,7 @@
|
||||||
"typescript": "^5.3.2",
|
"typescript": "^5.3.2",
|
||||||
"vue": "^3.2",
|
"vue": "^3.2",
|
||||||
"vue-axios": "^3.5",
|
"vue-axios": "^3.5",
|
||||||
"vue-codemirror6": "^1",
|
"vue-codemirror6": "1.2.0",
|
||||||
"vue-easy-lightbox": "^1.16",
|
"vue-easy-lightbox": "^1.16",
|
||||||
"vue-router": "^4.2.4",
|
"vue-router": "^4.2.4",
|
||||||
"vue3-gettext": "3.0.0-beta.4",
|
"vue3-gettext": "3.0.0-beta.4",
|
||||||
|
@ -61,11 +62,11 @@
|
||||||
"@vitejs/plugin-vue": "^5",
|
"@vitejs/plugin-vue": "^5",
|
||||||
"@vue/eslint-config-typescript": "^12",
|
"@vue/eslint-config-typescript": "^12",
|
||||||
"del": "^7",
|
"del": "^7",
|
||||||
"esbuild": "^0.19.9",
|
"esbuild": "^0.20",
|
||||||
"eslint": "^8.45.0",
|
"eslint": "^8.45.0",
|
||||||
"eslint-plugin-vue": "^9.8.0",
|
"eslint-plugin-vue": "^9.8.0",
|
||||||
"glob": "^10.2.7",
|
"glob": "^10.2.7",
|
||||||
"jsdom": "^23",
|
"jsdom": "^24",
|
||||||
"sass": "^1.39.2",
|
"sass": "^1.39.2",
|
||||||
"svg.js": "^2.7.1",
|
"svg.js": "^2.7.1",
|
||||||
"swagger-typescript-api": "^13.0.3",
|
"swagger-typescript-api": "^13.0.3",
|
||||||
|
|
|
@ -151,7 +151,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Icon from "~/components/Common/Icon.vue";
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue";
|
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||||
import AdminBackupsLastOutputModal from "./Backups/LastOutputModal.vue";
|
import AdminBackupsLastOutputModal from "./Backups/LastOutputModal.vue";
|
||||||
import formatFileSize from "~/functions/formatFileSize";
|
import formatFileSize from "~/functions/formatFileSize";
|
||||||
import AdminBackupsConfigureModal from "~/components/Admin/Backups/ConfigureModal.vue";
|
import AdminBackupsConfigureModal from "~/components/Admin/Backups/ConfigureModal.vue";
|
||||||
|
@ -196,7 +196,7 @@ const settings = ref({...blankSettings});
|
||||||
|
|
||||||
const {$gettext} = useTranslate();
|
const {$gettext} = useTranslate();
|
||||||
const {timeConfig} = useAzuraCast();
|
const {timeConfig} = useAzuraCast();
|
||||||
const {DateTime} = useLuxon();
|
const {DateTime, timestampToRelative} = useLuxon();
|
||||||
|
|
||||||
const fields: DataTableField[] = [
|
const fields: DataTableField[] = [
|
||||||
{
|
{
|
||||||
|
@ -250,8 +250,6 @@ const relist = () => {
|
||||||
|
|
||||||
onMounted(relist);
|
onMounted(relist);
|
||||||
|
|
||||||
const {timestampToRelative} = useLuxon();
|
|
||||||
|
|
||||||
const $lastOutputModal = ref<InstanceType<typeof AdminBackupsLastOutputModal> | null>(null);
|
const $lastOutputModal = ref<InstanceType<typeof AdminBackupsLastOutputModal> | null>(null);
|
||||||
const showLastOutput = () => {
|
const showLastOutput = () => {
|
||||||
$lastOutputModal.value?.show();
|
$lastOutputModal.value?.show();
|
||||||
|
|
|
@ -28,7 +28,6 @@
|
||||||
class="col-md-6"
|
class="col-md-6"
|
||||||
:field="v$.backup_time_code"
|
:field="v$.backup_time_code"
|
||||||
:label="$gettext('Scheduled Backup Time')"
|
:label="$gettext('Scheduled Backup Time')"
|
||||||
:description="$gettext('If the end time is before the start time, the playlist will play overnight.')"
|
|
||||||
>
|
>
|
||||||
<template #default="slotProps">
|
<template #default="slotProps">
|
||||||
<time-code
|
<time-code
|
||||||
|
|
|
@ -41,6 +41,14 @@
|
||||||
>
|
>
|
||||||
{{ $gettext('Clone') }}
|
{{ $gettext('Clone') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="(row.item.is_enabled) ? 'btn-warning' : 'btn-success'"
|
||||||
|
@click="doToggle(row.item)"
|
||||||
|
>
|
||||||
|
{{ (row.item.is_enabled) ? $gettext('Disable') : $gettext('Enable') }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
@ -74,7 +82,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||||
import AdminStationsEditModal from "./Stations/EditModal.vue";
|
import AdminStationsEditModal from "./Stations/EditModal.vue";
|
||||||
import {get} from "lodash";
|
import {get} from "lodash";
|
||||||
import AdminStationsCloneModal from "./Stations/CloneModal.vue";
|
import AdminStationsCloneModal from "./Stations/CloneModal.vue";
|
||||||
|
@ -89,6 +97,9 @@ import CardPage from "~/components/Common/CardPage.vue";
|
||||||
import {getApiUrl} from "~/router";
|
import {getApiUrl} from "~/router";
|
||||||
import AddButton from "~/components/Common/AddButton.vue";
|
import AddButton from "~/components/Common/AddButton.vue";
|
||||||
import CloneModal from "~/components/Admin/Stations/CloneModal.vue";
|
import CloneModal from "~/components/Admin/Stations/CloneModal.vue";
|
||||||
|
import {useSweetAlert} from "~/vendor/sweetalert.ts";
|
||||||
|
import {useNotify} from "~/functions/useNotify.ts";
|
||||||
|
import {useAxios} from "~/vendor/axios.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
...stationFormProps,
|
...stationFormProps,
|
||||||
|
@ -149,6 +160,29 @@ const doClone = (stationName, url) => {
|
||||||
$cloneModal.value.create(stationName, url);
|
$cloneModal.value.create(stationName, url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {showAlert} = useSweetAlert();
|
||||||
|
const {notifySuccess} = useNotify();
|
||||||
|
const {axios} = useAxios();
|
||||||
|
|
||||||
|
const doToggle = (station) => {
|
||||||
|
const title = (station.is_enabled)
|
||||||
|
? $gettext('Disable station?')
|
||||||
|
: $gettext('Enable station?');
|
||||||
|
|
||||||
|
showAlert({
|
||||||
|
title: title
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.value) {
|
||||||
|
axios.put(station.links.self, {
|
||||||
|
is_enabled: !station.is_enabled
|
||||||
|
}).then((resp) => {
|
||||||
|
notifySuccess(resp.data.message);
|
||||||
|
relist();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const {doDelete} = useConfirmAndDelete(
|
const {doDelete} = useConfirmAndDelete(
|
||||||
$gettext('Delete Station?'),
|
$gettext('Delete Station?'),
|
||||||
relist
|
relist
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
class="col-md-4"
|
class="col-md-4"
|
||||||
:field="v$.backend_config.hls_segment_length"
|
:field="v$.backend_config.hls_segment_length"
|
||||||
input-type="number"
|
input-type="number"
|
||||||
:input-attrs="{ min: '0', max: '60' }"
|
:input-attrs="{ min: '0', max: '9999' }"
|
||||||
advanced
|
advanced
|
||||||
:label="$gettext('Segment Length (Seconds)')"
|
:label="$gettext('Segment Length (Seconds)')"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -43,13 +43,10 @@
|
||||||
:label="$gettext('Live Broadcast Recording Format')"
|
:label="$gettext('Live Broadcast Recording Format')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form-group-multi-check
|
<bitrate-options
|
||||||
id="edit_form_backend_record_streams_bitrate"
|
id="edit_form_backend_record_streams_bitrate"
|
||||||
class="col-md-6"
|
class="col-md-6"
|
||||||
:field="v$.backend_config.record_streams_bitrate"
|
:field="v$.backend_config.record_streams_bitrate"
|
||||||
:options="recordBitrateOptions"
|
|
||||||
stacked
|
|
||||||
radio
|
|
||||||
:label="$gettext('Live Broadcast Recording Bitrate (kbps)')"
|
:label="$gettext('Live Broadcast Recording Bitrate (kbps)')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -140,6 +137,7 @@ import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||||
import {numeric} from "@vuelidate/validators";
|
import {numeric} from "@vuelidate/validators";
|
||||||
import {useAzuraCast} from "~/vendor/azuracast";
|
import {useAzuraCast} from "~/vendor/azuracast";
|
||||||
import Tab from "~/components/Common/Tab.vue";
|
import Tab from "~/components/Common/Tab.vue";
|
||||||
|
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
form: {
|
form: {
|
||||||
|
@ -250,17 +248,4 @@ const recordStreamsOptions = computed(() => {
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
const recordBitrateOptions = computed(() => {
|
|
||||||
return [
|
|
||||||
{text: '32', value: 32},
|
|
||||||
{text: '48', value: 48},
|
|
||||||
{text: '64', value: 64},
|
|
||||||
{text: '96', value: 96},
|
|
||||||
{text: '128', value: 128},
|
|
||||||
{text: '192', value: 192},
|
|
||||||
{text: '256', value: 256},
|
|
||||||
{text: '320', value: 320}
|
|
||||||
];
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import {getApiUrl} from "~/router.ts";
|
import {getApiUrl} from "~/router.ts";
|
||||||
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
|
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
|
||||||
|
import {RouteRecordRaw} from "vue-router";
|
||||||
|
|
||||||
export default function useAdminRoutes() {
|
export default function useAdminRoutes(): RouteRecordRaw[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|
|
@ -0,0 +1,153 @@
|
||||||
|
<template>
|
||||||
|
<form-group
|
||||||
|
v-bind="$attrs"
|
||||||
|
:id="id"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-if="label || slots.label"
|
||||||
|
#label="slotProps"
|
||||||
|
>
|
||||||
|
<form-label
|
||||||
|
:is-required="isRequired"
|
||||||
|
:advanced="advanced"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
name="label"
|
||||||
|
v-bind="slotProps"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</slot>
|
||||||
|
</form-label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<form-multi-check
|
||||||
|
:id="id"
|
||||||
|
v-model="radioField"
|
||||||
|
:name="name || id"
|
||||||
|
:options="bitrateOptions"
|
||||||
|
radio
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(_, slot) of useSlotsExcept(['default', 'label', 'description'])"
|
||||||
|
#[slot]="scope"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
:name="slot"
|
||||||
|
v-bind="scope"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #label(custom)>
|
||||||
|
{{ $gettext('Custom') }}
|
||||||
|
|
||||||
|
<input
|
||||||
|
:id="id+'_custom'"
|
||||||
|
v-model="customField"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="4096"
|
||||||
|
step="1"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</form-multi-check>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-if="description || slots.description"
|
||||||
|
#description="slotProps"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
v-bind="slotProps"
|
||||||
|
name="description"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
</form-group>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {formFieldProps, useFormField} from "~/components/Form/useFormField";
|
||||||
|
import {computed, ComputedRef, useSlots} from "vue";
|
||||||
|
import {includes, map} from "lodash";
|
||||||
|
import useSlotsExcept from "~/functions/useSlotsExcept.ts";
|
||||||
|
import FormMultiCheck from "~/components/Form/FormMultiCheck.vue";
|
||||||
|
import FormLabel from "~/components/Form/FormLabel.vue";
|
||||||
|
import FormGroup from "~/components/Form/FormGroup.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
...formFieldProps,
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
advanced: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
const {model} = useFormField(props, emit);
|
||||||
|
|
||||||
|
const radioBitrates = [
|
||||||
|
32, 48, 64, 96, 128, 192, 256, 320
|
||||||
|
];
|
||||||
|
|
||||||
|
const customField: ComputedRef<number | null> = computed({
|
||||||
|
get() {
|
||||||
|
return includes(radioBitrates, model.value)
|
||||||
|
? ''
|
||||||
|
: model.value;
|
||||||
|
},
|
||||||
|
set(newValue) {
|
||||||
|
model.value = newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const radioField: ComputedRef<number | string | null> = computed({
|
||||||
|
get() {
|
||||||
|
return includes(radioBitrates, model.value)
|
||||||
|
? model.value
|
||||||
|
: 'custom';
|
||||||
|
},
|
||||||
|
set(newValue) {
|
||||||
|
if (newValue !== 'custom') {
|
||||||
|
model.value = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const bitrateOptions = map(
|
||||||
|
radioBitrates,
|
||||||
|
(val) => {
|
||||||
|
return {
|
||||||
|
value: val,
|
||||||
|
text: val
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
bitrateOptions.push({
|
||||||
|
value: 'custom',
|
||||||
|
text: 'Custom'
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:id="id"
|
:id="id"
|
||||||
style="display: contents"
|
class="datatable-wrapper"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="showToolbar"
|
v-if="showToolbar"
|
||||||
|
@ -110,7 +110,7 @@
|
||||||
<div class="px-3 py-1">
|
<div class="px-3 py-1">
|
||||||
<form-multi-check
|
<form-multi-check
|
||||||
id="field_select"
|
id="field_select"
|
||||||
v-model="settings.visibleFieldKeys"
|
v-model="visibleFieldKeys"
|
||||||
:options="selectableFieldOptions"
|
:options="selectableFieldOptions"
|
||||||
stacked
|
stacked
|
||||||
/>
|
/>
|
||||||
|
@ -184,7 +184,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="isLoading">
|
<template v-if="isLoading && hideOnLoading">
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
:colspan="columnCount"
|
:colspan="columnCount"
|
||||||
|
@ -247,6 +247,7 @@
|
||||||
:name="'cell('+column.key+')'"
|
:name="'cell('+column.key+')'"
|
||||||
:column="column"
|
:column="column"
|
||||||
:item="row"
|
:item="row"
|
||||||
|
:is-active="isActiveDetailRow(row)"
|
||||||
:toggle-details="() => toggleDetails(row)"
|
:toggle-details="() => toggleDetails(row)"
|
||||||
>
|
>
|
||||||
{{ getColumnValue(column, row) }}
|
{{ getColumnValue(column, row) }}
|
||||||
|
@ -285,7 +286,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts" generic="Row extends object">
|
||||||
import {filter, forEach, get, includes, indexOf, isEmpty, map, reverse, slice, some} from 'lodash';
|
import {filter, forEach, get, includes, indexOf, isEmpty, map, reverse, slice, some} from 'lodash';
|
||||||
import Icon from './Icon.vue';
|
import Icon from './Icon.vue';
|
||||||
import {computed, onMounted, ref, shallowRef, toRaw, toRef, useSlots, watch} from "vue";
|
import {computed, onMounted, ref, shallowRef, toRaw, toRef, useSlots, watch} from "vue";
|
||||||
|
@ -298,71 +299,43 @@ import useOptionalStorage from "~/functions/useOptionalStorage";
|
||||||
import {IconArrowDropDown, IconArrowDropUp, IconFilterList, IconRefresh, IconSearch} from "~/components/Common/icons";
|
import {IconArrowDropDown, IconArrowDropUp, IconFilterList, IconRefresh, IconSearch} from "~/components/Common/icons";
|
||||||
import {useAzuraCast} from "~/vendor/azuracast.ts";
|
import {useAzuraCast} from "~/vendor/azuracast.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
export interface DataTableProps {
|
||||||
id: {
|
id?: string,
|
||||||
type: String,
|
fields: DataTableField[],
|
||||||
default: null
|
apiUrl?: string, // URL to fetch for server-side data
|
||||||
},
|
items?: Row[], // Array of items for client-side data
|
||||||
apiUrl: {
|
responsive?: boolean | string, // Make table responsive (boolean or CSS class for specific responsiveness width)
|
||||||
type: String,
|
paginated?: boolean, // Enable pagination.
|
||||||
default: null
|
loading?: boolean, // Pass to override the "loading" property for this table.
|
||||||
},
|
hideOnLoading?: boolean, // Replace the table contents with a loading animation when data is being retrieved.
|
||||||
items: {
|
showToolbar?: boolean, // Show the header "Toolbar" with search, refresh, per-page, etc.
|
||||||
type: Array,
|
pageOptions?: number[],
|
||||||
default: null
|
defaultPerPage?: number,
|
||||||
},
|
selectable?: boolean, // Allow selecting individual rows with checkboxes at the side of each row
|
||||||
responsive: {
|
detailed?: boolean, // Allow showing "Detail" panel for selected rows.
|
||||||
type: [Boolean, String],
|
selectFields?: boolean, // Allow selecting which columns are visible.
|
||||||
default: true
|
handleClientSide?: boolean, // Handle searching, sorting and pagination client-side without API calls.
|
||||||
},
|
requestConfig?(config: object): object, // Custom server-side request configuration (pre-request)
|
||||||
paginated: {
|
requestProcess?(rawData: object[]): Row[], // Custom server-side request result processing (post-request)
|
||||||
type: Boolean,
|
}
|
||||||
default: false
|
|
||||||
},
|
const props = withDefaults(defineProps<DataTableProps>(), {
|
||||||
loading: {
|
id: null,
|
||||||
type: Boolean,
|
apiUrl: null,
|
||||||
default: false
|
items: null,
|
||||||
},
|
responsive: () => true,
|
||||||
showToolbar: {
|
paginated: false,
|
||||||
type: Boolean,
|
loading: false,
|
||||||
default: true
|
hideOnLoading: true,
|
||||||
},
|
showToolbar: true,
|
||||||
pageOptions: {
|
pageOptions: () => [10, 25, 50, 100, 250, 500, 0],
|
||||||
type: Array<number>,
|
defaultPerPage: 10,
|
||||||
default: () => [10, 25, 50, 100, 250, 500, 0]
|
selectable: false,
|
||||||
},
|
detailed: false,
|
||||||
defaultPerPage: {
|
selectFields: false,
|
||||||
type: Number,
|
handleClientSide: false,
|
||||||
default: 10
|
requestConfig: undefined,
|
||||||
},
|
requestProcess: undefined
|
||||||
fields: {
|
|
||||||
type: Array<DataTableField>,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
selectable: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
detailed: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
selectFields: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
handleClientSide: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
requestConfig: {
|
|
||||||
type: Function,
|
|
||||||
default: null
|
|
||||||
},
|
|
||||||
requestProcess: {
|
|
||||||
type: Function,
|
|
||||||
default: null
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
|
@ -375,7 +348,7 @@ const emit = defineEmits([
|
||||||
'data-loaded'
|
'data-loaded'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const selectedRows = shallowRef([]);
|
const selectedRows = shallowRef<Row[]>([]);
|
||||||
|
|
||||||
const searchPhrase = ref<string>('');
|
const searchPhrase = ref<string>('');
|
||||||
const currentPage = ref<number>(1);
|
const currentPage = ref<number>(1);
|
||||||
|
@ -390,10 +363,10 @@ watch(toRef(props, 'loading'), (newLoading: boolean) => {
|
||||||
isLoading.value = newLoading;
|
isLoading.value = newLoading;
|
||||||
});
|
});
|
||||||
|
|
||||||
const visibleItems = shallowRef([]);
|
const visibleItems = shallowRef<Row[]>([]);
|
||||||
const totalRows = ref(0);
|
const totalRows = ref(0);
|
||||||
|
|
||||||
const activeDetailsRow = shallowRef(null);
|
const activeDetailsRow = shallowRef<Row>(null);
|
||||||
|
|
||||||
export interface DataTableField {
|
export interface DataTableField {
|
||||||
key: string,
|
key: string,
|
||||||
|
@ -453,15 +426,25 @@ const settings = useOptionalStorage(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleFieldKeys = computed(() => {
|
const visibleFieldKeys = computed({
|
||||||
if (!isEmpty(settings.value.visibleFieldKeys)) {
|
get: () => {
|
||||||
return settings.value.visibleFieldKeys;
|
const settingsKeys = toRaw(settings.value.visibleFieldKeys);
|
||||||
}
|
if (!isEmpty(settingsKeys)) {
|
||||||
|
return settingsKeys;
|
||||||
|
}
|
||||||
|
|
||||||
return map(defaultSelectableFields.value, (field) => field.key);
|
return map(defaultSelectableFields.value, (field) => field.key);
|
||||||
|
},
|
||||||
|
set: (newValue) => {
|
||||||
|
if (isEmpty(newValue)) {
|
||||||
|
newValue = map(defaultSelectableFields.value, (field) => field.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.value.visibleFieldKeys = newValue;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const perPage = computed(() => {
|
const perPage = computed<number>(() => {
|
||||||
if (!props.paginated) {
|
if (!props.paginated) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
@ -487,15 +470,15 @@ const visibleFields = computed<DataTableField[]>(() => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const getPerPageLabel = (num) => {
|
const getPerPageLabel = (num): string => {
|
||||||
return (num === 0) ? 'All' : num.toString();
|
return (num === 0) ? 'All' : num.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const perPageLabel = computed(() => {
|
const perPageLabel = computed<string>(() => {
|
||||||
return getPerPageLabel(perPage.value);
|
return getPerPageLabel(perPage.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const showPagination = computed(() => {
|
const showPagination = computed<boolean>(() => {
|
||||||
return props.paginated && perPage.value !== 0;
|
return props.paginated && perPage.value !== 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -683,7 +666,7 @@ const isAllChecked = computed<boolean>(() => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRowChecked = (row) => {
|
const isRowChecked = (row: Row) => {
|
||||||
return indexOf(selectedRows.value, row) >= 0;
|
return indexOf(selectedRows.value, row) >= 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -711,7 +694,7 @@ const sort = (column: DataTableField) => {
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkRow = (row) => {
|
const checkRow = (row: Row) => {
|
||||||
const newSelectedRows = selectedRows.value;
|
const newSelectedRows = selectedRows.value;
|
||||||
|
|
||||||
if (isRowChecked(row)) {
|
if (isRowChecked(row)) {
|
||||||
|
@ -740,11 +723,11 @@ const checkAll = () => {
|
||||||
selectedRows.value = newSelectedRows;
|
selectedRows.value = newSelectedRows;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActiveDetailRow = (row) => {
|
const isActiveDetailRow = (row: Row) => {
|
||||||
return activeDetailsRow.value === row;
|
return activeDetailsRow.value === row;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDetails = (row) => {
|
const toggleDetails = (row: Row) => {
|
||||||
activeDetailsRow.value = isActiveDetailRow(row)
|
activeDetailsRow.value = isActiveDetailRow(row)
|
||||||
? null
|
? null
|
||||||
: row;
|
: row;
|
||||||
|
@ -758,7 +741,7 @@ const responsiveClass = computed(() => {
|
||||||
return (props.responsive ? 'table-responsive' : '');
|
return (props.responsive ? 'table-responsive' : '');
|
||||||
});
|
});
|
||||||
|
|
||||||
const getColumnValue = (field: DataTableField, row: object): string => {
|
const getColumnValue = (field: DataTableField, row: Row): string => {
|
||||||
const columnValue = get(row, field.key, null);
|
const columnValue = get(row, field.key, null);
|
||||||
|
|
||||||
return (field.formatter)
|
return (field.formatter)
|
||||||
|
|
|
@ -52,21 +52,37 @@ export const IconCheck: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: materialIconsViewBox,
|
||||||
contents: '<path d="M378-235 142-471l52-52 184 184 388-388 52 52-440 440Z"/>'
|
contents: '<path d="M378-235 142-471l52-52 184 184 388-388 52 52-440 440Z"/>'
|
||||||
};
|
};
|
||||||
|
export const IconChevronBarDown: Icon = {
|
||||||
|
viewBox: bootstrapIconsViewBox,
|
||||||
|
contents: '<path fill-rule="evenodd" d="M3.646 4.146a.5.5 0 0 1 .708 0L8 7.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 11.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5"/>'
|
||||||
|
};
|
||||||
|
export const IconChevronBarUp: Icon = {
|
||||||
|
viewBox: bootstrapIconsViewBox,
|
||||||
|
contents: '<path fill-rule="evenodd" d="M3.646 11.854a.5.5 0 0 0 .708 0L8 8.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708M2.4 5.2c0 .22.18.4.4.4h10.4a.4.4 0 0 0 0-.8H2.8a.4.4 0 0 0-.4.4"/>'
|
||||||
|
};
|
||||||
|
export const IconChevronDoubleDown: Icon = {
|
||||||
|
viewBox: bootstrapIconsViewBox,
|
||||||
|
contents: '<path fill-rule="evenodd" d="M1.646 6.646a.5.5 0 0 1 .708 0L8 12.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/> <path xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" d="M1.646 2.646a.5.5 0 0 1 .708 0L8 8.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>'
|
||||||
|
};
|
||||||
|
export const IconChevronDoubleUp: Icon = {
|
||||||
|
viewBox: bootstrapIconsViewBox,
|
||||||
|
contents: '<path fill-rule="evenodd" d="M7.646 2.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 3.707 2.354 9.354a.5.5 0 1 1-.708-.708z"/> <path xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" d="M7.646 6.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 7.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>'
|
||||||
|
};
|
||||||
export const IconChevronDown: Icon = {
|
export const IconChevronDown: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: bootstrapIconsViewBox,
|
||||||
contents: '<path d="M480-335 230-585l53-53 197 199 198-198 52 53-250 249Z"/>'
|
contents: '<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>'
|
||||||
};
|
};
|
||||||
export const IconChevronLeft: Icon = {
|
export const IconChevronLeft: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: bootstrapIconsViewBox,
|
||||||
contents: '<path d="M562-231 311-482l251-251 52 52-199 199 199 199-52 52Z"/>'
|
contents: '<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/>'
|
||||||
};
|
};
|
||||||
export const IconChevronRight: Icon = {
|
export const IconChevronRight: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: bootstrapIconsViewBox,
|
||||||
contents: '<path d="M522-482 323-681l52-52 251 251-251 251-52-52 199-199Z"/>'
|
contents: '<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/>'
|
||||||
};
|
};
|
||||||
export const IconChevronUp: Icon = {
|
export const IconChevronUp: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: bootstrapIconsViewBox,
|
||||||
contents: '<path d="m283-335-53-53 250-250 250 249-52 53-198-198-197 199Z"/>'
|
contents: '<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>'
|
||||||
};
|
};
|
||||||
export const IconClearAll: Icon = {
|
export const IconClearAll: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: materialIconsViewBox,
|
||||||
|
@ -80,6 +96,10 @@ export const IconCode: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: materialIconsViewBox,
|
||||||
contents: '<path d="M318-232 68-483l253-253 52 53-200 200 198 198-53 53Zm322 2-52-53 201-201-198-198 51-51 251 250-253 253Z"/>'
|
contents: '<path d="M318-232 68-483l253-253 52 53-200 200 198 198-53 53Zm322 2-52-53 201-201-198-198 51-51 251 250-253 253Z"/>'
|
||||||
};
|
};
|
||||||
|
export const IconContract: Icon = {
|
||||||
|
viewBox: bootstrapIconsViewBox,
|
||||||
|
contents: '<path fill-rule="evenodd" d="M3.646 14.854a.5.5 0 0 0 .708 0L8 11.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708m0-13.708a.5.5 0 0 1 .708 0L8 4.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8"/>'
|
||||||
|
};
|
||||||
export const IconCopy: Icon = {
|
export const IconCopy: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: materialIconsViewBox,
|
||||||
contents: '<path d="M305-185q-29.725 0-51.363-22.137Q232-229.275 232-258v-564q0-28.725 21.637-50.862Q275.275-895 305-895h444q28.725 0 50.862 22.138Q822-850.725 822-822v564q0 28.725-22.138 50.863Q777.725-185 749-185H305ZM172-52q-29.725 0-51.363-22.138Q99-96.275 99-125v-637h73v637h517v73H172Z"/>'
|
contents: '<path d="M305-185q-29.725 0-51.363-22.137Q232-229.275 232-258v-564q0-28.725 21.637-50.862Q275.275-895 305-895h444q28.725 0 50.862 22.138Q822-850.725 822-822v564q0 28.725-22.138 50.863Q777.725-185 749-185H305ZM172-52q-29.725 0-51.363-22.138Q99-96.275 99-125v-637h73v637h517v73H172Z"/>'
|
||||||
|
@ -112,6 +132,10 @@ export const IconExitToApp: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: materialIconsViewBox,
|
||||||
contents: '<path d="M177-104q-28.725 0-50.863-22.137Q104-148.275 104-177v-194h73v194h606v-606H177v193h-73v-193q0-28.725 22.137-50.862Q148.275-856 177-856h606q28.725 0 50.862 22.138Q856-811.725 856-783v606q0 28.725-22.138 50.863Q811.725-104 783-104H177Zm235-166-54-56 118-118H104v-73h372L358-635l54-55 209 209-209 211Z"/>'
|
contents: '<path d="M177-104q-28.725 0-50.863-22.137Q104-148.275 104-177v-194h73v194h606v-606H177v193h-73v-193q0-28.725 22.137-50.862Q148.275-856 177-856h606q28.725 0 50.862 22.138Q856-811.725 856-783v606q0 28.725-22.138 50.863Q811.725-104 783-104H177Zm235-166-54-56 118-118H104v-73h372L358-635l54-55 209 209-209 211Z"/>'
|
||||||
};
|
};
|
||||||
|
export const IconExpand: Icon = {
|
||||||
|
viewBox: bootstrapIconsViewBox,
|
||||||
|
contents: '<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708m0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708"/>'
|
||||||
|
};
|
||||||
export const IconFastForward: Icon = {
|
export const IconFastForward: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: materialIconsViewBox,
|
||||||
contents: '<path d="M78-221v-518l375 259L78-221Zm431 0v-518l375 259-375 259Z"/>'
|
contents: '<path d="M78-221v-518l375 259L78-221Zm431 0v-518l375 259-375 259Z"/>'
|
||||||
|
@ -260,6 +284,10 @@ export const IconRouter: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: materialIconsViewBox,
|
||||||
contents: '<path d="M160-84q-28.725 0-50.863-21.637Q87-127.275 87-157v-209q0-28.725 22.137-50.862Q131.275-439 160-439h460v-178h73v178h99q28.725 0 50.862 22.138Q865-394.725 865-366v209q0 29.725-22.138 51.363Q820.725-84 792-84H160Zm142-177.105Q302-280 288.895-293q-13.106-13-32-13Q238-306 225-292.895q-13 13.106-13 32Q212-241 225.105-228.5q13.106 12.5 32 12.5Q276-216 289-228.605q13-12.606 13-32.5Zm150 0Q452-280 438.895-293q-13.106-13-32-13Q387-306 374.5-292.895q-12.5 13.106-12.5 32Q362-241 374.605-228.5q12.606 12.5 32.5 12.5Q426-216 439-228.605q13-12.606 13-32.5ZM556.105-216Q575-216 588-228.605q13-12.606 13-32.5Q601-280 587.895-293q-13.106-13-32-13Q537-306 524-292.895q-13 13.106-13 32Q511-241 524.105-228.5q13.106 12.5 32 12.5ZM566-664l-50-50q28-27 62.989-43.5 34.988-16.5 78.068-16.5 42.08 0 77.012 16.5Q769-741 797-714l-49 50q-17-16-40.409-26.5t-50-10.5Q630-701 606-690.5T566-664ZM466-764l-53-53q39.2-39.638 103.1-68.819Q580-915 657-915q76 0 139.5 29T901-817l-54 53q-31-33-79.5-55.5T657-842q-63 0-111 22.5T466-764Z"/>'
|
contents: '<path d="M160-84q-28.725 0-50.863-21.637Q87-127.275 87-157v-209q0-28.725 22.137-50.862Q131.275-439 160-439h460v-178h73v178h99q28.725 0 50.862 22.138Q865-394.725 865-366v209q0 29.725-22.138 51.363Q820.725-84 792-84H160Zm142-177.105Q302-280 288.895-293q-13.106-13-32-13Q238-306 225-292.895q-13 13.106-13 32Q212-241 225.105-228.5q13.106 12.5 32 12.5Q276-216 289-228.605q13-12.606 13-32.5Zm150 0Q452-280 438.895-293q-13.106-13-32-13Q387-306 374.5-292.895q-12.5 13.106-12.5 32Q362-241 374.605-228.5q12.606 12.5 32.5 12.5Q426-216 439-228.605q13-12.606 13-32.5ZM556.105-216Q575-216 588-228.605q13-12.606 13-32.5Q601-280 587.895-293q-13.106-13-32-13Q537-306 524-292.895q-13 13.106-13 32Q511-241 524.105-228.5q13.106 12.5 32 12.5ZM566-664l-50-50q28-27 62.989-43.5 34.988-16.5 78.068-16.5 42.08 0 77.012 16.5Q769-741 797-714l-49 50q-17-16-40.409-26.5t-50-10.5Q630-701 606-690.5T566-664ZM466-764l-53-53q39.2-39.638 103.1-68.819Q580-915 657-915q76 0 139.5 29T901-817l-54 53q-31-33-79.5-55.5T657-842q-63 0-111 22.5T466-764Z"/>'
|
||||||
};
|
};
|
||||||
|
export const IconRss: Icon = {
|
||||||
|
viewBox: bootstrapIconsViewBox,
|
||||||
|
contents: '<path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm1.5 2.5c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1 0-2m0 4a6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1 0-2m.5 7a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>'
|
||||||
|
};
|
||||||
export const IconSearch: Icon = {
|
export const IconSearch: Icon = {
|
||||||
viewBox: materialIconsViewBox,
|
viewBox: materialIconsViewBox,
|
||||||
contents: '<path d="M801-108 537-372q-31 26-72.959 40t-86.603 14q-114.6 0-192.519-78Q107-474 107-586t78-190q78-78 190-78t190 78q78 78 78 190.15 0 43.85-13.5 84.35Q616-461 588-425l266 264-53 53ZM375.5-391q81.75 0 138.125-56.792Q570-504.583 570-586t-56.287-138.208Q457.426-781 375.588-781q-82.671 0-139.13 56.792Q180-667.417 180-586t56.458 138.208Q292.917-391 375.5-391Z"/>'
|
contents: '<path d="M801-108 537-372q-31 26-72.959 40t-86.603 14q-114.6 0-192.519-78Q107-474 107-586t78-190q78-78 190-78t190 78q78 78 78 190.15 0 43.85-13.5 84.35Q616-461 588-425l266 264-53 53ZM375.5-391q81.75 0 138.125-56.792Q570-504.583 570-586t-56.287-138.208Q457.426-781 375.588-781q-82.671 0-139.13 56.792Q180-667.417 180-586t56.458 138.208Q292.917-391 375.5-391Z"/>'
|
||||||
|
|
|
@ -143,126 +143,98 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<loading :loading="stationsLoading">
|
<data-table
|
||||||
<div class="table-responsive">
|
id="dashboard_stations"
|
||||||
<table
|
ref="$datatable"
|
||||||
id="station_dashboard"
|
:fields="stationFields"
|
||||||
class="table table-striped"
|
:api-url="stationsUrl"
|
||||||
>
|
paginated
|
||||||
<colgroup>
|
responsive
|
||||||
<col width="5%">
|
show-toolbar
|
||||||
<col width="30%">
|
:hide-on-loading="false"
|
||||||
<col width="10%">
|
>
|
||||||
<col width="40%">
|
<template #cell(play_button)="{ item }">
|
||||||
<col width="15%">
|
<play-button
|
||||||
</colgroup>
|
class="file-icon btn-lg"
|
||||||
<thead>
|
:url="item.station.listen_url"
|
||||||
<tr>
|
is-stream
|
||||||
<th class="pe-3">
|
/>
|
||||||
|
</template>
|
||||||
</th>
|
<template #cell(name)="{ item }">
|
||||||
<th class="ps-2">
|
<div class="h5 m-0">
|
||||||
{{ $gettext('Station Name') }}
|
{{ item.station.name }}
|
||||||
</th>
|
</div>
|
||||||
<th class="text-center">
|
<div v-if="item.station.is_public">
|
||||||
{{ $gettext('Listeners') }}
|
<a
|
||||||
</th>
|
:href="item.links.public"
|
||||||
<th>{{ $gettext('Now Playing') }}</th>
|
target="_blank"
|
||||||
<th class="text-end" />
|
>
|
||||||
</tr>
|
{{ $gettext('Public Page') }}
|
||||||
</thead>
|
</a>
|
||||||
<tbody>
|
</div>
|
||||||
<tr
|
</template>
|
||||||
v-for="item in stations"
|
<template #cell(listeners)="{ item }">
|
||||||
:key="item.station.id"
|
<span class="pe-1">
|
||||||
class="align-middle"
|
<icon
|
||||||
>
|
class="sm align-middle"
|
||||||
<td class="text-center pe-1">
|
:icon="IconHeadphones"
|
||||||
<play-button
|
/>
|
||||||
class="file-icon btn-lg"
|
</span>
|
||||||
:url="item.station.listen_url"
|
<template v-if="item.links.listeners">
|
||||||
is-stream
|
<a
|
||||||
/>
|
:href="item.links.listeners"
|
||||||
</td>
|
:aria-label="$gettext('View Listener Report')"
|
||||||
<td class="ps-2">
|
>
|
||||||
<div class="h5 m-0">
|
{{ item.listeners.total }}
|
||||||
{{ item.station.name }}
|
</a>
|
||||||
</div>
|
</template>
|
||||||
<div v-if="item.station.is_public">
|
<template v-else>
|
||||||
<a
|
{{ item.listeners.total }}
|
||||||
:href="item.links.public"
|
</template>
|
||||||
target="_blank"
|
</template>
|
||||||
>
|
<template #cell(now_playing)="{ item }">
|
||||||
{{ $gettext('Public Page') }}
|
<div class="d-flex align-items-center">
|
||||||
</a>
|
<album-art
|
||||||
</div>
|
v-if="showAlbumArt"
|
||||||
</td>
|
:src="item.now_playing.song.art"
|
||||||
<td class="text-center">
|
class="flex-shrink-0 pe-3"
|
||||||
<span class="pe-1">
|
/>
|
||||||
<icon
|
|
||||||
class="sm align-middle"
|
|
||||||
:icon="IconHeadphones"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<template v-if="item.links.listeners">
|
|
||||||
<a
|
|
||||||
:href="item.links.listeners"
|
|
||||||
:aria-label="$gettext('View Listener Report')"
|
|
||||||
>
|
|
||||||
{{ item.listeners.total }}
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ item.listeners.total }}
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<album-art
|
|
||||||
v-if="showAlbumArt"
|
|
||||||
:src="item.now_playing.song.art"
|
|
||||||
class="flex-shrink-0 pe-3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!item.is_online"
|
v-if="!item.is_online"
|
||||||
class="flex-fill text-muted"
|
class="flex-fill text-muted"
|
||||||
>
|
>
|
||||||
{{ $gettext('Station Offline') }}
|
{{ $gettext('Station Offline') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="item.now_playing.song.title !== ''"
|
v-else-if="item.now_playing.song.title !== ''"
|
||||||
class="flex-fill"
|
class="flex-fill"
|
||||||
>
|
>
|
||||||
<strong><span class="nowplaying-title">
|
<strong><span class="nowplaying-title">
|
||||||
{{ item.now_playing.song.title }}
|
{{ item.now_playing.song.title }}
|
||||||
</span></strong><br>
|
</span></strong><br>
|
||||||
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
|
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="flex-fill"
|
class="flex-fill"
|
||||||
>
|
>
|
||||||
<strong><span class="nowplaying-title">
|
<strong><span class="nowplaying-title">
|
||||||
{{ item.now_playing.song.text }}
|
{{ item.now_playing.song.text }}
|
||||||
</span></strong>
|
</span></strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</template>
|
||||||
<td class="text-end">
|
<template #cell(actions)="{ item }">
|
||||||
<a
|
<a
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:href="item.links.manage"
|
:href="item.links.manage"
|
||||||
role="button"
|
role="button"
|
||||||
>
|
>
|
||||||
{{ $gettext('Manage') }}
|
{{ $gettext('Manage') }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</template>
|
||||||
</tr>
|
</data-table>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</loading>
|
|
||||||
</card-page>
|
</card-page>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -276,11 +248,10 @@ import Icon from '~/components/Common/Icon.vue';
|
||||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||||
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||||
import {useAxios} from "~/vendor/axios";
|
import {useAxios} from "~/vendor/axios";
|
||||||
import {useAsyncState} from "@vueuse/core";
|
import {useAsyncState, useIntervalFn} from "@vueuse/core";
|
||||||
import {computed, ref} from "vue";
|
import {computed, ref} from "vue";
|
||||||
import DashboardCharts from "~/components/DashboardCharts.vue";
|
import DashboardCharts from "~/components/DashboardCharts.vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import Loading from "~/components/Common/Loading.vue";
|
|
||||||
import Lightbox from "~/components/Common/Lightbox.vue";
|
import Lightbox from "~/components/Common/Lightbox.vue";
|
||||||
import CardPage from "~/components/Common/CardPage.vue";
|
import CardPage from "~/components/Common/CardPage.vue";
|
||||||
import HeaderInlinePlayer from "~/components/HeaderInlinePlayer.vue";
|
import HeaderInlinePlayer from "~/components/HeaderInlinePlayer.vue";
|
||||||
|
@ -289,7 +260,8 @@ import useOptionalStorage from "~/functions/useOptionalStorage";
|
||||||
import {IconAccountCircle, IconHeadphones, IconInfo, IconSettings, IconWarning} from "~/components/Common/icons";
|
import {IconAccountCircle, IconHeadphones, IconInfo, IconSettings, IconWarning} from "~/components/Common/icons";
|
||||||
import UserInfoPanel from "~/components/Account/UserInfoPanel.vue";
|
import UserInfoPanel from "~/components/Account/UserInfoPanel.vue";
|
||||||
import {getApiUrl} from "~/router.ts";
|
import {getApiUrl} from "~/router.ts";
|
||||||
import useAutoRefreshingAsyncState from "~/functions/useAutoRefreshingAsyncState.ts";
|
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||||
|
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
profileUrl: {
|
profileUrl: {
|
||||||
|
@ -332,19 +304,47 @@ const langShowHideCharts = computed(() => {
|
||||||
: $gettext('Show Charts')
|
: $gettext('Show Charts')
|
||||||
});
|
});
|
||||||
|
|
||||||
const {axios, axiosSilent} = useAxios();
|
const {axios} = useAxios();
|
||||||
|
|
||||||
const {state: notifications, isLoading: notificationsLoading} = useAsyncState(
|
const {state: notifications, isLoading: notificationsLoading} = useAsyncState(
|
||||||
() => axios.get(notificationsUrl.value).then((r) => r.data),
|
() => axios.get(notificationsUrl.value).then((r) => r.data),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const {state: stations, isLoading: stationsLoading} = useAutoRefreshingAsyncState(
|
const stationFields: DataTableField[] = [
|
||||||
() => axiosSilent.get(stationsUrl.value).then((r) => r.data),
|
|
||||||
[],
|
|
||||||
{
|
{
|
||||||
timeout: 15000
|
key: 'play_button',
|
||||||
|
sortable: false,
|
||||||
|
class: 'shrink'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: $gettext('Station Name'),
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'listeners',
|
||||||
|
label: $gettext('Listeners'),
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'now_playing',
|
||||||
|
label: $gettext('Now Playing'),
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
sortable: false,
|
||||||
|
class: 'shrink'
|
||||||
}
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const $datatable = ref<DataTableTemplateRef>(null);
|
||||||
|
const {refresh} = useHasDatatable($datatable);
|
||||||
|
|
||||||
|
useIntervalFn(
|
||||||
|
refresh,
|
||||||
|
computed(() => (document.hidden) ? 30000 : 15000)
|
||||||
);
|
);
|
||||||
|
|
||||||
const $lightbox = ref<LightboxTemplateRef>(null);
|
const $lightbox = ref<LightboxTemplateRef>(null);
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
class="form-check-label"
|
class="form-check-label"
|
||||||
:for="id+'_'+option.value"
|
:for="id+'_'+option.value"
|
||||||
>
|
>
|
||||||
<slot :name="`label(${option.value})`">
|
<slot :name="'label('+option.value+')'">
|
||||||
<template v-if="option.description">
|
<template v-if="option.description">
|
||||||
<strong>{{ option.text }}</strong>
|
<strong>{{ option.text }}</strong>
|
||||||
<br>
|
<br>
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="content"
|
||||||
|
class="full-height-wrapper"
|
||||||
|
role="main"
|
||||||
|
>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header text-bg-primary">
|
||||||
|
<slot name="header">
|
||||||
|
<h2 class="card-title py-2">
|
||||||
|
<slot name="title" />
|
||||||
|
</h2>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot name="default" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<lightbox ref="$lightbox" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Lightbox from "~/components/Common/Lightbox.vue";
|
||||||
|
import {ref} from "vue";
|
||||||
|
import {LightboxTemplateRef, useProvideLightbox} from "~/vendor/lightbox.ts";
|
||||||
|
|
||||||
|
const $lightbox = ref<LightboxTemplateRef>(null);
|
||||||
|
useProvideLightbox($lightbox);
|
||||||
|
</script>
|
|
@ -1,70 +1,62 @@
|
||||||
<template>
|
<template>
|
||||||
<section
|
<full-height-card>
|
||||||
id="content"
|
<template #header>
|
||||||
class="full-height-wrapper"
|
<div class="d-flex align-items-center">
|
||||||
role="main"
|
<div class="flex-shrink">
|
||||||
>
|
<h2 class="card-title py-2">
|
||||||
<div class="container">
|
<template v-if="stationName">
|
||||||
<div class="card">
|
{{ stationName }}
|
||||||
<div class="card-header text-bg-primary">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="flex-shrink">
|
|
||||||
<h2 class="card-title py-2">
|
|
||||||
<template v-if="stationName">
|
|
||||||
{{ stationName }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ $gettext('On-Demand Media') }}
|
|
||||||
</template>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="flex-fill text-end">
|
|
||||||
<inline-player ref="player" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<data-table
|
|
||||||
id="public_on_demand"
|
|
||||||
ref="datatable"
|
|
||||||
paginated
|
|
||||||
select-fields
|
|
||||||
:fields="fields"
|
|
||||||
:api-url="listUrl"
|
|
||||||
>
|
|
||||||
<template #cell(download_url)="row">
|
|
||||||
<play-button
|
|
||||||
class="btn-lg"
|
|
||||||
:url="row.item.download_url"
|
|
||||||
/>
|
|
||||||
<template v-if="showDownloadButton">
|
|
||||||
<a
|
|
||||||
class="name btn btn-lg p-0 ms-2"
|
|
||||||
:href="row.item.download_url"
|
|
||||||
target="_blank"
|
|
||||||
:title="$gettext('Download')"
|
|
||||||
>
|
|
||||||
<icon :icon="IconDownload" />
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template #cell(art)="row">
|
|
||||||
<album-art :src="row.item.media.art" />
|
|
||||||
</template>
|
|
||||||
<template #cell(size)="row">
|
|
||||||
<template v-if="!row.item.size">
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ formatFileSize(row.item.size) }}
|
{{ $gettext('On-Demand Media') }}
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</h2>
|
||||||
</data-table>
|
</div>
|
||||||
|
<div class="flex-fill text-end">
|
||||||
|
<inline-player ref="player" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</section>
|
|
||||||
|
|
||||||
<lightbox ref="$lightbox" />
|
<template #default>
|
||||||
|
<data-table
|
||||||
|
id="public_on_demand"
|
||||||
|
ref="datatable"
|
||||||
|
paginated
|
||||||
|
select-fields
|
||||||
|
:fields="fields"
|
||||||
|
:api-url="listUrl"
|
||||||
|
>
|
||||||
|
<template #cell(download_url)="row">
|
||||||
|
<play-button
|
||||||
|
class="btn-lg"
|
||||||
|
:url="row.item.download_url"
|
||||||
|
/>
|
||||||
|
<template v-if="showDownloadButton">
|
||||||
|
<a
|
||||||
|
class="name btn btn-lg p-0 ms-2"
|
||||||
|
:href="row.item.download_url"
|
||||||
|
target="_blank"
|
||||||
|
:title="$gettext('Download')"
|
||||||
|
>
|
||||||
|
<icon :icon="IconDownload" />
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template #cell(art)="row">
|
||||||
|
<album-art :src="row.item.media.art" />
|
||||||
|
</template>
|
||||||
|
<template #cell(size)="row">
|
||||||
|
<template v-if="!row.item.size">
|
||||||
|
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ formatFileSize(row.item.size) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</data-table>
|
||||||
|
</template>
|
||||||
|
</full-height-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
@ -74,12 +66,9 @@ import {forEach} from 'lodash';
|
||||||
import Icon from '~/components/Common/Icon.vue';
|
import Icon from '~/components/Common/Icon.vue';
|
||||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import formatFileSize from "../../functions/formatFileSize";
|
|
||||||
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||||
import Lightbox from "~/components/Common/Lightbox.vue";
|
|
||||||
import {ref} from "vue";
|
|
||||||
import {LightboxTemplateRef, useProvideLightbox} from "~/vendor/lightbox";
|
|
||||||
import {IconDownload} from "~/components/Common/icons";
|
import {IconDownload} from "~/components/Common/icons";
|
||||||
|
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
listUrl: {
|
listUrl: {
|
||||||
|
@ -141,7 +130,4 @@ forEach(props.customFields.slice(), (field) => {
|
||||||
formatter: (_value, _key, item) => item.media.custom_fields[field.key]
|
formatter: (_value, _key, item) => item.media.custom_fields[field.key]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const $lightbox = ref<LightboxTemplateRef>(null);
|
|
||||||
useProvideLightbox($lightbox);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -0,0 +1,170 @@
|
||||||
|
<template>
|
||||||
|
<div class="full-height-scrollable">
|
||||||
|
<loading
|
||||||
|
:loading="isLoading"
|
||||||
|
lazy
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="flex-fill">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<router-link :to="{name: 'public:podcasts'}">
|
||||||
|
{{ $gettext('Podcasts') }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
{{ podcast.title }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h4 class="card-title mb-1">
|
||||||
|
{{ podcast.title }}
|
||||||
|
<br>
|
||||||
|
<small>
|
||||||
|
{{ $gettext('by') }} <a
|
||||||
|
:href="'mailto:'+podcast.email"
|
||||||
|
target="_blank"
|
||||||
|
>{{ podcast.author }}</a>
|
||||||
|
</small>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="badges my-2">
|
||||||
|
<span class="badge text-bg-info">
|
||||||
|
{{ podcast.language_name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="category in podcast.categories"
|
||||||
|
:key="category.category"
|
||||||
|
class="badge text-bg-secondary"
|
||||||
|
>
|
||||||
|
{{ category.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-text">
|
||||||
|
{{ podcast.description }}
|
||||||
|
</p>
|
||||||
|
<div class="buttons">
|
||||||
|
<a
|
||||||
|
class="btn btn-warning btn-sm"
|
||||||
|
:href="podcast.links.public_feed"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<icon :icon="IconRss" />
|
||||||
|
{{ $gettext('RSS') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink ps-3">
|
||||||
|
<album-art
|
||||||
|
:src="podcast.art"
|
||||||
|
:width="128"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</loading>
|
||||||
|
|
||||||
|
<data-table
|
||||||
|
id="podcast-episodes"
|
||||||
|
ref="$datatable"
|
||||||
|
paginated
|
||||||
|
:fields="fields"
|
||||||
|
:api-url="episodesUrl"
|
||||||
|
>
|
||||||
|
<template #cell(play_button)="{item}">
|
||||||
|
<play-button
|
||||||
|
icon-class="lg"
|
||||||
|
:url="item.links.download"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell(art)="{item}">
|
||||||
|
<album-art
|
||||||
|
:src="item.art"
|
||||||
|
:width="64"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell(title)="{item}">
|
||||||
|
<h5 class="m-0">
|
||||||
|
<router-link
|
||||||
|
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</router-link>
|
||||||
|
</h5>
|
||||||
|
<div class="badges my-2">
|
||||||
|
<span
|
||||||
|
v-if="item.publish_at"
|
||||||
|
class="badge text-bg-secondary"
|
||||||
|
>
|
||||||
|
{{ formatTimestampAsDateTime(item.publish_at) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="badge text-bg-secondary"
|
||||||
|
>
|
||||||
|
{{ formatTimestampAsDateTime(item.created_at) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.explicit"
|
||||||
|
class="badge text-bg-danger"
|
||||||
|
>
|
||||||
|
{{ $gettext('Explicit') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-text">
|
||||||
|
{{ item.description_short }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template #cell(actions)="{item}">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<router-link
|
||||||
|
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
{{ $gettext('Details') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</data-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {getStationApiUrl} from "~/router.ts";
|
||||||
|
import {useRoute} from "vue-router";
|
||||||
|
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||||
|
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||||
|
import {useAxios} from "~/vendor/axios.ts";
|
||||||
|
import Loading from "~/components/Common/Loading.vue";
|
||||||
|
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||||
|
import {useTranslate} from "~/vendor/gettext.ts";
|
||||||
|
import {IconRss} from "~/components/Common/icons.ts";
|
||||||
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
|
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
|
||||||
|
const {params} = useRoute();
|
||||||
|
|
||||||
|
const podcastUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}`);
|
||||||
|
|
||||||
|
const {axios} = useAxios();
|
||||||
|
const {state: podcast, isLoading} = useRefreshableAsyncState(
|
||||||
|
() => axios.get(podcastUrl.value).then((r) => r.data),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const episodesUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}/episodes`);
|
||||||
|
|
||||||
|
const {$gettext} = useTranslate();
|
||||||
|
const fields: DataTableField[] = [
|
||||||
|
{key: 'play_button', label: '', sortable: false, class: 'shrink pe-0'},
|
||||||
|
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
|
||||||
|
{key: 'title', label: $gettext('Episode'), sortable: true},
|
||||||
|
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||||
|
</script>
|
|
@ -0,0 +1,148 @@
|
||||||
|
<template>
|
||||||
|
<div class="full-height-scrollable">
|
||||||
|
<div class="card-body">
|
||||||
|
<loading
|
||||||
|
:loading="podcastLoading || episodeLoading"
|
||||||
|
lazy
|
||||||
|
>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb m-0">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<router-link :to="{name: 'public:podcasts'}">
|
||||||
|
{{ $gettext('Podcasts') }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<router-link :to="{name: 'public:podcast', params: {podcast_id: podcast.id}}">
|
||||||
|
{{ podcast.title }}
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
{{ episode.title }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="card-body alert alert-secondary"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<loading
|
||||||
|
:loading="podcastLoading"
|
||||||
|
lazy
|
||||||
|
>
|
||||||
|
<h4 class="card-title mb-1">
|
||||||
|
{{ podcast.title }}
|
||||||
|
<br>
|
||||||
|
<small>
|
||||||
|
{{ $gettext('by') }} <a
|
||||||
|
:href="'mailto:'+podcast.email"
|
||||||
|
class="alert-link"
|
||||||
|
target="_blank"
|
||||||
|
>{{ podcast.author }}</a>
|
||||||
|
</small>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="badges my-2">
|
||||||
|
<span class="badge text-bg-info">
|
||||||
|
{{ podcast.language_name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="category in podcast.categories"
|
||||||
|
:key="category.category"
|
||||||
|
class="badge text-bg-secondary"
|
||||||
|
>
|
||||||
|
{{ category.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-text">
|
||||||
|
{{ podcast.description }}
|
||||||
|
</p>
|
||||||
|
</loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<loading
|
||||||
|
:loading="episodeLoading"
|
||||||
|
lazy
|
||||||
|
>
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="flex-shrink-0 pe-3">
|
||||||
|
<play-button
|
||||||
|
icon-class="xl"
|
||||||
|
:url="episode.links.download"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-fill">
|
||||||
|
<h4 class="card-title mb-1">
|
||||||
|
{{ episode.title }}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="badges my-2">
|
||||||
|
<span
|
||||||
|
v-if="episode.publish_at"
|
||||||
|
class="badge text-bg-secondary"
|
||||||
|
>
|
||||||
|
{{ formatTimestampAsDateTime(episode.publish_at) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="badge text-bg-secondary"
|
||||||
|
>
|
||||||
|
{{ formatTimestampAsDateTime(episode.created_at) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="episode.explicit"
|
||||||
|
class="badge text-bg-danger"
|
||||||
|
>
|
||||||
|
{{ $gettext('Explicit') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="card-text">
|
||||||
|
{{ episode.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0 ps-3">
|
||||||
|
<album-art
|
||||||
|
:src="episode.art"
|
||||||
|
:width="96"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</loading>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Loading from "~/components/Common/Loading.vue";
|
||||||
|
import {useRoute} from "vue-router";
|
||||||
|
import {getStationApiUrl} from "~/router.ts";
|
||||||
|
import {useAxios} from "~/vendor/axios.ts";
|
||||||
|
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||||
|
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||||
|
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
|
||||||
|
const {params} = useRoute();
|
||||||
|
|
||||||
|
const podcastUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}`);
|
||||||
|
const episodeUrl = getStationApiUrl(`/public/podcast/${params.podcast_id}/episode/${params.episode_id}`);
|
||||||
|
|
||||||
|
const {axios} = useAxios();
|
||||||
|
|
||||||
|
const {state: podcast, isLoading: podcastLoading} = useRefreshableAsyncState(
|
||||||
|
() => axios.get(podcastUrl.value).then((r) => r.data),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const {state: episode, isLoading: episodeLoading} = useRefreshableAsyncState(
|
||||||
|
() => axios.get(episodeUrl.value).then((r) => r.data),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||||
|
</script>
|
|
@ -0,0 +1,85 @@
|
||||||
|
<template>
|
||||||
|
<data-table
|
||||||
|
id="podcasts"
|
||||||
|
ref="$datatable"
|
||||||
|
paginated
|
||||||
|
:fields="fields"
|
||||||
|
:api-url="apiUrl"
|
||||||
|
>
|
||||||
|
<template #cell(art)="{item}">
|
||||||
|
<album-art
|
||||||
|
:src="item.art"
|
||||||
|
:width="96"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell(title)="{item}">
|
||||||
|
<h5 class="m-0">
|
||||||
|
<router-link
|
||||||
|
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</router-link>
|
||||||
|
<br>
|
||||||
|
<small>
|
||||||
|
{{ $gettext('by') }} <a
|
||||||
|
:href="'mailto:'+item.email"
|
||||||
|
target="_blank"
|
||||||
|
>{{ item.author }}</a>
|
||||||
|
</small>
|
||||||
|
</h5>
|
||||||
|
<div class="badges my-2">
|
||||||
|
<span class="badge text-bg-info">
|
||||||
|
{{ item.language_name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="category in item.categories"
|
||||||
|
:key="category.category"
|
||||||
|
class="badge text-bg-secondary"
|
||||||
|
>
|
||||||
|
{{ category.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-text">
|
||||||
|
{{ item.description_short }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template #cell(actions)="{item}">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<router-link
|
||||||
|
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
{{ $gettext('Episodes') }}
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="btn btn-warning"
|
||||||
|
:href="item.links.public_feed"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<icon :icon="IconRss" />
|
||||||
|
{{ $gettext('RSS') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</data-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||||
|
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||||
|
import {getStationApiUrl} from "~/router.ts";
|
||||||
|
import {useTranslate} from "~/vendor/gettext.ts";
|
||||||
|
import {IconRss} from "~/components/Common/icons.ts";
|
||||||
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
|
|
||||||
|
const apiUrl = getStationApiUrl('/public/podcasts');
|
||||||
|
|
||||||
|
const {$gettext} = useTranslate();
|
||||||
|
|
||||||
|
const fields: DataTableField[] = [
|
||||||
|
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
|
||||||
|
{key: 'title', label: $gettext('Podcast'), sortable: true},
|
||||||
|
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
||||||
|
];
|
||||||
|
</script>
|
|
@ -0,0 +1,32 @@
|
||||||
|
<template>
|
||||||
|
<minimal-layout>
|
||||||
|
<full-height-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink">
|
||||||
|
<h2 class="card-title py-2">
|
||||||
|
<slot name="title">
|
||||||
|
{{ name }}
|
||||||
|
</slot>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex-fill text-end">
|
||||||
|
<inline-player ref="player" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
</full-height-card>
|
||||||
|
</minimal-layout>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
|
||||||
|
import InlinePlayer from "~/components/InlinePlayer.vue";
|
||||||
|
import {useAzuraCastStation} from "~/vendor/azuracast.ts";
|
||||||
|
import MinimalLayout from "~/components/MinimalLayout.vue";
|
||||||
|
|
||||||
|
const {name} = useAzuraCastStation();
|
||||||
|
</script>
|
|
@ -0,0 +1,21 @@
|
||||||
|
import {RouteRecordRaw} from "vue-router";
|
||||||
|
|
||||||
|
export default function usePodcastRoutes(): RouteRecordRaw[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path: '/podcasts',
|
||||||
|
component: () => import('~/components/Public/Podcasts/PodcastList.vue'),
|
||||||
|
name: 'public:podcasts'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/podcast/:podcast_id',
|
||||||
|
component: () => import('~/components/Public/Podcasts/Podcast.vue'),
|
||||||
|
name: 'public:podcast'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/podcast/:podcast_id/episode/:episode_id',
|
||||||
|
component: () => import('~/components/Public/Podcasts/PodcastEpisode.vue'),
|
||||||
|
name: 'public:podcast:episode'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
|
@ -1,41 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<section
|
<full-height-card>
|
||||||
id="content"
|
<template #title>
|
||||||
class="full-height-wrapper"
|
<template v-if="stationName">
|
||||||
role="main"
|
{{ stationName }}
|
||||||
>
|
</template>
|
||||||
<div class="container">
|
<template v-else>
|
||||||
<div class="card">
|
{{ $gettext('Schedule') }}
|
||||||
<div class="card-header text-bg-primary">
|
</template>
|
||||||
<div class="d-flex align-items-center">
|
</template>
|
||||||
<div class="flex-shrink">
|
|
||||||
<h2 class="card-title py-2">
|
<template #default>
|
||||||
<template v-if="stationName">
|
<div id="station-schedule-calendar">
|
||||||
{{ stationName }}
|
<schedule
|
||||||
</template>
|
ref="schedule"
|
||||||
<template v-else>
|
:timezone="stationTimeZone"
|
||||||
{{ $gettext('Schedule') }}
|
:schedule-url="scheduleUrl"
|
||||||
</template>
|
:station-time-zone="stationTimeZone"
|
||||||
</h2>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="station-schedule-calendar">
|
|
||||||
<schedule
|
|
||||||
ref="schedule"
|
|
||||||
:timezone="stationTimeZone"
|
|
||||||
:schedule-url="scheduleUrl"
|
|
||||||
:station-time-zone="stationTimeZone"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</section>
|
</full-height-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Schedule from '~/components/Common/ScheduleView.vue';
|
import Schedule from '~/components/Common/ScheduleView.vue';
|
||||||
|
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
scheduleUrl: {
|
scheduleUrl: {
|
||||||
|
|
|
@ -241,7 +241,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||||
import MediaToolbar from './Media/MediaToolbar.vue';
|
import MediaToolbar from './Media/MediaToolbar.vue';
|
||||||
import Breadcrumb from './Media/Breadcrumb.vue';
|
import Breadcrumb from './Media/Breadcrumb.vue';
|
||||||
import FileUpload from './Media/FileUpload.vue';
|
import FileUpload from './Media/FileUpload.vue';
|
||||||
|
@ -256,14 +256,13 @@ import PlayButton from "~/components/Common/PlayButton.vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import {computed, ref, watch} from "vue";
|
import {computed, ref, watch} from "vue";
|
||||||
import {forEach, map, partition} from "lodash";
|
import {forEach, map, partition} from "lodash";
|
||||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
|
||||||
import formatFileSize from "../../functions/formatFileSize";
|
import formatFileSize from "../../functions/formatFileSize";
|
||||||
import InfoCard from "~/components/Common/InfoCard.vue";
|
import InfoCard from "~/components/Common/InfoCard.vue";
|
||||||
import {useLuxon} from "~/vendor/luxon";
|
|
||||||
import {getStationApiUrl} from "~/router";
|
import {getStationApiUrl} from "~/router";
|
||||||
import {useRoute, useRouter} from "vue-router";
|
import {useRoute, useRouter} from "vue-router";
|
||||||
import {IconFile, IconFolder, IconImage} from "~/components/Common/icons";
|
import {IconFile, IconFolder, IconImage} from "~/components/Common/icons";
|
||||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initialPlaylists: {
|
initialPlaylists: {
|
||||||
|
@ -300,9 +299,8 @@ const renameUrl = getStationApiUrl('/files/rename');
|
||||||
const quotaUrl = getStationApiUrl('/quota/station_media');
|
const quotaUrl = getStationApiUrl('/quota/station_media');
|
||||||
|
|
||||||
const {$gettext} = useTranslate();
|
const {$gettext} = useTranslate();
|
||||||
const {timeConfig} = useAzuraCast();
|
|
||||||
const {timezone} = useAzuraCastStation();
|
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||||
const {DateTime} = useLuxon();
|
|
||||||
|
|
||||||
const fields = computed<DataTableField[]>(() => {
|
const fields = computed<DataTableField[]>(() => {
|
||||||
const fields: DataTableField[] = [
|
const fields: DataTableField[] = [
|
||||||
|
@ -337,15 +335,7 @@ const fields = computed<DataTableField[]>(() => {
|
||||||
key: 'timestamp',
|
key: 'timestamp',
|
||||||
label: $gettext('Modified'),
|
label: $gettext('Modified'),
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: (value) => {
|
formatter: (value) => formatTimestampAsDateTime(value),
|
||||||
if (!value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return DateTime.fromSeconds(value).setZone(timezone).toLocaleString(
|
|
||||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
selectable: true,
|
selectable: true,
|
||||||
visible: true
|
visible: true
|
||||||
},
|
},
|
||||||
|
|
|
@ -37,7 +37,6 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, toRef, watch} from "vue";
|
import {ref, toRef, watch} from "vue";
|
||||||
import {syncRef} from "@vueuse/core";
|
|
||||||
import {useAxios} from "~/vendor/axios";
|
import {useAxios} from "~/vendor/axios";
|
||||||
import FormGroup from "~/components/Form/FormGroup.vue";
|
import FormGroup from "~/components/Form/FormGroup.vue";
|
||||||
import FormFile from "~/components/Form/FormFile.vue";
|
import FormFile from "~/components/Form/FormFile.vue";
|
||||||
|
@ -49,9 +48,7 @@ const props = defineProps({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const albumArtSrc = ref(null);
|
const albumArtSrc = ref(props.albumArtUrl);
|
||||||
syncRef(toRef(props, 'albumArtUrl'), albumArtSrc, {direction: 'ltr'});
|
|
||||||
|
|
||||||
const reloadArt = () => {
|
const reloadArt = () => {
|
||||||
albumArtSrc.value = props.albumArtUrl + '?' + Math.floor(Date.now() / 1000);
|
albumArtSrc.value = props.albumArtUrl + '?' + Math.floor(Date.now() / 1000);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,14 +27,11 @@
|
||||||
:label="$gettext('AutoDJ Format')"
|
:label="$gettext('AutoDJ Format')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form-group-multi-check
|
<bitrate-options
|
||||||
v-if="formatSupportsBitrateOptions"
|
v-if="formatSupportsBitrateOptions"
|
||||||
id="edit_form_autodj_bitrate"
|
id="edit_form_autodj_bitrate"
|
||||||
class="col-md-6"
|
class="col-md-6"
|
||||||
:field="v$.autodj_bitrate"
|
:field="v$.autodj_bitrate"
|
||||||
:options="bitrateOptions"
|
|
||||||
stacked
|
|
||||||
radio
|
|
||||||
:label="$gettext('AutoDJ Bitrate (kbps)')"
|
:label="$gettext('AutoDJ Bitrate (kbps)')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -43,12 +40,12 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
||||||
import {map} from "lodash";
|
|
||||||
import {computed} from "vue";
|
import {computed} from "vue";
|
||||||
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
|
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
|
||||||
import {useVModel} from "@vueuse/core";
|
import {useVModel} from "@vueuse/core";
|
||||||
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||||
import Tab from "~/components/Common/Tab.vue";
|
import Tab from "~/components/Common/Tab.vue";
|
||||||
|
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
form: {
|
form: {
|
||||||
|
@ -101,17 +98,7 @@ const formatOptions = [
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const bitrateOptions = map(
|
|
||||||
[32, 48, 64, 96, 128, 192, 256, 320],
|
|
||||||
(val) => {
|
|
||||||
return {
|
|
||||||
value: val,
|
|
||||||
text: val
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatSupportsBitrateOptions = computed(() => {
|
const formatSupportsBitrateOptions = computed(() => {
|
||||||
return (props.form.autodj_format.$model !== 'flac');
|
return (props.form.autodj_format !== 'flac');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -45,38 +45,12 @@
|
||||||
:api-url="listUrl"
|
:api-url="listUrl"
|
||||||
detailed
|
detailed
|
||||||
>
|
>
|
||||||
<template #cell(actions)="{ item, toggleDetails }">
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
@click="doEdit(item.links.self)"
|
|
||||||
>
|
|
||||||
{{ $gettext('Edit') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-danger"
|
|
||||||
@click="doDelete(item.links.self)"
|
|
||||||
>
|
|
||||||
{{ $gettext('Delete') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-secondary"
|
|
||||||
type="button"
|
|
||||||
@click="toggleDetails()"
|
|
||||||
>
|
|
||||||
{{ $gettext('More') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #cell(name)="row">
|
<template #cell(name)="row">
|
||||||
<h5 class="m-0">
|
<h5 class="m-0">
|
||||||
{{ row.item.name }}
|
{{ row.item.name }}
|
||||||
</h5>
|
</h5>
|
||||||
<div>
|
<div class="badges">
|
||||||
<span class="badge text-bg-secondary me-1">
|
<span class="badge text-bg-secondary">
|
||||||
<template v-if="row.item.source === 'songs'">
|
<template v-if="row.item.source === 'songs'">
|
||||||
{{ $gettext('Song-based') }}
|
{{ $gettext('Song-based') }}
|
||||||
</template>
|
</template>
|
||||||
|
@ -86,31 +60,31 @@
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="row.item.is_jingle"
|
v-if="row.item.is_jingle"
|
||||||
class="badge text-bg-primary me-1"
|
class="badge text-bg-primary"
|
||||||
>
|
>
|
||||||
{{ $gettext('Jingle Mode') }}
|
{{ $gettext('Jingle Mode') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="row.item.source === 'songs' && row.item.order === 'sequential'"
|
v-if="row.item.source === 'songs' && row.item.order === 'sequential'"
|
||||||
class="badge text-bg-info me-1"
|
class="badge text-bg-info"
|
||||||
>
|
>
|
||||||
{{ $gettext('Sequential') }}
|
{{ $gettext('Sequential') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="row.item.include_in_on_demand"
|
v-if="row.item.include_in_on_demand"
|
||||||
class="badge text-bg-info me-1"
|
class="badge text-bg-info"
|
||||||
>
|
>
|
||||||
{{ $gettext('On-Demand') }}
|
{{ $gettext('On-Demand') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="row.item.include_in_automation"
|
v-if="row.item.schedule_items.length > 0"
|
||||||
class="badge text-bg-success me-1"
|
class="badge text-bg-info"
|
||||||
>
|
>
|
||||||
{{ $gettext('Auto-Assigned') }}
|
{{ $gettext('Scheduled') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="!row.item.is_enabled"
|
v-if="!row.item.is_enabled"
|
||||||
class="badge text-bg-danger me-1"
|
class="badge text-bg-danger"
|
||||||
>
|
>
|
||||||
{{ $gettext('Disabled') }}
|
{{ $gettext('Disabled') }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -171,18 +145,54 @@
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
<template #cell(actions)="{ item, isActive, toggleDetails }">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="doEdit(item.links.self)"
|
||||||
|
>
|
||||||
|
{{ $gettext('Edit') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
@click="doDelete(item.links.self)"
|
||||||
|
>
|
||||||
|
{{ $gettext('Delete') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-secondary"
|
||||||
|
type="button"
|
||||||
|
@click="toggleDetails()"
|
||||||
|
>
|
||||||
|
<icon :icon="isActive ? IconContract : IconExpand" />
|
||||||
|
|
||||||
|
{{ $gettext('More') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #detail="{ item }">
|
<template #detail="{ item }">
|
||||||
<div
|
<div
|
||||||
class="buttons"
|
class="buttons"
|
||||||
style="line-height: 2.5;"
|
style="line-height: 2.5;"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
v-if="item.links.order"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
@click="doReorder(item.links.order)"
|
||||||
|
>
|
||||||
|
{{ $gettext('Reorder') }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
:class="toggleButtonClass(item)"
|
:class="(item.is_enabled) ? 'btn-warning' : 'btn-success'"
|
||||||
@click="doModify(item.links.toggle)"
|
@click="doModify(item.links.toggle)"
|
||||||
>
|
>
|
||||||
{{ langToggleButton(item) }}
|
{{ (item.is_enabled) ? $gettext('Disable') : $gettext('Enable') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="item.links.empty"
|
v-if="item.links.empty"
|
||||||
|
@ -208,14 +218,6 @@
|
||||||
>
|
>
|
||||||
{{ $gettext('Import from PLS/M3U') }}
|
{{ $gettext('Import from PLS/M3U') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="item.links.order"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-secondary"
|
|
||||||
@click="doReorder(item.links.order)"
|
|
||||||
>
|
|
||||||
{{ $gettext('Reorder') }}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="item.links.queue"
|
v-if="item.links.queue"
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -300,7 +302,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||||
import Schedule from '~/components/Common/ScheduleView.vue';
|
import Schedule from '~/components/Common/ScheduleView.vue';
|
||||||
import EditModal from './Playlists/EditModal.vue';
|
import EditModal from './Playlists/EditModal.vue';
|
||||||
import ReorderModal from './Playlists/ReorderModal.vue';
|
import ReorderModal from './Playlists/ReorderModal.vue';
|
||||||
|
@ -322,6 +324,8 @@ import TimeZone from "~/components/Stations/Common/TimeZone.vue";
|
||||||
import Tabs from "~/components/Common/Tabs.vue";
|
import Tabs from "~/components/Common/Tabs.vue";
|
||||||
import Tab from "~/components/Common/Tab.vue";
|
import Tab from "~/components/Common/Tab.vue";
|
||||||
import AddButton from "~/components/Common/AddButton.vue";
|
import AddButton from "~/components/Common/AddButton.vue";
|
||||||
|
import {IconContract, IconExpand} from "~/components/Common/icons.ts";
|
||||||
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
useManualAutoDj: {
|
useManualAutoDj: {
|
||||||
|
@ -344,18 +348,6 @@ const fields: DataTableField[] = [
|
||||||
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
||||||
];
|
];
|
||||||
|
|
||||||
const toggleButtonClass = (record) => {
|
|
||||||
return (record.is_enabled)
|
|
||||||
? 'btn-warning'
|
|
||||||
: 'btn-success';
|
|
||||||
}
|
|
||||||
|
|
||||||
const langToggleButton = (record) => {
|
|
||||||
return (record.is_enabled)
|
|
||||||
? $gettext('Disable')
|
|
||||||
: $gettext('Enable');
|
|
||||||
};
|
|
||||||
|
|
||||||
const {Duration} = useLuxon();
|
const {Duration} = useLuxon();
|
||||||
|
|
||||||
const formatLength = (length) => {
|
const formatLength = (length) => {
|
||||||
|
|
|
@ -50,11 +50,20 @@
|
||||||
<td>{{ element.media.album }}</td>
|
<td>{{ element.media.album }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
v-if="index+1 < media.length"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:title="$gettext('Move to Bottom')"
|
||||||
|
@click.prevent="moveToBottom(index)"
|
||||||
|
>
|
||||||
|
<icon :icon="IconChevronBarDown" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="index+1 < media.length"
|
v-if="index+1 < media.length"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:title="$gettext('Down')"
|
:title="$gettext('Move Down')"
|
||||||
@click.prevent="moveDown(index)"
|
@click.prevent="moveDown(index)"
|
||||||
>
|
>
|
||||||
<icon :icon="IconChevronDown" />
|
<icon :icon="IconChevronDown" />
|
||||||
|
@ -63,11 +72,20 @@
|
||||||
v-if="index > 0"
|
v-if="index > 0"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:title="$gettext('Up')"
|
:title="$gettext('Move Up')"
|
||||||
@click.prevent="moveUp(index)"
|
@click.prevent="moveUp(index)"
|
||||||
>
|
>
|
||||||
<icon :icon="IconChevronUp" />
|
<icon :icon="IconChevronUp" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="index > 0"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:title="$gettext('Move to Top')"
|
||||||
|
@click.prevent="moveToTop(index)"
|
||||||
|
>
|
||||||
|
<icon :icon="IconChevronBarUp" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -87,7 +105,7 @@ import {useAxios} from "~/vendor/axios";
|
||||||
import {useNotify} from "~/functions/useNotify";
|
import {useNotify} from "~/functions/useNotify";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import Modal from "~/components/Common/Modal.vue";
|
import Modal from "~/components/Common/Modal.vue";
|
||||||
import {IconChevronDown, IconChevronUp} from "~/components/Common/icons";
|
import {IconChevronBarDown, IconChevronBarUp, IconChevronDown, IconChevronUp} from "~/components/Common/icons";
|
||||||
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
||||||
import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
|
import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
|
||||||
|
|
||||||
|
@ -129,12 +147,26 @@ const save = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveDown = (index) => {
|
const moveDown = (index) => {
|
||||||
media.value.splice(index + 1, 0, media.value.splice(index, 1)[0]);
|
const currentItem = media.value.splice(index, 1)[0];
|
||||||
|
media.value.splice(index + 1, 0, currentItem);
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveToBottom = (index) => {
|
||||||
|
const currentItem = media.value.splice(index, 1)[0];
|
||||||
|
media.value.splice(media.value.length, 0, currentItem);
|
||||||
save();
|
save();
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveUp = (index) => {
|
const moveUp = (index) => {
|
||||||
media.value.splice(index - 1, 0, media.value.splice(index, 1)[0]);
|
const currentItem = media.value.splice(index, 1)[0];
|
||||||
|
media.value.splice(index - 1, 0, currentItem);
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveToTop = (index) => {
|
||||||
|
const currentItem = media.value.splice(index, 1)[0];
|
||||||
|
media.value.splice(0, 0, currentItem);
|
||||||
save();
|
save();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -30,16 +30,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body buttons">
|
<div class="card-body buttons">
|
||||||
<button
|
<router-link
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@click="doClearPodcast()"
|
:to="{name: 'stations:podcasts:index'}"
|
||||||
>
|
>
|
||||||
<icon :icon="IconChevronLeft" />
|
<icon :icon="IconChevronLeft" />
|
||||||
<span>
|
{{ $gettext('All Podcasts') }}
|
||||||
{{ $gettext('All Podcasts') }}
|
</router-link>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<add-button
|
<add-button
|
||||||
:text="$gettext('Add Episode')"
|
:text="$gettext('Add Episode')"
|
||||||
@click="doCreate"
|
@click="doCreate"
|
||||||
|
@ -50,48 +48,67 @@
|
||||||
id="station_podcast_episodes"
|
id="station_podcast_episodes"
|
||||||
ref="$datatable"
|
ref="$datatable"
|
||||||
paginated
|
paginated
|
||||||
|
select-fields
|
||||||
:fields="fields"
|
:fields="fields"
|
||||||
:api-url="podcast.links.episodes"
|
:api-url="podcast.links.episodes"
|
||||||
>
|
>
|
||||||
<template #cell(art)="row">
|
<template #cell(art)="{item}">
|
||||||
<album-art :src="row.item.art" />
|
<album-art :src="item.art" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell(title)="row">
|
<template #cell(title)="{item}">
|
||||||
<h5 class="m-0">
|
<h5 class="m-0">
|
||||||
{{ row.item.title }}
|
{{ item.title }}
|
||||||
</h5>
|
</h5>
|
||||||
<a
|
<div v-if="item.is_published">
|
||||||
:href="row.item.links.public"
|
<a
|
||||||
target="_blank"
|
:href="item.links.public"
|
||||||
>{{ $gettext('Public Page') }}</a>
|
target="_blank"
|
||||||
|
>{{ $gettext('Public Page') }}</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="badges"
|
||||||
|
>
|
||||||
|
<span class="badge text-bg-info">
|
||||||
|
{{ $gettext('Unpublished') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(podcast_media)="row">
|
<template #cell(podcast_media)="{item}">
|
||||||
<template v-if="row.item.media">
|
<template v-if="item.media">
|
||||||
<span>{{ row.item.media.original_name }}</span>
|
<span>{{ item.media.original_name }}</span>
|
||||||
<br>
|
<br>
|
||||||
<small>{{ row.item.media.length_text }}</small>
|
<small>{{ item.media.length_text }}</small>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(explicit)="row">
|
<template #cell(is_published)="{item}">
|
||||||
|
<span v-if="item.is_published">
|
||||||
|
{{ $gettext('Yes') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ $gettext('No') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell(explicit)="{item}">
|
||||||
<span
|
<span
|
||||||
v-if="row.item.explicit"
|
v-if="item.explicit"
|
||||||
class="badge text-bg-danger"
|
class="badge text-bg-danger"
|
||||||
>{{ $gettext('Explicit') }}</span>
|
>{{ $gettext('Explicit') }}</span>
|
||||||
<span v-else> </span>
|
<span v-else> </span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(actions)="row">
|
<template #cell(actions)="{item}">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="doEdit(row.item.links.self)"
|
@click="doEdit(item.links.self)"
|
||||||
>
|
>
|
||||||
{{ $gettext('Edit') }}
|
{{ $gettext('Edit') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-danger"
|
class="btn btn-danger"
|
||||||
@click="doDelete(row.item.links.self)"
|
@click="doDelete(item.links.self)"
|
||||||
>
|
>
|
||||||
{{ $gettext('Delete') }}
|
{{ $gettext('Delete') }}
|
||||||
</button>
|
</button>
|
||||||
|
@ -111,79 +128,93 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||||
import EditModal from './EpisodeEditModal.vue';
|
import EditModal from './Podcasts/EpisodeEditModal.vue';
|
||||||
import Icon from '~/components/Common/Icon.vue';
|
import Icon from '~/components/Common/Icon.vue';
|
||||||
import AlbumArt from '~/components/Common/AlbumArt.vue';
|
import AlbumArt from '~/components/Common/AlbumArt.vue';
|
||||||
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
|
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
|
||||||
import {useNotify} from "~/functions/useNotify";
|
|
||||||
import {useAxios} from "~/vendor/axios";
|
|
||||||
import AddButton from "~/components/Common/AddButton.vue";
|
import AddButton from "~/components/Common/AddButton.vue";
|
||||||
import {IconChevronLeft} from "~/components/Common/icons";
|
import {IconChevronLeft} from "~/components/Common/icons";
|
||||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||||
|
import {getStationApiUrl} from "~/router.ts";
|
||||||
|
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
|
||||||
|
import {ApiPodcast} from "~/entities/ApiInterfaces.ts";
|
||||||
|
import useHasEditModal from "~/functions/useHasEditModal.ts";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{
|
||||||
quotaUrl: {
|
podcast: ApiPodcast
|
||||||
type: String,
|
}>();
|
||||||
required: true
|
|
||||||
},
|
|
||||||
podcast: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['clear-podcast']);
|
const quotaUrl = getStationApiUrl('/quota/station_podcasts');
|
||||||
|
|
||||||
const {$gettext} = useTranslate();
|
const {$gettext} = useTranslate();
|
||||||
|
|
||||||
|
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||||
|
|
||||||
const fields: DataTableField[] = [
|
const fields: DataTableField[] = [
|
||||||
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
|
{
|
||||||
{key: 'title', label: $gettext('Episode'), sortable: false},
|
key: 'art',
|
||||||
{key: 'podcast_media', label: $gettext('File Name'), sortable: false},
|
label: $gettext('Art'),
|
||||||
{key: 'explicit', label: $gettext('Explicit'), sortable: false},
|
sortable: false,
|
||||||
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
class: 'shrink pe-0',
|
||||||
|
selectable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
label: $gettext('Episode'),
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'podcast_media',
|
||||||
|
label: $gettext('File Name'),
|
||||||
|
sortable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'is_published',
|
||||||
|
label: $gettext('Is Published'),
|
||||||
|
visible: false,
|
||||||
|
sortable: true,
|
||||||
|
selectable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'publish_at',
|
||||||
|
label: $gettext('Publish At'),
|
||||||
|
formatter: (_col, _key, item) => formatTimestampAsDateTime(item.publish_at),
|
||||||
|
sortable: true,
|
||||||
|
selectable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'explicit',
|
||||||
|
label: $gettext('Explicit'),
|
||||||
|
sortable: true,
|
||||||
|
selectable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: $gettext('Actions'),
|
||||||
|
sortable: false,
|
||||||
|
class: 'shrink'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
|
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
|
||||||
|
|
||||||
const $datatable = ref<DataTableTemplateRef>(null);
|
const $datatable = ref<DataTableTemplateRef>(null);
|
||||||
|
const {refresh} = useHasDatatable($datatable);
|
||||||
|
|
||||||
const relist = () => {
|
const relist = () => {
|
||||||
$quota.value?.update();
|
$quota.value?.update();
|
||||||
$datatable.value?.refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const $editEpisodeModal = ref<InstanceType<typeof EditModal> | null>(null);
|
const $editEpisodeModal = ref<InstanceType<typeof EditModal> | null>(null);
|
||||||
|
const {doCreate, doEdit} = useHasEditModal($editEpisodeModal);
|
||||||
|
|
||||||
const doCreate = () => {
|
const {doDelete} = useConfirmAndDelete(
|
||||||
$editEpisodeModal.value?.create();
|
$gettext('Delete Episode?'),
|
||||||
};
|
() => relist()
|
||||||
|
);
|
||||||
const doEdit = (url) => {
|
|
||||||
$editEpisodeModal.value?.edit(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doClearPodcast = () => {
|
|
||||||
emit('clear-podcast');
|
|
||||||
};
|
|
||||||
|
|
||||||
const {confirmDelete} = useSweetAlert();
|
|
||||||
const {notifySuccess} = useNotify();
|
|
||||||
const {axios} = useAxios();
|
|
||||||
|
|
||||||
const doDelete = (url) => {
|
|
||||||
confirmDelete({
|
|
||||||
title: $gettext('Delete Episode?'),
|
|
||||||
}).then((result) => {
|
|
||||||
if (result.value) {
|
|
||||||
axios.delete(url).then((resp) => {
|
|
||||||
notifySuccess(resp.data.message);
|
|
||||||
relist();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
|
@ -1,39 +1,154 @@
|
||||||
<template>
|
<template>
|
||||||
<episodes-view
|
<card-page>
|
||||||
v-if="activePodcast"
|
<template #header>
|
||||||
:podcast="activePodcast"
|
<div class="row align-items-center">
|
||||||
:quota-url="quotaUrl"
|
<div class="col-md-7">
|
||||||
@clear-podcast="onClearPodcast"
|
<h2 class="card-title">
|
||||||
/>
|
{{ $gettext('Podcasts') }}
|
||||||
<list-view
|
</h2>
|
||||||
v-else
|
</div>
|
||||||
v-bind="pickProps(props, listViewProps)"
|
<div class="col-md-5 text-end">
|
||||||
:quota-url="quotaUrl"
|
<stations-common-quota
|
||||||
@select-podcast="onSelectPodcast"
|
ref="$quota"
|
||||||
|
:quota-url="quotaUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<add-button
|
||||||
|
:text="$gettext('Add Podcast')"
|
||||||
|
@click="doCreate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<data-table
|
||||||
|
id="station_podcasts"
|
||||||
|
ref="$datatable"
|
||||||
|
paginated
|
||||||
|
:fields="fields"
|
||||||
|
:api-url="listUrl"
|
||||||
|
>
|
||||||
|
<template #cell(art)="{item}">
|
||||||
|
<album-art :src="item.art" />
|
||||||
|
</template>
|
||||||
|
<template #cell(title)="{item}">
|
||||||
|
<h5 class="m-0">
|
||||||
|
{{ item.title }}
|
||||||
|
</h5>
|
||||||
|
<div v-if="item.is_published">
|
||||||
|
<a
|
||||||
|
:href="item.links.public_episodes"
|
||||||
|
target="_blank"
|
||||||
|
>{{ $gettext('Public Page') }}</a> •
|
||||||
|
<a
|
||||||
|
:href="item.links.public_feed"
|
||||||
|
target="_blank"
|
||||||
|
>{{ $gettext('RSS Feed') }}</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="badges"
|
||||||
|
>
|
||||||
|
<span class="badge text-bg-info">
|
||||||
|
{{ $gettext('Unpublished') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell(actions)="{item}">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="doEdit(item.links.self)"
|
||||||
|
>
|
||||||
|
{{ $gettext('Edit') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
@click="doDelete(item.links.self)"
|
||||||
|
>
|
||||||
|
{{ $gettext('Delete') }}
|
||||||
|
</button>
|
||||||
|
<router-link
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:to="{name: 'stations:podcast:episodes', params: {podcast_id: item.id}}"
|
||||||
|
>
|
||||||
|
{{ $gettext('Episodes') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</data-table>
|
||||||
|
</card-page>
|
||||||
|
|
||||||
|
<edit-modal
|
||||||
|
ref="$editPodcastModal"
|
||||||
|
:create-url="listUrl"
|
||||||
|
:new-art-url="newArtUrl"
|
||||||
|
:language-options="languageOptions"
|
||||||
|
:categories-options="categoriesOptions"
|
||||||
|
@relist="relist"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import EpisodesView from './Podcasts/EpisodesView.vue';
|
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||||
import ListView from './Podcasts/ListView.vue';
|
import EditModal from './Podcasts/PodcastEditModal.vue';
|
||||||
|
import AlbumArt from '~/components/Common/AlbumArt.vue';
|
||||||
|
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
|
||||||
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import listViewProps from "./Podcasts/listViewProps";
|
|
||||||
import {pickProps} from "~/functions/pickProps";
|
|
||||||
import {getStationApiUrl} from "~/router";
|
import {getStationApiUrl} from "~/router";
|
||||||
|
import AddButton from "~/components/Common/AddButton.vue";
|
||||||
|
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||||
|
import CardPage from "~/components/Common/CardPage.vue";
|
||||||
|
import useConfirmAndDelete from "~/functions/useConfirmAndDelete.ts";
|
||||||
|
import useHasEditModal from "~/functions/useHasEditModal.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
...listViewProps
|
languageOptions: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
categoriesOptions: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const quotaUrl = getStationApiUrl('/quota/station_podcasts');
|
const quotaUrl = getStationApiUrl('/quota/station_podcasts');
|
||||||
|
const listUrl = getStationApiUrl('/podcasts');
|
||||||
|
const newArtUrl = getStationApiUrl('/podcasts/art');
|
||||||
|
|
||||||
const activePodcast = ref(null);
|
const {$gettext} = useTranslate();
|
||||||
|
|
||||||
const onSelectPodcast = (podcast) => {
|
const fields: DataTableField[] = [
|
||||||
activePodcast.value = podcast;
|
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
|
||||||
|
{key: 'title', label: $gettext('Podcast'), sortable: false},
|
||||||
|
{
|
||||||
|
key: 'episodes',
|
||||||
|
label: $gettext('# Episodes'),
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
|
||||||
|
|
||||||
|
const $datatable = ref<DataTableTemplateRef>(null);
|
||||||
|
const {refresh} = useHasDatatable($datatable);
|
||||||
|
|
||||||
|
const relist = () => {
|
||||||
|
$quota.value?.update();
|
||||||
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClearPodcast = () => {
|
const $editPodcastModal = ref<InstanceType<typeof EditModal> | null>(null);
|
||||||
activePodcast.value = null;
|
const {doCreate, doEdit} = useHasEditModal($editPodcastModal);
|
||||||
}
|
|
||||||
|
const {doDelete} = useConfirmAndDelete(
|
||||||
|
$gettext('Delete Podcast?'),
|
||||||
|
() => relist()
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, toRef} from "vue";
|
import {computed, ref, toRef, watch} from "vue";
|
||||||
import {useAxios} from "~/vendor/axios";
|
import {useAxios} from "~/vendor/axios";
|
||||||
import FormGroup from "~/components/Form/FormGroup.vue";
|
import FormGroup from "~/components/Form/FormGroup.vue";
|
||||||
import FormFile from "~/components/Form/FormFile.vue";
|
import FormFile from "~/components/Form/FormFile.vue";
|
||||||
|
@ -54,15 +54,11 @@ import Tab from "~/components/Common/Tab.vue";
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
default: null
|
||||||
},
|
},
|
||||||
artworkSrc: {
|
artworkSrc: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
default: null
|
||||||
},
|
|
||||||
editArtUrl: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
},
|
||||||
newArtUrl: {
|
newArtUrl: {
|
||||||
type: String,
|
type: String,
|
||||||
|
@ -72,7 +68,12 @@ const props = defineProps({
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
const artworkSrc = toRef(props, 'artworkSrc');
|
const artworkSrc = ref(props.artworkSrc);
|
||||||
|
const reloadArt = () => {
|
||||||
|
artworkSrc.value = props.artworkSrc + '?' + Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
watch(toRef(props, 'artworkSrc'), reloadArt);
|
||||||
|
|
||||||
const localSrc = ref(null);
|
const localSrc = ref(null);
|
||||||
|
|
||||||
const src = computed(() => {
|
const src = computed(() => {
|
||||||
|
@ -92,21 +93,24 @@ const uploaded = (file) => {
|
||||||
}, false);
|
}, false);
|
||||||
fileReader.readAsDataURL(file);
|
fileReader.readAsDataURL(file);
|
||||||
|
|
||||||
const url = (props.editArtUrl) ? props.editArtUrl : props.newArtUrl;
|
const url = (props.artworkSrc) ? props.artworkSrc : props.newArtUrl;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('art', file);
|
formData.append('art', file);
|
||||||
|
|
||||||
axios.post(url, formData).then((resp) => {
|
axios.post(url, formData).then((resp) => {
|
||||||
emit('update:modelValue', resp.data);
|
emit('update:modelValue', resp.data);
|
||||||
|
reloadArt();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteArt = () => {
|
const deleteArt = () => {
|
||||||
if (props.editArtUrl) {
|
if (props.artworkSrc) {
|
||||||
axios.delete(props.editArtUrl).then(() => {
|
axios.delete(props.artworkSrc).then(() => {
|
||||||
|
reloadArt();
|
||||||
localSrc.value = null;
|
localSrc.value = null;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
reloadArt();
|
||||||
localSrc.value = null;
|
localSrc.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,14 +18,12 @@
|
||||||
:record-has-media="record.has_media"
|
:record-has-media="record.has_media"
|
||||||
:new-media-url="newMediaUrl"
|
:new-media-url="newMediaUrl"
|
||||||
:edit-media-url="record.links.media"
|
:edit-media-url="record.links.media"
|
||||||
:download-url="record.links.download"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<podcast-common-artwork
|
<podcast-common-artwork
|
||||||
v-model="form.artwork_file"
|
v-model="form.artwork_file"
|
||||||
:artwork-src="record.art"
|
:artwork-src="record.links.art"
|
||||||
:new-art-url="newArtUrl"
|
:new-art-url="newArtUrl"
|
||||||
:edit-art-url="record.links.art"
|
|
||||||
/>
|
/>
|
||||||
</tabs>
|
</tabs>
|
||||||
</modal-form>
|
</modal-form>
|
||||||
|
|
|
@ -32,9 +32,9 @@
|
||||||
<template v-if="hasMedia">
|
<template v-if="hasMedia">
|
||||||
<div class="block-buttons pt-3">
|
<div class="block-buttons pt-3">
|
||||||
<a
|
<a
|
||||||
v-if="downloadUrl"
|
v-if="editMediaUrl"
|
||||||
class="btn btn-block btn-dark"
|
class="btn btn-block btn-dark"
|
||||||
:href="downloadUrl"
|
:href="editMediaUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{{ $gettext('Download') }}
|
{{ $gettext('Download') }}
|
||||||
|
@ -74,10 +74,6 @@ const props = defineProps({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
downloadUrl: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
editMediaUrl: {
|
editMediaUrl: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
|
|
@ -1,172 +0,0 @@
|
||||||
<template>
|
|
||||||
<section
|
|
||||||
class="card"
|
|
||||||
role="region"
|
|
||||||
>
|
|
||||||
<div class="card-header text-bg-primary">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col-md-7">
|
|
||||||
<h2 class="card-title">
|
|
||||||
{{ $gettext('Podcasts') }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5 text-end">
|
|
||||||
<stations-common-quota
|
|
||||||
ref="$quota"
|
|
||||||
:quota-url="quotaUrl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-body buttons">
|
|
||||||
<add-button
|
|
||||||
:text="$gettext('Add Podcast')"
|
|
||||||
@click="doCreate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<data-table
|
|
||||||
id="station_podcasts"
|
|
||||||
ref="$datatable"
|
|
||||||
paginated
|
|
||||||
:fields="fields"
|
|
||||||
:api-url="listUrl"
|
|
||||||
>
|
|
||||||
<template #cell(art)="row">
|
|
||||||
<album-art :src="row.item.art" />
|
|
||||||
</template>
|
|
||||||
<template #cell(title)="row">
|
|
||||||
<h5 class="m-0">
|
|
||||||
{{ row.item.title }}
|
|
||||||
</h5>
|
|
||||||
<a
|
|
||||||
:href="row.item.links.public_episodes"
|
|
||||||
target="_blank"
|
|
||||||
>{{ $gettext('Public Page') }}</a> •
|
|
||||||
<a
|
|
||||||
:href="row.item.links.public_feed"
|
|
||||||
target="_blank"
|
|
||||||
>{{ $gettext('RSS Feed') }}</a>
|
|
||||||
</template>
|
|
||||||
<template #cell(actions)="row">
|
|
||||||
<div class="btn-group btn-group-sm">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
@click="doEdit(row.item.links.self)"
|
|
||||||
>
|
|
||||||
{{ $gettext('Edit') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-danger"
|
|
||||||
@click="doDelete(row.item.links.self)"
|
|
||||||
>
|
|
||||||
{{ $gettext('Delete') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
@click="doSelectPodcast(row.item)"
|
|
||||||
>
|
|
||||||
{{ $gettext('Episodes') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</data-table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<edit-modal
|
|
||||||
ref="$editPodcastModal"
|
|
||||||
:create-url="listUrl"
|
|
||||||
:new-art-url="newArtUrl"
|
|
||||||
:language-options="languageOptions"
|
|
||||||
:categories-options="categoriesOptions"
|
|
||||||
@relist="relist"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
|
||||||
import EditModal from './PodcastEditModal.vue';
|
|
||||||
import AlbumArt from '~/components/Common/AlbumArt.vue';
|
|
||||||
import StationsCommonQuota from "~/components/Stations/Common/Quota.vue";
|
|
||||||
import listViewProps from "./listViewProps";
|
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
|
||||||
import {ref} from "vue";
|
|
||||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
|
||||||
import {useNotify} from "~/functions/useNotify";
|
|
||||||
import {useAxios} from "~/vendor/axios";
|
|
||||||
import {getStationApiUrl} from "~/router";
|
|
||||||
import AddButton from "~/components/Common/AddButton.vue";
|
|
||||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
...listViewProps,
|
|
||||||
quotaUrl: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const listUrl = getStationApiUrl('/podcasts');
|
|
||||||
const newArtUrl = getStationApiUrl('/podcasts/art');
|
|
||||||
|
|
||||||
const emit = defineEmits(['select-podcast']);
|
|
||||||
|
|
||||||
const {$gettext} = useTranslate();
|
|
||||||
|
|
||||||
const fields: DataTableField[] = [
|
|
||||||
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
|
|
||||||
{key: 'title', label: $gettext('Podcast'), sortable: false},
|
|
||||||
{
|
|
||||||
key: 'episodes',
|
|
||||||
label: $gettext('# Episodes'),
|
|
||||||
sortable: false,
|
|
||||||
formatter: (val) => {
|
|
||||||
return val?.length ?? 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
|
||||||
];
|
|
||||||
|
|
||||||
const $quota = ref<InstanceType<typeof StationsCommonQuota> | null>(null);
|
|
||||||
const $datatable = ref<DataTableTemplateRef>(null);
|
|
||||||
|
|
||||||
const relist = () => {
|
|
||||||
$quota.value?.update();
|
|
||||||
$datatable.value?.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const $editPodcastModal = ref<InstanceType<typeof EditModal> | null>(null);
|
|
||||||
|
|
||||||
const doCreate = () => {
|
|
||||||
$editPodcastModal.value?.create();
|
|
||||||
};
|
|
||||||
|
|
||||||
const doEdit = (url) => {
|
|
||||||
$editPodcastModal.value?.edit(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doSelectPodcast = (podcast) => {
|
|
||||||
emit('select-podcast', podcast);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {confirmDelete} = useSweetAlert();
|
|
||||||
const {notifySuccess} = useNotify();
|
|
||||||
const {axios} = useAxios();
|
|
||||||
|
|
||||||
const doDelete = (url) => {
|
|
||||||
confirmDelete({
|
|
||||||
title: $gettext('Delete Podcast?'),
|
|
||||||
}).then((result) => {
|
|
||||||
if (result.value) {
|
|
||||||
axios.delete(url).then((resp) => {
|
|
||||||
notifySuccess(resp.data.message);
|
|
||||||
relist();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
|
@ -17,9 +17,8 @@
|
||||||
|
|
||||||
<podcast-common-artwork
|
<podcast-common-artwork
|
||||||
v-model="form.artwork_file"
|
v-model="form.artwork_file"
|
||||||
:artwork-src="record.art"
|
:artwork-src="record.links.art"
|
||||||
:new-art-url="newArtUrl"
|
:new-art-url="newArtUrl"
|
||||||
:edit-art-url="record.links.art"
|
|
||||||
/>
|
/>
|
||||||
</tabs>
|
</tabs>
|
||||||
</modal-form>
|
</modal-form>
|
||||||
|
@ -35,6 +34,7 @@ import {useResettableRef} from "~/functions/useResettableRef";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import ModalForm from "~/components/Common/ModalForm.vue";
|
import ModalForm from "~/components/Common/ModalForm.vue";
|
||||||
import Tabs from "~/components/Common/Tabs.vue";
|
import Tabs from "~/components/Common/Tabs.vue";
|
||||||
|
import {map} from "lodash";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
...baseEditModalProps,
|
...baseEditModalProps,
|
||||||
|
@ -59,7 +59,9 @@ const $modal = ref<ModalFormTemplateRef>(null);
|
||||||
const {record, reset} = useResettableRef({
|
const {record, reset} = useResettableRef({
|
||||||
has_custom_art: false,
|
has_custom_art: false,
|
||||||
art: null,
|
art: null,
|
||||||
links: {}
|
links: {
|
||||||
|
art: null
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -87,6 +89,11 @@ const {
|
||||||
reset();
|
reset();
|
||||||
},
|
},
|
||||||
populateForm: (data, formRef) => {
|
populateForm: (data, formRef) => {
|
||||||
|
data.categories = map(
|
||||||
|
data.categories,
|
||||||
|
(row) => row.category
|
||||||
|
);
|
||||||
|
|
||||||
record.value = data;
|
record.value = data;
|
||||||
formRef.value = mergeExisting(formRef.value, data);
|
formRef.value = mergeExisting(formRef.value, data);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
export default {
|
|
||||||
languageOptions: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
categoriesOptions: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -120,6 +120,10 @@ const types = computed(() => {
|
||||||
value: 'history',
|
value: 'history',
|
||||||
text: $gettext('History')
|
text: $gettext('History')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'podcasts',
|
||||||
|
text: $gettext('Podcasts')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'schedule',
|
value: 'schedule',
|
||||||
text: $gettext('Schedule')
|
text: $gettext('Schedule')
|
||||||
|
@ -174,6 +178,9 @@ const baseEmbedUrl = computed(() => {
|
||||||
case 'schedule':
|
case 'schedule':
|
||||||
return props.publicScheduleEmbedUri;
|
return props.publicScheduleEmbedUri;
|
||||||
|
|
||||||
|
case 'podcasts':
|
||||||
|
return props.publicPodcastsEmbedUri;
|
||||||
|
|
||||||
case 'player':
|
case 'player':
|
||||||
default:
|
default:
|
||||||
return props.publicPageEmbedUri;
|
return props.publicPageEmbedUri;
|
||||||
|
@ -181,14 +188,17 @@ const baseEmbedUrl = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const embedUrl = computed(() => {
|
const embedUrl = computed(() => {
|
||||||
return (selectedTheme.value !== "browser")
|
const baseUrl = new URL(baseEmbedUrl.value);
|
||||||
? baseEmbedUrl.value + '?theme=' + selectedTheme.value
|
if (selectedTheme.value !== 'browser') {
|
||||||
: baseEmbedUrl.value;
|
baseUrl.searchParams.set('theme', selectedTheme.value);
|
||||||
|
}
|
||||||
|
return baseUrl.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
const embedHeight = computed(() => {
|
const embedHeight = computed(() => {
|
||||||
switch (selectedType.value) {
|
switch (selectedType.value) {
|
||||||
case 'ondemand':
|
case 'ondemand':
|
||||||
|
case 'podcasts':
|
||||||
return '400px';
|
return '400px';
|
||||||
|
|
||||||
case 'requests':
|
case 'requests':
|
||||||
|
|
|
@ -223,14 +223,29 @@
|
||||||
{{ $gettext('Disconnect Streamer') }}
|
{{ $gettext('Disconnect Streamer') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="btn_update_metadata"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-link text-secondary"
|
||||||
|
@click="updateMetadata()"
|
||||||
|
>
|
||||||
|
<icon :icon="IconUpdate" />
|
||||||
|
<span>
|
||||||
|
{{ $gettext('Update Metadata') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</card-page>
|
</card-page>
|
||||||
|
|
||||||
|
<template v-if="isLiquidsoap && userAllowedForStation(StationPermission.Broadcasting)">
|
||||||
|
<update-metadata-modal ref="$updateMetadataModal" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {BackendAdapter} from '~/entities/RadioAdapters';
|
import {BackendAdapter} from '~/entities/RadioAdapters';
|
||||||
import Icon from '~/components/Common/Icon.vue';
|
import Icon from '~/components/Common/Icon.vue';
|
||||||
import {computed} from "vue";
|
import {computed, Ref, ref} from "vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import nowPlayingPanelProps from "~/components/Stations/Profile/nowPlayingPanelProps";
|
import nowPlayingPanelProps from "~/components/Stations/Profile/nowPlayingPanelProps";
|
||||||
import useNowPlaying from "~/functions/useNowPlaying";
|
import useNowPlaying from "~/functions/useNowPlaying";
|
||||||
|
@ -239,7 +254,16 @@ import CardPage from "~/components/Common/CardPage.vue";
|
||||||
import {useLightbox} from "~/vendor/lightbox";
|
import {useLightbox} from "~/vendor/lightbox";
|
||||||
import {StationPermission, userAllowedForStation} from "~/acl";
|
import {StationPermission, userAllowedForStation} from "~/acl";
|
||||||
import {useAzuraCastStation} from "~/vendor/azuracast";
|
import {useAzuraCastStation} from "~/vendor/azuracast";
|
||||||
import {IconHeadphones, IconLogs, IconMic, IconMusicNote, IconSkipNext, IconVolumeOff} from "~/components/Common/icons";
|
import {
|
||||||
|
IconHeadphones,
|
||||||
|
IconLogs,
|
||||||
|
IconMic,
|
||||||
|
IconMusicNote,
|
||||||
|
IconSkipNext,
|
||||||
|
IconUpdate,
|
||||||
|
IconVolumeOff
|
||||||
|
} from "~/components/Common/icons";
|
||||||
|
import UpdateMetadataModal from "~/components/Stations/Profile/UpdateMetadataModal.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
...nowPlayingPanelProps,
|
...nowPlayingPanelProps,
|
||||||
|
@ -275,4 +299,9 @@ const {vLightbox} = useLightbox();
|
||||||
const makeApiCall = (uri) => {
|
const makeApiCall = (uri) => {
|
||||||
emit('api-call', uri);
|
emit('api-call', uri);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const $updateMetadataModal: Ref<InstanceType<typeof UpdateMetadataModal> | null> = ref(null);
|
||||||
|
const updateMetadata = () => {
|
||||||
|
$updateMetadataModal.value?.open();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -45,9 +45,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {map} from "lodash";
|
import {map} from "lodash";
|
||||||
import {computed} from "vue";
|
import {computed} from "vue";
|
||||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
|
||||||
import CardPage from "~/components/Common/CardPage.vue";
|
import CardPage from "~/components/Common/CardPage.vue";
|
||||||
import {useLuxon} from "~/vendor/luxon";
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
import {useLuxon} from "~/vendor/luxon.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
scheduleItems: {
|
scheduleItems: {
|
||||||
|
@ -56,39 +56,35 @@ const props = defineProps({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const {timeConfig} = useAzuraCast();
|
|
||||||
const {timezone} = useAzuraCastStation();
|
|
||||||
|
|
||||||
const {DateTime} = useLuxon();
|
const {DateTime} = useLuxon();
|
||||||
|
const {
|
||||||
|
now,
|
||||||
|
timestampToDateTime,
|
||||||
|
formatDateTime
|
||||||
|
} = useStationDateTimeFormatter();
|
||||||
|
|
||||||
const processedScheduleItems = computed(() => {
|
const processedScheduleItems = computed(() => {
|
||||||
const now = DateTime.now().setZone(timezone);
|
const nowTz = now();
|
||||||
|
|
||||||
return map(props.scheduleItems, (row) => {
|
return map(props.scheduleItems, (row) => {
|
||||||
const start_moment = DateTime.fromSeconds(row.start_timestamp).setZone(timezone);
|
const startMoment = timestampToDateTime(row.start_timestamp);
|
||||||
const end_moment = DateTime.fromSeconds(row.end_timestamp).setZone(timezone);
|
const endMoment = timestampToDateTime(row.end_timestamp);
|
||||||
|
|
||||||
row.time_until = start_moment.toRelative();
|
row.time_until = startMoment.toRelative();
|
||||||
|
|
||||||
if (start_moment.hasSame(now, 'day')) {
|
row.start_formatted = formatDateTime(
|
||||||
row.start_formatted = start_moment.toLocaleString(
|
startMoment,
|
||||||
{...DateTime.TIME_SIMPLE, ...timeConfig}
|
startMoment.hasSame(nowTz, 'day')
|
||||||
);
|
? DateTime.TIME_SIMPLE
|
||||||
} else {
|
: DateTime.DATETIME_MED
|
||||||
row.start_formatted = start_moment.toLocaleString(
|
);
|
||||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end_moment.hasSame(start_moment, 'day')) {
|
row.end_formatted = formatDateTime(
|
||||||
row.end_formatted = end_moment.toLocaleString(
|
endMoment,
|
||||||
{...DateTime.TIME_SIMPLE, ...timeConfig}
|
endMoment.hasSame(startMoment, 'day')
|
||||||
);
|
? DateTime.TIME_SIMPLE
|
||||||
} else {
|
: DateTime.DATETIME_MED
|
||||||
row.end_formatted = end_moment.toLocaleString(
|
);
|
||||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
<template>
|
||||||
|
<modal
|
||||||
|
id="update_metadata"
|
||||||
|
ref="$modal"
|
||||||
|
centered
|
||||||
|
:title="$gettext('Update Metadata')"
|
||||||
|
@hidden="onHidden"
|
||||||
|
@shown="onShown"
|
||||||
|
>
|
||||||
|
<info-card>
|
||||||
|
{{
|
||||||
|
$gettext('Use this form to send a manual metadata update. Note that this will override any existing metadata on the stream.')
|
||||||
|
}}
|
||||||
|
</info-card>
|
||||||
|
|
||||||
|
<form @submit.prevent="doUpdateMetadata">
|
||||||
|
<div class="row g-3">
|
||||||
|
<form-group-field
|
||||||
|
id="update_metadata_title"
|
||||||
|
ref="$field"
|
||||||
|
class="col-12"
|
||||||
|
:field="v$.title"
|
||||||
|
:label="$gettext('Title')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form-group-field
|
||||||
|
id="update_metadata_artist"
|
||||||
|
class="col-12"
|
||||||
|
:field="v$.artist"
|
||||||
|
:label="$gettext('Artist')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<invisible-submit-button />
|
||||||
|
</form>
|
||||||
|
<template #modal-footer>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
@click="hide"
|
||||||
|
>
|
||||||
|
{{ $gettext('Close') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:class="(v$.$invalid) ? 'btn-danger' : 'btn-primary'"
|
||||||
|
@click="doUpdateMetadata"
|
||||||
|
>
|
||||||
|
{{ $gettext('Update Metadata') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {required} from '@vuelidate/validators';
|
||||||
|
import FormGroupField from "~/components/Form/FormGroupField.vue";
|
||||||
|
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
||||||
|
import {nextTick, ref} from "vue";
|
||||||
|
import {useNotify} from "~/functions/useNotify";
|
||||||
|
import {useAxios} from "~/vendor/axios";
|
||||||
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
|
import Modal from "~/components/Common/Modal.vue";
|
||||||
|
import InvisibleSubmitButton from "~/components/Common/InvisibleSubmitButton.vue";
|
||||||
|
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
||||||
|
import {getStationApiUrl} from "~/router.ts";
|
||||||
|
import InfoCard from "~/components/Common/InfoCard.vue";
|
||||||
|
|
||||||
|
const updateMetadataUrl = getStationApiUrl('/nowplaying/update');
|
||||||
|
|
||||||
|
const {form, v$, resetForm, ifValid} = useVuelidateOnForm(
|
||||||
|
{
|
||||||
|
title: {required},
|
||||||
|
artist: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: null,
|
||||||
|
artist: null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const $modal = ref<ModalTemplateRef>(null);
|
||||||
|
const {hide, show: open} = useHasModal($modal);
|
||||||
|
|
||||||
|
const onHidden = () => {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
const $field = ref<InstanceType<typeof FormGroupField> | null>(null);
|
||||||
|
|
||||||
|
const onShown = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
$field.value?.focus();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const {notifySuccess} = useNotify();
|
||||||
|
const {axios} = useAxios();
|
||||||
|
const {$gettext} = useTranslate();
|
||||||
|
|
||||||
|
const doUpdateMetadata = () => {
|
||||||
|
ifValid(() => {
|
||||||
|
axios.post(updateMetadataUrl.value, form.value).then(() => {
|
||||||
|
notifySuccess($gettext('Metadata updated.'));
|
||||||
|
}).finally(() => {
|
||||||
|
hide();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -42,5 +42,9 @@ export default {
|
||||||
publicScheduleEmbedUri: {
|
publicScheduleEmbedUri: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
publicPodcastsEmbedUri: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
ref="$datatable"
|
ref="$datatable"
|
||||||
:fields="fields"
|
:fields="fields"
|
||||||
:api-url="listUrl"
|
:api-url="listUrl"
|
||||||
|
:hide-on-loading="false"
|
||||||
>
|
>
|
||||||
<template #cell(actions)="row">
|
<template #cell(actions)="row">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
|
@ -52,8 +53,8 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(played_at)="row">
|
<template #cell(played_at)="row">
|
||||||
{{ formatTime(row.item.played_at) }}<br>
|
{{ formatTimestampAsTime(row.item.played_at) }}<br>
|
||||||
<small>{{ formatRelativeTime(row.item.played_at) }}</small>
|
<small>{{ formatTimestampAsRelative(row.item.played_at) }}</small>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(source)="row">
|
<template #cell(source)="row">
|
||||||
<div v-if="row.item.is_request">
|
<div v-if="row.item.is_request">
|
||||||
|
@ -70,21 +71,21 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DataTable, { DataTableField } from '../Common/DataTable.vue';
|
import DataTable, {DataTableField} from '../Common/DataTable.vue';
|
||||||
import QueueLogsModal from './Queue/LogsModal.vue';
|
import QueueLogsModal from './Queue/LogsModal.vue';
|
||||||
import Icon from "~/components/Common/Icon.vue";
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import {ref} from "vue";
|
import {computed, ref} from "vue";
|
||||||
import useConfirmAndDelete from "~/functions/useConfirmAndDelete";
|
import useConfirmAndDelete from "~/functions/useConfirmAndDelete";
|
||||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
|
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
|
||||||
import {useNotify} from "~/functions/useNotify";
|
import {useNotify} from "~/functions/useNotify";
|
||||||
import {useAxios} from "~/vendor/axios";
|
import {useAxios} from "~/vendor/axios";
|
||||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
import {useSweetAlert} from "~/vendor/sweetalert";
|
||||||
import CardPage from "~/components/Common/CardPage.vue";
|
import CardPage from "~/components/Common/CardPage.vue";
|
||||||
import {useLuxon} from "~/vendor/luxon";
|
|
||||||
import {getStationApiUrl} from "~/router";
|
import {getStationApiUrl} from "~/router";
|
||||||
import {IconRemove} from "~/components/Common/icons";
|
import {IconRemove} from "~/components/Common/icons";
|
||||||
|
import {useIntervalFn} from "@vueuse/core";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
|
||||||
const listUrl = getStationApiUrl('/queue');
|
const listUrl = getStationApiUrl('/queue');
|
||||||
const clearUrl = getStationApiUrl('/queue/clear');
|
const clearUrl = getStationApiUrl('/queue/clear');
|
||||||
|
@ -98,24 +99,19 @@ const fields: DataTableField[] = [
|
||||||
{key: 'source', label: $gettext('Source'), sortable: false}
|
{key: 'source', label: $gettext('Source'), sortable: false}
|
||||||
];
|
];
|
||||||
|
|
||||||
const {timezone} = useAzuraCastStation();
|
const {
|
||||||
|
formatTimestampAsTime,
|
||||||
const {DateTime} = useLuxon();
|
formatTimestampAsRelative
|
||||||
|
} = useStationDateTimeFormatter();
|
||||||
const getDateTime = (timestamp) =>
|
|
||||||
DateTime.fromSeconds(timestamp).setZone(timezone);
|
|
||||||
|
|
||||||
const {timeConfig} = useAzuraCast();
|
|
||||||
|
|
||||||
const formatTime = (time) => getDateTime(time).toLocaleString(
|
|
||||||
{...DateTime.TIME_WITH_SECONDS, ...timeConfig}
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatRelativeTime = (time) => getDateTime(time).toRelative();
|
|
||||||
|
|
||||||
const $datatable = ref<DataTableTemplateRef>(null);
|
const $datatable = ref<DataTableTemplateRef>(null);
|
||||||
const {relist} = useHasDatatable($datatable);
|
const {relist} = useHasDatatable($datatable);
|
||||||
|
|
||||||
|
useIntervalFn(
|
||||||
|
relist,
|
||||||
|
computed(() => (document.hidden) ? 60000 : 30000)
|
||||||
|
);
|
||||||
|
|
||||||
const $logsModal = ref<InstanceType<typeof QueueLogsModal> | null>(null);
|
const $logsModal = ref<InstanceType<typeof QueueLogsModal> | null>(null);
|
||||||
const doShowLogs = (logs) => {
|
const doShowLogs = (logs) => {
|
||||||
$logsModal.value?.show(logs);
|
$logsModal.value?.show(logs);
|
||||||
|
|
|
@ -27,14 +27,11 @@
|
||||||
:label="$gettext('AutoDJ Format')"
|
:label="$gettext('AutoDJ Format')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form-group-multi-check
|
<bitrate-options
|
||||||
v-if="formatSupportsBitrateOptions"
|
v-if="formatSupportsBitrateOptions"
|
||||||
id="edit_form_autodj_bitrate"
|
id="edit_form_autodj_bitrate"
|
||||||
class="col-md-6"
|
class="col-md-6"
|
||||||
:field="v$.autodj_bitrate"
|
:field="v$.autodj_bitrate"
|
||||||
:options="bitrateOptions"
|
|
||||||
stacked
|
|
||||||
radio
|
|
||||||
:label="$gettext('AutoDJ Bitrate (kbps)')"
|
:label="$gettext('AutoDJ Bitrate (kbps)')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -89,12 +86,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormGroupField from "~/components/Form/FormGroupField.vue";
|
import FormGroupField from "~/components/Form/FormGroupField.vue";
|
||||||
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
||||||
import {map} from "lodash";
|
|
||||||
import {computed} from "vue";
|
import {computed} from "vue";
|
||||||
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
|
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
|
||||||
import {useVModel} from "@vueuse/core";
|
import {useVModel} from "@vueuse/core";
|
||||||
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||||
import Tab from "~/components/Common/Tab.vue";
|
import Tab from "~/components/Common/Tab.vue";
|
||||||
|
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
form: {
|
form: {
|
||||||
|
@ -153,16 +150,6 @@ const formatOptions = [
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const bitrateOptions = map(
|
|
||||||
[32, 48, 64, 96, 128, 192, 256, 320],
|
|
||||||
(val) => {
|
|
||||||
return {
|
|
||||||
value: val,
|
|
||||||
text: val
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatSupportsBitrateOptions = computed(() => {
|
const formatSupportsBitrateOptions = computed(() => {
|
||||||
return form.value?.autodj_format !== 'flac';
|
return form.value?.autodj_format !== 'flac';
|
||||||
});
|
});
|
||||||
|
|
|
@ -75,67 +75,79 @@
|
||||||
|
|
||||||
<div id="map">
|
<div id="map">
|
||||||
<StationReportsListenersMap
|
<StationReportsListenersMap
|
||||||
:listeners="listeners"
|
:listeners="filteredListeners"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="card-body row">
|
<div class="card-body">
|
||||||
<div class="col-md-4">
|
<div class="row row-cols-md-auto align-items-center">
|
||||||
<h5>
|
<div class="col-12 text-start text-md-end h5">
|
||||||
{{ $gettext('Unique Listeners') }}
|
{{ $gettext('Unique Listeners') }}
|
||||||
<br>
|
<br>
|
||||||
<small>
|
<small>
|
||||||
{{ $gettext('for selected period') }}
|
{{ $gettext('for selected period') }}
|
||||||
</small>
|
</small>
|
||||||
</h5>
|
</div>
|
||||||
<h3>{{ listeners.length }}</h3>
|
<div class="col-12 h3">
|
||||||
</div>
|
{{ listeners.length }}
|
||||||
<div class="col-md-4">
|
</div>
|
||||||
<h5>
|
<div class="col-12 text-start text-md-end h5">
|
||||||
{{ $gettext('Total Listener Hours') }}
|
{{ $gettext('Total Listener Hours') }}
|
||||||
<br>
|
<br>
|
||||||
<small>
|
<small>
|
||||||
{{ $gettext('for selected period') }}
|
{{ $gettext('for selected period') }}
|
||||||
</small>
|
</small>
|
||||||
</h5>
|
</div>
|
||||||
<h3>{{ totalListenerHours }}</h3>
|
<div class="col-12 h3">
|
||||||
|
{{ totalListenerHours }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<listener-filters-bar v-model:filters="filters" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<data-table
|
<data-table
|
||||||
id="station_playlists"
|
id="station_listeners"
|
||||||
ref="$datatable"
|
ref="$datatable"
|
||||||
paginated
|
paginated
|
||||||
handle-client-side
|
handle-client-side
|
||||||
:fields="fields"
|
:fields="fields"
|
||||||
:items="listeners"
|
:items="filteredListeners"
|
||||||
|
select-fields
|
||||||
@refresh-clicked="updateListeners()"
|
@refresh-clicked="updateListeners()"
|
||||||
>
|
>
|
||||||
<template #cell(time)="row">
|
<!-- eslint-disable-next-line -->
|
||||||
{{ formatTime(row.item.connected_time) }}
|
<template #cell(device.client)="row">
|
||||||
</template>
|
<div class="d-flex align-items-center">
|
||||||
<template #cell(time_sec)="row">
|
<div class="flex-shrink-0 pe-2">
|
||||||
{{ row.item.connected_time }}
|
<span v-if="row.item.device.is_bot">
|
||||||
</template>
|
<icon :icon="IconRouter" />
|
||||||
<template #cell(user_agent)="row">
|
<span class="visually-hidden">
|
||||||
<div>
|
{{ $gettext('Bot/Crawler') }}
|
||||||
<span v-if="row.item.is_mobile">
|
</span>
|
||||||
<icon :icon="IconSmartphone" />
|
|
||||||
<span class="visually-hidden">
|
|
||||||
{{ $gettext('Mobile Device') }}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span v-else-if="row.item.device.is_mobile">
|
||||||
<span v-else>
|
<icon :icon="IconSmartphone" />
|
||||||
<icon :icon="IconDesktopWindows" />
|
<span class="visually-hidden">
|
||||||
<span class="visually-hidden">
|
{{ $gettext('Mobile') }}
|
||||||
{{ $gettext('Desktop Device') }}
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span v-else>
|
||||||
|
<icon :icon="IconDesktopWindows" />
|
||||||
{{ row.item.user_agent }}
|
<span class="visually-hidden">
|
||||||
</div>
|
{{ $gettext('Desktop') }}
|
||||||
<div v-if="row.item.device.client">
|
</span>
|
||||||
<small>{{ row.item.device.client }}</small>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-fill">
|
||||||
|
<div v-if="row.item.device.client">
|
||||||
|
{{ row.item.device.client }}
|
||||||
|
</div>
|
||||||
|
<div class="small">
|
||||||
|
{{ row.item.user_agent }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(stream)="row">
|
<template #cell(stream)="row">
|
||||||
|
@ -174,17 +186,21 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import StationReportsListenersMap from "./Listeners/Map.vue";
|
import StationReportsListenersMap from "./Listeners/Map.vue";
|
||||||
import Icon from "~/components/Common/Icon.vue";
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
import formatTime from "~/functions/formatTime";
|
|
||||||
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||||
import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue";
|
import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue";
|
||||||
import {computed, nextTick, onMounted, ref, shallowRef, watch} from "vue";
|
import {computed, ComputedRef, nextTick, onMounted, Ref, ref, ShallowRef, shallowRef, watch} from "vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import {useAxios} from "~/vendor/axios";
|
import {useAxios} from "~/vendor/axios";
|
||||||
import {useAzuraCastStation} from "~/vendor/azuracast";
|
|
||||||
import {useLuxon} from "~/vendor/luxon";
|
|
||||||
import {getStationApiUrl} from "~/router";
|
import {getStationApiUrl} from "~/router";
|
||||||
import {IconDesktopWindows, IconDownload, IconSmartphone} from "~/components/Common/icons";
|
import {IconDesktopWindows, IconDownload, IconRouter, IconSmartphone} from "~/components/Common/icons";
|
||||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
|
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
|
||||||
|
import {ListenerFilters, ListenerTypeFilter} from "~/components/Stations/Reports/Listeners/listenerFilters.ts";
|
||||||
|
import {filter} from "lodash";
|
||||||
|
import formatTime from "~/functions/formatTime.ts";
|
||||||
|
import ListenerFiltersBar from "./Listeners/FiltersBar.vue";
|
||||||
|
import {ApiListener} from "~/entities/ApiInterfaces.ts";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
import {useLuxon} from "~/vendor/luxon.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
attribution: {
|
attribution: {
|
||||||
|
@ -196,12 +212,15 @@ const props = defineProps({
|
||||||
const apiUrl = getStationApiUrl('/listeners');
|
const apiUrl = getStationApiUrl('/listeners');
|
||||||
|
|
||||||
const isLive = ref<boolean>(true);
|
const isLive = ref<boolean>(true);
|
||||||
const listeners = shallowRef([]);
|
const listeners: ShallowRef<ApiListener[]> = shallowRef([]);
|
||||||
|
|
||||||
const {timezone} = useAzuraCastStation();
|
|
||||||
|
|
||||||
const {DateTime} = useLuxon();
|
const {DateTime} = useLuxon();
|
||||||
const nowTz = DateTime.now().setZone(timezone);
|
const {
|
||||||
|
now,
|
||||||
|
formatTimestampAsDateTime
|
||||||
|
} = useStationDateTimeFormatter();
|
||||||
|
|
||||||
|
const nowTz = now();
|
||||||
|
|
||||||
const minDate = nowTz.minus({years: 5}).toJSDate();
|
const minDate = nowTz.minus({years: 5}).toJSDate();
|
||||||
const maxDate = nowTz.plus({days: 5}).toJSDate();
|
const maxDate = nowTz.plus({days: 5}).toJSDate();
|
||||||
|
@ -211,38 +230,107 @@ const dateRange = ref({
|
||||||
endDate: nowTz.toJSDate()
|
endDate: nowTz.toJSDate()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filters: Ref<ListenerFilters> = ref({
|
||||||
|
minLength: null,
|
||||||
|
maxLength: null,
|
||||||
|
type: ListenerTypeFilter.All,
|
||||||
|
});
|
||||||
|
|
||||||
const {$gettext} = useTranslate();
|
const {$gettext} = useTranslate();
|
||||||
|
|
||||||
const fields: DataTableField[] = [
|
const fields: DataTableField[] = [
|
||||||
{key: 'ip', label: $gettext('IP'), sortable: false},
|
{
|
||||||
{key: 'time', label: $gettext('Time'), sortable: false},
|
key: 'ip', label: $gettext('IP'), sortable: false,
|
||||||
{key: 'time_sec', label: $gettext('Time (sec)'), sortable: false},
|
selectable: true,
|
||||||
{key: 'user_agent', isRowHeader: true, label: $gettext('User Agent'), sortable: false},
|
visible: true
|
||||||
{key: 'stream', label: $gettext('Stream'), sortable: false},
|
},
|
||||||
{key: 'location', label: $gettext('Location'), sortable: false}
|
{
|
||||||
|
key: 'connected_time',
|
||||||
|
label: $gettext('Time'),
|
||||||
|
sortable: true,
|
||||||
|
formatter: (_col, _key, item) => {
|
||||||
|
return formatTime(item.connected_time)
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
visible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'connected_time_sec',
|
||||||
|
label: $gettext('Time (sec)'),
|
||||||
|
sortable: false,
|
||||||
|
formatter: (_col, _key, item) => {
|
||||||
|
return item.connected_time;
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'connected_on',
|
||||||
|
label: $gettext('Start Time'),
|
||||||
|
sortable: true,
|
||||||
|
formatter: (_col, _key, item) => formatTimestampAsDateTime(
|
||||||
|
item.connected_on,
|
||||||
|
DateTime.DATETIME_SHORT
|
||||||
|
),
|
||||||
|
selectable: true,
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'connected_until',
|
||||||
|
label: $gettext('End Time'),
|
||||||
|
sortable: true,
|
||||||
|
formatter: (_col, _key, item) => formatTimestampAsDateTime(
|
||||||
|
item.connected_until,
|
||||||
|
DateTime.DATETIME_SHORT
|
||||||
|
),
|
||||||
|
selectable: true,
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'device.client',
|
||||||
|
isRowHeader: true,
|
||||||
|
label: $gettext('User Agent'),
|
||||||
|
sortable: true,
|
||||||
|
selectable: true,
|
||||||
|
visible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stream',
|
||||||
|
label: $gettext('Stream'),
|
||||||
|
sortable: true,
|
||||||
|
selectable: true,
|
||||||
|
visible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'location',
|
||||||
|
label: $gettext('Location'),
|
||||||
|
sortable: true,
|
||||||
|
selectable: true,
|
||||||
|
visible: true
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const exportUrl = computed(() => {
|
const exportUrl = computed(() => {
|
||||||
const exportUrl = new URL(apiUrl.value, document.location.href);
|
const exportUrl = new URL(apiUrl.value, document.location.href);
|
||||||
const exportUrlParams = exportUrl.searchParams;
|
const exportUrlParams = exportUrl.searchParams;
|
||||||
exportUrlParams.set('format', 'csv');
|
exportUrlParams.set('format', 'csv');
|
||||||
|
|
||||||
if (!isLive.value) {
|
if (!isLive.value) {
|
||||||
exportUrlParams.set('start', DateTime.fromJSDate(dateRange.value.startDate).toISO());
|
exportUrlParams.set('start', DateTime.fromJSDate(dateRange.value.startDate).toISO());
|
||||||
exportUrlParams.set('end', DateTime.fromJSDate(dateRange.value.endDate).toISO());
|
exportUrlParams.set('end', DateTime.fromJSDate(dateRange.value.endDate).toISO());
|
||||||
}
|
}
|
||||||
|
|
||||||
return exportUrl.toString();
|
return exportUrl.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalListenerHours = computed(() => {
|
const totalListenerHours = computed(() => {
|
||||||
let tlh_seconds = 0;
|
let tlh_seconds = 0;
|
||||||
listeners.value.forEach(function (listener) {
|
filteredListeners.value.forEach(function (listener) {
|
||||||
tlh_seconds += listener.connected_time;
|
tlh_seconds += listener.connected_time;
|
||||||
});
|
});
|
||||||
|
|
||||||
const tlh_hours = tlh_seconds / 3600;
|
const tlh_hours = tlh_seconds / 3600;
|
||||||
return Math.round((tlh_hours + 0.00001) * 100) / 100;
|
return Math.round((tlh_hours + 0.00001) * 100) / 100;
|
||||||
});
|
});
|
||||||
|
|
||||||
const {axios} = useAxios();
|
const {axios} = useAxios();
|
||||||
|
@ -250,6 +338,40 @@ const {axios} = useAxios();
|
||||||
const $datatable = ref<DataTableTemplateRef>(null);
|
const $datatable = ref<DataTableTemplateRef>(null);
|
||||||
const {navigate} = useHasDatatable($datatable);
|
const {navigate} = useHasDatatable($datatable);
|
||||||
|
|
||||||
|
const hasFilters: ComputedRef<boolean> = computed(() => {
|
||||||
|
return null !== filters.value.minLength
|
||||||
|
|| null !== filters.value.maxLength
|
||||||
|
|| ListenerTypeFilter.All !== filters.value.type;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredListeners: ComputedRef<ApiListener[]> = computed(() => {
|
||||||
|
if (!hasFilters.value) {
|
||||||
|
return listeners.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter(
|
||||||
|
listeners.value,
|
||||||
|
(row: ApiListener) => {
|
||||||
|
const connectedTime: number = row.connected_time;
|
||||||
|
if (null !== filters.value.minLength && connectedTime < filters.value.minLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (null !== filters.value.maxLength && connectedTime > filters.value.maxLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ListenerTypeFilter.All !== filters.value.type) {
|
||||||
|
if (ListenerTypeFilter.Mobile === filters.value.type && !row.device.is_mobile) {
|
||||||
|
return false;
|
||||||
|
} else if (ListenerTypeFilter.Desktop === filters.value.type && row.device.is_mobile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const updateListeners = () => {
|
const updateListeners = () => {
|
||||||
const params: {
|
const params: {
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
<template>
|
||||||
|
<div class="row row-cols-md-auto g-3 align-items-center">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="minLength">{{ $gettext('Min. Connected Time') }}</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input
|
||||||
|
id="minLength"
|
||||||
|
v-model="filters.minLength"
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="maxLength">{{ $gettext('Max. Connected Time') }}</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input
|
||||||
|
id="maxLength"
|
||||||
|
v-model="filters.maxLength"
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="type">{{ $gettext('Listener Type') }}</label>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<select
|
||||||
|
v-model="filters.type"
|
||||||
|
class="form-select form-select-sm"
|
||||||
|
>
|
||||||
|
<option :value="ListenerTypeFilter.All">
|
||||||
|
{{ $gettext('All Types') }}
|
||||||
|
</option>
|
||||||
|
<option :value="ListenerTypeFilter.Mobile">
|
||||||
|
{{ $gettext('Mobile') }}
|
||||||
|
</option>
|
||||||
|
<option :value="ListenerTypeFilter.Desktop">
|
||||||
|
{{ $gettext('Desktop') }}
|
||||||
|
</option>
|
||||||
|
<option :value="ListenerTypeFilter.Bot">
|
||||||
|
{{ $gettext('Bot/Crawler') }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-secondary"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<icon :icon="IconClearAll" />
|
||||||
|
<span>
|
||||||
|
{{ $gettext('Clear Filters') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ListenerFilters, ListenerTypeFilter} from "./listenerFilters.ts";
|
||||||
|
import {useVModel} from "@vueuse/core";
|
||||||
|
import {WritableComputedRef} from "vue";
|
||||||
|
import {IconClearAll} from "~/components/Common/icons.ts";
|
||||||
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
filters: ListenerFilters
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:filters']);
|
||||||
|
const filters: WritableComputedRef<ListenerFilters> = useVModel(props, 'filters', emit);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
filters.value.minLength = null;
|
||||||
|
filters.value.maxLength = null;
|
||||||
|
filters.value.type = ListenerTypeFilter.All;
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,12 @@
|
||||||
|
export enum ListenerTypeFilter {
|
||||||
|
All = 'all',
|
||||||
|
Mobile = 'mobile',
|
||||||
|
Desktop = 'desktop',
|
||||||
|
Bot = 'bot'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListenerFilters {
|
||||||
|
type: ListenerTypeFilter,
|
||||||
|
minLength: number | null,
|
||||||
|
maxLength: number | null
|
||||||
|
}
|
|
@ -92,11 +92,10 @@ import StreamsTab from "./Overview/StreamsTab.vue";
|
||||||
import ClientsTab from "./Overview/ClientsTab.vue";
|
import ClientsTab from "./Overview/ClientsTab.vue";
|
||||||
import ListeningTimeTab from "~/components/Stations/Reports/Overview/ListeningTimeTab.vue";
|
import ListeningTimeTab from "~/components/Stations/Reports/Overview/ListeningTimeTab.vue";
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import {useAzuraCastStation} from "~/vendor/azuracast";
|
|
||||||
import {useLuxon} from "~/vendor/luxon";
|
|
||||||
import {getStationApiUrl} from "~/router";
|
import {getStationApiUrl} from "~/router";
|
||||||
import Tabs from "~/components/Common/Tabs.vue";
|
import Tabs from "~/components/Common/Tabs.vue";
|
||||||
import Tab from "~/components/Common/Tab.vue";
|
import Tab from "~/components/Common/Tab.vue";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
showFullAnalytics: {
|
showFullAnalytics: {
|
||||||
|
@ -113,11 +112,9 @@ const byCountryUrl = getStationApiUrl('/reports/overview/by-country');
|
||||||
const byClientUrl = getStationApiUrl('/reports/overview/by-client');
|
const byClientUrl = getStationApiUrl('/reports/overview/by-client');
|
||||||
const listeningTimeUrl = getStationApiUrl('/reports/overview/by-listening-time');
|
const listeningTimeUrl = getStationApiUrl('/reports/overview/by-listening-time');
|
||||||
|
|
||||||
const {timezone} = useAzuraCastStation();
|
const {now} = useStationDateTimeFormatter();
|
||||||
const {DateTime} = useLuxon();
|
|
||||||
|
|
||||||
const nowTz = DateTime.now().setZone(timezone);
|
|
||||||
|
|
||||||
|
const nowTz = now();
|
||||||
const dateRange = ref({
|
const dateRange = ref({
|
||||||
startDate: nowTz.minus({days: 13}).toJSDate(),
|
startDate: nowTz.minus({days: 13}).toJSDate(),
|
||||||
endDate: nowTz.toJSDate(),
|
endDate: nowTz.toJSDate(),
|
||||||
|
|
|
@ -56,14 +56,14 @@
|
||||||
:api-url="listUrlForType"
|
:api-url="listUrlForType"
|
||||||
>
|
>
|
||||||
<template #cell(timestamp)="row">
|
<template #cell(timestamp)="row">
|
||||||
{{ formatTime(row.item.timestamp) }}
|
{{ formatTimestampAsDateTime(row.item.timestamp) }}
|
||||||
</template>
|
</template>
|
||||||
<template #cell(played_at)="row">
|
<template #cell(played_at)="row">
|
||||||
<span v-if="row.item.played_at === 0">
|
<span v-if="row.item.played_at === 0">
|
||||||
{{ $gettext('Not Played') }}
|
{{ $gettext('Not Played') }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ formatTime(row.item.played_at) }}
|
{{ formatTimestampAsDateTime(row.item.played_at) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(song_title)="row">
|
<template #cell(song_title)="row">
|
||||||
|
@ -93,18 +93,17 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||||
import Icon from "~/components/Common/Icon.vue";
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
|
||||||
import {computed, nextTick, ref} from "vue";
|
import {computed, nextTick, ref} from "vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
import {useSweetAlert} from "~/vendor/sweetalert";
|
||||||
import {useNotify} from "~/functions/useNotify";
|
import {useNotify} from "~/functions/useNotify";
|
||||||
import {useAxios} from "~/vendor/axios";
|
import {useAxios} from "~/vendor/axios";
|
||||||
import {useLuxon} from "~/vendor/luxon";
|
|
||||||
import {getStationApiUrl} from "~/router";
|
import {getStationApiUrl} from "~/router";
|
||||||
import {IconRemove} from "~/components/Common/icons";
|
import {IconRemove} from "~/components/Common/icons";
|
||||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
|
||||||
const listUrl = getStationApiUrl('/reports/requests');
|
const listUrl = getStationApiUrl('/reports/requests');
|
||||||
const clearUrl = getStationApiUrl('/reports/requests/clear');
|
const clearUrl = getStationApiUrl('/reports/requests/clear');
|
||||||
|
@ -147,16 +146,7 @@ const setType = (type) => {
|
||||||
nextTick(relist);
|
nextTick(relist);
|
||||||
};
|
};
|
||||||
|
|
||||||
const {timeConfig} = useAzuraCast();
|
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||||
const {timezone} = useAzuraCastStation();
|
|
||||||
|
|
||||||
const {DateTime} = useLuxon();
|
|
||||||
|
|
||||||
const formatTime = (time) => {
|
|
||||||
return DateTime.fromSeconds(time).setZone(timezone).toLocaleString(
|
|
||||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const {confirmDelete} = useSweetAlert();
|
const {confirmDelete} = useSweetAlert();
|
||||||
const {notifySuccess} = useNotify();
|
const {notifySuccess} = useNotify();
|
||||||
|
|
|
@ -114,16 +114,14 @@ import FormFieldset from "~/components/Form/FormFieldset.vue";
|
||||||
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
||||||
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
||||||
import {getStationApiUrl} from "~/router";
|
import {getStationApiUrl} from "~/router";
|
||||||
import {useLuxon} from "~/vendor/luxon";
|
|
||||||
import {useAzuraCastStation} from "~/vendor/azuracast";
|
|
||||||
import CardPage from "~/components/Common/CardPage.vue";
|
import CardPage from "~/components/Common/CardPage.vue";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
|
||||||
const apiUrl = getStationApiUrl('/reports/soundexchange');
|
const apiUrl = getStationApiUrl('/reports/soundexchange');
|
||||||
|
|
||||||
const {DateTime} = useLuxon();
|
const {now} = useStationDateTimeFormatter();
|
||||||
const {timezone} = useAzuraCastStation();
|
|
||||||
|
|
||||||
const lastMonth = DateTime.now().setZone(timezone).minus({months: 1});
|
const lastMonth = now().minus({months: 1});
|
||||||
|
|
||||||
const {v$} = useVuelidateOnForm(
|
const {v$} = useVuelidateOnForm(
|
||||||
{
|
{
|
||||||
|
|
|
@ -86,22 +86,28 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Icon from "~/components/Common/Icon.vue";
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue";
|
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||||
import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue";
|
import DateRangeDropdown from "~/components/Common/DateRangeDropdown.vue";
|
||||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
|
||||||
import {computed, ref, watch} from "vue";
|
import {computed, ref, watch} from "vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import {useLuxon} from "~/vendor/luxon";
|
|
||||||
import {getStationApiUrl} from "~/router";
|
import {getStationApiUrl} from "~/router";
|
||||||
import {IconDownload, IconTrendingDown, IconTrendingUp} from "~/components/Common/icons";
|
import {IconDownload, IconTrendingDown, IconTrendingUp} from "~/components/Common/icons";
|
||||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
import {useLuxon} from "~/vendor/luxon.ts";
|
||||||
|
import {useAzuraCastStation} from "~/vendor/azuracast.ts";
|
||||||
|
|
||||||
const baseApiUrl = getStationApiUrl('/history');
|
const baseApiUrl = getStationApiUrl('/history');
|
||||||
|
|
||||||
const {timezone} = useAzuraCastStation();
|
const {timezone} = useAzuraCastStation();
|
||||||
const {DateTime} = useLuxon();
|
const {DateTime} = useLuxon();
|
||||||
|
const {
|
||||||
|
now,
|
||||||
|
formatDateTimeAsDateTime,
|
||||||
|
formatTimestampAsDateTime
|
||||||
|
} = useStationDateTimeFormatter();
|
||||||
|
|
||||||
const nowTz = DateTime.now().setZone(timezone);
|
const nowTz = now();
|
||||||
|
|
||||||
const dateRange = ref(
|
const dateRange = ref(
|
||||||
{
|
{
|
||||||
|
@ -111,7 +117,6 @@ const dateRange = ref(
|
||||||
);
|
);
|
||||||
|
|
||||||
const {$gettext} = useTranslate();
|
const {$gettext} = useTranslate();
|
||||||
const {timeConfig} = useAzuraCast();
|
|
||||||
|
|
||||||
const fields: DataTableField[] = [
|
const fields: DataTableField[] = [
|
||||||
{
|
{
|
||||||
|
@ -119,29 +124,22 @@ const fields: DataTableField[] = [
|
||||||
label: $gettext('Date/Time (Browser)'),
|
label: $gettext('Date/Time (Browser)'),
|
||||||
selectable: true,
|
selectable: true,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
formatter: (value) => {
|
visible: false,
|
||||||
return DateTime.fromSeconds(
|
formatter: (value) => formatDateTimeAsDateTime(
|
||||||
value,
|
DateTime.fromSeconds(value, {zone: 'system'}),
|
||||||
{zone: 'system'}
|
DateTime.DATETIME_SHORT
|
||||||
).toLocaleString(
|
)
|
||||||
{...DateTime.DATETIME_SHORT, ...timeConfig}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'played_at_station',
|
key: 'played_at_station',
|
||||||
label: $gettext('Date/Time (Station)'),
|
label: $gettext('Date/Time (Station)'),
|
||||||
sortable: false,
|
sortable: false,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
visible: false,
|
visible: true,
|
||||||
formatter: (_value, _key, item) => {
|
formatter: (_value, _key, item) => formatTimestampAsDateTime(
|
||||||
return DateTime.fromSeconds(
|
item.played_at,
|
||||||
item.played_at,
|
DateTime.DATETIME_SHORT
|
||||||
{zone: timezone}
|
)
|
||||||
).toLocaleString(
|
|
||||||
{...DateTime.DATETIME_SHORT, ...timeConfig}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'listeners_start',
|
key: 'listeners_start',
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<data-table
|
<data-table
|
||||||
id="station_remotes"
|
id="station_sftp_users"
|
||||||
ref="$datatable"
|
ref="$datatable"
|
||||||
:show-toolbar="false"
|
:show-toolbar="false"
|
||||||
:fields="fields"
|
:fields="fields"
|
||||||
|
@ -75,7 +75,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DataTable, { DataTableField } from "~/components/Common/DataTable.vue";
|
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||||
import SftpUsersEditModal from "./SftpUsers/EditModal.vue";
|
import SftpUsersEditModal from "./SftpUsers/EditModal.vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
|
|
|
@ -64,14 +64,15 @@
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import Icon from "~/components/Common/Icon.vue";
|
import Icon from "~/components/Common/Icon.vue";
|
||||||
import SidebarMenu from "~/components/Common/SidebarMenu.vue";
|
import SidebarMenu from "~/components/Common/SidebarMenu.vue";
|
||||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
import {useAzuraCastStation} from "~/vendor/azuracast";
|
||||||
import {useEventBus, useIntervalFn} from "@vueuse/core";
|
import {useEventBus, useIntervalFn} from "@vueuse/core";
|
||||||
import {useStationsMenu} from "~/components/Stations/menu";
|
import {useStationsMenu} from "~/components/Stations/menu";
|
||||||
import {StationPermission, userAllowedForStation} from "~/acl";
|
import {StationPermission, userAllowedForStation} from "~/acl";
|
||||||
import {useAxios} from "~/vendor/axios.ts";
|
import {useAxios} from "~/vendor/axios.ts";
|
||||||
import {getStationApiUrl} from "~/router.ts";
|
import {getStationApiUrl} from "~/router.ts";
|
||||||
import {useLuxon} from "~/vendor/luxon.ts";
|
|
||||||
import {IconEdit} from "~/components/Common/icons.ts";
|
import {IconEdit} from "~/components/Common/icons.ts";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
import {useLuxon} from "~/vendor/luxon.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
station: {
|
station: {
|
||||||
|
@ -82,17 +83,15 @@ const props = defineProps({
|
||||||
|
|
||||||
const menuItems = useStationsMenu();
|
const menuItems = useStationsMenu();
|
||||||
|
|
||||||
const {timeConfig} = useAzuraCast();
|
const {name} = useAzuraCastStation();
|
||||||
const {name, timezone} = useAzuraCastStation();
|
|
||||||
const {DateTime} = useLuxon();
|
const {DateTime} = useLuxon();
|
||||||
|
const {now, formatDateTimeAsTime} = useStationDateTimeFormatter();
|
||||||
|
|
||||||
const clock = ref('');
|
const clock = ref('');
|
||||||
|
|
||||||
useIntervalFn(() => {
|
useIntervalFn(() => {
|
||||||
clock.value = DateTime.now().setZone(timezone).toLocaleString({
|
clock.value = formatDateTimeAsTime(now(), DateTime.TIME_WITH_SHORT_OFFSET);
|
||||||
...DateTime.TIME_WITH_SHORT_OFFSET,
|
|
||||||
...timeConfig
|
|
||||||
})
|
|
||||||
}, 1000, {
|
}, 1000, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
immediateCallback: true
|
immediateCallback: true
|
||||||
|
|
|
@ -63,24 +63,23 @@ import InlinePlayer from '~/components/InlinePlayer.vue';
|
||||||
import Icon from '~/components/Common/Icon.vue';
|
import Icon from '~/components/Common/Icon.vue';
|
||||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||||
import '~/vendor/sweetalert';
|
import '~/vendor/sweetalert';
|
||||||
import {useAzuraCast} from "~/vendor/azuracast";
|
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import {useTranslate} from "~/vendor/gettext";
|
import {useTranslate} from "~/vendor/gettext";
|
||||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
import {useSweetAlert} from "~/vendor/sweetalert";
|
||||||
import {useNotify} from "~/functions/useNotify";
|
import {useNotify} from "~/functions/useNotify";
|
||||||
import {useAxios} from "~/vendor/axios";
|
import {useAxios} from "~/vendor/axios";
|
||||||
import Modal from "~/components/Common/Modal.vue";
|
import Modal from "~/components/Common/Modal.vue";
|
||||||
import {useLuxon} from "~/vendor/luxon";
|
|
||||||
import {IconDownload} from "~/components/Common/icons";
|
import {IconDownload} from "~/components/Common/icons";
|
||||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||||
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
||||||
import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
|
import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
|
||||||
|
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||||
|
|
||||||
const listUrl = ref(null);
|
const listUrl = ref(null);
|
||||||
|
|
||||||
const {$gettext} = useTranslate();
|
const {$gettext} = useTranslate();
|
||||||
const {timeConfig} = useAzuraCast();
|
|
||||||
const {DateTime} = useLuxon();
|
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||||
|
|
||||||
const fields: DataTableField[] = [
|
const fields: DataTableField[] = [
|
||||||
{
|
{
|
||||||
|
@ -93,11 +92,7 @@ const fields: DataTableField[] = [
|
||||||
key: 'timestampStart',
|
key: 'timestampStart',
|
||||||
label: $gettext('Start Time'),
|
label: $gettext('Start Time'),
|
||||||
sortable: false,
|
sortable: false,
|
||||||
formatter: (value) => {
|
formatter: (value) => formatTimestampAsDateTime(value),
|
||||||
return DateTime.fromSeconds(value).toLocaleString(
|
|
||||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
class: 'ps-3'
|
class: 'ps-3'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -105,13 +100,9 @@ const fields: DataTableField[] = [
|
||||||
label: $gettext('End Time'),
|
label: $gettext('End Time'),
|
||||||
sortable: false,
|
sortable: false,
|
||||||
formatter: (value) => {
|
formatter: (value) => {
|
||||||
if (value === 0) {
|
return value === 0
|
||||||
return $gettext('Live');
|
? $gettext('Live')
|
||||||
}
|
: formatTimestampAsDateTime(value);
|
||||||
|
|
||||||
return DateTime.fromSeconds(value).toLocaleString(
|
|
||||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import {getStationApiUrl} from "~/router.ts";
|
import {getStationApiUrl} from "~/router.ts";
|
||||||
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
|
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
|
||||||
|
import {RouteRecordRaw} from "vue-router";
|
||||||
|
import {useAxios} from "~/vendor/axios.ts";
|
||||||
|
|
||||||
export default function useStationsRoutes() {
|
export default function useStationsRoutes(): RouteRecordRaw[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
@ -61,6 +63,22 @@ export default function useStationsRoutes() {
|
||||||
name: 'stations:podcasts:index',
|
name: 'stations:podcasts:index',
|
||||||
...populateComponentRemotely(getStationApiUrl('/vue/podcasts'))
|
...populateComponentRemotely(getStationApiUrl('/vue/podcasts'))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/podcast/:podcast_id',
|
||||||
|
component: () => import('~/components/Stations/PodcastEpisodes.vue'),
|
||||||
|
name: 'stations:podcast:episodes',
|
||||||
|
beforeEnter: async (to, _, next) => {
|
||||||
|
const apiUrl = getStationApiUrl(`/podcast/${to.params.podcast_id}`);
|
||||||
|
const {axios} = useAxios();
|
||||||
|
to.meta.state = {
|
||||||
|
podcast: await axios.get(apiUrl.value).then(r => r.data)
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
props: (to) => {
|
||||||
|
return to.meta.state;
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
name: 'stations:profile:index',
|
name: 'stations:profile:index',
|
||||||
|
|
|
@ -269,10 +269,76 @@ export interface ApiListener {
|
||||||
* @example 30
|
* @example 30
|
||||||
*/
|
*/
|
||||||
connected_time?: number;
|
connected_time?: number;
|
||||||
/** Device metadata, if available */
|
device?: ApiListenerDevice;
|
||||||
device?: any[];
|
location?: ApiListenerLocation;
|
||||||
/** Location metadata, if available */
|
}
|
||||||
location?: any[];
|
|
||||||
|
export interface ApiListenerDevice {
|
||||||
|
/**
|
||||||
|
* If the listener device is likely a browser.
|
||||||
|
* @example true
|
||||||
|
*/
|
||||||
|
is_browser?: boolean;
|
||||||
|
/**
|
||||||
|
* If the listener device is likely a mobile device.
|
||||||
|
* @example true
|
||||||
|
*/
|
||||||
|
is_mobile?: boolean;
|
||||||
|
/**
|
||||||
|
* If the listener device is likely a crawler.
|
||||||
|
* @example true
|
||||||
|
*/
|
||||||
|
is_bot?: boolean;
|
||||||
|
/**
|
||||||
|
* Summary of the listener client.
|
||||||
|
* @example "Firefox 121.0, Windows"
|
||||||
|
*/
|
||||||
|
client?: string | null;
|
||||||
|
/**
|
||||||
|
* Summary of the listener browser family.
|
||||||
|
* @example "Firefox"
|
||||||
|
*/
|
||||||
|
browser_family?: string | null;
|
||||||
|
/**
|
||||||
|
* Summary of the listener OS family.
|
||||||
|
* @example "Windows"
|
||||||
|
*/
|
||||||
|
os_family?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiListenerLocation {
|
||||||
|
/**
|
||||||
|
* The approximate city of the listener.
|
||||||
|
* @example "Austin"
|
||||||
|
*/
|
||||||
|
city?: string | null;
|
||||||
|
/**
|
||||||
|
* The approximate region/state of the listener.
|
||||||
|
* @example "Texas"
|
||||||
|
*/
|
||||||
|
region?: string | null;
|
||||||
|
/**
|
||||||
|
* The approximate country of the listener.
|
||||||
|
* @example "United States"
|
||||||
|
*/
|
||||||
|
country?: string | null;
|
||||||
|
/**
|
||||||
|
* A description of the location.
|
||||||
|
* @example "Austin, Texas, US"
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* Latitude.
|
||||||
|
* @format float
|
||||||
|
* @example "30.000000"
|
||||||
|
*/
|
||||||
|
lat?: number | null;
|
||||||
|
/**
|
||||||
|
* Latitude.
|
||||||
|
* @format float
|
||||||
|
* @example "-97.000000"
|
||||||
|
*/
|
||||||
|
lon?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiNewRecord = ApiStatus & {
|
export type ApiNewRecord = ApiStatus & {
|
||||||
|
@ -408,6 +474,11 @@ export interface ApiNowPlayingStation {
|
||||||
* @example "liquidsoap"
|
* @example "liquidsoap"
|
||||||
*/
|
*/
|
||||||
backend?: string;
|
backend?: string;
|
||||||
|
/**
|
||||||
|
* The station's IANA time zone
|
||||||
|
* @example "America/Chicago"
|
||||||
|
*/
|
||||||
|
timezone?: string;
|
||||||
/**
|
/**
|
||||||
* The full URL to listen to the default mount of the station
|
* The full URL to listen to the default mount of the station
|
||||||
* @example "http://localhost:8000/radio.mp3"
|
* @example "http://localhost:8000/radio.mp3"
|
||||||
|
@ -531,26 +602,39 @@ export interface ApiNowPlayingStationRemote {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiPodcast = HasLinks & {
|
export type ApiPodcast = HasLinks & {
|
||||||
id?: string | null;
|
id?: string;
|
||||||
storage_location_id?: number | null;
|
storage_location_id?: number;
|
||||||
title?: string | null;
|
title?: string;
|
||||||
link?: string | null;
|
link?: string | null;
|
||||||
description?: string | null;
|
description?: string;
|
||||||
language?: string | null;
|
description_short?: string;
|
||||||
author?: string | null;
|
language?: string;
|
||||||
email?: string | null;
|
language_name?: string;
|
||||||
|
author?: string;
|
||||||
|
email?: string;
|
||||||
has_custom_art?: boolean;
|
has_custom_art?: boolean;
|
||||||
art?: string | null;
|
art?: string;
|
||||||
art_updated_at?: number;
|
art_updated_at?: number;
|
||||||
categories?: string[];
|
is_published?: boolean;
|
||||||
episodes?: string[];
|
episodes?: number;
|
||||||
|
categories?: ApiPodcastCategory[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ApiPodcastCategory {
|
||||||
|
category?: string;
|
||||||
|
text?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type ApiPodcastEpisode = HasLinks & {
|
export type ApiPodcastEpisode = HasLinks & {
|
||||||
id?: string | null;
|
id?: string;
|
||||||
title?: string | null;
|
title?: string;
|
||||||
description?: string | null;
|
description?: string;
|
||||||
|
description_short?: string;
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
|
created_at?: number;
|
||||||
|
is_published?: boolean;
|
||||||
publish_at?: number | null;
|
publish_at?: number | null;
|
||||||
has_media?: boolean;
|
has_media?: boolean;
|
||||||
media?: ApiPodcastMedia;
|
media?: ApiPodcastMedia;
|
||||||
|
@ -583,32 +667,32 @@ export interface ApiSong {
|
||||||
* The song artist.
|
* The song artist.
|
||||||
* @example "Chet Porter"
|
* @example "Chet Porter"
|
||||||
*/
|
*/
|
||||||
artist?: string;
|
artist?: string | null;
|
||||||
/**
|
/**
|
||||||
* The song title.
|
* The song title.
|
||||||
* @example "Aluko River"
|
* @example "Aluko River"
|
||||||
*/
|
*/
|
||||||
title?: string;
|
title?: string | null;
|
||||||
/**
|
/**
|
||||||
* The song album.
|
* The song album.
|
||||||
* @example "Moving Castle"
|
* @example "Moving Castle"
|
||||||
*/
|
*/
|
||||||
album?: string;
|
album?: string | null;
|
||||||
/**
|
/**
|
||||||
* The song genre.
|
* The song genre.
|
||||||
* @example "Rock"
|
* @example "Rock"
|
||||||
*/
|
*/
|
||||||
genre?: string;
|
genre?: string | null;
|
||||||
/**
|
/**
|
||||||
* The International Standard Recording Code (ISRC) of the file.
|
* The International Standard Recording Code (ISRC) of the file.
|
||||||
* @example "US28E1600021"
|
* @example "US28E1600021"
|
||||||
*/
|
*/
|
||||||
isrc?: string;
|
isrc?: string | null;
|
||||||
/**
|
/**
|
||||||
* Lyrics to the song.
|
* Lyrics to the song.
|
||||||
* @example ""
|
* @example ""
|
||||||
*/
|
*/
|
||||||
lyrics?: string;
|
lyrics?: string | null;
|
||||||
/**
|
/**
|
||||||
* URL to the album artwork (if available).
|
* URL to the album artwork (if available).
|
||||||
* @example "https://picsum.photos/1200/1200"
|
* @example "https://picsum.photos/1200/1200"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export default function (seconds: string | number) {
|
export default function (seconds: string | number): string {
|
||||||
seconds = Math.floor(Number(seconds));
|
seconds = Math.floor(Number(seconds));
|
||||||
|
|
||||||
const d: number = Math.floor(seconds / 86400),
|
const d: number = Math.floor(seconds / 86400),
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
export default function (volume: number) {
|
export default function (volume: number): number {
|
||||||
return Math.min((Math.exp(volume / 100) - 1) / (Math.E - 1), 1);
|
return Math.min((Math.exp(volume / 100) - 1) / (Math.E - 1), 1);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export default function isObject(value) {
|
export default function isObject(value: any): boolean {
|
||||||
return typeof value === "object"
|
return typeof value === "object"
|
||||||
&& (Object(value) === value)
|
&& (Object(value) === value)
|
||||||
&& !Array.isArray(value);
|
&& !Array.isArray(value);
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import {reactivePick} from "@vueuse/core";
|
import {reactivePick} from "@vueuse/core";
|
||||||
import {keys} from "lodash";
|
import {keys} from "lodash";
|
||||||
|
|
||||||
export function pickProps(props, subset) {
|
export function pickProps<T extends object, K extends T>(
|
||||||
|
props: T,
|
||||||
|
subset: K
|
||||||
|
): Pick<T, keyof K> {
|
||||||
return reactivePick(
|
return reactivePick(
|
||||||
props,
|
props,
|
||||||
...keys(subset)
|
...keys(subset)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import {resolveRef, watchOnce} from "@vueuse/core";
|
import {watchOnce} from "@vueuse/core";
|
||||||
import {ref} from "vue";
|
import {ref, toRef} from "vue";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ref that syncs with its "source" value only once.
|
* Creates a ref that syncs with its "source" value only once.
|
||||||
|
@ -7,7 +7,7 @@ import {ref} from "vue";
|
||||||
* subsequent refreshes.
|
* subsequent refreshes.
|
||||||
*/
|
*/
|
||||||
export default function syncOnce(sourceMaybeRef) {
|
export default function syncOnce(sourceMaybeRef) {
|
||||||
const sourceRef = resolveRef(sourceMaybeRef);
|
const sourceRef = toRef(sourceMaybeRef);
|
||||||
|
|
||||||
const newRef = ref(sourceRef.value);
|
const newRef = ref(sourceRef.value);
|
||||||
watchOnce(sourceRef, (newVal) => {
|
watchOnce(sourceRef, (newVal) => {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {useNotify} from "~/functions/useNotify";
|
||||||
import {useAxios} from "~/vendor/axios";
|
import {useAxios} from "~/vendor/axios";
|
||||||
|
|
||||||
export default function useConfirmAndDelete(
|
export default function useConfirmAndDelete(
|
||||||
confirmMessage,
|
confirmMessage: string,
|
||||||
onSuccess = null
|
onSuccess = null
|
||||||
) {
|
) {
|
||||||
const {confirmDelete} = useSweetAlert();
|
const {confirmDelete} = useSweetAlert();
|
||||||
|
|
|
@ -4,22 +4,41 @@ import {Ref} from "vue";
|
||||||
export type DataTableTemplateRef = InstanceType<typeof DataTable> | null;
|
export type DataTableTemplateRef = InstanceType<typeof DataTable> | null;
|
||||||
|
|
||||||
export default function useHasDatatable($datatableRef: Ref<DataTableTemplateRef>) {
|
export default function useHasDatatable($datatableRef: Ref<DataTableTemplateRef>) {
|
||||||
|
/**
|
||||||
|
* Reset selected rows, active row, and trigger data reload.
|
||||||
|
*/
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
return $datatableRef.value?.refresh();
|
return $datatableRef.value?.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh, but clearing the cache where relevant.
|
||||||
|
* @see refresh
|
||||||
|
*/
|
||||||
const relist = () => {
|
const relist = () => {
|
||||||
return $datatableRef.value?.relist();
|
return $datatableRef.value?.relist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear search phrase and current page, then call refresh().
|
||||||
|
* @see relist
|
||||||
|
*/
|
||||||
const navigate = () => {
|
const navigate = () => {
|
||||||
return $datatableRef.value?.navigate();
|
return $datatableRef.value?.navigate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the current search filer string.
|
||||||
|
* @param newTerm The new search term.
|
||||||
|
*/
|
||||||
const setFilter = (newTerm: string) => {
|
const setFilter = (newTerm: string) => {
|
||||||
return $datatableRef.value?.setFilter(newTerm);
|
return $datatableRef.value?.setFilter(newTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either set the specified row as active, or disable it if it already is active.
|
||||||
|
* @param row
|
||||||
|
*/
|
||||||
const toggleDetails = (row) => {
|
const toggleDetails = (row) => {
|
||||||
return $datatableRef.value?.toggleDetails(row);
|
return $datatableRef.value?.toggleDetails(row);
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,7 +8,6 @@ interface EditModalCompatible {
|
||||||
|
|
||||||
export type EditModalTemplateRef = InstanceType<EditModalCompatible> | null;
|
export type EditModalTemplateRef = InstanceType<EditModalCompatible> | null;
|
||||||
|
|
||||||
|
|
||||||
export default function useHasEditModal($modalRef: Ref<EditModalTemplateRef>) {
|
export default function useHasEditModal($modalRef: Ref<EditModalTemplateRef>) {
|
||||||
const doCreate = (): void => {
|
const doCreate = (): void => {
|
||||||
$modalRef.value?.create();
|
$modalRef.value?.create();
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
import {useLuxon} from "~/vendor/luxon.ts";
|
||||||
|
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast.ts";
|
||||||
|
import {DateTimeMaybeValid} from "luxon";
|
||||||
|
|
||||||
|
export default function useStationDateTimeFormatter() {
|
||||||
|
const {DateTime} = useLuxon();
|
||||||
|
const {timeConfig} = useAzuraCast();
|
||||||
|
const {timezone} = useAzuraCastStation();
|
||||||
|
|
||||||
|
const now = (): DateTimeMaybeValid =>
|
||||||
|
DateTime.local({zone: timezone});
|
||||||
|
|
||||||
|
const timestampToDateTime = (value): DateTimeMaybeValid =>
|
||||||
|
DateTime.fromSeconds(value, {zone: timezone});
|
||||||
|
|
||||||
|
const formatDateTime = (
|
||||||
|
value: DateTimeMaybeValid,
|
||||||
|
format: Intl.DateTimeFormatOptions
|
||||||
|
) => value.toLocaleString(
|
||||||
|
{...format, ...timeConfig}
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDateTimeAsDateTime = (
|
||||||
|
value: DateTimeMaybeValid,
|
||||||
|
format: Intl.DateTimeFormatOptions | null = null
|
||||||
|
) => formatDateTime(value, format ?? DateTime.DATETIME_MED);
|
||||||
|
|
||||||
|
const formatDateTimeAsTime = (
|
||||||
|
value: DateTimeMaybeValid,
|
||||||
|
format: Intl.DateTimeFormatOptions | null = null
|
||||||
|
) => formatDateTime(value, format ?? DateTime.TIME_WITH_SECONDS);
|
||||||
|
|
||||||
|
const formatDateTimeAsRelative = (
|
||||||
|
value: DateTimeMaybeValid
|
||||||
|
) => value.toRelative();
|
||||||
|
|
||||||
|
const formatTimestampAsDateTime = (
|
||||||
|
value: any,
|
||||||
|
format: Intl.DateTimeFormatOptions | null = null
|
||||||
|
) =>
|
||||||
|
(value)
|
||||||
|
? formatDateTimeAsDateTime(timestampToDateTime(value), format)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const formatTimestampAsTime = (
|
||||||
|
value: any,
|
||||||
|
format: Intl.DateTimeFormatOptions | null = null
|
||||||
|
) =>
|
||||||
|
(value)
|
||||||
|
? formatDateTimeAsTime(timestampToDateTime(value), format)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const formatTimestampAsRelative = (value) =>
|
||||||
|
(value)
|
||||||
|
? formatDateTimeAsRelative(timestampToDateTime(value))
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
now,
|
||||||
|
timestampToDateTime,
|
||||||
|
formatDateTime,
|
||||||
|
formatDateTimeAsDateTime,
|
||||||
|
formatDateTimeAsTime,
|
||||||
|
formatDateTimeAsRelative,
|
||||||
|
formatTimestampAsDateTime,
|
||||||
|
formatTimestampAsTime,
|
||||||
|
formatTimestampAsRelative
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-bar-down" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M3.646 4.146a.5.5 0 0 1 .708 0L8 7.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 11.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 355 B |
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-bar-up" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M3.646 11.854a.5.5 0 0 0 .708 0L8 8.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708M2.4 5.2c0 .22.18.4.4.4h10.4a.4.4 0 0 0 0-.8H2.8a.4.4 0 0 0-.4.4"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 356 B |
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-double-down" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M1.646 6.646a.5.5 0 0 1 .708 0L8 12.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
|
||||||
|
<path fill-rule="evenodd" d="M1.646 2.646a.5.5 0 0 1 .708 0L8 8.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 447 B |
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-double-up" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M7.646 2.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 3.707 2.354 9.354a.5.5 0 1 1-.708-.708z"/>
|
||||||
|
<path fill-rule="evenodd" d="M7.646 6.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 7.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 405 B |
|
@ -1 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M480-335 230-585l53-53 197 199 198-198 52 53-250 249Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-down" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708"/>
|
||||||
|
</svg>
|
Before Width: | Height: | Size: 159 B After Width: | Height: | Size: 289 B |
|
@ -1 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M562-231 311-482l251-251 52 52-199 199 199 199-52 52Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-left" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/>
|
||||||
|
</svg>
|
Before Width: | Height: | Size: 159 B After Width: | Height: | Size: 288 B |
|
@ -1 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M522-482 323-681l52-52 251 251-251 251-52-52 199-199Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-right" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/>
|
||||||
|
</svg>
|
Before Width: | Height: | Size: 159 B After Width: | Height: | Size: 290 B |
|
@ -1 +1,3 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="m283-335-53-53 250-250 250 249-52 53-198-198-197 199Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-up" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708z"/>
|
||||||
|
</svg>
|
Before Width: | Height: | Size: 159 B After Width: | Height: | Size: 267 B |
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-bar-contract" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M3.646 14.854a.5.5 0 0 0 .708 0L8 11.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708m0-13.708a.5.5 0 0 1 .708 0L8 4.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 470 B |
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708m0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 404 B |
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-rss-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm1.5 2.5c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1 0-2m0 4a6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1 0-2m.5 7a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 391 B |
|
@ -0,0 +1,24 @@
|
||||||
|
import initApp from "~/layout";
|
||||||
|
import {h} from "vue";
|
||||||
|
import {createRouter, createWebHistory} from "vue-router";
|
||||||
|
import {useAzuraCast} from "~/vendor/azuracast";
|
||||||
|
import {installRouter} from "~/vendor/router";
|
||||||
|
import PodcastsLayout from "~/components/Public/Podcasts/PodcastsLayout.vue";
|
||||||
|
import usePodcastRoutes from "~/components/Public/Podcasts/routes";
|
||||||
|
|
||||||
|
initApp({
|
||||||
|
render() {
|
||||||
|
return h(PodcastsLayout);
|
||||||
|
}
|
||||||
|
}, async (vueApp) => {
|
||||||
|
const routes = usePodcastRoutes();
|
||||||
|
const {componentProps} = useAzuraCast();
|
||||||
|
|
||||||
|
installRouter(
|
||||||
|
createRouter({
|
||||||
|
history: createWebHistory(componentProps.baseUrl),
|
||||||
|
routes
|
||||||
|
}),
|
||||||
|
vueApp
|
||||||
|
);
|
||||||
|
});
|
|
@ -0,0 +1,9 @@
|
||||||
|
.badges {
|
||||||
|
& > * {
|
||||||
|
margin-right: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > *:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,7 +23,7 @@
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .alert {
|
.card-body.alert {
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
div.datatable-wrapper {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
div.datatable-toolbar-top,
|
div.datatable-toolbar-top,
|
||||||
div.datatable-toolbar-bottom {
|
div.datatable-toolbar-bottom {
|
||||||
&:empty {
|
&:empty {
|
||||||
|
|
|
@ -61,10 +61,21 @@ body.page-minimal {
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
.datatable-main {
|
.card-body {
|
||||||
overflow-y: auto;
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only make the datatable scrollable if it's the only element in the card.
|
||||||
|
& > .datatable-wrapper {
|
||||||
|
.datatable-main {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .full-height-scrollable {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
|
|
||||||
// Overrides for the Daemonite Material theme
|
// Overrides for the Daemonite Material theme
|
||||||
@import "root";
|
@import "root";
|
||||||
|
@import 'overrides/badges';
|
||||||
@import 'overrides/body';
|
@import 'overrides/body';
|
||||||
@import 'overrides/buttons';
|
@import 'overrides/buttons';
|
||||||
@import 'overrides/card';
|
@import 'overrides/card';
|
||||||
|
|
|
@ -11,7 +11,7 @@ export function useTranslate(): Language {
|
||||||
export async function installTranslate(vueApp: App): Promise<void> {
|
export async function installTranslate(vueApp: App): Promise<void> {
|
||||||
const {locale} = useAzuraCast();
|
const {locale} = useAzuraCast();
|
||||||
|
|
||||||
const translations = import.meta.glob('../../../translations/**/translations.json', {as: 'json'});
|
const translations = import.meta.glob('../../../translations/**/translations.json', {query: '?json'});
|
||||||
const localePath = '../../../translations/' + locale + '.UTF-8/translations.json';
|
const localePath = '../../../translations/' + locale + '.UTF-8/translations.json';
|
||||||
|
|
||||||
gettext = createGettext({
|
gettext = createGettext({
|
||||||
|
|
|
@ -31,6 +31,10 @@ parameters:
|
||||||
scanDirectories:
|
scanDirectories:
|
||||||
- ./vendor/zircote/swagger-php/src/Annotations
|
- ./vendor/zircote/swagger-php/src/Annotations
|
||||||
|
|
||||||
|
stubFiles:
|
||||||
|
- util/phpstan_di.stub
|
||||||
|
- util/phpstan_phpdi.stub
|
||||||
|
|
||||||
universalObjectCratesClasses:
|
universalObjectCratesClasses:
|
||||||
- App\Session\NamespaceInterface
|
- App\Session\NamespaceInterface
|
||||||
- App\View
|
- App\View
|
||||||
|
|
25
src/Acl.php
|
@ -19,12 +19,30 @@ use Psr\EventDispatcher\EventDispatcherInterface;
|
||||||
use function in_array;
|
use function in_array;
|
||||||
use function is_array;
|
use function is_array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-type PermissionsArray array{
|
||||||
|
* global: array<string, string>,
|
||||||
|
* station: array<string, string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
final class Acl
|
final class Acl
|
||||||
{
|
{
|
||||||
use RequestAwareTrait;
|
use RequestAwareTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var PermissionsArray
|
||||||
|
*/
|
||||||
private array $permissions;
|
private array $permissions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var null|array<
|
||||||
|
* int,
|
||||||
|
* array{
|
||||||
|
* stations?: array<int, array<string>>,
|
||||||
|
* global?: array<string>
|
||||||
|
* }
|
||||||
|
* >
|
||||||
|
*/
|
||||||
private ?array $actions;
|
private ?array $actions;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
@ -41,12 +59,12 @@ final class Acl
|
||||||
{
|
{
|
||||||
$sql = $this->em->createQuery(
|
$sql = $this->em->createQuery(
|
||||||
<<<'DQL'
|
<<<'DQL'
|
||||||
SELECT rp FROM App\Entity\RolePermission rp
|
SELECT rp.station_id, rp.role_id, rp.action_name FROM App\Entity\RolePermission rp
|
||||||
DQL
|
DQL
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->actions = [];
|
$this->actions = [];
|
||||||
foreach ($sql->getArrayResult() as $row) {
|
foreach ($sql->toIterable() as $row) {
|
||||||
if ($row['station_id']) {
|
if ($row['station_id']) {
|
||||||
$this->actions[$row['role_id']]['stations'][$row['station_id']][] = $row['action_name'];
|
$this->actions[$row['role_id']]['stations'][$row['station_id']][] = $row['action_name'];
|
||||||
} else {
|
} else {
|
||||||
|
@ -69,12 +87,11 @@ final class Acl
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return mixed[]
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function listPermissions(): array
|
public function listPermissions(): array
|
||||||
{
|
{
|
||||||
if (!isset($this->permissions)) {
|
if (!isset($this->permissions)) {
|
||||||
/** @var array<string,array<string, string>> $permissions */
|
|
||||||
$permissions = [
|
$permissions = [
|
||||||
'global' => [],
|
'global' => [],
|
||||||
'station' => [],
|
'station' => [],
|
||||||
|
|
|
@ -8,6 +8,7 @@ use App\Container\EnvironmentAwareTrait;
|
||||||
use App\Entity\Repository\UserRepository;
|
use App\Entity\Repository\UserRepository;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Exception\NotLoggedInException;
|
use App\Exception\NotLoggedInException;
|
||||||
|
use App\Utilities\Types;
|
||||||
use Mezzio\Session\SessionInterface;
|
use Mezzio\Session\SessionInterface;
|
||||||
|
|
||||||
final class Auth
|
final class Auth
|
||||||
|
@ -83,7 +84,7 @@ final class Auth
|
||||||
if (!$this->session->has(self::SESSION_MASQUERADE_USER_ID_KEY)) {
|
if (!$this->session->has(self::SESSION_MASQUERADE_USER_ID_KEY)) {
|
||||||
$this->masqueraded_user = false;
|
$this->masqueraded_user = false;
|
||||||
} else {
|
} else {
|
||||||
$maskUserId = (int)$this->session->get(self::SESSION_MASQUERADE_USER_ID_KEY);
|
$maskUserId = Types::int($this->session->get(self::SESSION_MASQUERADE_USER_ID_KEY));
|
||||||
if (0 !== $maskUserId) {
|
if (0 !== $maskUserId) {
|
||||||
$user = $this->userRepo->getRepository()->find($maskUserId);
|
$user = $this->userRepo->getRepository()->find($maskUserId);
|
||||||
} else {
|
} else {
|
||||||
|
@ -125,7 +126,7 @@ final class Auth
|
||||||
*/
|
*/
|
||||||
public function isLoginComplete(): bool
|
public function isLoginComplete(): bool
|
||||||
{
|
{
|
||||||
return $this->session->get(self::SESSION_IS_LOGIN_COMPLETE_KEY, false) ?? false;
|
return Types::bool($this->session->get(self::SESSION_IS_LOGIN_COMPLETE_KEY, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -136,7 +137,7 @@ final class Auth
|
||||||
public function getUser(): ?User
|
public function getUser(): ?User
|
||||||
{
|
{
|
||||||
if (null === $this->user) {
|
if (null === $this->user) {
|
||||||
$userId = (int)$this->session->get(self::SESSION_USER_ID_KEY);
|
$userId = Types::int($this->session->get(self::SESSION_USER_ID_KEY));
|
||||||
|
|
||||||
if (0 === $userId) {
|
if (0 === $userId) {
|
||||||
$this->user = false;
|
$this->user = false;
|
||||||
|
@ -238,7 +239,7 @@ final class Auth
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
|
|
||||||
if (!($user instanceof User)) {
|
if (!($user instanceof User)) {
|
||||||
throw new NotLoggedInException();
|
throw NotLoggedInException::create();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->verifyTwoFactor($otp)) {
|
if ($user->verifyTwoFactor($otp)) {
|
||||||
|
|
|
@ -6,9 +6,17 @@ namespace App\Cache;
|
||||||
|
|
||||||
use App\Entity\Api\NowPlaying\NowPlaying;
|
use App\Entity\Api\NowPlaying\NowPlaying;
|
||||||
use App\Entity\Station;
|
use App\Entity\Station;
|
||||||
|
use App\Utilities\Types;
|
||||||
use Psr\Cache\CacheItemInterface;
|
use Psr\Cache\CacheItemInterface;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @phpstan-type LookupRow array{
|
||||||
|
* short_name: string,
|
||||||
|
* is_public: bool,
|
||||||
|
* updated_at: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
final class NowPlayingCache
|
final class NowPlayingCache
|
||||||
{
|
{
|
||||||
private const NOWPLAYING_CACHE_TTL = 180;
|
private const NOWPLAYING_CACHE_TTL = 180;
|
||||||
|
@ -41,9 +49,13 @@ final class NowPlayingCache
|
||||||
|
|
||||||
$stationCacheItem = $this->getStationCache($station);
|
$stationCacheItem = $this->getStationCache($station);
|
||||||
|
|
||||||
return ($stationCacheItem->isHit())
|
if (!$stationCacheItem->isHit()) {
|
||||||
? $stationCacheItem->get()
|
return null;
|
||||||
: null;
|
}
|
||||||
|
|
||||||
|
$np = $stationCacheItem->get();
|
||||||
|
assert($np instanceof NowPlaying);
|
||||||
|
return $np;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -58,6 +70,8 @@ final class NowPlayingCache
|
||||||
}
|
}
|
||||||
|
|
||||||
$np = [];
|
$np = [];
|
||||||
|
|
||||||
|
/** @var LookupRow[] $lookupCache */
|
||||||
$lookupCache = (array)$lookupCacheItem->get();
|
$lookupCache = (array)$lookupCacheItem->get();
|
||||||
|
|
||||||
foreach ($lookupCache as $stationInfo) {
|
foreach ($lookupCache as $stationInfo) {
|
||||||
|
@ -78,11 +92,14 @@ final class NowPlayingCache
|
||||||
return $np;
|
return $np;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, LookupRow>
|
||||||
|
*/
|
||||||
public function getLookup(): array
|
public function getLookup(): array
|
||||||
{
|
{
|
||||||
$lookupCacheItem = $this->getLookupCache();
|
$lookupCacheItem = $this->getLookupCache();
|
||||||
return $lookupCacheItem->isHit()
|
return $lookupCacheItem->isHit()
|
||||||
? (array)$lookupCacheItem->get()
|
? Types::array($lookupCacheItem->get())
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,7 +131,7 @@ final class NowPlayingCache
|
||||||
$lookupCacheItem = $this->getLookupCache();
|
$lookupCacheItem = $this->getLookupCache();
|
||||||
|
|
||||||
$lookupCache = $lookupCacheItem->isHit()
|
$lookupCache = $lookupCacheItem->isHit()
|
||||||
? (array)$lookupCacheItem->get()
|
? Types::array($lookupCacheItem->get())
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
$lookupCache[$station->getIdRequired()] = [
|
$lookupCache[$station->getIdRequired()] = [
|
||||||
|
@ -131,12 +148,9 @@ final class NowPlayingCache
|
||||||
private function getStationCache(string $identifier): CacheItemInterface
|
private function getStationCache(string $identifier): CacheItemInterface
|
||||||
{
|
{
|
||||||
if (is_numeric($identifier)) {
|
if (is_numeric($identifier)) {
|
||||||
$lookupCacheItem = $this->getLookupCache();
|
$lookupCache = $this->getLookup();
|
||||||
$lookupCache = $lookupCacheItem->isHit()
|
|
||||||
? (array)$lookupCacheItem->get()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
$identifier = (int)$identifier;
|
$identifier = Types::int($identifier);
|
||||||
if (isset($lookupCache[$identifier])) {
|
if (isset($lookupCache[$identifier])) {
|
||||||
$identifier = $lookupCache[$identifier]['short_name'];
|
$identifier = $lookupCache[$identifier]['short_name'];
|
||||||
}
|
}
|
||||||
|
|