Merge commit '0b54c7e307109903515e64989202381cb5c523db' into stable
|
@ -37,12 +37,12 @@ jobs:
|
|||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
php-version: '8.3'
|
||||
extensions: intl, xdebug
|
||||
tools: composer:v2, cs2pr
|
||||
|
||||
- name: Cache PHP dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
|
||||
|
@ -77,12 +77,12 @@ jobs:
|
|||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.2'
|
||||
php-version: '8.3'
|
||||
extensions: intl, xdebug
|
||||
tools: composer:v2, cs2pr
|
||||
|
||||
- name: Cache PHP dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
|
||||
|
@ -135,7 +135,7 @@ jobs:
|
|||
chmod 777 .gitinfo
|
||||
|
||||
- name: Upload built static assets and translations
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: assets
|
||||
if-no-files-found: error
|
||||
|
@ -158,7 +158,7 @@ jobs:
|
|||
- uses: actions/checkout@master
|
||||
|
||||
- name: Download built static assets from previous step
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: assets
|
||||
|
||||
|
@ -166,13 +166,13 @@ jobs:
|
|||
uses: depot/setup-action@v1
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
@ -180,7 +180,7 @@ jobs:
|
|||
|
||||
- name: Build Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
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)
|
||||
|
||||
## New Features/Changes
|
||||
|
|
19
Dockerfile
|
@ -1,7 +1,7 @@
|
|||
#
|
||||
# Golang dependencies build step
|
||||
#
|
||||
FROM golang:1.21-bullseye AS go-dependencies
|
||||
FROM golang:1.21-bookworm AS go-dependencies
|
||||
|
||||
RUN apt-get update \
|
||||
&& 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/centrifugal/centrifugo/v5@v5.2.0
|
||||
RUN go install github.com/centrifugal/centrifugo/v5@v5.2.2
|
||||
|
||||
#
|
||||
# MariaDB dependencies build step
|
||||
|
@ -25,7 +25,7 @@ FROM ghcr.io/azuracast/azuracast.com:builtin AS docs
|
|||
#
|
||||
# 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
|
||||
|
@ -35,9 +35,16 @@ FROM ghcr.io/roadrunner-server/roadrunner:2023.3.8 AS roadrunner
|
|||
#
|
||||
# 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
|
||||
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
|
||||
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 /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
|
||||
COPY --from=icecast /usr/local/bin/icecast /usr/local/bin/icecast
|
||||
|
|
|
@ -16,7 +16,7 @@ MYSQL_ROOT_PASSWORD=azur4c457
|
|||
|
||||
# Developer options.
|
||||
# Populate these!
|
||||
INIT_BASE_URL=http://azuracast.local
|
||||
INIT_BASE_URL=https://azuracast.local
|
||||
INIT_INSTANCE_NAME="local development"
|
||||
INIT_DEMO_API_KEY=
|
||||
INIT_ADMIN_EMAIL=
|
||||
|
|
|
@ -12,7 +12,7 @@ $environment = App\AppFactory::buildEnvironment();
|
|||
|
||||
$console = new Symfony\Component\Console\Application(
|
||||
'AzuraCast installer',
|
||||
App\Version::FALLBACK_VERSION
|
||||
App\Version::STABLE_VERSION
|
||||
);
|
||||
|
||||
$installCommand = new App\Installer\Command\InstallCommand();
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.3",
|
||||
"ext-PDO": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-ffi": "*",
|
||||
|
@ -28,7 +28,7 @@
|
|||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"azuracast/nowplaying": "dev-main",
|
||||
"beberlei/doctrineextensions": "^1.2",
|
||||
"beberlei/doctrineextensions": "^1.4",
|
||||
"br33f/php-ga4-mp": "^0.1.2",
|
||||
"brick/math": "^0.11",
|
||||
"composer/ca-bundle": "^1.2",
|
||||
|
@ -57,7 +57,7 @@
|
|||
"mezzio/mezzio-session-cache": "^1.7",
|
||||
"monolog/monolog": "^3",
|
||||
"myclabs/deep-copy": "^1.10",
|
||||
"nesbot/carbon": "^2.36",
|
||||
"nesbot/carbon": "^3",
|
||||
"pagerfanta/doctrine-collections-adapter": "^4",
|
||||
"pagerfanta/doctrine-orm-adapter": "^4",
|
||||
"php-di/php-di": "^7.0.1",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controller\Admin\IndexAction;
|
||||
use App\Controller\AdminAction;
|
||||
use App\Enums\GlobalPermissions;
|
||||
use App\Middleware;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
@ -33,11 +33,11 @@ return static function (RouteCollectorProxy $app) {
|
|||
];
|
||||
|
||||
foreach ($routes as $routeName => $routePath) {
|
||||
$group->get($routePath, IndexAction::class)
|
||||
$group->get($routePath, AdminAction::class)
|
||||
->setName($routeName);
|
||||
}
|
||||
|
||||
$group->get('/{routes:.+}', IndexAction::class);
|
||||
$group->get('/{routes:.+}', AdminAction::class);
|
||||
}
|
||||
)->add(Middleware\Module\PanelLayout::class)
|
||||
->add(Middleware\EnableView::class)
|
||||
|
|
|
@ -68,31 +68,67 @@ return static function (RouteCollectorProxy $group) {
|
|||
$group->get(
|
||||
'/art/{media_id:[a-zA-Z0-9\-]+}[-{timestamp}.jpg]',
|
||||
Controller\Api\Stations\Art\GetArtAction::class
|
||||
)->setName('api:stations:media:art');
|
||||
)->setName('api:stations:media:art')
|
||||
->add(new Middleware\Cache\SetStaticFileCache());
|
||||
|
||||
// Streamer Art
|
||||
$group->get(
|
||||
'/streamer/{id}/art[-{timestamp}.jpg]',
|
||||
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(
|
||||
'/podcast/{podcast_id}',
|
||||
'/public',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->get(
|
||||
'/art[-{timestamp}.jpg]',
|
||||
Controller\Api\Stations\Podcasts\Art\GetArtAction::class
|
||||
)->setName('api:stations:podcast:art');
|
||||
// Podcast Public Pages
|
||||
$group->get('/podcasts', Controller\Api\Stations\Podcasts\ListPodcastsAction::class)
|
||||
->setName('api:stations:public:podcasts');
|
||||
|
||||
$group->get(
|
||||
'/episode/{episode_id}/art[-{timestamp}.jpg]',
|
||||
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
|
||||
)->setName('api:stations:podcast:episode:art');
|
||||
$group->group(
|
||||
'/podcast/{podcast_id}',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$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);
|
||||
};
|
||||
|
|
|
@ -19,7 +19,7 @@ return static function (RouteCollectorProxy $group) {
|
|||
->setName('api:stations:index')
|
||||
->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)
|
||||
->setName('api:stations:schedule');
|
||||
|
@ -48,38 +48,7 @@ return static function (RouteCollectorProxy $group) {
|
|||
->add(new Middleware\StationSupportsFeature(StationFeatures::OnDemand))
|
||||
->add(new Middleware\RateLimit('ondemand', 1, 2));
|
||||
|
||||
// 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.
|
||||
// NOTE: See ./api_public.php for podcast public pages.
|
||||
|
||||
/*
|
||||
* Authenticated Functions
|
||||
|
@ -159,6 +128,9 @@ return static function (RouteCollectorProxy $group) {
|
|||
$group->group(
|
||||
'/podcast/{podcast_id}',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->get('', Controller\Api\Stations\PodcastsController::class . ':getAction')
|
||||
->setName('api:stations:podcast');
|
||||
|
||||
$group->put('', Controller\Api\Stations\PodcastsController::class . ':editAction');
|
||||
|
||||
$group->delete(
|
||||
|
@ -166,16 +138,26 @@ return static function (RouteCollectorProxy $group) {
|
|||
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(
|
||||
'/art',
|
||||
Controller\Api\Stations\Podcasts\Art\PostArtAction::class
|
||||
)->setName('api:stations:podcast:art-internal');
|
||||
);
|
||||
|
||||
$group->delete(
|
||||
'/art',
|
||||
Controller\Api\Stations\Podcasts\Art\DeleteArtAction::class
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'/episodes',
|
||||
Controller\Api\Stations\PodcastEpisodesController::class . ':listAction'
|
||||
)->setName('api:stations:podcast:episodes');
|
||||
|
||||
$group->post(
|
||||
'/episodes',
|
||||
Controller\Api\Stations\PodcastEpisodesController::class . ':createAction'
|
||||
|
@ -194,6 +176,11 @@ return static function (RouteCollectorProxy $group) {
|
|||
$group->group(
|
||||
'/episode/{episode_id}',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->get(
|
||||
'',
|
||||
Controller\Api\Stations\PodcastEpisodesController::class . ':getAction'
|
||||
)->setName('api:stations:podcast:episode');
|
||||
|
||||
$group->put(
|
||||
'',
|
||||
Controller\Api\Stations\PodcastEpisodesController::class . ':editAction'
|
||||
|
@ -205,20 +192,30 @@ return static function (RouteCollectorProxy $group) {
|
|||
. ':deleteAction'
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'/art[-{timestamp}.jpg]',
|
||||
Controller\Api\Stations\Podcasts\Episodes\Art\GetArtAction::class
|
||||
)->setName('api:stations:podcast:episode:art');
|
||||
|
||||
$group->post(
|
||||
'/art',
|
||||
Controller\Api\Stations\Podcasts\Episodes\Art\PostArtAction::class
|
||||
)->setName('api:stations:podcast:episode:art-internal');
|
||||
);
|
||||
|
||||
$group->delete(
|
||||
'/art',
|
||||
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(
|
||||
'/media',
|
||||
Controller\Api\Stations\Podcasts\Episodes\Media\PostMediaAction::class
|
||||
)->setName('api:stations:podcast:episode:media-internal');
|
||||
);
|
||||
|
||||
$group->delete(
|
||||
'/media',
|
||||
|
@ -227,7 +224,7 @@ return static function (RouteCollectorProxy $group) {
|
|||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
)->add(Middleware\GetAndRequirePodcast::class);
|
||||
}
|
||||
)->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)
|
||||
->setName('public:schedule');
|
||||
|
||||
$group->get('/podcasts', Controller\Frontend\PublicPages\PodcastsAction::class)
|
||||
->setName('public:podcasts');
|
||||
$routes = [
|
||||
'public:podcasts' => '/podcasts',
|
||||
'public:podcast' => '/podcast/{podcast_id}',
|
||||
'public:podcast:episode' => '/podcast/{podcast_id}/episode/{episode_id}',
|
||||
];
|
||||
|
||||
$group->get(
|
||||
'/podcast/{podcast_id}/episodes',
|
||||
Controller\Frontend\PublicPages\PodcastEpisodesAction::class
|
||||
)
|
||||
->setName('public:podcast:episodes');
|
||||
|
||||
$group->get(
|
||||
'/podcast/{podcast_id}/episode/{episode_id}',
|
||||
Controller\Frontend\PublicPages\PodcastEpisodeAction::class
|
||||
)
|
||||
->setName('public:podcast:episode');
|
||||
foreach ($routes as $routeName => $routePath) {
|
||||
$group->get($routePath, Controller\Frontend\PublicPages\PodcastsAction::class)
|
||||
->setName($routeName);
|
||||
}
|
||||
|
||||
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class)
|
||||
->setName('public:podcast:feed');
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controller\Stations\IndexAction;
|
||||
use App\Controller\StationsAction;
|
||||
use App\Enums\StationPermissions;
|
||||
use App\Middleware;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
@ -23,6 +23,7 @@ return static function (RouteCollectorProxy $app) {
|
|||
'stations:logs' => '/logs',
|
||||
'stations:playlists:index' => '/playlists',
|
||||
'stations:podcasts:index' => '/podcasts',
|
||||
'stations:podcast:episodes' => '/podcast/{podcast_id}',
|
||||
'stations:mounts:index' => '/mounts',
|
||||
'stations:profile:index' => '/profile',
|
||||
'stations:profile:edit' => '/profile/edit',
|
||||
|
@ -40,11 +41,11 @@ return static function (RouteCollectorProxy $app) {
|
|||
];
|
||||
|
||||
foreach ($routes as $routeName => $routePath) {
|
||||
$group->get($routePath, IndexAction::class)
|
||||
$group->get($routePath, StationsAction::class)
|
||||
->setName($routeName);
|
||||
}
|
||||
|
||||
$group->get('/{routes:.+}', IndexAction::class);
|
||||
$group->get('/{routes:.+}', StationsAction::class);
|
||||
}
|
||||
)->add(Middleware\Module\PanelLayout::class)
|
||||
->add(new Middleware\Permissions(StationPermissions::View, true))
|
||||
|
|
|
@ -155,7 +155,7 @@ return [
|
|||
$cacheInterface = new Symfony\Component\Cache\Adapter\ArrayAdapter();
|
||||
} elseif ($redisFactory->isSupported()) {
|
||||
$cacheInterface = new Symfony\Component\Cache\Adapter\RedisAdapter(
|
||||
$redisFactory->createInstance(),
|
||||
$redisFactory->getInstance(),
|
||||
marshaller: new Symfony\Component\Cache\Marshaller\DefaultMarshaller(
|
||||
$environment->isProduction() ? null : false
|
||||
)
|
||||
|
@ -190,7 +190,7 @@ return [
|
|||
Environment $environment,
|
||||
App\Service\RedisFactory $redisFactory
|
||||
) => ($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()),
|
||||
|
||||
// Console
|
||||
|
@ -330,8 +330,13 @@ return [
|
|||
// Register plugin-provided message queue receivers
|
||||
$receivers = $plugins->registerMessageQueueReceivers($receivers);
|
||||
|
||||
/**
|
||||
* @var class-string $messageClass
|
||||
* @var class-string $handlerClass
|
||||
*/
|
||||
foreach ($receivers as $messageClass => $handlerClass) {
|
||||
$handlers[$messageClass][] = static function ($message) use ($handlerClass, $di) {
|
||||
/** @var callable $obj */
|
||||
$obj = $di->get($handlerClass);
|
||||
return $obj($message);
|
||||
};
|
||||
|
|
|
@ -8,6 +8,9 @@ services:
|
|||
- "127.0.0.1:3306:3306" # MariaDB
|
||||
- "127.0.0.1:6025:6025" # Centrifugo
|
||||
- "127.0.0.1:6379:6379" # Redis
|
||||
environment:
|
||||
VAR_DUMPER_FORMAT: server
|
||||
VAR_DUMPER_SERVER: host.docker.internal:9912
|
||||
volumes:
|
||||
- $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
|
||||
|
|
10
docker.sh
|
@ -536,16 +536,8 @@ install-dev() {
|
|||
.env --file .env set AZURACAST_PODMAN_MODE=true
|
||||
fi
|
||||
|
||||
chmod 777 ./frontend/ ./web/ ./vendor/ \
|
||||
./web/static/ ./web/static/api/ \
|
||||
./web/static/dist/ ./web/static/img/
|
||||
|
||||
dc build
|
||||
dc run --rm web -- azuracast_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 run --rm web -- azuracast_dev_install "$@"
|
||||
dc up -d
|
||||
exit
|
||||
}
|
||||
|
|
|
@ -9,8 +9,8 @@ import * as url from 'url';
|
|||
import {JSDOM} from "jsdom";
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
|
||||
const outputPath = path.resolve(__dirname, './vue/components/Common/icons.ts');
|
||||
const iconsPath = path.resolve(__dirname, './icons');
|
||||
const outputPath = path.resolve(__dirname, './src/components/Common/icons.ts');
|
||||
const iconsPath = path.resolve(__dirname, './src/icons');
|
||||
|
||||
const materialIconsViewBox = '0 -960 960 960';
|
||||
const bootstrapIconsViewBox = '0 0 16 16';
|
||||
|
@ -64,7 +64,7 @@ function genIconComponents() {
|
|||
svgViewBox = `'${svgViewBox}'`;
|
||||
}
|
||||
|
||||
const svgContents = svgInner.innerHTML.trim().replace(
|
||||
const svgContents = svgInner.innerHTML.trim().replace("\n", "").replace(
|
||||
' xmlns="http://www.w3.org/2000/svg"',
|
||||
''
|
||||
);
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
"scripts": {
|
||||
"build": "vite build",
|
||||
"serve": "vite",
|
||||
"generate-icons": "node genicons.mjs",
|
||||
"generate-locales": "vue-gettext-extract",
|
||||
"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",
|
||||
"vue": "^3.2",
|
||||
"vue-axios": "^3.5",
|
||||
"vue-codemirror6": "^1",
|
||||
"vue-codemirror6": "1.2.0",
|
||||
"vue-easy-lightbox": "^1.16",
|
||||
"vue-router": "^4.2.4",
|
||||
"vue3-gettext": "3.0.0-beta.4",
|
||||
|
@ -61,11 +62,11 @@
|
|||
"@vitejs/plugin-vue": "^5",
|
||||
"@vue/eslint-config-typescript": "^12",
|
||||
"del": "^7",
|
||||
"esbuild": "^0.19.9",
|
||||
"esbuild": "^0.20",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-vue": "^9.8.0",
|
||||
"glob": "^10.2.7",
|
||||
"jsdom": "^23",
|
||||
"jsdom": "^24",
|
||||
"sass": "^1.39.2",
|
||||
"svg.js": "^2.7.1",
|
||||
"swagger-typescript-api": "^13.0.3",
|
||||
|
|
|
@ -151,7 +151,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
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 formatFileSize from "~/functions/formatFileSize";
|
||||
import AdminBackupsConfigureModal from "~/components/Admin/Backups/ConfigureModal.vue";
|
||||
|
@ -196,7 +196,7 @@ const settings = ref({...blankSettings});
|
|||
|
||||
const {$gettext} = useTranslate();
|
||||
const {timeConfig} = useAzuraCast();
|
||||
const {DateTime} = useLuxon();
|
||||
const {DateTime, timestampToRelative} = useLuxon();
|
||||
|
||||
const fields: DataTableField[] = [
|
||||
{
|
||||
|
@ -250,8 +250,6 @@ const relist = () => {
|
|||
|
||||
onMounted(relist);
|
||||
|
||||
const {timestampToRelative} = useLuxon();
|
||||
|
||||
const $lastOutputModal = ref<InstanceType<typeof AdminBackupsLastOutputModal> | null>(null);
|
||||
const showLastOutput = () => {
|
||||
$lastOutputModal.value?.show();
|
||||
|
|
|
@ -28,7 +28,6 @@
|
|||
class="col-md-6"
|
||||
:field="v$.backup_time_code"
|
||||
:label="$gettext('Scheduled Backup Time')"
|
||||
:description="$gettext('If the end time is before the start time, the playlist will play overnight.')"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<time-code
|
||||
|
|
|
@ -41,6 +41,14 @@
|
|||
>
|
||||
{{ $gettext('Clone') }}
|
||||
</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
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
|
@ -74,7 +82,7 @@
|
|||
</template>
|
||||
|
||||
<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 {get} from "lodash";
|
||||
import AdminStationsCloneModal from "./Stations/CloneModal.vue";
|
||||
|
@ -89,6 +97,9 @@ import CardPage from "~/components/Common/CardPage.vue";
|
|||
import {getApiUrl} from "~/router";
|
||||
import AddButton from "~/components/Common/AddButton.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({
|
||||
...stationFormProps,
|
||||
|
@ -149,6 +160,29 @@ const doClone = (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(
|
||||
$gettext('Delete Station?'),
|
||||
relist
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
class="col-md-4"
|
||||
:field="v$.backend_config.hls_segment_length"
|
||||
input-type="number"
|
||||
:input-attrs="{ min: '0', max: '60' }"
|
||||
:input-attrs="{ min: '0', max: '9999' }"
|
||||
advanced
|
||||
:label="$gettext('Segment Length (Seconds)')"
|
||||
/>
|
||||
|
|
|
@ -43,13 +43,10 @@
|
|||
:label="$gettext('Live Broadcast Recording Format')"
|
||||
/>
|
||||
|
||||
<form-group-multi-check
|
||||
<bitrate-options
|
||||
id="edit_form_backend_record_streams_bitrate"
|
||||
class="col-md-6"
|
||||
:field="v$.backend_config.record_streams_bitrate"
|
||||
:options="recordBitrateOptions"
|
||||
stacked
|
||||
radio
|
||||
:label="$gettext('Live Broadcast Recording Bitrate (kbps)')"
|
||||
/>
|
||||
</div>
|
||||
|
@ -140,6 +137,7 @@ import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
|||
import {numeric} from "@vuelidate/validators";
|
||||
import {useAzuraCast} from "~/vendor/azuracast";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
|
||||
|
||||
const props = defineProps({
|
||||
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>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import {getApiUrl} from "~/router.ts";
|
||||
import populateComponentRemotely from "~/functions/populateComponentRemotely.ts";
|
||||
import {RouteRecordRaw} from "vue-router";
|
||||
|
||||
export default function useAdminRoutes() {
|
||||
export default function useAdminRoutes(): RouteRecordRaw[] {
|
||||
return [
|
||||
{
|
||||
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>
|
||||
<div
|
||||
:id="id"
|
||||
style="display: contents"
|
||||
class="datatable-wrapper"
|
||||
>
|
||||
<div
|
||||
v-if="showToolbar"
|
||||
|
@ -110,7 +110,7 @@
|
|||
<div class="px-3 py-1">
|
||||
<form-multi-check
|
||||
id="field_select"
|
||||
v-model="settings.visibleFieldKeys"
|
||||
v-model="visibleFieldKeys"
|
||||
:options="selectableFieldOptions"
|
||||
stacked
|
||||
/>
|
||||
|
@ -184,7 +184,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="isLoading">
|
||||
<template v-if="isLoading && hideOnLoading">
|
||||
<tr>
|
||||
<td
|
||||
:colspan="columnCount"
|
||||
|
@ -247,6 +247,7 @@
|
|||
:name="'cell('+column.key+')'"
|
||||
:column="column"
|
||||
:item="row"
|
||||
:is-active="isActiveDetailRow(row)"
|
||||
:toggle-details="() => toggleDetails(row)"
|
||||
>
|
||||
{{ getColumnValue(column, row) }}
|
||||
|
@ -285,7 +286,7 @@
|
|||
</div>
|
||||
</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 Icon from './Icon.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 {useAzuraCast} from "~/vendor/azuracast.ts";
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
apiUrl: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
responsive: {
|
||||
type: [Boolean, String],
|
||||
default: true
|
||||
},
|
||||
paginated: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showToolbar: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
pageOptions: {
|
||||
type: Array<number>,
|
||||
default: () => [10, 25, 50, 100, 250, 500, 0]
|
||||
},
|
||||
defaultPerPage: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
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
|
||||
}
|
||||
export interface DataTableProps {
|
||||
id?: string,
|
||||
fields: DataTableField[],
|
||||
apiUrl?: string, // URL to fetch for server-side data
|
||||
items?: Row[], // Array of items for client-side data
|
||||
responsive?: boolean | string, // Make table responsive (boolean or CSS class for specific responsiveness width)
|
||||
paginated?: boolean, // Enable pagination.
|
||||
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.
|
||||
showToolbar?: boolean, // Show the header "Toolbar" with search, refresh, per-page, etc.
|
||||
pageOptions?: number[],
|
||||
defaultPerPage?: number,
|
||||
selectable?: boolean, // Allow selecting individual rows with checkboxes at the side of each row
|
||||
detailed?: boolean, // Allow showing "Detail" panel for selected rows.
|
||||
selectFields?: boolean, // Allow selecting which columns are visible.
|
||||
handleClientSide?: boolean, // Handle searching, sorting and pagination client-side without API calls.
|
||||
requestConfig?(config: object): object, // Custom server-side request configuration (pre-request)
|
||||
requestProcess?(rawData: object[]): Row[], // Custom server-side request result processing (post-request)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DataTableProps>(), {
|
||||
id: null,
|
||||
apiUrl: null,
|
||||
items: null,
|
||||
responsive: () => true,
|
||||
paginated: false,
|
||||
loading: false,
|
||||
hideOnLoading: true,
|
||||
showToolbar: true,
|
||||
pageOptions: () => [10, 25, 50, 100, 250, 500, 0],
|
||||
defaultPerPage: 10,
|
||||
selectable: false,
|
||||
detailed: false,
|
||||
selectFields: false,
|
||||
handleClientSide: false,
|
||||
requestConfig: undefined,
|
||||
requestProcess: undefined
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
@ -375,7 +348,7 @@ const emit = defineEmits([
|
|||
'data-loaded'
|
||||
]);
|
||||
|
||||
const selectedRows = shallowRef([]);
|
||||
const selectedRows = shallowRef<Row[]>([]);
|
||||
|
||||
const searchPhrase = ref<string>('');
|
||||
const currentPage = ref<number>(1);
|
||||
|
@ -390,10 +363,10 @@ watch(toRef(props, 'loading'), (newLoading: boolean) => {
|
|||
isLoading.value = newLoading;
|
||||
});
|
||||
|
||||
const visibleItems = shallowRef([]);
|
||||
const visibleItems = shallowRef<Row[]>([]);
|
||||
const totalRows = ref(0);
|
||||
|
||||
const activeDetailsRow = shallowRef(null);
|
||||
const activeDetailsRow = shallowRef<Row>(null);
|
||||
|
||||
export interface DataTableField {
|
||||
key: string,
|
||||
|
@ -453,15 +426,25 @@ const settings = useOptionalStorage(
|
|||
}
|
||||
);
|
||||
|
||||
const visibleFieldKeys = computed(() => {
|
||||
if (!isEmpty(settings.value.visibleFieldKeys)) {
|
||||
return settings.value.visibleFieldKeys;
|
||||
}
|
||||
const visibleFieldKeys = computed({
|
||||
get: () => {
|
||||
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) {
|
||||
return -1;
|
||||
}
|
||||
|
@ -487,15 +470,15 @@ const visibleFields = computed<DataTableField[]>(() => {
|
|||
});
|
||||
});
|
||||
|
||||
const getPerPageLabel = (num) => {
|
||||
const getPerPageLabel = (num): string => {
|
||||
return (num === 0) ? 'All' : num.toString();
|
||||
};
|
||||
|
||||
const perPageLabel = computed(() => {
|
||||
const perPageLabel = computed<string>(() => {
|
||||
return getPerPageLabel(perPage.value);
|
||||
});
|
||||
|
||||
const showPagination = computed(() => {
|
||||
const showPagination = computed<boolean>(() => {
|
||||
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;
|
||||
};
|
||||
|
||||
|
@ -711,7 +694,7 @@ const sort = (column: DataTableField) => {
|
|||
refresh();
|
||||
};
|
||||
|
||||
const checkRow = (row) => {
|
||||
const checkRow = (row: Row) => {
|
||||
const newSelectedRows = selectedRows.value;
|
||||
|
||||
if (isRowChecked(row)) {
|
||||
|
@ -740,11 +723,11 @@ const checkAll = () => {
|
|||
selectedRows.value = newSelectedRows;
|
||||
};
|
||||
|
||||
const isActiveDetailRow = (row) => {
|
||||
const isActiveDetailRow = (row: Row) => {
|
||||
return activeDetailsRow.value === row;
|
||||
};
|
||||
|
||||
const toggleDetails = (row) => {
|
||||
const toggleDetails = (row: Row) => {
|
||||
activeDetailsRow.value = isActiveDetailRow(row)
|
||||
? null
|
||||
: row;
|
||||
|
@ -758,7 +741,7 @@ const responsiveClass = computed(() => {
|
|||
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);
|
||||
|
||||
return (field.formatter)
|
||||
|
|
|
@ -52,21 +52,37 @@ export const IconCheck: Icon = {
|
|||
viewBox: materialIconsViewBox,
|
||||
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 = {
|
||||
viewBox: materialIconsViewBox,
|
||||
contents: '<path d="M480-335 230-585l53-53 197 199 198-198 52 53-250 249Z"/>'
|
||||
viewBox: bootstrapIconsViewBox,
|
||||
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 = {
|
||||
viewBox: materialIconsViewBox,
|
||||
contents: '<path d="M562-231 311-482l251-251 52 52-199 199 199 199-52 52Z"/>'
|
||||
viewBox: bootstrapIconsViewBox,
|
||||
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 = {
|
||||
viewBox: materialIconsViewBox,
|
||||
contents: '<path d="M522-482 323-681l52-52 251 251-251 251-52-52 199-199Z"/>'
|
||||
viewBox: bootstrapIconsViewBox,
|
||||
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 = {
|
||||
viewBox: materialIconsViewBox,
|
||||
contents: '<path d="m283-335-53-53 250-250 250 249-52 53-198-198-197 199Z"/>'
|
||||
viewBox: bootstrapIconsViewBox,
|
||||
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 = {
|
||||
viewBox: materialIconsViewBox,
|
||||
|
@ -80,6 +96,10 @@ export const IconCode: Icon = {
|
|||
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"/>'
|
||||
};
|
||||
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 = {
|
||||
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"/>'
|
||||
|
@ -112,6 +132,10 @@ export const IconExitToApp: Icon = {
|
|||
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"/>'
|
||||
};
|
||||
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 = {
|
||||
viewBox: materialIconsViewBox,
|
||||
contents: '<path d="M78-221v-518l375 259L78-221Zm431 0v-518l375 259-375 259Z"/>'
|
||||
|
@ -260,6 +284,10 @@ export const IconRouter: Icon = {
|
|||
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"/>'
|
||||
};
|
||||
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 = {
|
||||
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"/>'
|
||||
|
|
|
@ -143,126 +143,98 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<loading :loading="stationsLoading">
|
||||
<div class="table-responsive">
|
||||
<table
|
||||
id="station_dashboard"
|
||||
class="table table-striped"
|
||||
>
|
||||
<colgroup>
|
||||
<col width="5%">
|
||||
<col width="30%">
|
||||
<col width="10%">
|
||||
<col width="40%">
|
||||
<col width="15%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="pe-3">
|
||||
|
||||
</th>
|
||||
<th class="ps-2">
|
||||
{{ $gettext('Station Name') }}
|
||||
</th>
|
||||
<th class="text-center">
|
||||
{{ $gettext('Listeners') }}
|
||||
</th>
|
||||
<th>{{ $gettext('Now Playing') }}</th>
|
||||
<th class="text-end" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in stations"
|
||||
:key="item.station.id"
|
||||
class="align-middle"
|
||||
>
|
||||
<td class="text-center pe-1">
|
||||
<play-button
|
||||
class="file-icon btn-lg"
|
||||
:url="item.station.listen_url"
|
||||
is-stream
|
||||
/>
|
||||
</td>
|
||||
<td class="ps-2">
|
||||
<div class="h5 m-0">
|
||||
{{ item.station.name }}
|
||||
</div>
|
||||
<div v-if="item.station.is_public">
|
||||
<a
|
||||
:href="item.links.public"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $gettext('Public Page') }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<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"
|
||||
/>
|
||||
<data-table
|
||||
id="dashboard_stations"
|
||||
ref="$datatable"
|
||||
:fields="stationFields"
|
||||
:api-url="stationsUrl"
|
||||
paginated
|
||||
responsive
|
||||
show-toolbar
|
||||
:hide-on-loading="false"
|
||||
>
|
||||
<template #cell(play_button)="{ item }">
|
||||
<play-button
|
||||
class="file-icon btn-lg"
|
||||
:url="item.station.listen_url"
|
||||
is-stream
|
||||
/>
|
||||
</template>
|
||||
<template #cell(name)="{ item }">
|
||||
<div class="h5 m-0">
|
||||
{{ item.station.name }}
|
||||
</div>
|
||||
<div v-if="item.station.is_public">
|
||||
<a
|
||||
:href="item.links.public"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $gettext('Public Page') }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(listeners)="{ item }">
|
||||
<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>
|
||||
</template>
|
||||
<template #cell(now_playing)="{ item }">
|
||||
<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
|
||||
v-if="!item.is_online"
|
||||
class="flex-fill text-muted"
|
||||
>
|
||||
{{ $gettext('Station Offline') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.now_playing.song.title !== ''"
|
||||
class="flex-fill"
|
||||
>
|
||||
<strong><span class="nowplaying-title">
|
||||
{{ item.now_playing.song.title }}
|
||||
</span></strong><br>
|
||||
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex-fill"
|
||||
>
|
||||
<strong><span class="nowplaying-title">
|
||||
{{ item.now_playing.song.text }}
|
||||
</span></strong>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a
|
||||
class="btn btn-primary"
|
||||
:href="item.links.manage"
|
||||
role="button"
|
||||
>
|
||||
{{ $gettext('Manage') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</loading>
|
||||
<div
|
||||
v-if="!item.is_online"
|
||||
class="flex-fill text-muted"
|
||||
>
|
||||
{{ $gettext('Station Offline') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.now_playing.song.title !== ''"
|
||||
class="flex-fill"
|
||||
>
|
||||
<strong><span class="nowplaying-title">
|
||||
{{ item.now_playing.song.title }}
|
||||
</span></strong><br>
|
||||
<span class="nowplaying-artist">{{ item.now_playing.song.artist }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex-fill"
|
||||
>
|
||||
<strong><span class="nowplaying-title">
|
||||
{{ item.now_playing.song.text }}
|
||||
</span></strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(actions)="{ item }">
|
||||
<a
|
||||
class="btn btn-primary"
|
||||
:href="item.links.manage"
|
||||
role="button"
|
||||
>
|
||||
{{ $gettext('Manage') }}
|
||||
</a>
|
||||
</template>
|
||||
</data-table>
|
||||
</card-page>
|
||||
</div>
|
||||
|
||||
|
@ -276,11 +248,10 @@ import Icon from '~/components/Common/Icon.vue';
|
|||
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {useAsyncState} from "@vueuse/core";
|
||||
import {useAsyncState, useIntervalFn} from "@vueuse/core";
|
||||
import {computed, ref} from "vue";
|
||||
import DashboardCharts from "~/components/DashboardCharts.vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import Lightbox from "~/components/Common/Lightbox.vue";
|
||||
import CardPage from "~/components/Common/CardPage.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 UserInfoPanel from "~/components/Account/UserInfoPanel.vue";
|
||||
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({
|
||||
profileUrl: {
|
||||
|
@ -332,19 +304,47 @@ const langShowHideCharts = computed(() => {
|
|||
: $gettext('Show Charts')
|
||||
});
|
||||
|
||||
const {axios, axiosSilent} = useAxios();
|
||||
const {axios} = useAxios();
|
||||
|
||||
const {state: notifications, isLoading: notificationsLoading} = useAsyncState(
|
||||
() => axios.get(notificationsUrl.value).then((r) => r.data),
|
||||
[]
|
||||
);
|
||||
|
||||
const {state: stations, isLoading: stationsLoading} = useAutoRefreshingAsyncState(
|
||||
() => axiosSilent.get(stationsUrl.value).then((r) => r.data),
|
||||
[],
|
||||
const stationFields: DataTableField[] = [
|
||||
{
|
||||
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);
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
class="form-check-label"
|
||||
:for="id+'_'+option.value"
|
||||
>
|
||||
<slot :name="`label(${option.value})`">
|
||||
<slot :name="'label('+option.value+')'">
|
||||
<template v-if="option.description">
|
||||
<strong>{{ option.text }}</strong>
|
||||
<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>
|
||||
<section
|
||||
id="content"
|
||||
class="full-height-wrapper"
|
||||
role="main"
|
||||
>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<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">
|
||||
|
||||
<full-height-card>
|
||||
<template #header>
|
||||
<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>
|
||||
{{ formatFileSize(row.item.size) }}
|
||||
{{ $gettext('On-Demand Media') }}
|
||||
</template>
|
||||
</template>
|
||||
</data-table>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex-fill text-end">
|
||||
<inline-player ref="player" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
@ -74,12 +66,9 @@ import {forEach} from 'lodash';
|
|||
import Icon from '~/components/Common/Icon.vue';
|
||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import formatFileSize from "../../functions/formatFileSize";
|
||||
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 FullHeightCard from "~/components/Public/FullHeightCard.vue";
|
||||
|
||||
const props = defineProps({
|
||||
listUrl: {
|
||||
|
@ -141,7 +130,4 @@ forEach(props.customFields.slice(), (field) => {
|
|||
formatter: (_value, _key, item) => item.media.custom_fields[field.key]
|
||||
});
|
||||
});
|
||||
|
||||
const $lightbox = ref<LightboxTemplateRef>(null);
|
||||
useProvideLightbox($lightbox);
|
||||
</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>
|
||||
<section
|
||||
id="content"
|
||||
class="full-height-wrapper"
|
||||
role="main"
|
||||
>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<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('Schedule') }}
|
||||
</template>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="station-schedule-calendar">
|
||||
<schedule
|
||||
ref="schedule"
|
||||
:timezone="stationTimeZone"
|
||||
:schedule-url="scheduleUrl"
|
||||
:station-time-zone="stationTimeZone"
|
||||
/>
|
||||
</div>
|
||||
<full-height-card>
|
||||
<template #title>
|
||||
<template v-if="stationName">
|
||||
{{ stationName }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $gettext('Schedule') }}
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div id="station-schedule-calendar">
|
||||
<schedule
|
||||
ref="schedule"
|
||||
:timezone="stationTimeZone"
|
||||
:schedule-url="scheduleUrl"
|
||||
:station-time-zone="stationTimeZone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</full-height-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Schedule from '~/components/Common/ScheduleView.vue';
|
||||
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
|
||||
|
||||
const props = defineProps({
|
||||
scheduleUrl: {
|
||||
|
|
|
@ -241,7 +241,7 @@
|
|||
</template>
|
||||
|
||||
<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 Breadcrumb from './Media/Breadcrumb.vue';
|
||||
import FileUpload from './Media/FileUpload.vue';
|
||||
|
@ -256,14 +256,13 @@ import PlayButton from "~/components/Common/PlayButton.vue";
|
|||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {computed, ref, watch} from "vue";
|
||||
import {forEach, map, partition} from "lodash";
|
||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
||||
import formatFileSize from "../../functions/formatFileSize";
|
||||
import InfoCard from "~/components/Common/InfoCard.vue";
|
||||
import {useLuxon} from "~/vendor/luxon";
|
||||
import {getStationApiUrl} from "~/router";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {IconFile, IconFolder, IconImage} from "~/components/Common/icons";
|
||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||
|
||||
const props = defineProps({
|
||||
initialPlaylists: {
|
||||
|
@ -300,9 +299,8 @@ const renameUrl = getStationApiUrl('/files/rename');
|
|||
const quotaUrl = getStationApiUrl('/quota/station_media');
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
const {timeConfig} = useAzuraCast();
|
||||
const {timezone} = useAzuraCastStation();
|
||||
const {DateTime} = useLuxon();
|
||||
|
||||
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||
|
||||
const fields = computed<DataTableField[]>(() => {
|
||||
const fields: DataTableField[] = [
|
||||
|
@ -337,15 +335,7 @@ const fields = computed<DataTableField[]>(() => {
|
|||
key: 'timestamp',
|
||||
label: $gettext('Modified'),
|
||||
sortable: true,
|
||||
formatter: (value) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return DateTime.fromSeconds(value).setZone(timezone).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
||||
);
|
||||
},
|
||||
formatter: (value) => formatTimestampAsDateTime(value),
|
||||
selectable: true,
|
||||
visible: true
|
||||
},
|
||||
|
|
|
@ -37,7 +37,6 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import {ref, toRef, watch} from "vue";
|
||||
import {syncRef} from "@vueuse/core";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import FormGroup from "~/components/Form/FormGroup.vue";
|
||||
import FormFile from "~/components/Form/FormFile.vue";
|
||||
|
@ -49,9 +48,7 @@ const props = defineProps({
|
|||
}
|
||||
});
|
||||
|
||||
const albumArtSrc = ref(null);
|
||||
syncRef(toRef(props, 'albumArtUrl'), albumArtSrc, {direction: 'ltr'});
|
||||
|
||||
const albumArtSrc = ref(props.albumArtUrl);
|
||||
const reloadArt = () => {
|
||||
albumArtSrc.value = props.albumArtUrl + '?' + Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
|
|
@ -27,14 +27,11 @@
|
|||
:label="$gettext('AutoDJ Format')"
|
||||
/>
|
||||
|
||||
<form-group-multi-check
|
||||
<bitrate-options
|
||||
v-if="formatSupportsBitrateOptions"
|
||||
id="edit_form_autodj_bitrate"
|
||||
class="col-md-6"
|
||||
:field="v$.autodj_bitrate"
|
||||
:options="bitrateOptions"
|
||||
stacked
|
||||
radio
|
||||
:label="$gettext('AutoDJ Bitrate (kbps)')"
|
||||
/>
|
||||
</div>
|
||||
|
@ -43,12 +40,12 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
||||
import {map} from "lodash";
|
||||
import {computed} from "vue";
|
||||
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
|
||||
import {useVModel} from "@vueuse/core";
|
||||
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
|
||||
|
||||
const props = defineProps({
|
||||
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(() => {
|
||||
return (props.form.autodj_format.$model !== 'flac');
|
||||
return (props.form.autodj_format !== 'flac');
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -45,38 +45,12 @@
|
|||
:api-url="listUrl"
|
||||
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">
|
||||
<h5 class="m-0">
|
||||
{{ row.item.name }}
|
||||
</h5>
|
||||
<div>
|
||||
<span class="badge text-bg-secondary me-1">
|
||||
<div class="badges">
|
||||
<span class="badge text-bg-secondary">
|
||||
<template v-if="row.item.source === 'songs'">
|
||||
{{ $gettext('Song-based') }}
|
||||
</template>
|
||||
|
@ -86,31 +60,31 @@
|
|||
</span>
|
||||
<span
|
||||
v-if="row.item.is_jingle"
|
||||
class="badge text-bg-primary me-1"
|
||||
class="badge text-bg-primary"
|
||||
>
|
||||
{{ $gettext('Jingle Mode') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.item.source === 'songs' && row.item.order === 'sequential'"
|
||||
class="badge text-bg-info me-1"
|
||||
class="badge text-bg-info"
|
||||
>
|
||||
{{ $gettext('Sequential') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.item.include_in_on_demand"
|
||||
class="badge text-bg-info me-1"
|
||||
class="badge text-bg-info"
|
||||
>
|
||||
{{ $gettext('On-Demand') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.item.include_in_automation"
|
||||
class="badge text-bg-success me-1"
|
||||
v-if="row.item.schedule_items.length > 0"
|
||||
class="badge text-bg-info"
|
||||
>
|
||||
{{ $gettext('Auto-Assigned') }}
|
||||
{{ $gettext('Scheduled') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!row.item.is_enabled"
|
||||
class="badge text-bg-danger me-1"
|
||||
class="badge text-bg-danger"
|
||||
>
|
||||
{{ $gettext('Disabled') }}
|
||||
</span>
|
||||
|
@ -171,18 +145,54 @@
|
|||
|
||||
</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 }">
|
||||
<div
|
||||
class="buttons"
|
||||
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
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="toggleButtonClass(item)"
|
||||
:class="(item.is_enabled) ? 'btn-warning' : 'btn-success'"
|
||||
@click="doModify(item.links.toggle)"
|
||||
>
|
||||
{{ langToggleButton(item) }}
|
||||
{{ (item.is_enabled) ? $gettext('Disable') : $gettext('Enable') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="item.links.empty"
|
||||
|
@ -208,14 +218,6 @@
|
|||
>
|
||||
{{ $gettext('Import from PLS/M3U') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="item.links.order"
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
@click="doReorder(item.links.order)"
|
||||
>
|
||||
{{ $gettext('Reorder') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="item.links.queue"
|
||||
type="button"
|
||||
|
@ -300,7 +302,7 @@
|
|||
</template>
|
||||
|
||||
<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 EditModal from './Playlists/EditModal.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 Tab from "~/components/Common/Tab.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({
|
||||
useManualAutoDj: {
|
||||
|
@ -344,18 +348,6 @@ const fields: DataTableField[] = [
|
|||
{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 formatLength = (length) => {
|
||||
|
|
|
@ -50,11 +50,20 @@
|
|||
<td>{{ element.media.album }}</td>
|
||||
<td>
|
||||
<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
|
||||
v-if="index+1 < media.length"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:title="$gettext('Down')"
|
||||
:title="$gettext('Move Down')"
|
||||
@click.prevent="moveDown(index)"
|
||||
>
|
||||
<icon :icon="IconChevronDown" />
|
||||
|
@ -63,11 +72,20 @@
|
|||
v-if="index > 0"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:title="$gettext('Up')"
|
||||
:title="$gettext('Move Up')"
|
||||
@click.prevent="moveUp(index)"
|
||||
>
|
||||
<icon :icon="IconChevronUp" />
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -87,7 +105,7 @@ import {useAxios} from "~/vendor/axios";
|
|||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
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 {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
|
||||
|
||||
|
@ -129,12 +147,26 @@ const save = () => {
|
|||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
|
|
|
@ -30,16 +30,14 @@
|
|||
</div>
|
||||
|
||||
<div class="card-body buttons">
|
||||
<button
|
||||
type="button"
|
||||
<router-link
|
||||
class="btn btn-secondary"
|
||||
@click="doClearPodcast()"
|
||||
:to="{name: 'stations:podcasts:index'}"
|
||||
>
|
||||
<icon :icon="IconChevronLeft" />
|
||||
<span>
|
||||
{{ $gettext('All Podcasts') }}
|
||||
</span>
|
||||
</button>
|
||||
{{ $gettext('All Podcasts') }}
|
||||
</router-link>
|
||||
|
||||
<add-button
|
||||
:text="$gettext('Add Episode')"
|
||||
@click="doCreate"
|
||||
|
@ -50,48 +48,67 @@
|
|||
id="station_podcast_episodes"
|
||||
ref="$datatable"
|
||||
paginated
|
||||
select-fields
|
||||
:fields="fields"
|
||||
:api-url="podcast.links.episodes"
|
||||
>
|
||||
<template #cell(art)="row">
|
||||
<album-art :src="row.item.art" />
|
||||
<template #cell(art)="{item}">
|
||||
<album-art :src="item.art" />
|
||||
</template>
|
||||
<template #cell(title)="row">
|
||||
<template #cell(title)="{item}">
|
||||
<h5 class="m-0">
|
||||
{{ row.item.title }}
|
||||
{{ item.title }}
|
||||
</h5>
|
||||
<a
|
||||
:href="row.item.links.public"
|
||||
target="_blank"
|
||||
>{{ $gettext('Public Page') }}</a>
|
||||
<div v-if="item.is_published">
|
||||
<a
|
||||
:href="item.links.public"
|
||||
target="_blank"
|
||||
>{{ $gettext('Public Page') }}</a>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="badges"
|
||||
>
|
||||
<span class="badge text-bg-info">
|
||||
{{ $gettext('Unpublished') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(podcast_media)="row">
|
||||
<template v-if="row.item.media">
|
||||
<span>{{ row.item.media.original_name }}</span>
|
||||
<template #cell(podcast_media)="{item}">
|
||||
<template v-if="item.media">
|
||||
<span>{{ item.media.original_name }}</span>
|
||||
<br>
|
||||
<small>{{ row.item.media.length_text }}</small>
|
||||
<small>{{ item.media.length_text }}</small>
|
||||
</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
|
||||
v-if="row.item.explicit"
|
||||
v-if="item.explicit"
|
||||
class="badge text-bg-danger"
|
||||
>{{ $gettext('Explicit') }}</span>
|
||||
<span v-else> </span>
|
||||
</template>
|
||||
<template #cell(actions)="row">
|
||||
<template #cell(actions)="{item}">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="doEdit(row.item.links.self)"
|
||||
@click="doEdit(item.links.self)"
|
||||
>
|
||||
{{ $gettext('Edit') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="doDelete(row.item.links.self)"
|
||||
@click="doDelete(item.links.self)"
|
||||
>
|
||||
{{ $gettext('Delete') }}
|
||||
</button>
|
||||
|
@ -111,79 +128,93 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DataTable, { DataTableField } from '~/components/Common/DataTable.vue';
|
||||
import EditModal from './EpisodeEditModal.vue';
|
||||
import DataTable, {DataTableField} from '~/components/Common/DataTable.vue';
|
||||
import EditModal from './Podcasts/EpisodeEditModal.vue';
|
||||
import Icon from '~/components/Common/Icon.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 {useSweetAlert} from "~/vendor/sweetalert";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import AddButton from "~/components/Common/AddButton.vue";
|
||||
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({
|
||||
quotaUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
podcast: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
const props = defineProps<{
|
||||
podcast: ApiPodcast
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['clear-podcast']);
|
||||
const quotaUrl = getStationApiUrl('/quota/station_podcasts');
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||
|
||||
const fields: DataTableField[] = [
|
||||
{key: 'art', label: $gettext('Art'), sortable: false, class: 'shrink pe-0'},
|
||||
{key: 'title', label: $gettext('Episode'), sortable: false},
|
||||
{key: 'podcast_media', label: $gettext('File Name'), sortable: false},
|
||||
{key: 'explicit', label: $gettext('Explicit'), sortable: false},
|
||||
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
||||
{
|
||||
key: 'art',
|
||||
label: $gettext('Art'),
|
||||
sortable: false,
|
||||
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 $datatable = ref<DataTableTemplateRef>(null);
|
||||
const {refresh} = useHasDatatable($datatable);
|
||||
|
||||
const relist = () => {
|
||||
$quota.value?.update();
|
||||
$datatable.value?.refresh();
|
||||
refresh();
|
||||
};
|
||||
|
||||
const $editEpisodeModal = ref<InstanceType<typeof EditModal> | null>(null);
|
||||
const {doCreate, doEdit} = useHasEditModal($editEpisodeModal);
|
||||
|
||||
const doCreate = () => {
|
||||
$editEpisodeModal.value?.create();
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
const {doDelete} = useConfirmAndDelete(
|
||||
$gettext('Delete Episode?'),
|
||||
() => relist()
|
||||
);
|
||||
</script>
|
|
@ -1,39 +1,154 @@
|
|||
<template>
|
||||
<episodes-view
|
||||
v-if="activePodcast"
|
||||
:podcast="activePodcast"
|
||||
:quota-url="quotaUrl"
|
||||
@clear-podcast="onClearPodcast"
|
||||
/>
|
||||
<list-view
|
||||
v-else
|
||||
v-bind="pickProps(props, listViewProps)"
|
||||
:quota-url="quotaUrl"
|
||||
@select-podcast="onSelectPodcast"
|
||||
<card-page>
|
||||
<template #header>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import EpisodesView from './Podcasts/EpisodesView.vue';
|
||||
import ListView from './Podcasts/ListView.vue';
|
||||
import DataTable, {DataTableField} from '~/components/Common/DataTable.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 listViewProps from "./Podcasts/listViewProps";
|
||||
import {pickProps} from "~/functions/pickProps";
|
||||
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({
|
||||
...listViewProps
|
||||
languageOptions: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
categoriesOptions: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
});
|
||||
|
||||
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) => {
|
||||
activePodcast.value = podcast;
|
||||
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,
|
||||
},
|
||||
{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 = () => {
|
||||
activePodcast.value = null;
|
||||
}
|
||||
const $editPodcastModal = ref<InstanceType<typeof EditModal> | null>(null);
|
||||
const {doCreate, doEdit} = useHasEditModal($editPodcastModal);
|
||||
|
||||
const {doDelete} = useConfirmAndDelete(
|
||||
$gettext('Delete Podcast?'),
|
||||
() => relist()
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, toRef} from "vue";
|
||||
import {computed, ref, toRef, watch} from "vue";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import FormGroup from "~/components/Form/FormGroup.vue";
|
||||
import FormFile from "~/components/Form/FormFile.vue";
|
||||
|
@ -54,15 +54,11 @@ import Tab from "~/components/Common/Tab.vue";
|
|||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
default: null
|
||||
},
|
||||
artworkSrc: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
editArtUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
default: null
|
||||
},
|
||||
newArtUrl: {
|
||||
type: String,
|
||||
|
@ -72,7 +68,12 @@ const props = defineProps({
|
|||
|
||||
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 src = computed(() => {
|
||||
|
@ -92,21 +93,24 @@ const uploaded = (file) => {
|
|||
}, false);
|
||||
fileReader.readAsDataURL(file);
|
||||
|
||||
const url = (props.editArtUrl) ? props.editArtUrl : props.newArtUrl;
|
||||
const url = (props.artworkSrc) ? props.artworkSrc : props.newArtUrl;
|
||||
const formData = new FormData();
|
||||
formData.append('art', file);
|
||||
|
||||
axios.post(url, formData).then((resp) => {
|
||||
emit('update:modelValue', resp.data);
|
||||
reloadArt();
|
||||
});
|
||||
};
|
||||
|
||||
const deleteArt = () => {
|
||||
if (props.editArtUrl) {
|
||||
axios.delete(props.editArtUrl).then(() => {
|
||||
if (props.artworkSrc) {
|
||||
axios.delete(props.artworkSrc).then(() => {
|
||||
reloadArt();
|
||||
localSrc.value = null;
|
||||
});
|
||||
} else {
|
||||
reloadArt();
|
||||
localSrc.value = null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,14 +18,12 @@
|
|||
:record-has-media="record.has_media"
|
||||
:new-media-url="newMediaUrl"
|
||||
:edit-media-url="record.links.media"
|
||||
:download-url="record.links.download"
|
||||
/>
|
||||
|
||||
<podcast-common-artwork
|
||||
v-model="form.artwork_file"
|
||||
:artwork-src="record.art"
|
||||
:artwork-src="record.links.art"
|
||||
:new-art-url="newArtUrl"
|
||||
:edit-art-url="record.links.art"
|
||||
/>
|
||||
</tabs>
|
||||
</modal-form>
|
||||
|
|
|
@ -32,9 +32,9 @@
|
|||
<template v-if="hasMedia">
|
||||
<div class="block-buttons pt-3">
|
||||
<a
|
||||
v-if="downloadUrl"
|
||||
v-if="editMediaUrl"
|
||||
class="btn btn-block btn-dark"
|
||||
:href="downloadUrl"
|
||||
:href="editMediaUrl"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $gettext('Download') }}
|
||||
|
@ -74,10 +74,6 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
downloadUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
editMediaUrl: {
|
||||
type: String,
|
||||
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
|
||||
v-model="form.artwork_file"
|
||||
:artwork-src="record.art"
|
||||
:artwork-src="record.links.art"
|
||||
:new-art-url="newArtUrl"
|
||||
:edit-art-url="record.links.art"
|
||||
/>
|
||||
</tabs>
|
||||
</modal-form>
|
||||
|
@ -35,6 +34,7 @@ import {useResettableRef} from "~/functions/useResettableRef";
|
|||
import {useTranslate} from "~/vendor/gettext";
|
||||
import ModalForm from "~/components/Common/ModalForm.vue";
|
||||
import Tabs from "~/components/Common/Tabs.vue";
|
||||
import {map} from "lodash";
|
||||
|
||||
const props = defineProps({
|
||||
...baseEditModalProps,
|
||||
|
@ -59,7 +59,9 @@ const $modal = ref<ModalFormTemplateRef>(null);
|
|||
const {record, reset} = useResettableRef({
|
||||
has_custom_art: false,
|
||||
art: null,
|
||||
links: {}
|
||||
links: {
|
||||
art: null
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
|
@ -87,6 +89,11 @@ const {
|
|||
reset();
|
||||
},
|
||||
populateForm: (data, formRef) => {
|
||||
data.categories = map(
|
||||
data.categories,
|
||||
(row) => row.category
|
||||
);
|
||||
|
||||
record.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',
|
||||
text: $gettext('History')
|
||||
},
|
||||
{
|
||||
value: 'podcasts',
|
||||
text: $gettext('Podcasts')
|
||||
},
|
||||
{
|
||||
value: 'schedule',
|
||||
text: $gettext('Schedule')
|
||||
|
@ -174,6 +178,9 @@ const baseEmbedUrl = computed(() => {
|
|||
case 'schedule':
|
||||
return props.publicScheduleEmbedUri;
|
||||
|
||||
case 'podcasts':
|
||||
return props.publicPodcastsEmbedUri;
|
||||
|
||||
case 'player':
|
||||
default:
|
||||
return props.publicPageEmbedUri;
|
||||
|
@ -181,14 +188,17 @@ const baseEmbedUrl = computed(() => {
|
|||
});
|
||||
|
||||
const embedUrl = computed(() => {
|
||||
return (selectedTheme.value !== "browser")
|
||||
? baseEmbedUrl.value + '?theme=' + selectedTheme.value
|
||||
: baseEmbedUrl.value;
|
||||
const baseUrl = new URL(baseEmbedUrl.value);
|
||||
if (selectedTheme.value !== 'browser') {
|
||||
baseUrl.searchParams.set('theme', selectedTheme.value);
|
||||
}
|
||||
return baseUrl.toString();
|
||||
});
|
||||
|
||||
const embedHeight = computed(() => {
|
||||
switch (selectedType.value) {
|
||||
case 'ondemand':
|
||||
case 'podcasts':
|
||||
return '400px';
|
||||
|
||||
case 'requests':
|
||||
|
|
|
@ -223,14 +223,29 @@
|
|||
{{ $gettext('Disconnect Streamer') }}
|
||||
</span>
|
||||
</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>
|
||||
</card-page>
|
||||
|
||||
<template v-if="isLiquidsoap && userAllowedForStation(StationPermission.Broadcasting)">
|
||||
<update-metadata-modal ref="$updateMetadataModal" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {BackendAdapter} from '~/entities/RadioAdapters';
|
||||
import Icon from '~/components/Common/Icon.vue';
|
||||
import {computed} from "vue";
|
||||
import {computed, Ref, ref} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import nowPlayingPanelProps from "~/components/Stations/Profile/nowPlayingPanelProps";
|
||||
import useNowPlaying from "~/functions/useNowPlaying";
|
||||
|
@ -239,7 +254,16 @@ import CardPage from "~/components/Common/CardPage.vue";
|
|||
import {useLightbox} from "~/vendor/lightbox";
|
||||
import {StationPermission, userAllowedForStation} from "~/acl";
|
||||
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({
|
||||
...nowPlayingPanelProps,
|
||||
|
@ -275,4 +299,9 @@ const {vLightbox} = useLightbox();
|
|||
const makeApiCall = (uri) => {
|
||||
emit('api-call', uri);
|
||||
};
|
||||
|
||||
const $updateMetadataModal: Ref<InstanceType<typeof UpdateMetadataModal> | null> = ref(null);
|
||||
const updateMetadata = () => {
|
||||
$updateMetadataModal.value?.open();
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -45,9 +45,9 @@
|
|||
<script setup lang="ts">
|
||||
import {map} from "lodash";
|
||||
import {computed} from "vue";
|
||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
||||
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({
|
||||
scheduleItems: {
|
||||
|
@ -56,39 +56,35 @@ const props = defineProps({
|
|||
}
|
||||
});
|
||||
|
||||
const {timeConfig} = useAzuraCast();
|
||||
const {timezone} = useAzuraCastStation();
|
||||
|
||||
const {DateTime} = useLuxon();
|
||||
const {
|
||||
now,
|
||||
timestampToDateTime,
|
||||
formatDateTime
|
||||
} = useStationDateTimeFormatter();
|
||||
|
||||
const processedScheduleItems = computed(() => {
|
||||
const now = DateTime.now().setZone(timezone);
|
||||
const nowTz = now();
|
||||
|
||||
return map(props.scheduleItems, (row) => {
|
||||
const start_moment = DateTime.fromSeconds(row.start_timestamp).setZone(timezone);
|
||||
const end_moment = DateTime.fromSeconds(row.end_timestamp).setZone(timezone);
|
||||
const startMoment = timestampToDateTime(row.start_timestamp);
|
||||
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 = start_moment.toLocaleString(
|
||||
{...DateTime.TIME_SIMPLE, ...timeConfig}
|
||||
);
|
||||
} else {
|
||||
row.start_formatted = start_moment.toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
||||
);
|
||||
}
|
||||
row.start_formatted = formatDateTime(
|
||||
startMoment,
|
||||
startMoment.hasSame(nowTz, 'day')
|
||||
? DateTime.TIME_SIMPLE
|
||||
: DateTime.DATETIME_MED
|
||||
);
|
||||
|
||||
if (end_moment.hasSame(start_moment, 'day')) {
|
||||
row.end_formatted = end_moment.toLocaleString(
|
||||
{...DateTime.TIME_SIMPLE, ...timeConfig}
|
||||
);
|
||||
} else {
|
||||
row.end_formatted = end_moment.toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
||||
);
|
||||
}
|
||||
row.end_formatted = formatDateTime(
|
||||
endMoment,
|
||||
endMoment.hasSame(startMoment, 'day')
|
||||
? DateTime.TIME_SIMPLE
|
||||
: DateTime.DATETIME_MED
|
||||
);
|
||||
|
||||
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: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
publicPodcastsEmbedUri: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
ref="$datatable"
|
||||
:fields="fields"
|
||||
:api-url="listUrl"
|
||||
:hide-on-loading="false"
|
||||
>
|
||||
<template #cell(actions)="row">
|
||||
<div class="btn-group btn-group-sm">
|
||||
|
@ -52,8 +53,8 @@
|
|||
</div>
|
||||
</template>
|
||||
<template #cell(played_at)="row">
|
||||
{{ formatTime(row.item.played_at) }}<br>
|
||||
<small>{{ formatRelativeTime(row.item.played_at) }}</small>
|
||||
{{ formatTimestampAsTime(row.item.played_at) }}<br>
|
||||
<small>{{ formatTimestampAsRelative(row.item.played_at) }}</small>
|
||||
</template>
|
||||
<template #cell(source)="row">
|
||||
<div v-if="row.item.is_request">
|
||||
|
@ -70,21 +71,21 @@
|
|||
</template>
|
||||
|
||||
<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 Icon from "~/components/Common/Icon.vue";
|
||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {ref} from "vue";
|
||||
import {computed, ref} from "vue";
|
||||
import useConfirmAndDelete from "~/functions/useConfirmAndDelete";
|
||||
import useHasDatatable, {DataTableTemplateRef} from "~/functions/useHasDatatable";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
import {useLuxon} from "~/vendor/luxon";
|
||||
import {getStationApiUrl} from "~/router";
|
||||
import {IconRemove} from "~/components/Common/icons";
|
||||
import {useIntervalFn} from "@vueuse/core";
|
||||
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||
|
||||
const listUrl = getStationApiUrl('/queue');
|
||||
const clearUrl = getStationApiUrl('/queue/clear');
|
||||
|
@ -98,24 +99,19 @@ const fields: DataTableField[] = [
|
|||
{key: 'source', label: $gettext('Source'), sortable: false}
|
||||
];
|
||||
|
||||
const {timezone} = useAzuraCastStation();
|
||||
|
||||
const {DateTime} = useLuxon();
|
||||
|
||||
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 {
|
||||
formatTimestampAsTime,
|
||||
formatTimestampAsRelative
|
||||
} = useStationDateTimeFormatter();
|
||||
|
||||
const $datatable = ref<DataTableTemplateRef>(null);
|
||||
const {relist} = useHasDatatable($datatable);
|
||||
|
||||
useIntervalFn(
|
||||
relist,
|
||||
computed(() => (document.hidden) ? 60000 : 30000)
|
||||
);
|
||||
|
||||
const $logsModal = ref<InstanceType<typeof QueueLogsModal> | null>(null);
|
||||
const doShowLogs = (logs) => {
|
||||
$logsModal.value?.show(logs);
|
||||
|
|
|
@ -27,14 +27,11 @@
|
|||
:label="$gettext('AutoDJ Format')"
|
||||
/>
|
||||
|
||||
<form-group-multi-check
|
||||
<bitrate-options
|
||||
v-if="formatSupportsBitrateOptions"
|
||||
id="edit_form_autodj_bitrate"
|
||||
class="col-md-6"
|
||||
:field="v$.autodj_bitrate"
|
||||
:options="bitrateOptions"
|
||||
stacked
|
||||
radio
|
||||
:label="$gettext('AutoDJ Bitrate (kbps)')"
|
||||
/>
|
||||
|
||||
|
@ -89,12 +86,12 @@
|
|||
<script setup lang="ts">
|
||||
import FormGroupField from "~/components/Form/FormGroupField.vue";
|
||||
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
||||
import {map} from "lodash";
|
||||
import {computed} from "vue";
|
||||
import FormGroupMultiCheck from "~/components/Form/FormGroupMultiCheck.vue";
|
||||
import {useVModel} from "@vueuse/core";
|
||||
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import BitrateOptions from "~/components/Common/BitrateOptions.vue";
|
||||
|
||||
const props = defineProps({
|
||||
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(() => {
|
||||
return form.value?.autodj_format !== 'flac';
|
||||
});
|
||||
|
|
|
@ -75,67 +75,79 @@
|
|||
|
||||
<div id="map">
|
||||
<StationReportsListenersMap
|
||||
:listeners="listeners"
|
||||
:listeners="filteredListeners"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card-body row">
|
||||
<div class="col-md-4">
|
||||
<h5>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-md-auto align-items-center">
|
||||
<div class="col-12 text-start text-md-end h5">
|
||||
{{ $gettext('Unique Listeners') }}
|
||||
<br>
|
||||
<small>
|
||||
{{ $gettext('for selected period') }}
|
||||
</small>
|
||||
</h5>
|
||||
<h3>{{ listeners.length }}</h3>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h5>
|
||||
</div>
|
||||
<div class="col-12 h3">
|
||||
{{ listeners.length }}
|
||||
</div>
|
||||
<div class="col-12 text-start text-md-end h5">
|
||||
{{ $gettext('Total Listener Hours') }}
|
||||
<br>
|
||||
<small>
|
||||
{{ $gettext('for selected period') }}
|
||||
</small>
|
||||
</h5>
|
||||
<h3>{{ totalListenerHours }}</h3>
|
||||
</div>
|
||||
<div class="col-12 h3">
|
||||
{{ totalListenerHours }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<listener-filters-bar v-model:filters="filters" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<data-table
|
||||
id="station_playlists"
|
||||
id="station_listeners"
|
||||
ref="$datatable"
|
||||
paginated
|
||||
handle-client-side
|
||||
:fields="fields"
|
||||
:items="listeners"
|
||||
:items="filteredListeners"
|
||||
select-fields
|
||||
@refresh-clicked="updateListeners()"
|
||||
>
|
||||
<template #cell(time)="row">
|
||||
{{ formatTime(row.item.connected_time) }}
|
||||
</template>
|
||||
<template #cell(time_sec)="row">
|
||||
{{ row.item.connected_time }}
|
||||
</template>
|
||||
<template #cell(user_agent)="row">
|
||||
<div>
|
||||
<span v-if="row.item.is_mobile">
|
||||
<icon :icon="IconSmartphone" />
|
||||
<span class="visually-hidden">
|
||||
{{ $gettext('Mobile Device') }}
|
||||
<!-- eslint-disable-next-line -->
|
||||
<template #cell(device.client)="row">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0 pe-2">
|
||||
<span v-if="row.item.device.is_bot">
|
||||
<icon :icon="IconRouter" />
|
||||
<span class="visually-hidden">
|
||||
{{ $gettext('Bot/Crawler') }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<icon :icon="IconDesktopWindows" />
|
||||
<span class="visually-hidden">
|
||||
{{ $gettext('Desktop Device') }}
|
||||
<span v-else-if="row.item.device.is_mobile">
|
||||
<icon :icon="IconSmartphone" />
|
||||
<span class="visually-hidden">
|
||||
{{ $gettext('Mobile') }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{{ row.item.user_agent }}
|
||||
</div>
|
||||
<div v-if="row.item.device.client">
|
||||
<small>{{ row.item.device.client }}</small>
|
||||
<span v-else>
|
||||
<icon :icon="IconDesktopWindows" />
|
||||
<span class="visually-hidden">
|
||||
{{ $gettext('Desktop') }}
|
||||
</span>
|
||||
</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>
|
||||
</template>
|
||||
<template #cell(stream)="row">
|
||||
|
@ -174,17 +186,21 @@
|
|||
<script setup lang="ts">
|
||||
import StationReportsListenersMap from "./Listeners/Map.vue";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import formatTime from "~/functions/formatTime";
|
||||
import DataTable, {DataTableField} from "~/components/Common/DataTable.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 {useAxios} from "~/vendor/axios";
|
||||
import {useAzuraCastStation} from "~/vendor/azuracast";
|
||||
import {useLuxon} from "~/vendor/luxon";
|
||||
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 {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({
|
||||
attribution: {
|
||||
|
@ -196,12 +212,15 @@ const props = defineProps({
|
|||
const apiUrl = getStationApiUrl('/listeners');
|
||||
|
||||
const isLive = ref<boolean>(true);
|
||||
const listeners = shallowRef([]);
|
||||
|
||||
const {timezone} = useAzuraCastStation();
|
||||
const listeners: ShallowRef<ApiListener[]> = shallowRef([]);
|
||||
|
||||
const {DateTime} = useLuxon();
|
||||
const nowTz = DateTime.now().setZone(timezone);
|
||||
const {
|
||||
now,
|
||||
formatTimestampAsDateTime
|
||||
} = useStationDateTimeFormatter();
|
||||
|
||||
const nowTz = now();
|
||||
|
||||
const minDate = nowTz.minus({years: 5}).toJSDate();
|
||||
const maxDate = nowTz.plus({days: 5}).toJSDate();
|
||||
|
@ -211,38 +230,107 @@ const dateRange = ref({
|
|||
endDate: nowTz.toJSDate()
|
||||
});
|
||||
|
||||
const filters: Ref<ListenerFilters> = ref({
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
type: ListenerTypeFilter.All,
|
||||
});
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const fields: DataTableField[] = [
|
||||
{key: 'ip', label: $gettext('IP'), sortable: false},
|
||||
{key: 'time', label: $gettext('Time'), sortable: false},
|
||||
{key: 'time_sec', label: $gettext('Time (sec)'), sortable: false},
|
||||
{key: 'user_agent', isRowHeader: true, label: $gettext('User Agent'), sortable: false},
|
||||
{key: 'stream', label: $gettext('Stream'), sortable: false},
|
||||
{key: 'location', label: $gettext('Location'), sortable: false}
|
||||
{
|
||||
key: 'ip', label: $gettext('IP'), sortable: false,
|
||||
selectable: true,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
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 = new URL(apiUrl.value, document.location.href);
|
||||
const exportUrlParams = exportUrl.searchParams;
|
||||
exportUrlParams.set('format', 'csv');
|
||||
exportUrlParams.set('format', 'csv');
|
||||
|
||||
if (!isLive.value) {
|
||||
exportUrlParams.set('start', DateTime.fromJSDate(dateRange.value.startDate).toISO());
|
||||
exportUrlParams.set('end', DateTime.fromJSDate(dateRange.value.endDate).toISO());
|
||||
}
|
||||
if (!isLive.value) {
|
||||
exportUrlParams.set('start', DateTime.fromJSDate(dateRange.value.startDate).toISO());
|
||||
exportUrlParams.set('end', DateTime.fromJSDate(dateRange.value.endDate).toISO());
|
||||
}
|
||||
|
||||
return exportUrl.toString();
|
||||
return exportUrl.toString();
|
||||
});
|
||||
|
||||
const totalListenerHours = computed(() => {
|
||||
let tlh_seconds = 0;
|
||||
listeners.value.forEach(function (listener) {
|
||||
tlh_seconds += listener.connected_time;
|
||||
});
|
||||
let tlh_seconds = 0;
|
||||
filteredListeners.value.forEach(function (listener) {
|
||||
tlh_seconds += listener.connected_time;
|
||||
});
|
||||
|
||||
const tlh_hours = tlh_seconds / 3600;
|
||||
return Math.round((tlh_hours + 0.00001) * 100) / 100;
|
||||
const tlh_hours = tlh_seconds / 3600;
|
||||
return Math.round((tlh_hours + 0.00001) * 100) / 100;
|
||||
});
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
@ -250,6 +338,40 @@ const {axios} = useAxios();
|
|||
const $datatable = ref<DataTableTemplateRef>(null);
|
||||
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 params: {
|
||||
[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 ListeningTimeTab from "~/components/Stations/Reports/Overview/ListeningTimeTab.vue";
|
||||
import {ref} from "vue";
|
||||
import {useAzuraCastStation} from "~/vendor/azuracast";
|
||||
import {useLuxon} from "~/vendor/luxon";
|
||||
import {getStationApiUrl} from "~/router";
|
||||
import Tabs from "~/components/Common/Tabs.vue";
|
||||
import Tab from "~/components/Common/Tab.vue";
|
||||
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||
|
||||
const props = defineProps({
|
||||
showFullAnalytics: {
|
||||
|
@ -113,11 +112,9 @@ const byCountryUrl = getStationApiUrl('/reports/overview/by-country');
|
|||
const byClientUrl = getStationApiUrl('/reports/overview/by-client');
|
||||
const listeningTimeUrl = getStationApiUrl('/reports/overview/by-listening-time');
|
||||
|
||||
const {timezone} = useAzuraCastStation();
|
||||
const {DateTime} = useLuxon();
|
||||
|
||||
const nowTz = DateTime.now().setZone(timezone);
|
||||
const {now} = useStationDateTimeFormatter();
|
||||
|
||||
const nowTz = now();
|
||||
const dateRange = ref({
|
||||
startDate: nowTz.minus({days: 13}).toJSDate(),
|
||||
endDate: nowTz.toJSDate(),
|
||||
|
|
|
@ -56,14 +56,14 @@
|
|||
:api-url="listUrlForType"
|
||||
>
|
||||
<template #cell(timestamp)="row">
|
||||
{{ formatTime(row.item.timestamp) }}
|
||||
{{ formatTimestampAsDateTime(row.item.timestamp) }}
|
||||
</template>
|
||||
<template #cell(played_at)="row">
|
||||
<span v-if="row.item.played_at === 0">
|
||||
{{ $gettext('Not Played') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ formatTime(row.item.played_at) }}
|
||||
{{ formatTimestampAsDateTime(row.item.played_at) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell(song_title)="row">
|
||||
|
@ -93,18 +93,17 @@
|
|||
</template>
|
||||
|
||||
<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 {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
||||
import {computed, nextTick, ref} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {useLuxon} from "~/vendor/luxon";
|
||||
import {getStationApiUrl} from "~/router";
|
||||
import {IconRemove} from "~/components/Common/icons";
|
||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||
|
||||
const listUrl = getStationApiUrl('/reports/requests');
|
||||
const clearUrl = getStationApiUrl('/reports/requests/clear');
|
||||
|
@ -147,16 +146,7 @@ const setType = (type) => {
|
|||
nextTick(relist);
|
||||
};
|
||||
|
||||
const {timeConfig} = useAzuraCast();
|
||||
const {timezone} = useAzuraCastStation();
|
||||
|
||||
const {DateTime} = useLuxon();
|
||||
|
||||
const formatTime = (time) => {
|
||||
return DateTime.fromSeconds(time).setZone(timezone).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
||||
);
|
||||
};
|
||||
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||
|
||||
const {confirmDelete} = useSweetAlert();
|
||||
const {notifySuccess} = useNotify();
|
||||
|
|
|
@ -114,16 +114,14 @@ import FormFieldset from "~/components/Form/FormFieldset.vue";
|
|||
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
|
||||
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
||||
import {getStationApiUrl} from "~/router";
|
||||
import {useLuxon} from "~/vendor/luxon";
|
||||
import {useAzuraCastStation} from "~/vendor/azuracast";
|
||||
import CardPage from "~/components/Common/CardPage.vue";
|
||||
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||
|
||||
const apiUrl = getStationApiUrl('/reports/soundexchange');
|
||||
|
||||
const {DateTime} = useLuxon();
|
||||
const {timezone} = useAzuraCastStation();
|
||||
const {now} = useStationDateTimeFormatter();
|
||||
|
||||
const lastMonth = DateTime.now().setZone(timezone).minus({months: 1});
|
||||
const lastMonth = now().minus({months: 1});
|
||||
|
||||
const {v$} = useVuelidateOnForm(
|
||||
{
|
||||
|
|
|
@ -86,22 +86,28 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
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 {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast";
|
||||
import {computed, ref, watch} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useLuxon} from "~/vendor/luxon";
|
||||
import {getStationApiUrl} from "~/router";
|
||||
import {IconDownload, IconTrendingDown, IconTrendingUp} from "~/components/Common/icons";
|
||||
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 {timezone} = useAzuraCastStation();
|
||||
const {DateTime} = useLuxon();
|
||||
const {
|
||||
now,
|
||||
formatDateTimeAsDateTime,
|
||||
formatTimestampAsDateTime
|
||||
} = useStationDateTimeFormatter();
|
||||
|
||||
const nowTz = DateTime.now().setZone(timezone);
|
||||
const nowTz = now();
|
||||
|
||||
const dateRange = ref(
|
||||
{
|
||||
|
@ -111,7 +117,6 @@ const dateRange = ref(
|
|||
);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
const {timeConfig} = useAzuraCast();
|
||||
|
||||
const fields: DataTableField[] = [
|
||||
{
|
||||
|
@ -119,29 +124,22 @@ const fields: DataTableField[] = [
|
|||
label: $gettext('Date/Time (Browser)'),
|
||||
selectable: true,
|
||||
sortable: false,
|
||||
formatter: (value) => {
|
||||
return DateTime.fromSeconds(
|
||||
value,
|
||||
{zone: 'system'}
|
||||
).toLocaleString(
|
||||
{...DateTime.DATETIME_SHORT, ...timeConfig}
|
||||
);
|
||||
}
|
||||
visible: false,
|
||||
formatter: (value) => formatDateTimeAsDateTime(
|
||||
DateTime.fromSeconds(value, {zone: 'system'}),
|
||||
DateTime.DATETIME_SHORT
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'played_at_station',
|
||||
label: $gettext('Date/Time (Station)'),
|
||||
sortable: false,
|
||||
selectable: true,
|
||||
visible: false,
|
||||
formatter: (_value, _key, item) => {
|
||||
return DateTime.fromSeconds(
|
||||
item.played_at,
|
||||
{zone: timezone}
|
||||
).toLocaleString(
|
||||
{...DateTime.DATETIME_SHORT, ...timeConfig}
|
||||
);
|
||||
}
|
||||
visible: true,
|
||||
formatter: (_value, _key, item) => formatTimestampAsDateTime(
|
||||
item.played_at,
|
||||
DateTime.DATETIME_SHORT
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'listeners_start',
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
</template>
|
||||
|
||||
<data-table
|
||||
id="station_remotes"
|
||||
id="station_sftp_users"
|
||||
ref="$datatable"
|
||||
:show-toolbar="false"
|
||||
:fields="fields"
|
||||
|
@ -75,7 +75,7 @@
|
|||
</template>
|
||||
|
||||
<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 {useTranslate} from "~/vendor/gettext";
|
||||
import {ref} from "vue";
|
||||
|
|
|
@ -64,14 +64,15 @@
|
|||
import {ref} from "vue";
|
||||
import Icon from "~/components/Common/Icon.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 {useStationsMenu} from "~/components/Stations/menu";
|
||||
import {StationPermission, userAllowedForStation} from "~/acl";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import {getStationApiUrl} from "~/router.ts";
|
||||
import {useLuxon} from "~/vendor/luxon.ts";
|
||||
import {IconEdit} from "~/components/Common/icons.ts";
|
||||
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||
import {useLuxon} from "~/vendor/luxon.ts";
|
||||
|
||||
const props = defineProps({
|
||||
station: {
|
||||
|
@ -82,17 +83,15 @@ const props = defineProps({
|
|||
|
||||
const menuItems = useStationsMenu();
|
||||
|
||||
const {timeConfig} = useAzuraCast();
|
||||
const {name, timezone} = useAzuraCastStation();
|
||||
const {name} = useAzuraCastStation();
|
||||
|
||||
const {DateTime} = useLuxon();
|
||||
const {now, formatDateTimeAsTime} = useStationDateTimeFormatter();
|
||||
|
||||
const clock = ref('');
|
||||
|
||||
useIntervalFn(() => {
|
||||
clock.value = DateTime.now().setZone(timezone).toLocaleString({
|
||||
...DateTime.TIME_WITH_SHORT_OFFSET,
|
||||
...timeConfig
|
||||
})
|
||||
clock.value = formatDateTimeAsTime(now(), DateTime.TIME_WITH_SHORT_OFFSET);
|
||||
}, 1000, {
|
||||
immediate: true,
|
||||
immediateCallback: true
|
||||
|
|
|
@ -63,24 +63,23 @@ import InlinePlayer from '~/components/InlinePlayer.vue';
|
|||
import Icon from '~/components/Common/Icon.vue';
|
||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||
import '~/vendor/sweetalert';
|
||||
import {useAzuraCast} from "~/vendor/azuracast";
|
||||
import {ref} from "vue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useSweetAlert} from "~/vendor/sweetalert";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import Modal from "~/components/Common/Modal.vue";
|
||||
import {useLuxon} from "~/vendor/luxon";
|
||||
import {IconDownload} from "~/components/Common/icons";
|
||||
import {DataTableTemplateRef} from "~/functions/useHasDatatable.ts";
|
||||
import {ModalTemplateRef, useHasModal} from "~/functions/useHasModal.ts";
|
||||
import {usePlayerStore, useProvidePlayerStore} from "~/functions/usePlayerStore.ts";
|
||||
import useStationDateTimeFormatter from "~/functions/useStationDateTimeFormatter.ts";
|
||||
|
||||
const listUrl = ref(null);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
const {timeConfig} = useAzuraCast();
|
||||
const {DateTime} = useLuxon();
|
||||
|
||||
const {formatTimestampAsDateTime} = useStationDateTimeFormatter();
|
||||
|
||||
const fields: DataTableField[] = [
|
||||
{
|
||||
|
@ -93,11 +92,7 @@ const fields: DataTableField[] = [
|
|||
key: 'timestampStart',
|
||||
label: $gettext('Start Time'),
|
||||
sortable: false,
|
||||
formatter: (value) => {
|
||||
return DateTime.fromSeconds(value).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
||||
);
|
||||
},
|
||||
formatter: (value) => formatTimestampAsDateTime(value),
|
||||
class: 'ps-3'
|
||||
},
|
||||
{
|
||||
|
@ -105,13 +100,9 @@ const fields: DataTableField[] = [
|
|||
label: $gettext('End Time'),
|
||||
sortable: false,
|
||||
formatter: (value) => {
|
||||
if (value === 0) {
|
||||
return $gettext('Live');
|
||||
}
|
||||
|
||||
return DateTime.fromSeconds(value).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
||||
);
|
||||
return value === 0
|
||||
? $gettext('Live')
|
||||
: formatTimestampAsDateTime(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import {getStationApiUrl} from "~/router.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 [
|
||||
{
|
||||
path: '/',
|
||||
|
@ -61,6 +63,22 @@ export default function useStationsRoutes() {
|
|||
name: 'stations:podcasts:index',
|
||||
...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',
|
||||
name: 'stations:profile:index',
|
||||
|
|
|
@ -269,10 +269,76 @@ export interface ApiListener {
|
|||
* @example 30
|
||||
*/
|
||||
connected_time?: number;
|
||||
/** Device metadata, if available */
|
||||
device?: any[];
|
||||
/** Location metadata, if available */
|
||||
location?: any[];
|
||||
device?: ApiListenerDevice;
|
||||
location?: ApiListenerLocation;
|
||||
}
|
||||
|
||||
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 & {
|
||||
|
@ -408,6 +474,11 @@ export interface ApiNowPlayingStation {
|
|||
* @example "liquidsoap"
|
||||
*/
|
||||
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
|
||||
* @example "http://localhost:8000/radio.mp3"
|
||||
|
@ -531,26 +602,39 @@ export interface ApiNowPlayingStationRemote {
|
|||
}
|
||||
|
||||
export type ApiPodcast = HasLinks & {
|
||||
id?: string | null;
|
||||
storage_location_id?: number | null;
|
||||
title?: string | null;
|
||||
id?: string;
|
||||
storage_location_id?: number;
|
||||
title?: string;
|
||||
link?: string | null;
|
||||
description?: string | null;
|
||||
language?: string | null;
|
||||
author?: string | null;
|
||||
email?: string | null;
|
||||
description?: string;
|
||||
description_short?: string;
|
||||
language?: string;
|
||||
language_name?: string;
|
||||
author?: string;
|
||||
email?: string;
|
||||
has_custom_art?: boolean;
|
||||
art?: string | null;
|
||||
art?: string;
|
||||
art_updated_at?: number;
|
||||
categories?: string[];
|
||||
episodes?: string[];
|
||||
is_published?: boolean;
|
||||
episodes?: number;
|
||||
categories?: ApiPodcastCategory[];
|
||||
};
|
||||
|
||||
export interface ApiPodcastCategory {
|
||||
category?: string;
|
||||
text?: string;
|
||||
title?: string;
|
||||
subtitle?: string | null;
|
||||
}
|
||||
|
||||
export type ApiPodcastEpisode = HasLinks & {
|
||||
id?: string | null;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
description_short?: string;
|
||||
explicit?: boolean;
|
||||
created_at?: number;
|
||||
is_published?: boolean;
|
||||
publish_at?: number | null;
|
||||
has_media?: boolean;
|
||||
media?: ApiPodcastMedia;
|
||||
|
@ -583,32 +667,32 @@ export interface ApiSong {
|
|||
* The song artist.
|
||||
* @example "Chet Porter"
|
||||
*/
|
||||
artist?: string;
|
||||
artist?: string | null;
|
||||
/**
|
||||
* The song title.
|
||||
* @example "Aluko River"
|
||||
*/
|
||||
title?: string;
|
||||
title?: string | null;
|
||||
/**
|
||||
* The song album.
|
||||
* @example "Moving Castle"
|
||||
*/
|
||||
album?: string;
|
||||
album?: string | null;
|
||||
/**
|
||||
* The song genre.
|
||||
* @example "Rock"
|
||||
*/
|
||||
genre?: string;
|
||||
genre?: string | null;
|
||||
/**
|
||||
* The International Standard Recording Code (ISRC) of the file.
|
||||
* @example "US28E1600021"
|
||||
*/
|
||||
isrc?: string;
|
||||
isrc?: string | null;
|
||||
/**
|
||||
* Lyrics to the song.
|
||||
* @example ""
|
||||
*/
|
||||
lyrics?: string;
|
||||
lyrics?: string | null;
|
||||
/**
|
||||
* URL to the album artwork (if available).
|
||||
* @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));
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export default function isObject(value) {
|
||||
export default function isObject(value: any): boolean {
|
||||
return typeof value === "object"
|
||||
&& (Object(value) === value)
|
||||
&& !Array.isArray(value);
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import {reactivePick} from "@vueuse/core";
|
||||
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(
|
||||
props,
|
||||
...keys(subset)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {resolveRef, watchOnce} from "@vueuse/core";
|
||||
import {ref} from "vue";
|
||||
import {watchOnce} from "@vueuse/core";
|
||||
import {ref, toRef} from "vue";
|
||||
|
||||
/**
|
||||
* Creates a ref that syncs with its "source" value only once.
|
||||
|
@ -7,7 +7,7 @@ import {ref} from "vue";
|
|||
* subsequent refreshes.
|
||||
*/
|
||||
export default function syncOnce(sourceMaybeRef) {
|
||||
const sourceRef = resolveRef(sourceMaybeRef);
|
||||
const sourceRef = toRef(sourceMaybeRef);
|
||||
|
||||
const newRef = ref(sourceRef.value);
|
||||
watchOnce(sourceRef, (newVal) => {
|
||||
|
|
|
@ -3,7 +3,7 @@ import {useNotify} from "~/functions/useNotify";
|
|||
import {useAxios} from "~/vendor/axios";
|
||||
|
||||
export default function useConfirmAndDelete(
|
||||
confirmMessage,
|
||||
confirmMessage: string,
|
||||
onSuccess = null
|
||||
) {
|
||||
const {confirmDelete} = useSweetAlert();
|
||||
|
|
|
@ -4,22 +4,41 @@ import {Ref} from "vue";
|
|||
export type DataTableTemplateRef = InstanceType<typeof DataTable> | null;
|
||||
|
||||
export default function useHasDatatable($datatableRef: Ref<DataTableTemplateRef>) {
|
||||
/**
|
||||
* Reset selected rows, active row, and trigger data reload.
|
||||
*/
|
||||
const refresh = () => {
|
||||
return $datatableRef.value?.refresh();
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh, but clearing the cache where relevant.
|
||||
* @see refresh
|
||||
*/
|
||||
const relist = () => {
|
||||
return $datatableRef.value?.relist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search phrase and current page, then call refresh().
|
||||
* @see relist
|
||||
*/
|
||||
const navigate = () => {
|
||||
return $datatableRef.value?.navigate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current search filer string.
|
||||
* @param newTerm The new search term.
|
||||
*/
|
||||
const setFilter = (newTerm: string) => {
|
||||
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) => {
|
||||
return $datatableRef.value?.toggleDetails(row);
|
||||
};
|
||||
|
|
|
@ -8,7 +8,6 @@ interface EditModalCompatible {
|
|||
|
||||
export type EditModalTemplateRef = InstanceType<EditModalCompatible> | null;
|
||||
|
||||
|
||||
export default function useHasEditModal($modalRef: Ref<EditModalTemplateRef>) {
|
||||
const doCreate = (): void => {
|
||||
$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;
|
||||
}
|
||||
|
||||
& > .alert {
|
||||
.card-body.alert {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
div.datatable-wrapper {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
div.datatable-toolbar-top,
|
||||
div.datatable-toolbar-bottom {
|
||||
&:empty {
|
||||
|
|
|
@ -61,10 +61,21 @@ body.page-minimal {
|
|||
|
||||
.card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.datatable-main {
|
||||
overflow-y: auto;
|
||||
.card-body {
|
||||
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
|
||||
@import "root";
|
||||
@import 'overrides/badges';
|
||||
@import 'overrides/body';
|
||||
@import 'overrides/buttons';
|
||||
@import 'overrides/card';
|
||||
|
|
|
@ -11,7 +11,7 @@ export function useTranslate(): Language {
|
|||
export async function installTranslate(vueApp: App): Promise<void> {
|
||||
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';
|
||||
|
||||
gettext = createGettext({
|
||||
|
|
|
@ -31,6 +31,10 @@ parameters:
|
|||
scanDirectories:
|
||||
- ./vendor/zircote/swagger-php/src/Annotations
|
||||
|
||||
stubFiles:
|
||||
- util/phpstan_di.stub
|
||||
- util/phpstan_phpdi.stub
|
||||
|
||||
universalObjectCratesClasses:
|
||||
- App\Session\NamespaceInterface
|
||||
- App\View
|
||||
|
|
25
src/Acl.php
|
@ -19,12 +19,30 @@ use Psr\EventDispatcher\EventDispatcherInterface;
|
|||
use function in_array;
|
||||
use function is_array;
|
||||
|
||||
/**
|
||||
* @phpstan-type PermissionsArray array{
|
||||
* global: array<string, string>,
|
||||
* station: array<string, string>
|
||||
* }
|
||||
*/
|
||||
final class Acl
|
||||
{
|
||||
use RequestAwareTrait;
|
||||
|
||||
/**
|
||||
* @var PermissionsArray
|
||||
*/
|
||||
private array $permissions;
|
||||
|
||||
/**
|
||||
* @var null|array<
|
||||
* int,
|
||||
* array{
|
||||
* stations?: array<int, array<string>>,
|
||||
* global?: array<string>
|
||||
* }
|
||||
* >
|
||||
*/
|
||||
private ?array $actions;
|
||||
|
||||
public function __construct(
|
||||
|
@ -41,12 +59,12 @@ final class Acl
|
|||
{
|
||||
$sql = $this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT rp FROM App\Entity\RolePermission rp
|
||||
SELECT rp.station_id, rp.role_id, rp.action_name FROM App\Entity\RolePermission rp
|
||||
DQL
|
||||
);
|
||||
|
||||
$this->actions = [];
|
||||
foreach ($sql->getArrayResult() as $row) {
|
||||
foreach ($sql->toIterable() as $row) {
|
||||
if ($row['station_id']) {
|
||||
$this->actions[$row['role_id']]['stations'][$row['station_id']][] = $row['action_name'];
|
||||
} else {
|
||||
|
@ -69,12 +87,11 @@ final class Acl
|
|||
}
|
||||
|
||||
/**
|
||||
* @return mixed[]
|
||||
* @return array
|
||||
*/
|
||||
public function listPermissions(): array
|
||||
{
|
||||
if (!isset($this->permissions)) {
|
||||
/** @var array<string,array<string, string>> $permissions */
|
||||
$permissions = [
|
||||
'global' => [],
|
||||
'station' => [],
|
||||
|
|
|
@ -8,6 +8,7 @@ use App\Container\EnvironmentAwareTrait;
|
|||
use App\Entity\Repository\UserRepository;
|
||||
use App\Entity\User;
|
||||
use App\Exception\NotLoggedInException;
|
||||
use App\Utilities\Types;
|
||||
use Mezzio\Session\SessionInterface;
|
||||
|
||||
final class Auth
|
||||
|
@ -83,7 +84,7 @@ final class Auth
|
|||
if (!$this->session->has(self::SESSION_MASQUERADE_USER_ID_KEY)) {
|
||||
$this->masqueraded_user = false;
|
||||
} 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) {
|
||||
$user = $this->userRepo->getRepository()->find($maskUserId);
|
||||
} else {
|
||||
|
@ -125,7 +126,7 @@ final class Auth
|
|||
*/
|
||||
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
|
||||
{
|
||||
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) {
|
||||
$this->user = false;
|
||||
|
@ -238,7 +239,7 @@ final class Auth
|
|||
$user = $this->getUser();
|
||||
|
||||
if (!($user instanceof User)) {
|
||||
throw new NotLoggedInException();
|
||||
throw NotLoggedInException::create();
|
||||
}
|
||||
|
||||
if ($user->verifyTwoFactor($otp)) {
|
||||
|
|
|
@ -6,9 +6,17 @@ namespace App\Cache;
|
|||
|
||||
use App\Entity\Api\NowPlaying\NowPlaying;
|
||||
use App\Entity\Station;
|
||||
use App\Utilities\Types;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
/**
|
||||
* @phpstan-type LookupRow array{
|
||||
* short_name: string,
|
||||
* is_public: bool,
|
||||
* updated_at: int
|
||||
* }
|
||||
*/
|
||||
final class NowPlayingCache
|
||||
{
|
||||
private const NOWPLAYING_CACHE_TTL = 180;
|
||||
|
@ -41,9 +49,13 @@ final class NowPlayingCache
|
|||
|
||||
$stationCacheItem = $this->getStationCache($station);
|
||||
|
||||
return ($stationCacheItem->isHit())
|
||||
? $stationCacheItem->get()
|
||||
: null;
|
||||
if (!$stationCacheItem->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$np = $stationCacheItem->get();
|
||||
assert($np instanceof NowPlaying);
|
||||
return $np;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -58,6 +70,8 @@ final class NowPlayingCache
|
|||
}
|
||||
|
||||
$np = [];
|
||||
|
||||
/** @var LookupRow[] $lookupCache */
|
||||
$lookupCache = (array)$lookupCacheItem->get();
|
||||
|
||||
foreach ($lookupCache as $stationInfo) {
|
||||
|
@ -78,11 +92,14 @@ final class NowPlayingCache
|
|||
return $np;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, LookupRow>
|
||||
*/
|
||||
public function getLookup(): array
|
||||
{
|
||||
$lookupCacheItem = $this->getLookupCache();
|
||||
return $lookupCacheItem->isHit()
|
||||
? (array)$lookupCacheItem->get()
|
||||
? Types::array($lookupCacheItem->get())
|
||||
: [];
|
||||
}
|
||||
|
||||
|
@ -114,7 +131,7 @@ final class NowPlayingCache
|
|||
$lookupCacheItem = $this->getLookupCache();
|
||||
|
||||
$lookupCache = $lookupCacheItem->isHit()
|
||||
? (array)$lookupCacheItem->get()
|
||||
? Types::array($lookupCacheItem->get())
|
||||
: [];
|
||||
|
||||
$lookupCache[$station->getIdRequired()] = [
|
||||
|
@ -131,12 +148,9 @@ final class NowPlayingCache
|
|||
private function getStationCache(string $identifier): CacheItemInterface
|
||||
{
|
||||
if (is_numeric($identifier)) {
|
||||
$lookupCacheItem = $this->getLookupCache();
|
||||
$lookupCache = $lookupCacheItem->isHit()
|
||||
? (array)$lookupCacheItem->get()
|
||||
: [];
|
||||
$lookupCache = $this->getLookup();
|
||||
|
||||
$identifier = (int)$identifier;
|
||||
$identifier = Types::int($identifier);
|
||||
if (isset($lookupCache[$identifier])) {
|
||||
$identifier = $lookupCache[$identifier]['short_name'];
|
||||
}
|
||||
|
|