Initial Commit, Basic Navigation implemented
This commit is contained in:
parent
60c3a2da87
commit
d531356693
16 changed files with 6874 additions and 0 deletions
5628
Cargo.lock
generated
Normal file
5628
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
[package]
|
||||||
|
name = "kavita-upload-helper"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Neshura <neshura@neshweb.net>"]
|
||||||
|
edition = "2021"
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
slint = "1.6"
|
||||||
|
reqwest = { version = "0.12.4", features = ["blocking"]}
|
||||||
|
serde_json = "1.0.117"
|
||||||
|
chrono = "0.4.38"
|
||||||
|
arrayvec = "0.7.4"
|
||||||
|
serde = { version = "1.0.203", features = ["derive"] }
|
||||||
|
copypasta = "0.10.1"
|
||||||
|
parking_lot = { version = "0.12.3", features = ["deadlock_detection"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
slint-build = "1.6"
|
5
build.rs
Normal file
5
build.rs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
fn main() {
|
||||||
|
let config = slint_build::CompilerConfiguration::new()
|
||||||
|
.with_style("qt".to_owned());
|
||||||
|
slint_build::compile_with_config("ui/appwindow.slint", config).unwrap();
|
||||||
|
}
|
154
src/auth/jnovel/mod.rs
Normal file
154
src/auth/jnovel/mod.rs
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
use std::str::FromStr;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use crate::auth::{GenericAuthentication, Otp, OtpAuthentication, OtpState, AuthToken};
|
||||||
|
use crate::LoginState;
|
||||||
|
|
||||||
|
static API_BASE: &str = "https://labs.j-novel.club";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct JNCAuth {
|
||||||
|
pub otp: Otp,
|
||||||
|
token: AuthToken,
|
||||||
|
pub state: LoginState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenericAuthentication for JNCAuth {
|
||||||
|
fn login(&mut self) {
|
||||||
|
if self.check_otp() == Ok(OtpState::NotCreated) && self.state == LoginState::LoggedOut {
|
||||||
|
self.state = LoginState::Processing;
|
||||||
|
match self.create_otp() {
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(e) => {
|
||||||
|
println!("{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if self.state != LoginState::LoggedIn { // not logged in
|
||||||
|
println!("Login Button should not be visible when OTP state is '{:?}' and Auth state is '{:?}'", self.otp, self.state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logout(&mut self) {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let url = format!("{API_BASE}/app/v1/auth/logout?access_token={}", self.token.token);
|
||||||
|
let res = client.post(url).send();
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(data) => println!("{}", data.status()),
|
||||||
|
Err(e) => println!("{}", e.to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.otp.state = OtpState::NotCreated;
|
||||||
|
self.state = LoginState::LoggedOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_login(&mut self) -> LoginState {
|
||||||
|
if self.check_otp() == Ok(OtpState::Validated) {
|
||||||
|
println!("Logged In");
|
||||||
|
self.otp.state = OtpState::Used;
|
||||||
|
self.state = LoginState::LoggedIn;
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
LoginState::Processing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_auth(&self) -> String {
|
||||||
|
self.token.token.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OtpAuthentication for JNCAuth {
|
||||||
|
fn create_otp(&mut self) -> Result<OtpState, String> {
|
||||||
|
let res = match reqwest::blocking::get(format!("{API_BASE}/app/v1/auth/otp4app/generate?format=json")) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(e) => return Err(e.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
return match res.text() {
|
||||||
|
Ok(otp_data) => {
|
||||||
|
let mut otp: Otp = match serde_json::from_str(&otp_data) {
|
||||||
|
Ok(otp) => otp,
|
||||||
|
Err(e) => return Err(e.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = Utc::now();
|
||||||
|
|
||||||
|
otp.start = start;
|
||||||
|
otp.state = OtpState::Created;
|
||||||
|
|
||||||
|
self.otp = otp;
|
||||||
|
|
||||||
|
Ok(self.otp.state)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_otp(&mut self) -> Result<OtpState, String> {
|
||||||
|
if self.otp.state == OtpState::NotCreated || self.otp.state == OtpState::Validated {
|
||||||
|
return Ok(self.otp.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{API_BASE}/app/v1/auth/otp4app/check/{}/{}?format=json", self.otp.code, self.otp.proof);
|
||||||
|
|
||||||
|
let res = match reqwest::blocking::get(url) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(e) => return Err(e.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
return match res.status() {
|
||||||
|
StatusCode::NO_CONTENT => {
|
||||||
|
self.otp.state = OtpState::Created;
|
||||||
|
Ok(self.otp.state)
|
||||||
|
}
|
||||||
|
StatusCode::OK => {
|
||||||
|
match res.text() {
|
||||||
|
Ok(text) => {
|
||||||
|
let data: JNCAuthReturn = match serde_json::from_str(&text) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(e) => return Err(e.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let valid_for: u32 = data.ttl.strip_suffix('s').unwrap().parse().unwrap();
|
||||||
|
let created = DateTime::<Utc>::from_str(&data.created).unwrap();
|
||||||
|
|
||||||
|
self.token = AuthToken {
|
||||||
|
token: data.id,
|
||||||
|
valid_for,
|
||||||
|
created
|
||||||
|
};
|
||||||
|
},
|
||||||
|
Err(e) => return Err(e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
self.otp.state = OtpState::Validated;
|
||||||
|
Ok(self.otp.state)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.otp.state = OtpState::TimedOut;
|
||||||
|
Ok(self.otp.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for JNCAuth {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
otp: Otp::default(),
|
||||||
|
token: AuthToken::default(),
|
||||||
|
state: LoginState::LoggedOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JNCAuthReturn {
|
||||||
|
id: String,
|
||||||
|
ttl: String,
|
||||||
|
created: String,
|
||||||
|
}
|
82
src/auth/mod.rs
Normal file
82
src/auth/mod.rs
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use crate::LoginState;
|
||||||
|
|
||||||
|
pub mod jnovel;
|
||||||
|
|
||||||
|
pub enum AuthenticationType {
|
||||||
|
OTP,
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait GenericAuthentication {
|
||||||
|
fn login(&mut self);
|
||||||
|
fn logout(&mut self);
|
||||||
|
fn check_login(&mut self) -> LoginState;
|
||||||
|
fn get_auth(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthToken {
|
||||||
|
token: String,
|
||||||
|
valid_for: u32,
|
||||||
|
created: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthToken {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
token: "".to_owned(),
|
||||||
|
valid_for: 0,
|
||||||
|
created: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait OtpAuthentication: GenericAuthentication {
|
||||||
|
fn create_otp(&mut self) -> Result<OtpState, String>;
|
||||||
|
fn check_otp(&mut self) -> Result<OtpState, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Eq, PartialEq, Deserialize, Copy, Clone, Debug)]
|
||||||
|
pub enum OtpState {
|
||||||
|
Created,
|
||||||
|
Validated,
|
||||||
|
Used,
|
||||||
|
TimedOut,
|
||||||
|
NotCreated,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct Otp {
|
||||||
|
#[serde(default = "Otp::default_state")]
|
||||||
|
state: OtpState,
|
||||||
|
#[serde(alias = "otp")]
|
||||||
|
pub code: String,
|
||||||
|
pub proof: String,
|
||||||
|
#[serde(skip_deserializing, default = "Otp::default_time")]
|
||||||
|
pub start: DateTime<Utc>,
|
||||||
|
pub ttl: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Otp {
|
||||||
|
fn default_time() -> DateTime<Utc> {
|
||||||
|
Utc::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_state() -> OtpState {
|
||||||
|
OtpState::NotCreated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Otp {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
state: Self::default_state(),
|
||||||
|
code: "".to_owned(),
|
||||||
|
proof: "".to_owned(),
|
||||||
|
start: Self::default_time(),
|
||||||
|
ttl: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
184
src/main.rs
Normal file
184
src/main.rs
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
mod sources;
|
||||||
|
mod auth;
|
||||||
|
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::sync::{Arc};
|
||||||
|
use std::thread;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use copypasta::{ClipboardContext, ClipboardProvider};
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use slint::{SharedString, Weak};
|
||||||
|
use crate::auth::{GenericAuthentication};
|
||||||
|
use crate::auth::jnovel::JNCAuth;
|
||||||
|
use crate::sources::jnc::JNCSource;
|
||||||
|
use crate::sources::{Source, SourceState};
|
||||||
|
slint::include_modules!();
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct AppState {
|
||||||
|
jnc: JNovelClub,
|
||||||
|
main: UploadPageState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct UploadPageState {
|
||||||
|
tab: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct JNovelClub {
|
||||||
|
auth: JNCAuth,
|
||||||
|
source: JNCSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), slint::PlatformError> {
|
||||||
|
let ui = AppWindow::new()?;
|
||||||
|
let data: Arc<RwLock<AppState>> = Arc::new(RwLock::new(AppState::default()));
|
||||||
|
|
||||||
|
let callback_ui = ui.as_weak();
|
||||||
|
let callback_data = data.clone();
|
||||||
|
ui.global::<JNCSettingsInterface>().on_start_login({
|
||||||
|
move || {
|
||||||
|
let thread_ui = callback_ui.clone();
|
||||||
|
let thread_data = callback_data.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread_data.write().jnc.auth.login();
|
||||||
|
|
||||||
|
println!("Login State: {:?}", thread_data.read().jnc.auth.state);
|
||||||
|
|
||||||
|
let ttl = thread_data.read().jnc.auth.otp.start + Duration::seconds(thread_data.read().jnc.auth.otp.ttl as i64) - Utc::now();
|
||||||
|
let ttl_string = format!("{}s", ttl.to_std().unwrap().as_secs());
|
||||||
|
|
||||||
|
let data = thread_data.read().clone();
|
||||||
|
thread_ui.upgrade_in_event_loop(move |handle| {
|
||||||
|
handle.global::<JNCSettingsInterface>().set_login_state(data.jnc.auth.state);
|
||||||
|
handle.global::<JNCSettingsInterface>().set_login_timeout(SharedString::from(ttl_string));
|
||||||
|
handle.global::<JNCSettingsInterface>().set_otp_code(SharedString::from(&data.jnc.auth.otp.code));
|
||||||
|
}).expect("TODO: panic message");
|
||||||
|
|
||||||
|
thread_data.write().jnc.auth.check_login();
|
||||||
|
|
||||||
|
while thread_data.read().jnc.auth.state == LoginState::Processing {
|
||||||
|
println!("Running");
|
||||||
|
thread_data.write().jnc.auth.check_login();
|
||||||
|
|
||||||
|
thread::sleep(Duration::milliseconds(100).to_std().unwrap());
|
||||||
|
|
||||||
|
let state = thread_data.read().jnc.auth.state;
|
||||||
|
let ttl = thread_data.read().jnc.auth.otp.start + Duration::seconds(thread_data.read().jnc.auth.otp.ttl as i64) - Utc::now();
|
||||||
|
let ttl_string = format!("{}s", ttl.to_std().unwrap().as_secs());
|
||||||
|
thread_ui.upgrade_in_event_loop(move |handle| {
|
||||||
|
handle.global::<JNCSettingsInterface>().set_login_state(state);
|
||||||
|
handle.global::<JNCSettingsInterface>().set_login_timeout(SharedString::from(ttl_string));
|
||||||
|
}).expect("TODO: panic message");
|
||||||
|
}
|
||||||
|
let state = thread_data.read().jnc.auth.state;
|
||||||
|
match state {
|
||||||
|
LoginState::LoggedIn => {
|
||||||
|
thread_ui.upgrade_in_event_loop(move |handle| {
|
||||||
|
handle.global::<JNCSettingsInterface>().set_login_state(state);
|
||||||
|
}).expect("TODO: panic message");
|
||||||
|
}
|
||||||
|
LoginState::LoginTimeout => {
|
||||||
|
println!("Login timed out, handle");
|
||||||
|
}
|
||||||
|
LoginState::LoginError => {
|
||||||
|
println!("Error during login, handle");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!("Impossible State reached");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("Login Start");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_weak = ui.as_weak();
|
||||||
|
let callback_data = data.clone();
|
||||||
|
ui.global::<JNCSettingsInterface>().on_logout({
|
||||||
|
move || {
|
||||||
|
let thread_data = callback_data.clone();
|
||||||
|
let ui_weak = ui_weak.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread_data.write().jnc.auth.logout();
|
||||||
|
ui_weak.upgrade_in_event_loop(move |handle| {
|
||||||
|
handle.global::<JNCSettingsInterface>().set_login_state(thread_data.read().jnc.auth.state);
|
||||||
|
}).expect("TODO: panic message");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_weak = ui.as_weak();
|
||||||
|
let thread_data = data.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
if update_library(thread_data.clone(), ui_weak.clone()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::milliseconds(50).to_std().unwrap());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let callback_ui = ui.as_weak();
|
||||||
|
let callback_data = data.clone();
|
||||||
|
ui.global::<JNCSettingsInterface>().on_refresh_library({
|
||||||
|
move || {
|
||||||
|
let thread_ui = callback_ui.clone();
|
||||||
|
let thread_data = callback_data.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
if update_library(thread_data.clone(), thread_ui.clone()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::milliseconds(50).to_std().unwrap());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let callback_ui = ui.as_weak();
|
||||||
|
let callback_data = data.clone();
|
||||||
|
ui.global::<UploadPageInterface>().on_paginate_tabs({
|
||||||
|
const TAB_COUNT: i32 = 3;
|
||||||
|
let mut tab = callback_data.read().main.tab;
|
||||||
|
move |input: i32| -> i32 {
|
||||||
|
tab = input;
|
||||||
|
callback_ui.upgrade_in_event_loop(move |handle| {
|
||||||
|
handle.global::<UploadPageInterface>().set_prev_button_enabled(tab > 0);
|
||||||
|
handle.global::<UploadPageInterface>().set_next_button_enabled(tab < TAB_COUNT - 1);
|
||||||
|
}).expect("TODO: panic message");
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_library(data: Arc<RwLock<AppState>>, ui_weak: Weak<AppWindow>) -> bool {
|
||||||
|
let mut jnc: JNovelClub = data.read().jnc.clone();
|
||||||
|
if jnc.auth.state == LoginState::LoggedIn {
|
||||||
|
match jnc.source.get_state() {
|
||||||
|
SourceState::Fresh => {
|
||||||
|
let auth_data = jnc.auth.clone();
|
||||||
|
jnc.source.initialize(&auth_data, ui_weak.clone());
|
||||||
|
data.write().jnc = jnc;
|
||||||
|
}
|
||||||
|
SourceState::Initializing => {}
|
||||||
|
SourceState::Ready => {
|
||||||
|
let series_count = jnc.source.get_series_count();
|
||||||
|
let book_count = jnc.source.get_book_count(None);
|
||||||
|
ui_weak.upgrade_in_event_loop({
|
||||||
|
move |handle| {
|
||||||
|
handle.global::<JNCSettingsInterface>().set_series_count(series_count as i32);
|
||||||
|
handle.global::<JNCSettingsInterface>().set_book_count(book_count as i32);
|
||||||
|
}
|
||||||
|
}).expect("TODO: panic message");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
30
src/sources/file_sources/mod.rs
Normal file
30
src/sources/file_sources/mod.rs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use crate::auth::GenericAuthentication;
|
||||||
|
use crate::sources::metadata_sources::{SeriesInfo, SeriesType};
|
||||||
|
|
||||||
|
pub trait FileSource {
|
||||||
|
type FileAuth: GenericAuthentication;
|
||||||
|
|
||||||
|
fn fetch_library(&mut self, auth: Self::FileAuth) -> HashMap<SeriesType, Vec<SeriesInfo>>;
|
||||||
|
|
||||||
|
fn download();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub enum FileType {
|
||||||
|
#[serde(alias="EPUB")]
|
||||||
|
Epub,
|
||||||
|
#[serde(alias="PDF")]
|
||||||
|
Pdf,
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct Download {
|
||||||
|
link: String,
|
||||||
|
r#type: FileType,
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
130
src/sources/jnc/api.rs
Normal file
130
src/sources/jnc/api.rs
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use crate::sources::file_sources::Download;
|
||||||
|
use crate::sources::metadata_sources::SeriesType;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApiSeries {
|
||||||
|
id: String,
|
||||||
|
pub legacy_id: String,
|
||||||
|
#[serde(rename="type")]
|
||||||
|
series_type: SeriesType,
|
||||||
|
status: ApiStatus,
|
||||||
|
pub title: String,
|
||||||
|
short_title: String,
|
||||||
|
original_title: String,
|
||||||
|
slug: String,
|
||||||
|
hidden: bool,
|
||||||
|
created: String,
|
||||||
|
description: String,
|
||||||
|
short_description: String,
|
||||||
|
tags: Vec<String>,
|
||||||
|
cover: ApiCover,
|
||||||
|
following: bool,
|
||||||
|
catchup: bool,
|
||||||
|
rentals: bool,
|
||||||
|
topic_id: Option<i64>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct ApiLibrary {
|
||||||
|
pub books: Vec<ApiBook>,
|
||||||
|
pub pagination: ApiPagination,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ApiPagination {
|
||||||
|
limit: usize,
|
||||||
|
skip: usize,
|
||||||
|
last_page: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApiBook {
|
||||||
|
id: String,
|
||||||
|
legacy_id: String,
|
||||||
|
pub volume: ApiVolume,
|
||||||
|
serie: Value,
|
||||||
|
purchased: Option<String>,
|
||||||
|
last_download: Option<String>,
|
||||||
|
last_updated: Option<String>,
|
||||||
|
pub downloads: Vec<Download>,
|
||||||
|
status: ApiStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApiVolume {
|
||||||
|
id: String,
|
||||||
|
pub legacy_id: String,
|
||||||
|
pub title: String,
|
||||||
|
short_title: String,
|
||||||
|
original_title: String,
|
||||||
|
slug: String,
|
||||||
|
number: usize,
|
||||||
|
original_publisher: String,
|
||||||
|
label: String,
|
||||||
|
creators: Vec<ApiCreator>,
|
||||||
|
hidden: bool,
|
||||||
|
forum_topic_id: usize,
|
||||||
|
created: String,
|
||||||
|
publishing: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(rename="shortDescription")]
|
||||||
|
description_short: String,
|
||||||
|
cover: ApiCover,
|
||||||
|
owned: bool,
|
||||||
|
premium_extras: Option<String>,
|
||||||
|
no_ebook: bool,
|
||||||
|
total_parts: usize
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ApiCreator {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
role: CreatorRole,
|
||||||
|
original_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
enum CreatorRole {
|
||||||
|
#[serde(alias="AUTHOR")]
|
||||||
|
Author,
|
||||||
|
#[serde(alias="ARTIST")]
|
||||||
|
Artist,
|
||||||
|
#[serde(alias="EDITOR")]
|
||||||
|
Editor,
|
||||||
|
#[serde(alias="ILLUSTRATOR")]
|
||||||
|
Illustrator,
|
||||||
|
#[serde(alias="LETTERER")]
|
||||||
|
Letterer,
|
||||||
|
#[serde(alias="TRANSLATOR")]
|
||||||
|
Translator,
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ApiCover {
|
||||||
|
original_url: String,
|
||||||
|
cover_url: String,
|
||||||
|
thumbnail_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
enum ApiStatus {
|
||||||
|
#[serde(alias="PREORDER")]
|
||||||
|
Preorder,
|
||||||
|
#[serde(alias="READY")]
|
||||||
|
Ready,
|
||||||
|
#[serde(alias="DEFAULT")]
|
||||||
|
Default,
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown
|
||||||
|
}
|
197
src/sources/jnc/mod.rs
Normal file
197
src/sources/jnc/mod.rs
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
mod api;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use arrayvec::ArrayVec;
|
||||||
|
use chrono::Duration;
|
||||||
|
use parking_lot::{Mutex, RwLock};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::from_str;
|
||||||
|
use slint::{ComponentHandle, Weak};
|
||||||
|
use crate::{AppWindow, JNCSettingsInterface};
|
||||||
|
use crate::auth::GenericAuthentication;
|
||||||
|
use crate::auth::jnovel::JNCAuth;
|
||||||
|
use crate::sources::file_sources::FileSource;
|
||||||
|
use crate::sources::jnc::api::{ApiLibrary, ApiSeries};
|
||||||
|
use crate::sources::metadata_sources::{BookInfo, MetadataSource, SeriesInfo, SeriesType};
|
||||||
|
use crate::sources::{Source, SourceState};
|
||||||
|
|
||||||
|
static API_BASE: &str = "https://labs.j-novel.club";
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct JNCSource {
|
||||||
|
series: HashMap<String, SeriesInfo>,
|
||||||
|
state: SourceState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JNCSource {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSource for JNCSource {
|
||||||
|
type FileAuth = JNCAuth;
|
||||||
|
|
||||||
|
fn fetch_library(&mut self, auth: Self::FileAuth) -> HashMap<SeriesType, Vec<SeriesInfo>> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download() {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetadataSource for JNCSource {
|
||||||
|
type MetadataAuth = JNCAuth;
|
||||||
|
|
||||||
|
fn get_series_info(&self) {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_book_info(&self) {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_chapter_info(&self) {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source for JNCSource {
|
||||||
|
type Auth = JNCAuth;
|
||||||
|
fn get_state(&self) -> SourceState {
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
fn initialize(&mut self, auth: &Self::Auth, ui: Weak<AppWindow>) {
|
||||||
|
println!("alive");
|
||||||
|
self.state = SourceState::Initializing;
|
||||||
|
println!("alive");
|
||||||
|
let url = format!("{API_BASE}/app/v1/me/library?format=json&access_token={}", auth.get_auth());
|
||||||
|
println!("alive");
|
||||||
|
let res = reqwest::blocking::get(url).unwrap();
|
||||||
|
println!("alive");
|
||||||
|
let text = res.text().unwrap();
|
||||||
|
println!("alive");
|
||||||
|
let library = serde_json::from_str::<ApiLibrary>(&text).unwrap();
|
||||||
|
|
||||||
|
println!("alive");
|
||||||
|
|
||||||
|
let total = library.books.len();
|
||||||
|
let library: Arc<RwLock<ApiLibrary>> = Arc::new(RwLock::new(library));
|
||||||
|
const THREAD_NUM: usize = 3;
|
||||||
|
let mut threads: ArrayVec<JoinHandle<()>, THREAD_NUM> = ArrayVec::<JoinHandle<()>, THREAD_NUM>::new();
|
||||||
|
let next_index: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
|
||||||
|
let series_map: Arc<Mutex<HashMap<String, SeriesInfo>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
println!("alive");
|
||||||
|
|
||||||
|
for _i in 0..=(THREAD_NUM-1) {
|
||||||
|
let thread_index = next_index.clone();
|
||||||
|
let thread_library = library.clone();
|
||||||
|
let thread_map = series_map.clone();
|
||||||
|
let auth_token = auth.get_auth();
|
||||||
|
|
||||||
|
threads.push(thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
let index = thread_index.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if index >= total {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let book = thread_library.read().books[index].clone();
|
||||||
|
|
||||||
|
let url = format!("{API_BASE}/app/v1/volumes/{}/serie?format=json&access_toke={}", book.volume.legacy_id, auth_token);
|
||||||
|
let res = reqwest::blocking::get(url).unwrap();
|
||||||
|
let data_raw = res.text().unwrap();
|
||||||
|
let data = serde_json::from_str::<ApiSeries>(&data_raw).unwrap();
|
||||||
|
{
|
||||||
|
let mut map = thread_map.lock();
|
||||||
|
if map.contains_key(&data.legacy_id) {
|
||||||
|
let download_link = if book.downloads.len() > 0 {
|
||||||
|
Some(book.downloads[0].clone())
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let book_info = BookInfo {
|
||||||
|
id: book.volume.legacy_id.clone(),
|
||||||
|
title: book.volume.title.clone(),
|
||||||
|
description: book.volume.description.clone(),
|
||||||
|
chapters: vec![],
|
||||||
|
link: download_link,
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = map.get_mut(&data.legacy_id).unwrap();
|
||||||
|
data.books.push(book_info);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let download_link = if book.downloads.len() > 0 {
|
||||||
|
Some(book.downloads[0].clone())
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let book_info = BookInfo {
|
||||||
|
id: book.volume.legacy_id.clone(),
|
||||||
|
title: book.volume.title.clone(),
|
||||||
|
description: book.volume.description.clone(),
|
||||||
|
chapters: vec![],
|
||||||
|
link: download_link,
|
||||||
|
};
|
||||||
|
let info = SeriesInfo {
|
||||||
|
id: data.legacy_id.clone(),
|
||||||
|
title: data.title.clone(),
|
||||||
|
books: vec![book_info]
|
||||||
|
};
|
||||||
|
// create seriesInfo
|
||||||
|
map.insert(data.legacy_id, info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::milliseconds(50).to_std().unwrap());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("alive");
|
||||||
|
|
||||||
|
while threads.len() != 0 {
|
||||||
|
let mut finished_list = vec![];
|
||||||
|
for (index, thread) in threads.iter().enumerate() {
|
||||||
|
let finished = &thread.is_finished();
|
||||||
|
if !finished {
|
||||||
|
{
|
||||||
|
let series_count = series_map.lock().len();
|
||||||
|
let book_count = next_index.load(Ordering::SeqCst);
|
||||||
|
ui.upgrade_in_event_loop({
|
||||||
|
move |handle| {
|
||||||
|
handle.global::<JNCSettingsInterface>().set_series_count(series_count as i32);
|
||||||
|
handle.global::<JNCSettingsInterface>().set_book_count(book_count as i32);
|
||||||
|
}
|
||||||
|
}).expect("TODO: panic message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
finished_list.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finished_list.into_iter().for_each(|index| {
|
||||||
|
threads.pop_at(index);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
self.series = series_map.lock().clone();
|
||||||
|
self.state = SourceState::Ready;
|
||||||
|
}
|
||||||
|
fn get_series_count(&self) -> usize {
|
||||||
|
self.series.len()
|
||||||
|
}
|
||||||
|
fn get_book_count(&self, series: Option<String>) -> usize {
|
||||||
|
let mut count = 0;
|
||||||
|
for series in self.series.values() {
|
||||||
|
count += series.books.len();
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
49
src/sources/metadata_sources/mod.rs
Normal file
49
src/sources/metadata_sources/mod.rs
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use crate::auth::GenericAuthentication;
|
||||||
|
use crate::sources::file_sources::Download;
|
||||||
|
|
||||||
|
pub trait MetadataSource {
|
||||||
|
type MetadataAuth: GenericAuthentication;
|
||||||
|
|
||||||
|
fn get_series_info(&self);
|
||||||
|
fn get_book_info(&self);
|
||||||
|
fn get_chapter_info(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct SeriesInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub books: Vec<BookInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct BookInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub chapters: Vec<ChapterInfo>,
|
||||||
|
pub link: Option<Download>
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct ChapterInfo {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub enum BookStatus {
|
||||||
|
Preorder,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Deserialize)]
|
||||||
|
pub enum SeriesType {
|
||||||
|
#[serde(alias="NOVEL")]
|
||||||
|
Novel,
|
||||||
|
#[serde(alias="MANGA")]
|
||||||
|
Manga,
|
||||||
|
#[serde(alias="HENTAI")]
|
||||||
|
Hentai,
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown
|
||||||
|
}
|
33
src/sources/mod.rs
Normal file
33
src/sources/mod.rs
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
mod file_sources;
|
||||||
|
mod metadata_sources;
|
||||||
|
pub mod jnc;
|
||||||
|
|
||||||
|
use slint::Weak;
|
||||||
|
use crate::AppWindow;
|
||||||
|
use crate::auth::GenericAuthentication;
|
||||||
|
use crate::sources::file_sources::FileSource;
|
||||||
|
use crate::sources::metadata_sources::MetadataSource;
|
||||||
|
|
||||||
|
|
||||||
|
pub enum SourceType {
|
||||||
|
Full,
|
||||||
|
File,
|
||||||
|
Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Copy, Clone, Eq, PartialEq)]
|
||||||
|
pub enum SourceState {
|
||||||
|
#[default]
|
||||||
|
Fresh,
|
||||||
|
Initializing,
|
||||||
|
Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub trait Source: FileSource + MetadataSource {
|
||||||
|
type Auth: GenericAuthentication;
|
||||||
|
fn get_state(&self) -> SourceState;
|
||||||
|
fn initialize(&mut self, auth: &Self::Auth, ui: Weak<AppWindow>);
|
||||||
|
fn get_series_count(&self) -> usize;
|
||||||
|
fn get_book_count(&self, series: Option<String>) -> usize;
|
||||||
|
}
|
30
ui/appwindow.slint
Normal file
30
ui/appwindow.slint
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { Button, VerticalBox, HorizontalBox } from "std-widgets.slint";
|
||||||
|
import { SideBar } from "sidebar.slint";
|
||||||
|
import { AboutPage } from "pages/about_page.slint";
|
||||||
|
import { SettingsPage, SettingsPageInterface, JNCSettingsInterface } from "pages/settings_page.slint";
|
||||||
|
import { UploadPage, UploadPageInterface } from "pages/upload_page.slint";
|
||||||
|
|
||||||
|
export { SettingsPageInterface, JNCSettingsInterface, UploadPageInterface }
|
||||||
|
|
||||||
|
export component AppWindow inherits Window {
|
||||||
|
title: "Kavita Upload Tool";
|
||||||
|
preferred-width: 1024px;
|
||||||
|
preferred-height: 720px;
|
||||||
|
HorizontalLayout {
|
||||||
|
VerticalBox {
|
||||||
|
padding-right: 0px;
|
||||||
|
side-bar := SideBar {
|
||||||
|
title: "Kavita Upload Tool";
|
||||||
|
model: [
|
||||||
|
"Upload Tool",
|
||||||
|
"Settings",
|
||||||
|
"About"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (side-bar.current-item == 0) : UploadPage {}
|
||||||
|
if (side-bar.current-item == 1) : SettingsPage {}
|
||||||
|
if (side-bar.current-item == 2) : AboutPage {}
|
||||||
|
}
|
||||||
|
}
|
13
ui/pages/about_page.slint
Normal file
13
ui/pages/about_page.slint
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { VerticalBox, Palette } from "std-widgets.slint";
|
||||||
|
|
||||||
|
export component AboutPage inherits VerticalBox {
|
||||||
|
Rectangle {
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: Palette.border;
|
||||||
|
background: Palette.alternate-background;
|
||||||
|
Text {
|
||||||
|
text: "ToDo: Add Author Info and Link to Repository";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
131
ui/pages/settings_page.slint
Normal file
131
ui/pages/settings_page.slint
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import { VerticalBox, Palette, TextEdit, HorizontalBox, ScrollView, LineEdit, Button } from "std-widgets.slint";
|
||||||
|
|
||||||
|
export enum LoginState {
|
||||||
|
LoggedIn,
|
||||||
|
LoggedOut,
|
||||||
|
Processing,
|
||||||
|
LoginError,
|
||||||
|
LoginTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
export global JNCSettingsInterface {
|
||||||
|
in-out property <LoginState> login_state: LoginState.LoggedOut;
|
||||||
|
in-out property <string> login_timeout;
|
||||||
|
in-out property <string> otp_code;
|
||||||
|
in-out property <int> series_count: 0;
|
||||||
|
in-out property <int> book_count: 0;
|
||||||
|
|
||||||
|
callback start_login();
|
||||||
|
callback logout();
|
||||||
|
callback refresh_library();
|
||||||
|
}
|
||||||
|
|
||||||
|
export global SettingsPageInterface {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
component SettingsMember inherits Rectangle {
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: Palette.border;
|
||||||
|
background: Palette.alternate-background;
|
||||||
|
VerticalBox {
|
||||||
|
alignment: start;
|
||||||
|
spacing: 4px;
|
||||||
|
@children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component StringSetting inherits HorizontalBox {
|
||||||
|
in property <string> name;
|
||||||
|
in property <string> placeholder: "" ;
|
||||||
|
in property <length> w: 100px;
|
||||||
|
in-out property <string> value;
|
||||||
|
|
||||||
|
alignment: start;
|
||||||
|
height: 24px;
|
||||||
|
Text {
|
||||||
|
font-size: 12px;
|
||||||
|
height: self.font-size + 8px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
text: name;
|
||||||
|
}
|
||||||
|
LineEdit {
|
||||||
|
font-size: 12px;
|
||||||
|
preferred-width: w + 4px;
|
||||||
|
height: self.font-size + 8px;
|
||||||
|
placeholder-text: placeholder;
|
||||||
|
text <=> value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component SettingsPage inherits VerticalBox {
|
||||||
|
SettingsMember {
|
||||||
|
Text {
|
||||||
|
text: "Kavita Settings (sftp)";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
name: "Server:";
|
||||||
|
placeholder: "XXX.XXX.XXX.XXX";
|
||||||
|
value: "";
|
||||||
|
w: 120px;
|
||||||
|
}
|
||||||
|
StringSetting {
|
||||||
|
name: "Username:";
|
||||||
|
placeholder: "User";
|
||||||
|
value: "";
|
||||||
|
w: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsMember {
|
||||||
|
Text {
|
||||||
|
text: "J-Novel Club Settings";
|
||||||
|
}
|
||||||
|
|
||||||
|
if JNCSettingsInterface.login_state == LoginState.LoggedOut : HorizontalLayout {
|
||||||
|
alignment: start;
|
||||||
|
Button {
|
||||||
|
text: "Log In";
|
||||||
|
clicked => {JNCSettingsInterface.start_login()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if JNCSettingsInterface.login_state == LoginState.Processing : HorizontalBox {
|
||||||
|
otp := Text {
|
||||||
|
text: JNCSettingsInterface.otp_code;
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "Copy";
|
||||||
|
clicked => {}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "Awaiting Login Confirmation. \{JNCSettingsInterface.login_timeout} until expiry.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if JNCSettingsInterface.login_state == LoginState.LoggedIn : VerticalBox {
|
||||||
|
HorizontalBox {
|
||||||
|
Text {
|
||||||
|
text: "Logged In.";
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "Log Out";
|
||||||
|
clicked => {JNCSettingsInterface.logout()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalBox {
|
||||||
|
Text {
|
||||||
|
text: "Library: \{JNCSettingsInterface.series_count} Series, \{JNCSettingsInterface.book_count} Books loaded";
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "refresh";
|
||||||
|
clicked => {JNCSettingsInterface.refresh_library()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsMember {
|
||||||
|
Text {
|
||||||
|
text: "Manual Parser Settings";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
58
ui/pages/upload_page.slint
Normal file
58
ui/pages/upload_page.slint
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { VerticalBox, Palette, TabWidget, HorizontalBox, Button } from "std-widgets.slint";
|
||||||
|
|
||||||
|
export global UploadPageInterface {
|
||||||
|
in-out property <bool> prev_button_enabled: false;
|
||||||
|
in-out property <bool> next_button_enabled: true;
|
||||||
|
|
||||||
|
pure callback paginate_tabs(int) -> int;
|
||||||
|
}
|
||||||
|
|
||||||
|
export component UploadPage inherits VerticalBox {
|
||||||
|
Rectangle {
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: Palette.border;
|
||||||
|
background: Palette.alternate-background;
|
||||||
|
VerticalLayout {
|
||||||
|
property <int> current_tab: UploadPageInterface.paginate_tabs(tabs.current-index);
|
||||||
|
tabs := TabWidget {
|
||||||
|
Tab {
|
||||||
|
title: "Parser";
|
||||||
|
Text {
|
||||||
|
text: "Parser Settings here";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tab {
|
||||||
|
title: "Metadata";
|
||||||
|
Text {
|
||||||
|
text: "Metadata manipulation here";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tab {
|
||||||
|
title: "Upload";
|
||||||
|
Text {
|
||||||
|
text: "Upload Stuff here";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalBox {
|
||||||
|
previous := Button {
|
||||||
|
text: "Previous";
|
||||||
|
enabled: UploadPageInterface.prev_button_enabled;
|
||||||
|
clicked => {tabs.current-index -= 1}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "\{current_tab + 1}/3";
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
next := Button {
|
||||||
|
text: "Next";
|
||||||
|
enabled: UploadPageInterface.next_button_enabled;
|
||||||
|
clicked => {tabs.current-index += 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
129
ui/sidebar.slint
Normal file
129
ui/sidebar.slint
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import { HorizontalBox, VerticalBox, Palette } from "std-widgets.slint";
|
||||||
|
|
||||||
|
component SideBarItem inherits Rectangle {
|
||||||
|
in property <bool> selected;
|
||||||
|
in property <bool> has-focus;
|
||||||
|
in-out property <string> text <=> label.text;
|
||||||
|
|
||||||
|
callback clicked <=> touch.clicked;
|
||||||
|
|
||||||
|
min-height: l.preferred-height;
|
||||||
|
|
||||||
|
states [
|
||||||
|
pressed when touch.pressed : {
|
||||||
|
state.opacity: 0.8;
|
||||||
|
}
|
||||||
|
hover when touch.has-hover : {
|
||||||
|
state.opacity: 0.6;
|
||||||
|
}
|
||||||
|
selected when root.selected : {
|
||||||
|
state.opacity: 1;
|
||||||
|
}
|
||||||
|
focused when root.has-focus : {
|
||||||
|
state.opacity: 0.8;
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
state := Rectangle {
|
||||||
|
opacity: 0;
|
||||||
|
background: Palette.background;
|
||||||
|
|
||||||
|
animate opacity { duration: 150ms; }
|
||||||
|
}
|
||||||
|
|
||||||
|
l := HorizontalBox {
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
spacing: 0px;
|
||||||
|
|
||||||
|
label := Text {
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touch := TouchArea {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component SideBar inherits Rectangle {
|
||||||
|
in property <[string]> model: [];
|
||||||
|
in property <string> title <=> label.text;
|
||||||
|
out property <int> current-item: 0;
|
||||||
|
out property <int> current-focused: fs.has-focus ? fs.focused-tab : -1; // The currently focused tab
|
||||||
|
|
||||||
|
width: 180px;
|
||||||
|
forward-focus: fs;
|
||||||
|
accessible-role: tab;
|
||||||
|
accessible-delegate-focus: root.current-focused >= 0 ? root.current-focused : root.current-item;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
background: Palette.alternate-background;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: Palette.border;
|
||||||
|
|
||||||
|
fs := FocusScope {
|
||||||
|
key-pressed(event) => {
|
||||||
|
if (event.text == "\n") {
|
||||||
|
root.current-item = root.current-focused;
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
if (event.text == Key.UpArrow) {
|
||||||
|
self.focused-tab = Math.max(self.focused-tab - 1, 0);
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
if (event.text == Key.DownArrow) {
|
||||||
|
self.focused-tab = Math.min(self.focused-tab + 1, root.model.length - 1);
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
|
||||||
|
key-released(event) => {
|
||||||
|
if (event.text == " ") {
|
||||||
|
root.current-item = root.current-focused;
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
|
||||||
|
property <int> focused-tab: 0;
|
||||||
|
|
||||||
|
x: 0;
|
||||||
|
width: 0; // Do not react on clicks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalBox {
|
||||||
|
padding-left: 0px;
|
||||||
|
padding-right: 0px;
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
|
label := Text {
|
||||||
|
font-size: 16px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigation := VerticalLayout {
|
||||||
|
alignment: start;
|
||||||
|
vertical-stretch: 0;
|
||||||
|
for item[index] in root.model : SideBarItem {
|
||||||
|
clicked => { root.current-item = index; }
|
||||||
|
|
||||||
|
has-focus: index == root.current-focused;
|
||||||
|
text: item;
|
||||||
|
selected: index == root.current-item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
bottom := VerticalBox {
|
||||||
|
padding-top: 0px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
|
||||||
|
@children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue