refactor: Phase E — types de réponses API standardisés + SVGs inline → Icon
E1 - API responses: - Crée responses.rs avec OkResponse, DeletedResponse, UpdatedResponse, RevokedResponse, UnlinkedResponse, StatusResponse (6 tests de sérialisation) - Remplace ~15 json!() inline par des types structurés dans books, libraries, tokens, users, handlers, anilist, metadata, download_detection, torrent_import - Signatures de retour des handlers typées (plus de serde_json::Value) E2 - SVGs → Icon component: - Ajoute icon "lock" au composant Icon - Remplace ~30 SVGs inline par <Icon> dans 9 composants (FolderPicker, FolderBrowser, LiveSearchForm, JobRow, LibraryActions, ReadingStatusModal, EditBookForm, EditSeriesForm, UserSwitcher) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -433,7 +433,7 @@ pub async fn link_series(
|
|||||||
pub async fn unlink_series(
|
pub async fn unlink_series(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((library_id, series_name)): Path<(Uuid, String)>,
|
Path((library_id, series_name)): Path<(Uuid, String)>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::UnlinkedResponse>, ApiError> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"DELETE FROM anilist_series_links WHERE library_id = $1 AND series_name = $2",
|
"DELETE FROM anilist_series_links WHERE library_id = $1 AND series_name = $2",
|
||||||
)
|
)
|
||||||
@@ -446,7 +446,7 @@ pub async fn unlink_series(
|
|||||||
return Err(ApiError::not_found("AniList link not found"));
|
return Err(ApiError::not_found("AniList link not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"unlinked": true})))
|
Ok(Json(crate::responses::UnlinkedResponse::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle AniList sync for a library
|
/// Toggle AniList sync for a library
|
||||||
|
|||||||
@@ -669,7 +669,7 @@ pub async fn get_thumbnail(
|
|||||||
pub async fn delete_book(
|
pub async fn delete_book(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
|
||||||
// Fetch the book and its file path
|
// Fetch the book and its file path
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT b.library_id, b.thumbnail_path, bf.abs_path \
|
"SELECT b.library_id, b.thumbnail_path, bf.abs_path \
|
||||||
@@ -727,5 +727,5 @@ pub async fn delete_book(
|
|||||||
id, scan_job_id, library_id
|
id, scan_job_id, library_id
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(crate::responses::OkResponse::new()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ pub async fn delete_available_download(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
axum::extract::Query(query): axum::extract::Query<DeleteAvailableQuery>,
|
axum::extract::Query(query): axum::extract::Query<DeleteAvailableQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
|
||||||
if let Some(release_idx) = query.release {
|
if let Some(release_idx) = query.release {
|
||||||
// Remove a single release from the JSON array
|
// Remove a single release from the JSON array
|
||||||
let row = sqlx::query("SELECT available_releases FROM available_downloads WHERE id = $1")
|
let row = sqlx::query("SELECT available_releases FROM available_downloads WHERE id = $1")
|
||||||
@@ -486,7 +486,7 @@ pub async fn delete_available_download(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(crate::responses::OkResponse::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ pub async fn docs_redirect() -> impl axum::response::IntoResponse {
|
|||||||
axum::response::Redirect::to("/swagger-ui/")
|
axum::response::Redirect::to("/swagger-ui/")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ready(State(state): State<AppState>) -> Result<Json<serde_json::Value>, ApiError> {
|
pub async fn ready(State(state): State<AppState>) -> Result<Json<crate::responses::StatusResponse>, ApiError> {
|
||||||
sqlx::query("SELECT 1").execute(&state.pool).await?;
|
sqlx::query("SELECT 1").execute(&state.pool).await?;
|
||||||
Ok(Json(serde_json::json!({"status": "ready"})))
|
Ok(Json(crate::responses::StatusResponse::new("ready")))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn metrics(State(state): State<AppState>) -> String {
|
pub async fn metrics(State(state): State<AppState>) -> String {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ pub async fn create_library(
|
|||||||
pub async fn delete_library(
|
pub async fn delete_library(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(id): AxumPath<Uuid>,
|
AxumPath(id): AxumPath<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
|
||||||
let result = sqlx::query("DELETE FROM libraries WHERE id = $1")
|
let result = sqlx::query("DELETE FROM libraries WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
@@ -198,7 +198,7 @@ pub async fn delete_library(
|
|||||||
return Err(ApiError::not_found("library not found"));
|
return Err(ApiError::not_found("library not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
Ok(Json(crate::responses::DeletedResponse::new(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonicalize_library_root(root_path: &str) -> Result<PathBuf, ApiError> {
|
fn canonicalize_library_root(root_path: &str) -> Result<PathBuf, ApiError> {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ mod pages;
|
|||||||
mod prowlarr;
|
mod prowlarr;
|
||||||
mod qbittorrent;
|
mod qbittorrent;
|
||||||
mod reading_progress;
|
mod reading_progress;
|
||||||
|
mod responses;
|
||||||
mod torrent_import;
|
mod torrent_import;
|
||||||
mod reading_status_match;
|
mod reading_status_match;
|
||||||
mod reading_status_push;
|
mod reading_status_push;
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ pub async fn approve_metadata(
|
|||||||
pub async fn reject_metadata(
|
pub async fn reject_metadata(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(id): AxumPath<Uuid>,
|
AxumPath(id): AxumPath<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::StatusResponse>, ApiError> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE external_metadata_links SET status = 'rejected', updated_at = NOW() WHERE id = $1",
|
"UPDATE external_metadata_links SET status = 'rejected', updated_at = NOW() WHERE id = $1",
|
||||||
)
|
)
|
||||||
@@ -425,7 +425,7 @@ pub async fn reject_metadata(
|
|||||||
return Err(ApiError::not_found("link not found"));
|
return Err(ApiError::not_found("link not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"status": "rejected"})))
|
Ok(Json(crate::responses::StatusResponse::new("rejected")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -571,7 +571,7 @@ pub async fn get_missing_books(
|
|||||||
pub async fn delete_metadata_link(
|
pub async fn delete_metadata_link(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(id): AxumPath<Uuid>,
|
AxumPath(id): AxumPath<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
|
||||||
let result = sqlx::query("DELETE FROM external_metadata_links WHERE id = $1")
|
let result = sqlx::query("DELETE FROM external_metadata_links WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
@@ -581,7 +581,7 @@ pub async fn delete_metadata_link(
|
|||||||
return Err(ApiError::not_found("link not found"));
|
return Err(ApiError::not_found("link not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"deleted": true, "id": id.to_string()})))
|
Ok(Json(crate::responses::DeletedResponse::new(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
133
apps/api/src/responses.rs
Normal file
133
apps/api/src/responses.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Simple acknowledgment response.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct OkResponse {
|
||||||
|
pub ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OkResponse {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { ok: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for resource deletion operations.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct DeletedResponse {
|
||||||
|
pub deleted: bool,
|
||||||
|
pub id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeletedResponse {
|
||||||
|
pub fn new(id: Uuid) -> Self {
|
||||||
|
Self { deleted: true, id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for resource update operations.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct UpdatedResponse {
|
||||||
|
pub updated: bool,
|
||||||
|
pub id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdatedResponse {
|
||||||
|
pub fn new(id: Uuid) -> Self {
|
||||||
|
Self { updated: true, id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for token revocation.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct RevokedResponse {
|
||||||
|
pub revoked: bool,
|
||||||
|
pub id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RevokedResponse {
|
||||||
|
pub fn new(id: Uuid) -> Self {
|
||||||
|
Self { revoked: true, id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response for unlinking operations (e.g., AniList).
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct UnlinkedResponse {
|
||||||
|
pub unlinked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnlinkedResponse {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { unlinked: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple status response.
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct StatusResponse {
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatusResponse {
|
||||||
|
pub fn new(status: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: status.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ok_response_serializes() {
|
||||||
|
let r = OkResponse::new();
|
||||||
|
let json = serde_json::to_value(&r).unwrap();
|
||||||
|
assert_eq!(json, serde_json::json!({"ok": true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleted_response_serializes() {
|
||||||
|
let id = Uuid::nil();
|
||||||
|
let r = DeletedResponse::new(id);
|
||||||
|
let json = serde_json::to_value(&r).unwrap();
|
||||||
|
assert_eq!(json["deleted"], true);
|
||||||
|
assert_eq!(json["id"], id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn updated_response_serializes() {
|
||||||
|
let id = Uuid::nil();
|
||||||
|
let r = UpdatedResponse::new(id);
|
||||||
|
let json = serde_json::to_value(&r).unwrap();
|
||||||
|
assert_eq!(json["updated"], true);
|
||||||
|
assert_eq!(json["id"], id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_response_serializes() {
|
||||||
|
let id = Uuid::nil();
|
||||||
|
let r = RevokedResponse::new(id);
|
||||||
|
let json = serde_json::to_value(&r).unwrap();
|
||||||
|
assert_eq!(json["revoked"], true);
|
||||||
|
assert_eq!(json["id"], id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlinked_response_serializes() {
|
||||||
|
let r = UnlinkedResponse::new();
|
||||||
|
let json = serde_json::to_value(&r).unwrap();
|
||||||
|
assert_eq!(json, serde_json::json!({"unlinked": true}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_response_serializes() {
|
||||||
|
let r = StatusResponse::new("ready");
|
||||||
|
let json = serde_json::to_value(&r).unwrap();
|
||||||
|
assert_eq!(json, serde_json::json!({"status": "ready"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ pub async fn list_tokens(State(state): State<AppState>) -> Result<Json<Vec<Token
|
|||||||
pub async fn revoke_token(
|
pub async fn revoke_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::RevokedResponse>, ApiError> {
|
||||||
let result = sqlx::query("UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL")
|
let result = sqlx::query("UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
@@ -186,7 +186,7 @@ pub async fn revoke_token(
|
|||||||
return Err(ApiError::not_found("token not found"));
|
return Err(ApiError::not_found("token not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
Ok(Json(crate::responses::RevokedResponse::new(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
@@ -216,7 +216,7 @@ pub async fn update_token(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(input): Json<UpdateTokenRequest>,
|
Json(input): Json<UpdateTokenRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::UpdatedResponse>, ApiError> {
|
||||||
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
|
let result = sqlx::query("UPDATE api_tokens SET user_id = $1 WHERE id = $2")
|
||||||
.bind(input.user_id)
|
.bind(input.user_id)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -227,7 +227,7 @@ pub async fn update_token(
|
|||||||
return Err(ApiError::not_found("token not found"));
|
return Err(ApiError::not_found("token not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
Ok(Json(crate::responses::UpdatedResponse::new(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Permanently delete a revoked API token
|
/// Permanently delete a revoked API token
|
||||||
@@ -249,7 +249,7 @@ pub async fn update_token(
|
|||||||
pub async fn delete_token(
|
pub async fn delete_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
|
||||||
let result = sqlx::query("DELETE FROM api_tokens WHERE id = $1 AND revoked_at IS NOT NULL")
|
let result = sqlx::query("DELETE FROM api_tokens WHERE id = $1 AND revoked_at IS NOT NULL")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
@@ -259,5 +259,5 @@ pub async fn delete_token(
|
|||||||
return Err(ApiError::not_found("token not found or not revoked"));
|
return Err(ApiError::not_found("token not found or not revoked"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
Ok(Json(crate::responses::DeletedResponse::new(id)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,14 +55,14 @@ struct ImportedFile {
|
|||||||
pub async fn notify_torrent_done(
|
pub async fn notify_torrent_done(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(body): Json<TorrentNotifyRequest>,
|
Json(body): Json<TorrentNotifyRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
|
||||||
if body.hash.is_empty() {
|
if body.hash.is_empty() {
|
||||||
return Err(ApiError::bad_request("hash is required"));
|
return Err(ApiError::bad_request("hash is required"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !is_torrent_import_enabled(&state.pool).await {
|
if !is_torrent_import_enabled(&state.pool).await {
|
||||||
info!("Torrent import disabled, ignoring notification for hash {}", body.hash);
|
info!("Torrent import disabled, ignoring notification for hash {}", body.hash);
|
||||||
return Ok(Json(serde_json::json!({ "ok": true })));
|
return Ok(Json(crate::responses::OkResponse::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
@@ -74,7 +74,7 @@ pub async fn notify_torrent_done(
|
|||||||
|
|
||||||
let Some(row) = row else {
|
let Some(row) = row else {
|
||||||
info!("Torrent notification for unknown hash {}, ignoring", body.hash);
|
info!("Torrent notification for unknown hash {}, ignoring", body.hash);
|
||||||
return Ok(Json(serde_json::json!({ "ok": true })));
|
return Ok(Json(crate::responses::OkResponse::new()));
|
||||||
};
|
};
|
||||||
|
|
||||||
let torrent_id: Uuid = row.get("id");
|
let torrent_id: Uuid = row.get("id");
|
||||||
@@ -96,7 +96,7 @@ pub async fn notify_torrent_done(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(crate::responses::OkResponse::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List recent torrent downloads (admin).
|
/// List recent torrent downloads (admin).
|
||||||
@@ -145,7 +145,7 @@ pub async fn list_torrent_downloads(
|
|||||||
pub async fn delete_torrent_download(
|
pub async fn delete_torrent_download(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::OkResponse>, ApiError> {
|
||||||
let row = sqlx::query("SELECT qb_hash, status FROM torrent_downloads WHERE id = $1")
|
let row = sqlx::query("SELECT qb_hash, status FROM torrent_downloads WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
@@ -187,7 +187,7 @@ pub async fn delete_torrent_download(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
info!("Deleted torrent download {id}");
|
info!("Deleted torrent download {id}");
|
||||||
Ok(Json(serde_json::json!({ "ok": true })))
|
Ok(Json(crate::responses::OkResponse::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Background poller ────────────────────────────────────────────────────────
|
// ─── Background poller ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ pub async fn update_user(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(input): Json<CreateUserRequest>,
|
Json(input): Json<CreateUserRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::UpdatedResponse>, ApiError> {
|
||||||
if input.username.trim().is_empty() {
|
if input.username.trim().is_empty() {
|
||||||
return Err(ApiError::bad_request("username is required"));
|
return Err(ApiError::bad_request("username is required"));
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ pub async fn update_user(
|
|||||||
return Err(ApiError::not_found("user not found"));
|
return Err(ApiError::not_found("user not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"updated": true, "id": id})))
|
Ok(Json(crate::responses::UpdatedResponse::new(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a reader user (cascades on tokens and reading progress)
|
/// Delete a reader user (cascades on tokens and reading progress)
|
||||||
@@ -181,7 +181,7 @@ pub async fn update_user(
|
|||||||
pub async fn delete_user(
|
pub async fn delete_user(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<crate::responses::DeletedResponse>, ApiError> {
|
||||||
let result = sqlx::query("DELETE FROM users WHERE id = $1")
|
let result = sqlx::query("DELETE FROM users WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
@@ -191,5 +191,5 @@ pub async fn delete_user(
|
|||||||
return Err(ApiError::not_found("user not found"));
|
return Err(ApiError::not_found("user not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"deleted": true, "id": id})))
|
Ok(Json(crate::responses::DeletedResponse::new(id)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Modal } from "./ui/Modal";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { BookDto } from "@/lib/api";
|
import { BookDto } from "@/lib/api";
|
||||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||||
|
import { Icon } from "./ui";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
function LockButton({
|
function LockButton({
|
||||||
@@ -30,9 +31,7 @@ function LockButton({
|
|||||||
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
||||||
>
|
>
|
||||||
{locked ? (
|
{locked ? (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="lock" size="sm" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||||
@@ -306,9 +305,7 @@ export function EditBookForm({ book }: EditBookFormProps) {
|
|||||||
{/* Lock legend */}
|
{/* Lock legend */}
|
||||||
{Object.values(lockedFields).some(Boolean) && (
|
{Object.values(lockedFields).some(Boolean) && (
|
||||||
<p className="text-xs text-amber-500 flex items-center gap-1.5">
|
<p className="text-xs text-amber-500 flex items-center gap-1.5">
|
||||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="lock" size="sm" className="!w-3.5 !h-3.5 shrink-0" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
{t("editBook.lockedFieldsNote")}
|
{t("editBook.lockedFieldsNote")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useTransition, useEffect, useCallback } from "react";
|
|||||||
import { Modal } from "./ui/Modal";
|
import { Modal } from "./ui/Modal";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
import { FormField, FormLabel, FormInput } from "./ui/Form";
|
||||||
|
import { Icon } from "./ui";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
function LockButton({
|
function LockButton({
|
||||||
@@ -29,9 +30,7 @@ function LockButton({
|
|||||||
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
title={locked ? t("editBook.lockedField") : t("editBook.clickToLock")}
|
||||||
>
|
>
|
||||||
{locked ? (
|
{locked ? (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="lock" size="sm" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||||
@@ -444,9 +443,7 @@ export function EditSeriesForm({
|
|||||||
{/* Lock legend */}
|
{/* Lock legend */}
|
||||||
{Object.values(lockedFields).some(Boolean) && (
|
{Object.values(lockedFields).some(Boolean) && (
|
||||||
<p className="text-xs text-amber-500 flex items-center gap-1.5">
|
<p className="text-xs text-amber-500 flex items-center gap-1.5">
|
||||||
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="lock" size="sm" className="!w-3.5 !h-3.5 shrink-0" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
{t("editBook.lockedFieldsNote")}
|
{t("editBook.lockedFieldsNote")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { FolderItem } from "../../lib/api";
|
import { FolderItem } from "../../lib/api";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
|
||||||
interface TreeNode extends FolderItem {
|
interface TreeNode extends FolderItem {
|
||||||
children?: TreeNode[];
|
children?: TreeNode[];
|
||||||
@@ -116,14 +117,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
|
|||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg
|
<Icon name="chevronRight" size="sm" className={`!w-3 !h-3 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} />
|
||||||
className={`w-3 h-3 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -153,9 +147,7 @@ export function FolderBrowser({ initialFolders, selectedPath, onSelect }: Folder
|
|||||||
|
|
||||||
{/* Selected indicator */}
|
{/* Selected indicator */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="check" size="sm" className="text-primary flex-shrink-0" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { FolderBrowser } from "./FolderBrowser";
|
import { FolderBrowser } from "./FolderBrowser";
|
||||||
import { FolderItem } from "../../lib/api";
|
import { FolderItem } from "../../lib/api";
|
||||||
import { Button } from "./ui";
|
import { Button, Icon } from "./ui";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
interface FolderPickerProps {
|
interface FolderPickerProps {
|
||||||
@@ -45,9 +45,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
onClick={() => onSelect("")}
|
onClick={() => onSelect("")}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-destructive transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="x" size="sm" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -57,9 +55,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="folder" size="sm" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
{t("common.browse")}
|
{t("common.browse")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,9 +75,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-muted/30">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-muted/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="folder" size="md" className="text-primary" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">{t("folder.selectFolderTitle")}</span>
|
<span className="font-medium">{t("folder.selectFolderTitle")}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -89,9 +83,7 @@ export function FolderPicker({ initialFolders, selectedPath, onSelect }: FolderP
|
|||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
|
className="text-muted-foreground hover:text-foreground transition-colors p-1 hover:bg-accent rounded"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="x" size="md" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -279,9 +279,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, form
|
|||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => onCancel(job.id)}
|
onClick={() => onCancel(job.id)}
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="x" size="sm" className="!w-3.5 !h-3.5 mr-1.5" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Button } from "../components/ui";
|
import { Button, Icon } from "../components/ui";
|
||||||
import { ProviderIcon } from "../components/ProviderIcon";
|
import { ProviderIcon } from "../components/ProviderIcon";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
|
||||||
@@ -134,9 +134,7 @@ export function LibraryActions({
|
|||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"
|
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="x" size="md" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -147,9 +145,7 @@ export function LibraryActions({
|
|||||||
{/* Section: Indexation */}
|
{/* Section: Indexation */}
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||||
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="folder" size="sm" className="text-primary" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
{t("libraryActions.sectionIndexation")}
|
{t("libraryActions.sectionIndexation")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -322,9 +318,7 @@ export function LibraryActions({
|
|||||||
{/* Section: Prowlarr */}
|
{/* Section: Prowlarr */}
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground uppercase tracking-wide">
|
||||||
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="download" size="sm" className="text-primary" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
||||||
</svg>
|
|
||||||
{t("libraryActions.sectionProwlarr")}
|
{t("libraryActions.sectionProwlarr")}
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRef, useCallback, useEffect } from "react";
|
import { useRef, useCallback, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
|
||||||
// SVG path data for filter icons, keyed by field name
|
// SVG path data for filter icons, keyed by field name
|
||||||
const FILTER_ICONS: Record<string, string> = {
|
const FILTER_ICONS: Record<string, string> = {
|
||||||
@@ -137,14 +138,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
{/* Search input with icon */}
|
{/* Search input with icon */}
|
||||||
{textFields.map((field) => (
|
{textFields.map((field) => (
|
||||||
<div key={field.name} className="relative">
|
<div key={field.name} className="relative">
|
||||||
<svg
|
<Icon name="search" size="md" className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
<input
|
<input
|
||||||
name={field.name}
|
name={field.name}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -205,9 +199,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="x" size="sm" className="!w-3.5 !h-3.5" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
{t("common.clear")}
|
{t("common.clear")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "./ui";
|
import { Button, Icon } from "./ui";
|
||||||
import { useTranslation } from "../../lib/i18n/context";
|
import { useTranslation } from "../../lib/i18n/context";
|
||||||
import type { AnilistMediaResultDto, AnilistSeriesLinkDto } from "../../lib/api";
|
import type { AnilistMediaResultDto, AnilistSeriesLinkDto } from "../../lib/api";
|
||||||
|
|
||||||
@@ -116,9 +116,7 @@ export function ReadingStatusModal({
|
|||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-sm font-medium text-muted-foreground hover:text-foreground hover:border-primary transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="link" size="sm" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
||||||
</svg>
|
|
||||||
{t("readingStatus.button")}
|
{t("readingStatus.button")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -130,15 +128,11 @@ export function ReadingStatusModal({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-border/50 bg-muted/30">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<svg className="w-5 h-5 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="link" size="md" className="text-cyan-500" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-semibold text-lg">{providerLabel} — {seriesName}</span>
|
<span className="font-semibold text-lg">{providerLabel} — {seriesName}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={handleClose} className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg">
|
<button type="button" onClick={handleClose} className="text-muted-foreground hover:text-foreground transition-colors p-1.5 hover:bg-accent rounded-lg">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="x" size="md" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition, useRef, useEffect } from "react";
|
import { useState, useTransition, useRef, useEffect } from "react";
|
||||||
import type { UserDto } from "@/lib/api";
|
import type { UserDto } from "@/lib/api";
|
||||||
|
import { Icon } from "./ui";
|
||||||
|
|
||||||
export function UserSwitcher({
|
export function UserSwitcher({
|
||||||
users,
|
users,
|
||||||
@@ -63,9 +64,7 @@ export function UserSwitcher({
|
|||||||
<span className="max-w-[80px] truncate hidden sm:inline">
|
<span className="max-w-[80px] truncate hidden sm:inline">
|
||||||
{activeUser ? activeUser.username : "Admin"}
|
{activeUser ? activeUser.username : "Admin"}
|
||||||
</span>
|
</span>
|
||||||
<svg className="w-3 h-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="chevronDown" size="sm" className="!w-3 !h-3 opacity-60" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
@@ -84,9 +83,7 @@ export function UserSwitcher({
|
|||||||
</svg>
|
</svg>
|
||||||
Admin
|
Admin
|
||||||
{!isImpersonating && (
|
{!isImpersonating && (
|
||||||
<svg className="w-3.5 h-3.5 ml-auto text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="check" size="sm" className="!w-3.5 !h-3.5 ml-auto text-primary" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -108,9 +105,7 @@ export function UserSwitcher({
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="truncate">{user.username}</span>
|
<span className="truncate">{user.username}</span>
|
||||||
{activeUserId === user.id && (
|
{activeUserId === user.id && (
|
||||||
<svg className="w-3.5 h-3.5 ml-auto text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Icon name="check" size="sm" className="!w-3.5 !h-3.5 ml-auto text-primary shrink-0" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ type IconName =
|
|||||||
| "bell"
|
| "bell"
|
||||||
| "link"
|
| "link"
|
||||||
| "eye"
|
| "eye"
|
||||||
| "download";
|
| "download"
|
||||||
|
| "lock";
|
||||||
|
|
||||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
type IconSize = "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ const icons: Record<IconName, string> = {
|
|||||||
link: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
|
link: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
|
||||||
eye: "M15 12a3 3 0 11-6 0 3 3 0 016 0zm-3-9C7.477 3 3.268 6.11 1.5 12c1.768 5.89 5.977 9 10.5 9s8.732-3.11 10.5-9C20.732 6.11 16.523 3 12 3z",
|
eye: "M15 12a3 3 0 11-6 0 3 3 0 016 0zm-3-9C7.477 3 3.268 6.11 1.5 12c1.768 5.89 5.977 9 10.5 9s8.732-3.11 10.5-9C20.732 6.11 16.523 3 12 3z",
|
||||||
download: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4",
|
download: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4",
|
||||||
|
lock: "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z",
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorClasses: Partial<Record<IconName, string>> = {
|
const colorClasses: Partial<Record<IconName, string>> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user