Reworked admin
Build and Publish / Build and Publish Docker Image (push) Successful in 3m11s

This commit is contained in:
2026-05-26 00:19:11 +03:00
parent a3a3f5368d
commit aafb364eb8
11 changed files with 4192 additions and 31 deletions
Generated
+1 -1
View File
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.1.7" version = "0.1.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.1.8" version = "0.1.9"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+284 -7
View File
@@ -1,3 +1,4 @@
mod v2;
pub mod views; pub mod views;
use std::sync::Arc; use std::sync::Arc;
@@ -131,20 +132,296 @@ impl App for AdminApp {
), ),
"admin_setup", "admin_setup",
), ),
// -- Admin v2 -----------------------------------------------------
Route::with_handler_and_name(
"/v2",
|session: Session, db: Database, i18n: I18n| async move {
let count = User::count_all(&db).await.unwrap_or(0);
if count == 0 {
return Ok(auth::redirect("/admin/setup"));
}
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
v2::page(admin, i18n).await?.into_response()
},
"admin_v2",
),
Route::with_handler_and_name(
"/v2/api/dashboard",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let registry = Arc::clone(&self.registry);
get(move |session: Session, db: Database| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let registry = Arc::clone(&registry);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::dashboard(session, db, pg_pool, &registry).await
}
})
},
"admin_v2_dashboard",
),
Route::with_handler_and_name(
"/v2/api/reviews",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database,
query: UrlQuery<v2::ReviewsQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::reviews(session, db, pg_pool, query.0).await
}
})
},
"admin_v2_reviews",
),
Route::with_handler_and_name(
"/v2/api/reviews/bulk",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(
move |session: Session,
db: Database,
json: Json<v2::BulkReviewsRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::bulk_reviews(session, db, pg_pool, json).await
}
},
)
},
"admin_v2_reviews_bulk",
),
Route::with_handler_and_name(
"/v2/api/jobs",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let registry = Arc::clone(&self.registry);
get(move |session: Session, db: Database| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let registry = Arc::clone(&registry);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::jobs(session, db, pg_pool, &registry).await
}
})
},
"admin_v2_jobs",
),
Route::with_handler_and_name(
"/v2/api/jobs/{name}/run",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>| {
let handle = Arc::clone(&handle);
async move { v2::run_job(session, db, &handle, &path.0.name).await }
}
}),
"admin_v2_job_run",
),
Route::with_handler_and_name(
"/v2/api/jobs/{name}/toggle",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>| {
let handle = Arc::clone(&handle);
async move { v2::toggle_job(session, db, &handle, &path.0.name).await }
}
}),
"admin_v2_job_toggle",
),
Route::with_handler_and_name(
"/v2/api/jobs/{name}/runs",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database, path: Path<PathName>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::job_runs(session, db, pg_pool, &path.0.name).await
}
})
},
"admin_v2_job_runs",
),
Route::with_handler_and_name(
"/v2/api/jobs/{name}/runs/{run_id}",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(
move |session: Session, db: Database, path: Path<PathNameRunId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::job_run_detail(session, db, pg_pool, path.0.run_id).await
}
},
)
},
"admin_v2_job_run_detail",
),
Route::with_handler_and_name(
"/v2/api/library",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database,
query: UrlQuery<v2::LibraryQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::library(session, db, pg_pool, query.0).await
}
})
},
"admin_v2_library",
),
Route::with_handler_and_name(
"/v2/api/library/item",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(
move |session: Session,
db: Database,
json: Json<v2::UpdateLibraryItemRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::update_library_item(session, db, pg_pool, json).await
}
},
)
},
"admin_v2_library_item",
),
Route::with_handler_and_name(
"/v2/api/library/bulk",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(
move |session: Session,
db: Database,
json: Json<v2::BulkLibraryRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::bulk_library(session, db, pg_pool, json).await
}
},
)
},
"admin_v2_library_bulk",
),
// -- Dashboard ---------------------------------------------------- // -- Dashboard ----------------------------------------------------
Route::with_handler_and_name( Route::with_handler_and_name(
"/", "/",
|session: Session, db: Database, i18n: I18n| async move { |session: Session, db: Database, i18n: I18n| async move {
let count = User::count_all(&db).await.unwrap_or(0); let count = User::count_all(&db).await.unwrap_or(0);
if count == 0 { if count == 0 {
return Ok(auth::redirect("/admin/setup")); return Ok::<cot::response::Response, cot::Error>(auth::redirect(
"/admin/setup",
));
} }
let admin = let _admin = match auth::require_admin_or_redirect(&session, &db).await {
match auth::require_admin_or_redirect(&session, &db).await { Ok(u) => u,
Ok(u) => u, Err(resp) => return Ok(resp),
Err(resp) => return Ok(resp), };
}; let _ = i18n;
views::admin_index(admin, i18n).await?.into_response() Ok::<cot::response::Response, cot::Error>(auth::redirect("/admin/v2"))
}, },
"admin_index", "admin_index",
), ),
+1766
View File
File diff suppressed because it is too large Load Diff
-17
View File
@@ -211,23 +211,6 @@ pub async fn debug_handler(
Ok(Html::new(template.render()?)) Ok(Html::new(template.render()?))
} }
#[derive(Debug, Template)]
#[template(path = "admin/index.html")]
struct AdminIndexTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
}
pub async fn admin_index(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
let template = AdminIndexTemplate {
t: i18n.t,
user_name: admin.name,
user_role: admin.role.code().to_owned(),
};
Ok(Html::new(template.render()?))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Settings page // Settings page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+6
View File
@@ -108,6 +108,9 @@ pub(super) struct PlaylistCard {
pub(super) title: String, pub(super) title: String,
pub(super) track_count: i64, pub(super) track_count: i64,
pub(super) is_own: bool, pub(super) is_own: bool,
pub(super) owner_name: Option<String>,
pub(super) is_public: bool,
pub(super) is_saved: bool,
pub(super) kind: String, pub(super) kind: String,
} }
@@ -128,6 +131,9 @@ pub(super) struct PlaylistDetail {
pub(super) title: String, pub(super) title: String,
pub(super) description: Option<String>, pub(super) description: Option<String>,
pub(super) is_own: bool, pub(super) is_own: bool,
pub(super) owner_name: Option<String>,
pub(super) is_public: bool,
pub(super) is_saved: bool,
pub(super) kind: String, pub(super) kind: String,
pub(super) tracks: Vec<TrackItem>, pub(super) tracks: Vec<TrackItem>,
} }
+41 -4
View File
@@ -546,18 +546,33 @@ async fn playlists_handler(
title: "Likes".to_string(), title: "Likes".to_string(),
track_count: likes_count.0, track_count: likes_count.0,
is_own: true, is_own: true,
owner_name: None,
is_public: false,
is_saved: false,
kind: "likes".to_string(), kind: "likes".to_string(),
}]; }];
let rows = sqlx::query_as::<_, PlaylistRow>( let rows = sqlx::query_as::<_, PlaylistRow>(
r#"SELECT p.id, p.title::text as title, r#"SELECT p.id, p.title::text as title,
COALESCE((SELECT COUNT(*) FROM furumusic__playlist_track pt WHERE pt.playlist_id = p.id), 0) as track_count, COALESCE((SELECT COUNT(*) FROM furumusic__playlist_track pt WHERE pt.playlist_id = p.id), 0) as track_count,
(p.owner_id = $1) as is_own (p.owner_id = $1) as is_own,
COALESCE(NULLIF(u.display_name, ''), u.username)::text as owner_name,
p.is_public,
EXISTS (
SELECT 1 FROM furumusic__saved_playlist sp
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
) as is_saved
FROM furumusic__playlist p FROM furumusic__playlist p
JOIN furumusic__user u ON u.id = p.owner_id
WHERE p.owner_id = $1 WHERE p.owner_id = $1
OR p.id IN (SELECT sp.playlist_id FROM furumusic__saved_playlist sp WHERE sp.user_id = $1) OR EXISTS (
SELECT 1 FROM furumusic__saved_playlist sp
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
)
OR p.is_public = true OR p.is_public = true
ORDER BY p.title"#, ORDER BY
CASE WHEN p.owner_id = $1 THEN 0 WHEN p.is_public THEN 2 ELSE 1 END,
p.title"#,
) )
.bind(user.id) .bind(user.id)
.fetch_all(pool) .fetch_all(pool)
@@ -569,6 +584,9 @@ async fn playlists_handler(
title: r.title, title: r.title,
track_count: r.track_count, track_count: r.track_count,
is_own: r.is_own, is_own: r.is_own,
owner_name: Some(r.owner_name),
is_public: r.is_public,
is_saved: r.is_saved,
kind: "user".to_string(), kind: "user".to_string(),
})); }));
@@ -597,9 +615,19 @@ async fn playlist_detail_handler(
} }
let info = sqlx::query_as::<_, PlaylistInfoRow>( let info = sqlx::query_as::<_, PlaylistInfoRow>(
"SELECT id, title::text as title, description, owner_id FROM furumusic__playlist WHERE id = $1", r#"SELECT p.id, p.title::text as title, p.description, p.owner_id,
COALESCE(NULLIF(u.display_name, ''), u.username)::text as owner_name,
p.is_public,
EXISTS (
SELECT 1 FROM furumusic__saved_playlist sp
WHERE sp.user_id = $2 AND sp.playlist_id = p.id
) as is_saved
FROM furumusic__playlist p
JOIN furumusic__user u ON u.id = p.owner_id
WHERE p.id = $1"#,
) )
.bind(playlist_id) .bind(playlist_id)
.bind(user.id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(|e| cot::Error::internal(e.to_string()))?;
@@ -637,6 +665,9 @@ async fn playlist_detail_handler(
title: info.title, title: info.title,
description: info.description, description: info.description,
is_own: info.owner_id == user.id, is_own: info.owner_id == user.id,
owner_name: Some(info.owner_name),
is_public: info.is_public,
is_saved: info.is_saved,
kind: "user".to_string(), kind: "user".to_string(),
tracks: track_items, tracks: track_items,
}) })
@@ -748,6 +779,9 @@ async fn likes_playlist_handler(
title: "Likes".to_string(), title: "Likes".to_string(),
description: None, description: None,
is_own: true, is_own: true,
owner_name: None,
is_public: false,
is_saved: false,
kind: "likes".to_string(), kind: "likes".to_string(),
tracks: track_items, tracks: track_items,
}) })
@@ -1429,6 +1463,9 @@ async fn create_playlist_handler(
title, title,
track_count: 0, track_count: 0,
is_own: true, is_own: true,
owner_name: Some(user.name),
is_public: false,
is_saved: false,
kind: "user".to_string(), kind: "user".to_string(),
}) })
.into_response() .into_response()
+6
View File
@@ -77,6 +77,9 @@ pub(super) struct PlaylistRow {
pub(super) title: String, pub(super) title: String,
pub(super) track_count: i64, pub(super) track_count: i64,
pub(super) is_own: bool, pub(super) is_own: bool,
pub(super) owner_name: String,
pub(super) is_public: bool,
pub(super) is_saved: bool,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -85,6 +88,9 @@ pub(super) struct PlaylistInfoRow {
pub(super) title: String, pub(super) title: String,
pub(super) description: Option<String>, pub(super) description: Option<String>,
pub(super) owner_id: i64, pub(super) owner_id: i64,
pub(super) owner_name: String,
pub(super) is_public: bool,
pub(super) is_saved: bool,
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
+73
View File
@@ -1124,6 +1124,34 @@ pub struct SchedulerHandle {
} }
impl SchedulerHandle { impl SchedulerHandle {
/// Start a job immediately in the background and return the created run id.
pub async fn trigger_job_now_background(
self: Arc<Self>,
job_name: &str,
) -> anyhow::Result<i64> {
self.registry
.get(job_name)
.ok_or_else(|| anyhow::anyhow!("unknown job: {job_name}"))?;
let db = self.shared_db.clone();
let pool = self.shared_pool.clone();
let (live_config, _) = AppConfig::load_with_db(&db).await;
let run = JobRun::create_running(&db, job_name, "manual")
.await
.map_err(|e| anyhow::anyhow!("failed to create job run: {e}"))?;
let run_id = run.id_val();
let job_name = job_name.to_owned();
let handle = Arc::clone(&self);
tokio::spawn(async move {
handle
.finish_manual_run(job_name, live_config, db, pool, run)
.await;
});
Ok(run_id)
}
/// Execute a job immediately (manual or programmatic trigger). /// Execute a job immediately (manual or programmatic trigger).
pub async fn trigger_job_now(&self, job_name: &str) -> anyhow::Result<i64> { pub async fn trigger_job_now(&self, job_name: &str) -> anyhow::Result<i64> {
let job_impl = self let job_impl = self
@@ -1172,6 +1200,51 @@ impl SchedulerHandle {
Ok(run.id_val()) Ok(run.id_val())
} }
async fn finish_manual_run(
self: Arc<Self>,
job_name: String,
live_config: AppConfig,
db: Database,
pool: sqlx::PgPool,
mut run: JobRun,
) {
let Some(job_impl) = self.registry.get(&job_name) else {
let _ = run
.set_failed(&db, 0, "", &format!("unknown job: {job_name}"))
.await;
return;
};
let start = std::time::Instant::now();
let ctx = JobContext {
config: Arc::new(live_config),
db: db.clone(),
pool: pool.clone(),
run_id: run.id_val(),
registry: Arc::clone(&self.registry),
};
let mut log = JobLog::with_live_flush(pool, run.id_val());
match job_impl.run(&ctx, &mut log).await {
Ok(()) => {
let duration_ms = start.elapsed().as_millis() as i64;
let _ = run.set_completed(&db, duration_ms, &log.output()).await;
}
Err(e) => {
let duration_ms = start.elapsed().as_millis() as i64;
let _ = run
.set_failed(&db, duration_ms, &log.output(), &e.to_string())
.await;
}
}
if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(&db, &job_name).await {
sched_job.last_run_at = Some(now_iso().to_string());
sched_job.updated_at = now_iso();
let _ = sched_job.save(&db).await;
}
}
/// Remove a cron job from the scheduler and re-add it with a new cron /// Remove a cron job from the scheduler and re-add it with a new cron
/// expression. Also updates the DB row. /// expression. Also updates the DB row.
pub async fn reschedule_job(&self, job_name: &str, new_cron: &str) -> anyhow::Result<()> { pub async fn reschedule_job(&self, job_name: &str, new_cron: &str) -> anyhow::Result<()> {
File diff suppressed because it is too large Load Diff
+109 -1
View File
@@ -312,6 +312,61 @@ button.user-stat:hover {
color: var(--text-subdued); color: var(--text-subdued);
} }
.playlist-public-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-color);
}
.playlist-subtitle {
padding-top: 2px;
padding-bottom: 8px;
}
.playlist-title-line,
.playlist-meta-line {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.playlist-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-item-public {
white-space: normal;
}
.playlist-meta-line {
margin-top: 2px;
font-size: 11px;
color: var(--text-subdued);
white-space: nowrap;
}
.playlist-owner {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.playlist-public-badge {
flex-shrink: 0;
padding: 1px 5px;
border-radius: 999px;
background: rgba(52, 211, 153, 0.12);
color: #6ee7b7;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.sidebar-bottom { .sidebar-bottom {
padding: 12px 16px; padding: 12px 16px;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
@@ -348,6 +403,15 @@ button.user-stat:hover {
margin-bottom: 20px; margin-bottom: 20px;
} }
.playlist-detail-meta {
display: flex;
align-items: center;
gap: 8px;
margin: -12px 0 18px;
color: var(--text-subdued);
font-size: 13px;
}
.breadcrumb { .breadcrumb {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2339,7 +2403,7 @@ button.user-stat:hover {
</div> </div>
</div> </div>
<div class="playlist-list"> <div class="playlist-list">
<template x-for="pl in $store.playlists.list" :key="pl.id"> <template x-for="pl in $store.playlists.regularList()" :key="pl.id">
<div class="playlist-item-row"> <div class="playlist-item-row">
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id)"> <div class="playlist-item" @click="$store.library.openPlaylist(pl.id)">
<template x-if="pl.kind === 'likes'"> <template x-if="pl.kind === 'likes'">
@@ -2369,6 +2433,26 @@ button.user-stat:hover {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Playlist New Playlist
</button> </button>
<template x-if="$store.playlists.publishedList().length > 0">
<div class="playlist-public-section">
<div class="sidebar-section-title playlist-subtitle">Published Playlists</div>
<template x-for="pl in $store.playlists.publishedList()" :key="'published-' + pl.id">
<div class="playlist-item-row">
<div class="playlist-item playlist-item-public" @click="$store.library.openPlaylist(pl.id)">
<div class="playlist-title-line">
<span class="playlist-title-text" x-text="pl.title"></span>
<span class="playlist-public-badge">Public</span>
</div>
<div class="playlist-meta-line">
<span class="playlist-owner" x-show="pl.owner_name" x-text="'by ' + pl.owner_name"></span>
<span x-show="pl.owner_name">&middot;</span>
<span x-text="pl.track_count + ' tracks'"></span>
</div>
</div>
</div>
</template>
</div>
</template>
</div> </div>
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<a href="/admin/">Admin Panel</a> <a href="/admin/">Admin Panel</a>
@@ -2869,6 +2953,14 @@ button.user-stat:hover {
<span x-text="$store.library.currentPlaylist.title"></span> <span x-text="$store.library.currentPlaylist.title"></span>
</div> </div>
<h1 class="section-title" x-text="$store.library.currentPlaylist.title"></h1> <h1 class="section-title" x-text="$store.library.currentPlaylist.title"></h1>
<div class="playlist-detail-meta"
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
<span x-show="$store.library.currentPlaylist.owner_name"
x-text="'by ' + $store.library.currentPlaylist.owner_name"></span>
<span x-show="$store.library.currentPlaylist.owner_name && $store.library.currentPlaylist.is_public">&middot;</span>
<span class="playlist-public-badge"
x-show="$store.library.currentPlaylist.is_public">Published</span>
</div>
<template x-if="$store.library.currentPlaylist.description"> <template x-if="$store.library.currentPlaylist.description">
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p> <p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
</template> </template>
@@ -4553,6 +4645,22 @@ document.addEventListener('alpine:init', () => {
} catch {} } catch {}
}, },
regularList() {
return this.list.filter(pl => (
pl.kind === 'likes' ||
pl.is_own ||
!pl.is_public
));
},
publishedList() {
return this.list.filter(pl => (
pl.kind === 'user' &&
!pl.is_own &&
pl.is_public
));
},
showCreate() { showCreate() {
this.modal = { mode: 'create', title: '' }; this.modal = { mode: 'create', title: '' };
}, },