mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-08-28 19:47:54 +08:00
feat: 前端支持根据 ID 手动添加订阅 (#374)
This commit is contained in:
@@ -8,7 +8,7 @@ use axum::extract::{Extension, Path, Query};
|
|||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use axum::routing::{get, post, put};
|
use axum::routing::{get, post, put};
|
||||||
use bili_sync_entity::*;
|
use bili_sync_entity::*;
|
||||||
use bili_sync_migration::{Expr, OnConflict};
|
use bili_sync_migration::Expr;
|
||||||
use reqwest::{Method, StatusCode, header};
|
use reqwest::{Method, StatusCode, header};
|
||||||
use sea_orm::ActiveValue::Set;
|
use sea_orm::ActiveValue::Set;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
@@ -23,8 +23,8 @@ use crate::api::auth::OpenAPIAuth;
|
|||||||
use crate::api::error::InnerApiError;
|
use crate::api::error::InnerApiError;
|
||||||
use crate::api::helper::{update_page_download_status, update_video_download_status};
|
use crate::api::helper::{update_page_download_status, update_video_download_status};
|
||||||
use crate::api::request::{
|
use crate::api::request::{
|
||||||
FollowedCollectionsRequest, FollowedUppersRequest, UpdateVideoSourceRequest, UpdateVideoStatusRequest,
|
FollowedCollectionsRequest, FollowedUppersRequest, InsertCollectionRequest, InsertFavoriteRequest,
|
||||||
UpsertCollectionRequest, UpsertFavoriteRequest, UpsertSubmissionRequest, VideosRequest,
|
InsertSubmissionRequest, UpdateVideoSourceRequest, UpdateVideoStatusRequest, VideosRequest,
|
||||||
};
|
};
|
||||||
use crate::api::response::{
|
use crate::api::response::{
|
||||||
CollectionWithSubscriptionStatus, CollectionsResponse, FavoriteWithSubscriptionStatus, FavoritesResponse, PageInfo,
|
CollectionWithSubscriptionStatus, CollectionsResponse, FavoriteWithSubscriptionStatus, FavoritesResponse, PageInfo,
|
||||||
@@ -43,7 +43,7 @@ use crate::utils::status::{PageStatus, VideoStatus};
|
|||||||
paths(
|
paths(
|
||||||
get_video_sources, get_video_sources_details, update_video_source, get_videos, get_video, reset_video, reset_all_videos, update_video_status,
|
get_video_sources, get_video_sources_details, update_video_source, get_videos, get_video, reset_video, reset_all_videos, update_video_status,
|
||||||
get_created_favorites, get_followed_collections, get_followed_uppers,
|
get_created_favorites, get_followed_collections, get_followed_uppers,
|
||||||
upsert_favorite, upsert_collection, upsert_submission
|
insert_favorite, insert_collection, insert_submission
|
||||||
),
|
),
|
||||||
modifiers(&OpenAPIAuth),
|
modifiers(&OpenAPIAuth),
|
||||||
security(
|
security(
|
||||||
@@ -57,9 +57,9 @@ pub fn api_router() -> Router {
|
|||||||
.route("/api/video-sources", get(get_video_sources))
|
.route("/api/video-sources", get(get_video_sources))
|
||||||
.route("/api/video-sources/details", get(get_video_sources_details))
|
.route("/api/video-sources/details", get(get_video_sources_details))
|
||||||
.route("/api/video-sources/{type}/{id}", put(update_video_source))
|
.route("/api/video-sources/{type}/{id}", put(update_video_source))
|
||||||
.route("/api/video-sources/collections", post(upsert_collection))
|
.route("/api/video-sources/collections", post(insert_collection))
|
||||||
.route("/api/video-sources/favorites", post(upsert_favorite))
|
.route("/api/video-sources/favorites", post(insert_favorite))
|
||||||
.route("/api/video-sources/submissions", post(upsert_submission))
|
.route("/api/video-sources/submissions", post(insert_submission))
|
||||||
.route("/api/videos", get(get_videos))
|
.route("/api/videos", get(get_videos))
|
||||||
.route("/api/videos/{id}", get(get_video))
|
.route("/api/videos/{id}", get(get_video))
|
||||||
.route("/api/videos/{id}/reset", post(reset_video))
|
.route("/api/videos/{id}/reset", post(reset_video))
|
||||||
@@ -314,19 +314,20 @@ pub async fn reset_all_videos(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let resetted = !(resetted_videos_info.is_empty() && resetted_pages_info.is_empty());
|
let has_video_updates = !resetted_videos_info.is_empty();
|
||||||
if resetted {
|
let has_page_updates = !resetted_pages_info.is_empty();
|
||||||
|
if has_video_updates || has_page_updates {
|
||||||
let txn = db.begin().await?;
|
let txn = db.begin().await?;
|
||||||
if !resetted_videos_info.is_empty() {
|
if has_video_updates {
|
||||||
update_video_download_status(&txn, &resetted_videos_info, Some(500)).await?;
|
update_video_download_status(&txn, &resetted_videos_info, Some(500)).await?;
|
||||||
}
|
}
|
||||||
if !resetted_pages_info.is_empty() {
|
if has_page_updates {
|
||||||
update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?;
|
update_page_download_status(&txn, &resetted_pages_info, Some(500)).await?;
|
||||||
}
|
}
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
}
|
}
|
||||||
Ok(ApiResponse::ok(ResetAllVideosResponse {
|
Ok(ApiResponse::ok(ResetAllVideosResponse {
|
||||||
resetted,
|
resetted: has_video_updates || has_page_updates,
|
||||||
resetted_videos_count: resetted_videos_info.len(),
|
resetted_videos_count: resetted_videos_info.len(),
|
||||||
resetted_pages_count: resetted_pages_info.len(),
|
resetted_pages_count: resetted_pages_info.len(),
|
||||||
}))
|
}))
|
||||||
@@ -549,15 +550,15 @@ pub async fn get_followed_uppers(
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/video-sources/favorites",
|
path = "/api/video-sources/favorites",
|
||||||
request_body = UpsertFavoriteRequest,
|
request_body = InsertFavoriteRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = ApiResponse<bool>),
|
(status = 200, body = ApiResponse<bool>),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn upsert_favorite(
|
pub async fn insert_favorite(
|
||||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
ValidatedJson(request): ValidatedJson<UpsertFavoriteRequest>,
|
ValidatedJson(request): ValidatedJson<InsertFavoriteRequest>,
|
||||||
) -> Result<ApiResponse<bool>, ApiError> {
|
) -> Result<ApiResponse<bool>, ApiError> {
|
||||||
let favorite = FavoriteList::new(bili_client.as_ref(), request.fid.to_string());
|
let favorite = FavoriteList::new(bili_client.as_ref(), request.fid.to_string());
|
||||||
let favorite_info = favorite.get_info().await?;
|
let favorite_info = favorite.get_info().await?;
|
||||||
@@ -567,11 +568,6 @@ pub async fn upsert_favorite(
|
|||||||
path: Set(request.path),
|
path: Set(request.path),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.on_conflict(
|
|
||||||
OnConflict::column(favorite::Column::FId)
|
|
||||||
.update_columns([favorite::Column::Name, favorite::Column::Path])
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec(db.as_ref())
|
.exec(db.as_ref())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -581,15 +577,15 @@ pub async fn upsert_favorite(
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/video-sources/collections",
|
path = "/api/video-sources/collections",
|
||||||
request_body = UpsertCollectionRequest,
|
request_body = InsertCollectionRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = ApiResponse<bool>),
|
(status = 200, body = ApiResponse<bool>),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn upsert_collection(
|
pub async fn insert_collection(
|
||||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
ValidatedJson(request): ValidatedJson<UpsertCollectionRequest>,
|
ValidatedJson(request): ValidatedJson<InsertCollectionRequest>,
|
||||||
) -> Result<ApiResponse<bool>, ApiError> {
|
) -> Result<ApiResponse<bool>, ApiError> {
|
||||||
let collection = Collection::new(
|
let collection = Collection::new(
|
||||||
bili_client.as_ref(),
|
bili_client.as_ref(),
|
||||||
@@ -609,15 +605,6 @@ pub async fn upsert_collection(
|
|||||||
path: Set(request.path),
|
path: Set(request.path),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.on_conflict(
|
|
||||||
OnConflict::columns([
|
|
||||||
collection::Column::SId,
|
|
||||||
collection::Column::MId,
|
|
||||||
collection::Column::Type,
|
|
||||||
])
|
|
||||||
.update_columns([collection::Column::Name, collection::Column::Path])
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec(db.as_ref())
|
.exec(db.as_ref())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -628,15 +615,15 @@ pub async fn upsert_collection(
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/video-sources/submissions",
|
path = "/api/video-sources/submissions",
|
||||||
request_body = UpsertSubmissionRequest,
|
request_body = InsertSubmissionRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, body = ApiResponse<bool>),
|
(status = 200, body = ApiResponse<bool>),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn upsert_submission(
|
pub async fn insert_submission(
|
||||||
Extension(db): Extension<Arc<DatabaseConnection>>,
|
Extension(db): Extension<Arc<DatabaseConnection>>,
|
||||||
Extension(bili_client): Extension<Arc<BiliClient>>,
|
Extension(bili_client): Extension<Arc<BiliClient>>,
|
||||||
ValidatedJson(request): ValidatedJson<UpsertSubmissionRequest>,
|
ValidatedJson(request): ValidatedJson<InsertSubmissionRequest>,
|
||||||
) -> Result<ApiResponse<bool>, ApiError> {
|
) -> Result<ApiResponse<bool>, ApiError> {
|
||||||
let submission = Submission::new(bili_client.as_ref(), request.upper_id.to_string());
|
let submission = Submission::new(bili_client.as_ref(), request.upper_id.to_string());
|
||||||
let upper = submission.get_info().await?;
|
let upper = submission.get_info().await?;
|
||||||
@@ -647,11 +634,6 @@ pub async fn upsert_submission(
|
|||||||
path: Set(request.path),
|
path: Set(request.path),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.on_conflict(
|
|
||||||
OnConflict::column(submission::Column::UpperId)
|
|
||||||
.update_columns([submission::Column::UpperName, submission::Column::Path])
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec(db.as_ref())
|
.exec(db.as_ref())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ pub struct FollowedUppersRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema, Validate)]
|
#[derive(Deserialize, ToSchema, Validate)]
|
||||||
pub struct UpsertFavoriteRequest {
|
pub struct InsertFavoriteRequest {
|
||||||
pub fid: i64,
|
pub fid: i64,
|
||||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||||
pub path: String,
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema, Validate)]
|
#[derive(Deserialize, ToSchema, Validate)]
|
||||||
pub struct UpsertCollectionRequest {
|
pub struct InsertCollectionRequest {
|
||||||
pub sid: i64,
|
pub sid: i64,
|
||||||
pub mid: i64,
|
pub mid: i64,
|
||||||
#[schema(value_type = i8)]
|
#[schema(value_type = i8)]
|
||||||
@@ -70,7 +70,7 @@ pub struct UpsertCollectionRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema, Validate)]
|
#[derive(Deserialize, ToSchema, Validate)]
|
||||||
pub struct UpsertSubmissionRequest {
|
pub struct InsertSubmissionRequest {
|
||||||
pub upper_id: i64,
|
pub upper_id: i64,
|
||||||
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
#[validate(custom(function = "crate::utils::validation::validate_path"))]
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
|||||||
+8
-10
@@ -7,14 +7,14 @@
|
|||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@internationalized/date": "^3.8.1",
|
"@internationalized/date": "^3.8.1",
|
||||||
"@lucide/svelte": "^0.482.0",
|
"@lucide/svelte": "^0.515.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"bits-ui": "^2.7.0",
|
"bits-ui": "^2.8.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@@ -110,9 +110,9 @@
|
|||||||
|
|
||||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.1", "", { "dependencies": { "@eslint/core": "^0.14.0", "levn": "^0.4.1" } }, "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w=="],
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.1", "", { "dependencies": { "@eslint/core": "^0.14.0", "levn": "^0.4.1" } }, "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w=="],
|
||||||
|
|
||||||
"@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="],
|
"@floating-ui/core": ["@floating-ui/core@1.7.1", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw=="],
|
||||||
|
|
||||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="],
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.1", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/utils": "^0.2.9" } }, "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ=="],
|
||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||||
|
|
||||||
"@lucide/svelte": ["@lucide/svelte@0.482.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-n2ycHU9cNcleRDwwpEHBJ6pYzVhHIaL3a+9dQa8kns9hB2g05bY+v2p2KP8v0pZwtNhYTHk/F2o2uZ1bVtQGhw=="],
|
"@lucide/svelte": ["@lucide/svelte@0.515.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"bits-ui": ["bits-ui@2.8.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-WiTZcCbYLm4Cx6/67NqXVSD0BkfNmdX8Abs84HpIaplX/wRRbg8tkMtJYlLw7mepgGvwGR3enLi6tFkcHU3JXA=="],
|
"bits-ui": ["bits-ui@2.8.10", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.29.1", "svelte-toolbelt": "^0.9.3", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-MOobkqapDZNrpcNmeL2g664xFmH4tZBOKBTxFmsQYMZQuybSZHQnPXy+AjM5XZEXRmCFx5+XRmo6+fC3vHh1hQ=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
||||||
|
|
||||||
@@ -302,8 +302,6 @@
|
|||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
|
||||||
|
|
||||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||||
@@ -526,7 +524,7 @@
|
|||||||
|
|
||||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
|
|
||||||
"runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
|
"runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
|
||||||
|
|
||||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||||
|
|
||||||
@@ -556,7 +554,7 @@
|
|||||||
|
|
||||||
"svelte-sonner": ["svelte-sonner@1.0.4", "", { "dependencies": { "runed": "^0.26.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-ctm9jeV0Rf3im2J6RU1emccrJFjRSdNSPsLlxaF62TLZw9bB1D40U/U7+wqEgohJY/X7FBdghdj0BFQF/IqKPQ=="],
|
"svelte-sonner": ["svelte-sonner@1.0.4", "", { "dependencies": { "runed": "^0.26.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-ctm9jeV0Rf3im2J6RU1emccrJFjRSdNSPsLlxaF62TLZw9bB1D40U/U7+wqEgohJY/X7FBdghdj0BFQF/IqKPQ=="],
|
||||||
|
|
||||||
"svelte-toolbelt": ["svelte-toolbelt@0.9.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.28.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-wBX6MtYw/kpht80j5zLpxJyR9soLizXPIAIWEVd9llAi17SR44ZdG291bldjB7r/K5duC0opDFcuhk2cA1hb8g=="],
|
"svelte-toolbelt": ["svelte-toolbelt@0.9.3", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.29.0", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw=="],
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -17,14 +17,14 @@
|
|||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^1.2.5",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@internationalized/date": "^3.8.1",
|
"@internationalized/date": "^3.8.1",
|
||||||
"@lucide/svelte": "^0.482.0",
|
"@lucide/svelte": "^0.515.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/kit": "^2.16.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"bits-ui": "^2.7.0",
|
"bits-ui": "^2.8.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
|||||||
+9
-9
@@ -12,9 +12,9 @@ import type {
|
|||||||
FavoritesResponse,
|
FavoritesResponse,
|
||||||
CollectionsResponse,
|
CollectionsResponse,
|
||||||
UppersResponse,
|
UppersResponse,
|
||||||
UpsertFavoriteRequest,
|
InsertFavoriteRequest,
|
||||||
UpsertCollectionRequest,
|
InsertCollectionRequest,
|
||||||
UpsertSubmissionRequest,
|
InsertSubmissionRequest,
|
||||||
VideoSourcesDetailsResponse,
|
VideoSourcesDetailsResponse,
|
||||||
UpdateVideoSourceRequest,
|
UpdateVideoSourceRequest,
|
||||||
Config
|
Config
|
||||||
@@ -185,15 +185,15 @@ class ApiClient {
|
|||||||
return this.get<UppersResponse>('/me/uppers', params as Record<string, unknown>);
|
return this.get<UppersResponse>('/me/uppers', params as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertFavorite(request: UpsertFavoriteRequest): Promise<ApiResponse<boolean>> {
|
async insertFavorite(request: InsertFavoriteRequest): Promise<ApiResponse<boolean>> {
|
||||||
return this.post<boolean>('/video-sources/favorites', request);
|
return this.post<boolean>('/video-sources/favorites', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertCollection(request: UpsertCollectionRequest): Promise<ApiResponse<boolean>> {
|
async insertCollection(request: InsertCollectionRequest): Promise<ApiResponse<boolean>> {
|
||||||
return this.post<boolean>('/video-sources/collections', request);
|
return this.post<boolean>('/video-sources/collections', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertSubmission(request: UpsertSubmissionRequest): Promise<ApiResponse<boolean>> {
|
async insertSubmission(request: InsertSubmissionRequest): Promise<ApiResponse<boolean>> {
|
||||||
return this.post<boolean>('/video-sources/submissions', request);
|
return this.post<boolean>('/video-sources/submissions', request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,9 +235,9 @@ const api = {
|
|||||||
apiClient.getFollowedCollections(pageNum, pageSize),
|
apiClient.getFollowedCollections(pageNum, pageSize),
|
||||||
getFollowedUppers: (pageNum?: number, pageSize?: number) =>
|
getFollowedUppers: (pageNum?: number, pageSize?: number) =>
|
||||||
apiClient.getFollowedUppers(pageNum, pageSize),
|
apiClient.getFollowedUppers(pageNum, pageSize),
|
||||||
upsertFavorite: (request: UpsertFavoriteRequest) => apiClient.upsertFavorite(request),
|
insertFavorite: (request: InsertFavoriteRequest) => apiClient.insertFavorite(request),
|
||||||
upsertCollection: (request: UpsertCollectionRequest) => apiClient.upsertCollection(request),
|
insertCollection: (request: InsertCollectionRequest) => apiClient.insertCollection(request),
|
||||||
upsertSubmission: (request: UpsertSubmissionRequest) => apiClient.upsertSubmission(request),
|
insertSubmission: (request: InsertSubmissionRequest) => apiClient.insertSubmission(request),
|
||||||
getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(),
|
getVideoSourcesDetails: () => apiClient.getVideoSourcesDetails(),
|
||||||
updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) =>
|
updateVideoSource: (type: string, id: number, request: UpdateVideoSourceRequest) =>
|
||||||
apiClient.updateVideoSource(type, id, request),
|
apiClient.updateVideoSource(type, id, request),
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
FavoriteWithSubscriptionStatus,
|
FavoriteWithSubscriptionStatus,
|
||||||
CollectionWithSubscriptionStatus,
|
CollectionWithSubscriptionStatus,
|
||||||
UpperWithSubscriptionStatus,
|
UpperWithSubscriptionStatus,
|
||||||
UpsertFavoriteRequest,
|
InsertFavoriteRequest,
|
||||||
UpsertCollectionRequest,
|
InsertCollectionRequest,
|
||||||
UpsertSubmissionRequest,
|
InsertSubmissionRequest,
|
||||||
ApiError
|
ApiError
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
|
||||||
@@ -94,30 +94,30 @@
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 'favorite': {
|
case 'favorite': {
|
||||||
const favorite = item as FavoriteWithSubscriptionStatus;
|
const favorite = item as FavoriteWithSubscriptionStatus;
|
||||||
const request: UpsertFavoriteRequest = {
|
const request: InsertFavoriteRequest = {
|
||||||
fid: favorite.fid,
|
fid: favorite.fid,
|
||||||
path: customPath.trim()
|
path: customPath.trim()
|
||||||
};
|
};
|
||||||
response = await api.upsertFavorite(request);
|
response = await api.insertFavorite(request);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'collection': {
|
case 'collection': {
|
||||||
const collection = item as CollectionWithSubscriptionStatus;
|
const collection = item as CollectionWithSubscriptionStatus;
|
||||||
const request: UpsertCollectionRequest = {
|
const request: InsertCollectionRequest = {
|
||||||
sid: collection.sid,
|
sid: collection.sid,
|
||||||
mid: collection.mid,
|
mid: collection.mid,
|
||||||
path: customPath.trim()
|
path: customPath.trim()
|
||||||
};
|
};
|
||||||
response = await api.upsertCollection(request);
|
response = await api.insertCollection(request);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'upper': {
|
case 'upper': {
|
||||||
const upper = item as UpperWithSubscriptionStatus;
|
const upper = item as UpperWithSubscriptionStatus;
|
||||||
const request: UpsertSubmissionRequest = {
|
const request: InsertSubmissionRequest = {
|
||||||
upper_id: upper.mid,
|
upper_id: upper.mid,
|
||||||
path: customPath.trim()
|
path: customPath.trim()
|
||||||
};
|
};
|
||||||
response = await api.upsertSubmission(request);
|
response = await api.insertSubmission(request);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import * as Dialog from './index.js';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: DialogPrimitive.PortalProps;
|
||||||
|
children: Snippet;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Portal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-content"
|
||||||
|
class={cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
class="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-description"
|
||||||
|
class={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-header"
|
||||||
|
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
class={cn(
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-title"
|
||||||
|
class={cn('text-lg leading-none font-semibold', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
import Title from './dialog-title.svelte';
|
||||||
|
import Footer from './dialog-footer.svelte';
|
||||||
|
import Header from './dialog-header.svelte';
|
||||||
|
import Overlay from './dialog-overlay.svelte';
|
||||||
|
import Content from './dialog-content.svelte';
|
||||||
|
import Description from './dialog-description.svelte';
|
||||||
|
import Trigger from './dialog-trigger.svelte';
|
||||||
|
import Close from './dialog-close.svelte';
|
||||||
|
|
||||||
|
const Root = DialogPrimitive.Root;
|
||||||
|
const Portal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Portal,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Trigger,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Title as DialogTitle,
|
||||||
|
Portal as DialogPortal,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Trigger as DialogTrigger,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Close as DialogClose
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
import Group from './select-group.svelte';
|
||||||
|
import Label from './select-label.svelte';
|
||||||
|
import Item from './select-item.svelte';
|
||||||
|
import Content from './select-content.svelte';
|
||||||
|
import Trigger from './select-trigger.svelte';
|
||||||
|
import Separator from './select-separator.svelte';
|
||||||
|
import ScrollDownButton from './select-scroll-down-button.svelte';
|
||||||
|
import ScrollUpButton from './select-scroll-up-button.svelte';
|
||||||
|
import GroupHeading from './select-group-heading.svelte';
|
||||||
|
|
||||||
|
const Root = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Group,
|
||||||
|
Label,
|
||||||
|
Item,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Separator,
|
||||||
|
ScrollDownButton,
|
||||||
|
ScrollUpButton,
|
||||||
|
GroupHeading,
|
||||||
|
//
|
||||||
|
Root as Select,
|
||||||
|
Group as SelectGroup,
|
||||||
|
Label as SelectLabel,
|
||||||
|
Item as SelectItem,
|
||||||
|
Content as SelectContent,
|
||||||
|
Trigger as SelectTrigger,
|
||||||
|
Separator as SelectSeparator,
|
||||||
|
ScrollDownButton as SelectScrollDownButton,
|
||||||
|
ScrollUpButton as SelectScrollUpButton,
|
||||||
|
GroupHeading as SelectGroupHeading
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
import SelectScrollUpButton from './select-scroll-up-button.svelte';
|
||||||
|
import SelectScrollDownButton from './select-scroll-down-button.svelte';
|
||||||
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||||
|
portalProps?: SelectPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Portal {...portalProps}>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
{sideOffset}
|
||||||
|
data-slot="select-content"
|
||||||
|
class={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
class={cn(
|
||||||
|
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.GroupHeading
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-group-heading"
|
||||||
|
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</SelectPrimitive.GroupHeading>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
{value}
|
||||||
|
data-slot="select-item"
|
||||||
|
class={cn(
|
||||||
|
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ selected, highlighted })}
|
||||||
|
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
{#if selected}
|
||||||
|
<CheckIcon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if childrenProp}
|
||||||
|
{@render childrenProp({ selected, highlighted })}
|
||||||
|
{:else}
|
||||||
|
{label || value}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</SelectPrimitive.Item>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="select-label"
|
||||||
|
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
class={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||||
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
class={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon class="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Separator as SeparatorPrimitive } from 'bits-ui';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SeparatorPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-separator"
|
||||||
|
class={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from 'bits-ui';
|
||||||
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
|
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
size = 'default',
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
class={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ChevronDownIcon class="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
@@ -145,19 +145,19 @@ export interface UppersResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpsertFavoriteRequest {
|
export interface InsertFavoriteRequest {
|
||||||
fid: number;
|
fid: number;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpsertCollectionRequest {
|
export interface InsertCollectionRequest {
|
||||||
sid: number;
|
sid: number;
|
||||||
mid: number;
|
mid: number;
|
||||||
collection_type?: number;
|
collection_type?: number;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpsertSubmissionRequest {
|
export interface InsertSubmissionRequest {
|
||||||
upper_id: number;
|
upper_id: number;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -623,8 +623,8 @@
|
|||||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
bind:value={formData.nfo_time_type}
|
bind:value={formData.nfo_time_type}
|
||||||
>
|
>
|
||||||
<option value="FavTime">收藏时间</option>
|
<option value="favtime">收藏时间</option>
|
||||||
<option value="PubTime">发布时间</option>
|
<option value="pubtime">发布时间</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
import { Switch } from '$lib/components/ui/switch/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
import EditIcon from '@lucide/svelte/icons/edit';
|
import EditIcon from '@lucide/svelte/icons/edit';
|
||||||
import SaveIcon from '@lucide/svelte/icons/save';
|
import SaveIcon from '@lucide/svelte/icons/save';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
@@ -11,6 +14,7 @@
|
|||||||
import HeartIcon from '@lucide/svelte/icons/heart';
|
import HeartIcon from '@lucide/svelte/icons/heart';
|
||||||
import UserIcon from '@lucide/svelte/icons/user';
|
import UserIcon from '@lucide/svelte/icons/user';
|
||||||
import ClockIcon from '@lucide/svelte/icons/clock';
|
import ClockIcon from '@lucide/svelte/icons/clock';
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
import { setBreadcrumb } from '$lib/stores/breadcrumb';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -22,6 +26,16 @@
|
|||||||
let loading = false;
|
let loading = false;
|
||||||
let activeTab = 'favorites';
|
let activeTab = 'favorites';
|
||||||
|
|
||||||
|
// 添加对话框状态
|
||||||
|
let showAddDialog = false;
|
||||||
|
let addDialogType: 'favorites' | 'collections' | 'submissions' = 'favorites';
|
||||||
|
let adding = false;
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
let favoriteForm = { fid: '', path: '' };
|
||||||
|
let collectionForm = { sid: '', mid: '', collection_type: '2', path: '' }; // 默认为合集
|
||||||
|
let submissionForm = { upper_id: '', path: '' };
|
||||||
|
|
||||||
type ExtendedVideoSource = VideoSourceDetail & {
|
type ExtendedVideoSource = VideoSourceDetail & {
|
||||||
type: string;
|
type: string;
|
||||||
originalIndex: number;
|
originalIndex: number;
|
||||||
@@ -31,10 +45,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TAB_CONFIG = {
|
const TAB_CONFIG = {
|
||||||
favorites: { label: '收藏夹', icon: HeartIcon, color: 'bg-red-500' },
|
favorites: { label: '收藏夹', icon: HeartIcon },
|
||||||
collections: { label: '合集 / 列表', icon: FolderIcon, color: 'bg-blue-500' },
|
collections: { label: '合集 / 列表', icon: FolderIcon },
|
||||||
submissions: { label: '用户投稿', icon: UserIcon, color: 'bg-green-500' },
|
submissions: { label: '用户投稿', icon: UserIcon },
|
||||||
watch_later: { label: '稍后再看', icon: ClockIcon, color: 'bg-yellow-500' }
|
watch_later: { label: '稍后再看', icon: ClockIcon }
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 数据加载
|
// 数据加载
|
||||||
@@ -123,6 +137,67 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开添加对话框
|
||||||
|
function openAddDialog(type: 'favorites' | 'collections' | 'submissions') {
|
||||||
|
addDialogType = type;
|
||||||
|
// 重置表单
|
||||||
|
favoriteForm = { fid: '', path: '' };
|
||||||
|
collectionForm = { sid: '', mid: '', collection_type: '2', path: '' };
|
||||||
|
submissionForm = { upper_id: '', path: '' };
|
||||||
|
showAddDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理添加
|
||||||
|
async function handleAdd() {
|
||||||
|
adding = true;
|
||||||
|
try {
|
||||||
|
switch (addDialogType) {
|
||||||
|
case 'favorites':
|
||||||
|
if (!favoriteForm.fid || !favoriteForm.path.trim()) {
|
||||||
|
toast.error('请填写完整的收藏夹信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.insertFavorite({
|
||||||
|
fid: parseInt(favoriteForm.fid),
|
||||||
|
path: favoriteForm.path
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'collections':
|
||||||
|
if (!collectionForm.sid || !collectionForm.mid || !collectionForm.path.trim()) {
|
||||||
|
toast.error('请填写完整的合集信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.insertCollection({
|
||||||
|
sid: parseInt(collectionForm.sid),
|
||||||
|
mid: parseInt(collectionForm.mid),
|
||||||
|
collection_type: parseInt(collectionForm.collection_type),
|
||||||
|
path: collectionForm.path
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'submissions':
|
||||||
|
if (!submissionForm.upper_id || !submissionForm.path.trim()) {
|
||||||
|
toast.error('请填写完整的用户投稿信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.insertSubmission({
|
||||||
|
upper_id: parseInt(submissionForm.upper_id),
|
||||||
|
path: submissionForm.path
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('添加成功');
|
||||||
|
showAddDialog = false;
|
||||||
|
loadVideoSources(); // 重新加载数据
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('添加失败', {
|
||||||
|
description: (error as ApiError).message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
adding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setBreadcrumb([
|
setBreadcrumb([
|
||||||
@@ -142,36 +217,33 @@
|
|||||||
<title>视频源管理 - Bili Sync</title>
|
<title>视频源管理 - Bili Sync</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="max-w-6xl">
|
<div class="space-y-6">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex items-center justify-center py-12">
|
<div class="flex items-center justify-center py-12">
|
||||||
<div class="text-muted-foreground">加载中...</div>
|
<div class="text-muted-foreground">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if videoSourcesData}
|
{:else if videoSourcesData}
|
||||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||||
<Tabs.List class="grid h-12 w-full grid-cols-4 bg-transparent p-0">
|
<Tabs.List class="grid w-full grid-cols-4">
|
||||||
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
|
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
|
||||||
{@const sources = getSourcesForTab(key)}
|
{@const sources = getSourcesForTab(key)}
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger value={key} class="relative">
|
||||||
value={key}
|
{config.label}({sources.length})
|
||||||
class="data-[state=active]:bg-muted/50 data-[state=active]:text-foreground text-muted-foreground hover:bg-muted/30 hover:text-foreground mx-1 flex min-w-0 items-center justify-center gap-2 rounded-lg bg-transparent px-2 py-3 text-sm font-medium transition-all sm:px-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex h-5 w-5 items-center justify-center rounded-full {config.color} flex-shrink-0"
|
|
||||||
>
|
|
||||||
<svelte:component this={config.icon} class="h-3 w-3 text-white" />
|
|
||||||
</div>
|
|
||||||
<span class="hidden truncate sm:inline">{config.label}</span>
|
|
||||||
<span
|
|
||||||
class="bg-background/50 flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-medium"
|
|
||||||
>{sources.length}</span
|
|
||||||
>
|
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
{/each}
|
{/each}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
|
{#each Object.entries(TAB_CONFIG) as [key, config] (key)}
|
||||||
{@const sources = getSourcesForTab(key)}
|
{@const sources = getSourcesForTab(key)}
|
||||||
<Tabs.Content value={key} class="mt-6">
|
<Tabs.Content value={key} class="mt-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium">{config.label}管理</h3>
|
||||||
|
{#if key === 'favorites' || key === 'collections' || key === 'submissions'}
|
||||||
|
<Button size="sm" onclick={() => openAddDialog(key)} class="flex items-center gap-2">
|
||||||
|
<PlusIcon class="h-4 w-4" />
|
||||||
|
手动添加
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if sources.length > 0}
|
{#if sources.length > 0}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
@@ -259,15 +331,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
<div class="flex flex-col items-center justify-center py-12">
|
||||||
<div
|
<svelte:component this={config.icon} class="text-muted-foreground mb-4 h-12 w-12" />
|
||||||
class="flex h-12 w-12 items-center justify-center rounded-full {config.color} mb-4"
|
<div class="text-muted-foreground mb-2 text-lg font-medium">暂无{config.label}</div>
|
||||||
>
|
<p class="text-muted-foreground mb-4 text-center text-sm">
|
||||||
<svelte:component this={config.icon} class="h-6 w-6 text-white" />
|
{#if key === 'favorites'}
|
||||||
</div>
|
还没有添加任何收藏夹订阅
|
||||||
<div class="text-muted-foreground mb-2">暂无{config.label}</div>
|
{:else if key === 'collections'}
|
||||||
<p class="text-muted-foreground text-sm">
|
还没有添加任何合集或列表订阅
|
||||||
请先添加{config.label}订阅
|
{:else if key === 'submissions'}
|
||||||
|
还没有添加任何用户投稿订阅
|
||||||
|
{:else}
|
||||||
|
还没有添加稍后再看订阅
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
{#if key === 'favorites' || key === 'collections' || key === 'submissions'}
|
||||||
|
<Button onclick={() => openAddDialog(key)} class="flex items-center gap-2">
|
||||||
|
<PlusIcon class="h-4 w-4" />
|
||||||
|
手动添加
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
@@ -280,4 +362,134 @@
|
|||||||
<Button class="mt-4" onclick={loadVideoSources}>重新加载</Button>
|
<Button class="mt-4" onclick={loadVideoSources}>重新加载</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={showAddDialog}>
|
||||||
|
<Dialog.Overlay class="data-[state=open]:animate-overlay-show fixed inset-0 bg-black/30" />
|
||||||
|
<Dialog.Content
|
||||||
|
class="data-[state=open]:animate-content-show bg-background fixed top-1/2 left-1/2 z-50 max-h-[85vh] w-full max-w-3xl -translate-x-1/2 -translate-y-1/2 rounded-lg border p-6 shadow-md outline-none"
|
||||||
|
>
|
||||||
|
<Dialog.Title class="text-lg font-semibold">
|
||||||
|
{#if addDialogType === 'favorites'}
|
||||||
|
添加收藏夹
|
||||||
|
{:else if addDialogType === 'collections'}
|
||||||
|
添加合集
|
||||||
|
{:else}
|
||||||
|
添加用户投稿
|
||||||
|
{/if}
|
||||||
|
</Dialog.Title>
|
||||||
|
<div class="mt-4">
|
||||||
|
{#if addDialogType === 'favorites'}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label for="fid" class="text-sm font-medium">收藏夹ID (fid)</Label>
|
||||||
|
<Input
|
||||||
|
id="fid"
|
||||||
|
type="number"
|
||||||
|
bind:value={favoriteForm.fid}
|
||||||
|
placeholder="请输入收藏夹ID"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if addDialogType === 'collections'}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label for="collection-type" class="text-sm font-medium">合集类型</Label>
|
||||||
|
<select
|
||||||
|
id="collection-type"
|
||||||
|
bind:value={collectionForm.collection_type}
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring mt-1 flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="1">列表 (Series)</option>
|
||||||
|
<option value="2">合集 (Season)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label for="sid" class="text-sm font-medium">
|
||||||
|
{collectionForm.collection_type === '1'
|
||||||
|
? '列表ID (series_id)'
|
||||||
|
: '合集ID (season_id)'}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="sid"
|
||||||
|
type="number"
|
||||||
|
bind:value={collectionForm.sid}
|
||||||
|
placeholder={collectionForm.collection_type === '1'
|
||||||
|
? '请输入列表ID'
|
||||||
|
: '请输入合集ID'}
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label for="mid" class="text-sm font-medium">用户ID (mid)</Label>
|
||||||
|
<Input
|
||||||
|
id="mid"
|
||||||
|
type="number"
|
||||||
|
bind:value={collectionForm.mid}
|
||||||
|
placeholder="请输入用户ID"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">可从合集/列表页面URL中获取相应ID</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label for="upper_id" class="text-sm font-medium">UP主ID (mid)</Label>
|
||||||
|
<Input
|
||||||
|
id="upper_id"
|
||||||
|
type="number"
|
||||||
|
bind:value={submissionForm.upper_id}
|
||||||
|
placeholder="请输入UP主ID"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-4">
|
||||||
|
<Label for="path" class="text-sm font-medium">下载路径</Label>
|
||||||
|
{#if addDialogType === 'favorites'}
|
||||||
|
<Input
|
||||||
|
id="path"
|
||||||
|
type="text"
|
||||||
|
bind:value={favoriteForm.path}
|
||||||
|
placeholder="请输入下载路径,例如:/path/to/download"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
{:else if addDialogType === 'collections'}
|
||||||
|
<Input
|
||||||
|
id="path"
|
||||||
|
type="text"
|
||||||
|
bind:value={collectionForm.path}
|
||||||
|
placeholder="请输入下载路径,例如:/path/to/download"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Input
|
||||||
|
id="path"
|
||||||
|
type="text"
|
||||||
|
bind:value={submissionForm.path}
|
||||||
|
placeholder="请输入下载路径,例如:/path/to/download"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => (showAddDialog = false)}
|
||||||
|
disabled={adding}
|
||||||
|
class="px-4"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onclick={handleAdd} disabled={adding} class="px-4">
|
||||||
|
{adding ? '添加中...' : '添加'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user