You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit 'dbde3c4669af10dd577590ed6980e5bd4552d13c' into feature-clean
This commit is contained in:
@@ -2,13 +2,14 @@
|
||||
name = "theseus"
|
||||
version = "0.9.5"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2024"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bytes.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
serde_ini.workspace = true
|
||||
serde_with.workspace = true
|
||||
sha1_smol.workspace = true
|
||||
sha2.workspace = true
|
||||
url = { workspace = true, features = ["serde"] }
|
||||
@@ -20,6 +21,8 @@ tempfile.workspace = true
|
||||
dashmap = { workspace = true, features = ["serde"] }
|
||||
quick-xml = { workspace = true, features = ["async-tokio"] }
|
||||
enumset.workspace = true
|
||||
chardetng.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
daedalus.workspace = true
|
||||
@@ -36,7 +39,7 @@ tracing-error.workspace = true
|
||||
|
||||
paste.workspace = true
|
||||
|
||||
tauri = { workspace = true, optional = true }
|
||||
tauri = { workspace = true, optional = true, features = ["unstable"] }
|
||||
indicatif = { workspace = true, optional = true }
|
||||
|
||||
async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rustls-webpki-roots"] }
|
||||
@@ -75,3 +78,6 @@ winreg.workspace = true
|
||||
[features]
|
||||
tauri = ["dep:tauri"]
|
||||
cli = ["dep:indicatif"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "@modrinth/app-lib",
|
||||
"scripts": {
|
||||
"build": "cargo build --release",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings",
|
||||
"fix": "cargo fmt && cargo clippy --fix",
|
||||
"test": "cargo test"
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
|
||||
"test": "cargo nextest run --all-targets --no-fail-fast"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,9 +166,8 @@ pub async fn test_jre(
|
||||
path: PathBuf,
|
||||
major_version: u32,
|
||||
) -> crate::Result<bool> {
|
||||
let jre = match jre::check_java_at_filepath(&path).await {
|
||||
Some(jre) => jre,
|
||||
None => return Ok(false),
|
||||
let Some(jre) = jre::check_java_at_filepath(&path).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
let (major, _) = extract_java_majorminor_version(&jre.version)?;
|
||||
Ok(major == major_version)
|
||||
|
||||
@@ -97,12 +97,15 @@ pub struct ATLauncherMod {
|
||||
|
||||
// Check if folder has a instance.json that parses
|
||||
pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool {
|
||||
let instance: String =
|
||||
io::read_to_string(&instance_folder.join("instance.json"))
|
||||
.await
|
||||
.unwrap_or("".to_string());
|
||||
let instance: Result<ATInstance, serde_json::Error> =
|
||||
serde_json::from_str::<ATInstance>(&instance);
|
||||
let instance = serde_json::from_str::<ATInstance>(
|
||||
&io::read_any_encoding_to_string(
|
||||
&instance_folder.join("instance.json"),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(("".into(), encoding_rs::UTF_8))
|
||||
.0,
|
||||
);
|
||||
|
||||
if let Err(e) = instance {
|
||||
tracing::warn!(
|
||||
"Could not parse instance.json at {}: {}",
|
||||
@@ -124,14 +127,17 @@ pub async fn import_atlauncher(
|
||||
) -> crate::Result<()> {
|
||||
let atlauncher_instance_path = atlauncher_base_path
|
||||
.join("instances")
|
||||
.join(instance_folder.clone());
|
||||
.join(&instance_folder);
|
||||
|
||||
// Load instance.json
|
||||
let atinstance: String =
|
||||
io::read_to_string(&atlauncher_instance_path.join("instance.json"))
|
||||
.await?;
|
||||
let atinstance: ATInstance =
|
||||
serde_json::from_str::<ATInstance>(&atinstance)?;
|
||||
let atinstance = serde_json::from_str::<ATInstance>(
|
||||
&io::read_any_encoding_to_string(
|
||||
&atlauncher_instance_path.join("instance.json"),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(("".into(), encoding_rs::UTF_8))
|
||||
.0,
|
||||
)?;
|
||||
|
||||
// Icon path should be {instance_folder}/instance.png if it exists,
|
||||
// Second possibility is ATLauncher/configs/images/{safe_pack_name}.png (safe pack name is alphanumeric lowercase)
|
||||
|
||||
@@ -36,13 +36,15 @@ pub struct InstalledModpack {
|
||||
|
||||
// Check if folder has a minecraftinstance.json that parses
|
||||
pub async fn is_valid_curseforge(instance_folder: PathBuf) -> bool {
|
||||
let minecraftinstance: String =
|
||||
io::read_to_string(&instance_folder.join("minecraftinstance.json"))
|
||||
.await
|
||||
.unwrap_or("".to_string());
|
||||
let minecraftinstance: Result<MinecraftInstance, serde_json::Error> =
|
||||
serde_json::from_str::<MinecraftInstance>(&minecraftinstance);
|
||||
minecraftinstance.is_ok()
|
||||
let minecraft_instance = serde_json::from_str::<MinecraftInstance>(
|
||||
&io::read_any_encoding_to_string(
|
||||
&instance_folder.join("minecraftinstance.json"),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(("".into(), encoding_rs::UTF_8))
|
||||
.0,
|
||||
);
|
||||
minecraft_instance.is_ok()
|
||||
}
|
||||
|
||||
pub async fn import_curseforge(
|
||||
@@ -50,19 +52,20 @@ pub async fn import_curseforge(
|
||||
profile_path: &str, // path to profile
|
||||
) -> crate::Result<()> {
|
||||
// Load minecraftinstance.json
|
||||
let minecraft_instance: String = io::read_to_string(
|
||||
&curseforge_instance_folder.join("minecraftinstance.json"),
|
||||
)
|
||||
.await?;
|
||||
let minecraft_instance: MinecraftInstance =
|
||||
serde_json::from_str::<MinecraftInstance>(&minecraft_instance)?;
|
||||
let override_title: Option<String> = minecraft_instance.name.clone();
|
||||
let minecraft_instance = serde_json::from_str::<MinecraftInstance>(
|
||||
&io::read_any_encoding_to_string(
|
||||
&curseforge_instance_folder.join("minecraftinstance.json"),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(("".into(), encoding_rs::UTF_8))
|
||||
.0,
|
||||
)?;
|
||||
let override_title = minecraft_instance.name;
|
||||
let backup_name = format!(
|
||||
"Curseforge-{}",
|
||||
curseforge_instance_folder
|
||||
.file_name()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.unwrap_or("Unknown".to_string())
|
||||
.map_or("Unknown".to_string(), |a| a.to_string_lossy().to_string())
|
||||
);
|
||||
|
||||
let state = State::get().await?;
|
||||
|
||||
@@ -25,12 +25,12 @@ pub struct GDLauncherLoader {
|
||||
|
||||
// Check if folder has a config.json that parses
|
||||
pub async fn is_valid_gdlauncher(instance_folder: PathBuf) -> bool {
|
||||
let config: String =
|
||||
io::read_to_string(&instance_folder.join("config.json"))
|
||||
let config = serde_json::from_str::<GDLauncherConfig>(
|
||||
&io::read_any_encoding_to_string(&instance_folder.join("config.json"))
|
||||
.await
|
||||
.unwrap_or("".to_string());
|
||||
let config: Result<GDLauncherConfig, serde_json::Error> =
|
||||
serde_json::from_str::<GDLauncherConfig>(&config);
|
||||
.unwrap_or(("".into(), encoding_rs::UTF_8))
|
||||
.0,
|
||||
);
|
||||
config.is_ok()
|
||||
}
|
||||
|
||||
@@ -39,18 +39,20 @@ pub async fn import_gdlauncher(
|
||||
profile_path: &str, // path to profile
|
||||
) -> crate::Result<()> {
|
||||
// Load config.json
|
||||
let config: String =
|
||||
io::read_to_string(&gdlauncher_instance_folder.join("config.json"))
|
||||
.await?;
|
||||
let config: GDLauncherConfig =
|
||||
serde_json::from_str::<GDLauncherConfig>(&config)?;
|
||||
let override_title: Option<String> = config.loader.source_name.clone();
|
||||
let config = serde_json::from_str::<GDLauncherConfig>(
|
||||
&io::read_any_encoding_to_string(
|
||||
&gdlauncher_instance_folder.join("config.json"),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(("".into(), encoding_rs::UTF_8))
|
||||
.0,
|
||||
)?;
|
||||
let override_title = config.loader.source_name;
|
||||
let backup_name = format!(
|
||||
"GDLauncher-{}",
|
||||
gdlauncher_instance_folder
|
||||
.file_name()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.unwrap_or("Unknown".to_string())
|
||||
.map_or("Unknown".to_string(), |a| a.to_string_lossy().to_string())
|
||||
);
|
||||
|
||||
// Re-cache icon
|
||||
|
||||
@@ -26,6 +26,7 @@ enum MMCInstanceEnum {
|
||||
struct MMCInstanceGeneral {
|
||||
pub general: MMCInstance,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct MMCInstance {
|
||||
@@ -144,9 +145,9 @@ pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
|
||||
let instance_cfg = instance_folder.join("instance.cfg");
|
||||
let mmc_pack = instance_folder.join("mmc-pack.json");
|
||||
|
||||
let mmc_pack = match io::read_to_string(&mmc_pack).await {
|
||||
Ok(mmc_pack) => mmc_pack,
|
||||
Err(_) => return false,
|
||||
let Ok((mmc_pack, _)) = io::read_any_encoding_to_string(&mmc_pack).await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
load_instance_cfg(&instance_cfg).await.is_ok()
|
||||
@@ -155,7 +156,7 @@ pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_instances_subpath(config: PathBuf) -> Option<String> {
|
||||
let launcher = io::read_to_string(&config).await.ok()?;
|
||||
let launcher = io::read_any_encoding_to_string(&config).await.ok()?.0;
|
||||
let launcher: MMCLauncherEnum = serde_ini::from_str(&launcher).ok()?;
|
||||
match launcher {
|
||||
MMCLauncherEnum::General(p) => Some(p.general.instance_dir),
|
||||
@@ -165,10 +166,9 @@ pub async fn get_instances_subpath(config: PathBuf) -> Option<String> {
|
||||
|
||||
// Loading the INI (instance.cfg) file
|
||||
async fn load_instance_cfg(file_path: &Path) -> crate::Result<MMCInstance> {
|
||||
let instance_cfg: String = io::read_to_string(file_path).await?;
|
||||
let instance_cfg_enum: MMCInstanceEnum =
|
||||
serde_ini::from_str::<MMCInstanceEnum>(&instance_cfg)?;
|
||||
match instance_cfg_enum {
|
||||
match serde_ini::from_str::<MMCInstanceEnum>(
|
||||
&io::read_any_encoding_to_string(file_path).await?.0,
|
||||
)? {
|
||||
MMCInstanceEnum::General(instance_cfg) => Ok(instance_cfg.general),
|
||||
MMCInstanceEnum::Instance(instance_cfg) => Ok(instance_cfg),
|
||||
}
|
||||
@@ -183,9 +183,13 @@ pub async fn import_mmc(
|
||||
let mmc_instance_path =
|
||||
mmc_base_path.join("instances").join(instance_folder);
|
||||
|
||||
let mmc_pack =
|
||||
io::read_to_string(&mmc_instance_path.join("mmc-pack.json")).await?;
|
||||
let mmc_pack: MMCPack = serde_json::from_str::<MMCPack>(&mmc_pack)?;
|
||||
let mmc_pack = serde_json::from_str::<MMCPack>(
|
||||
&io::read_any_encoding_to_string(
|
||||
&mmc_instance_path.join("mmc-pack.json"),
|
||||
)
|
||||
.await?
|
||||
.0,
|
||||
)?;
|
||||
|
||||
let instance_cfg =
|
||||
load_instance_cfg(&mmc_instance_path.join("instance.cfg")).await?;
|
||||
@@ -230,7 +234,7 @@ pub async fn import_mmc(
|
||||
// Kept separate as we may in the future want to add special handling for modrinth managed packs
|
||||
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modrinth Modpack".to_string(), description, mmc_pack).await?;
|
||||
}
|
||||
Some(MMCManagedPackType::Flame) | Some(MMCManagedPackType::ATLauncher) => {
|
||||
Some(MMCManagedPackType::Flame | MMCManagedPackType::ATLauncher) => {
|
||||
// For flame/atlauncher managed packs
|
||||
// Treat as unmanaged, but with 'minecraft' folder instead of '.minecraft'
|
||||
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modpack".to_string(), description, mmc_pack).await?;
|
||||
@@ -243,7 +247,7 @@ pub async fn import_mmc(
|
||||
_ => return Err(crate::ErrorKind::InputError("Instance is managed, but managed pack type not specified in instance.cfg".to_string()).into())
|
||||
}
|
||||
} else {
|
||||
// Direclty import unmanaged pack
|
||||
// Directly import unmanaged pack
|
||||
import_mmc_unmanaged(
|
||||
profile_path,
|
||||
minecraft_folder,
|
||||
|
||||
@@ -357,9 +357,7 @@ pub async fn set_profile_information(
|
||||
}
|
||||
}
|
||||
|
||||
let game_version = if let Some(game_version) = game_version {
|
||||
game_version
|
||||
} else {
|
||||
let Some(game_version) = game_version else {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Pack did not specify Minecraft version".to_string(),
|
||||
)
|
||||
@@ -393,10 +391,7 @@ pub async fn set_profile_information(
|
||||
locked: if !ignore_lock {
|
||||
true
|
||||
} else {
|
||||
prof.linked_data
|
||||
.as_ref()
|
||||
.map(|x| x.locked)
|
||||
.unwrap_or(true)
|
||||
prof.linked_data.as_ref().is_none_or(|x| x.locked)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,8 +152,7 @@ pub async fn install_zipped_mrpack_files(
|
||||
if let Some(env) = project.env {
|
||||
if env
|
||||
.get(&EnvType::Client)
|
||||
.map(|x| x == &SideType::Unsupported)
|
||||
.unwrap_or(false)
|
||||
.is_some_and(|x| x == &SideType::Unsupported)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -586,7 +586,7 @@ pub async fn get_pack_export_candidates(
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &profile_base_dir))?
|
||||
{
|
||||
let path: PathBuf = entry.path();
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// Two layers of files/folders if its a folder
|
||||
let mut read_dir = io::read_dir(&path).await?;
|
||||
@@ -595,10 +595,10 @@ pub async fn get_pack_export_candidates(
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &profile_base_dir))?
|
||||
{
|
||||
let path: PathBuf = entry.path();
|
||||
|
||||
path_list
|
||||
.push(pack_get_relative_path(&profile_base_dir, &path)?);
|
||||
path_list.push(pack_get_relative_path(
|
||||
&profile_base_dir,
|
||||
&entry.path(),
|
||||
)?);
|
||||
}
|
||||
} else {
|
||||
// One layer of files/folders if its a file
|
||||
@@ -644,7 +644,6 @@ pub async fn run(
|
||||
/// Run Minecraft using a profile, and credentials for authentication
|
||||
/// Returns Arc pointer to RwLock to Child
|
||||
#[tracing::instrument(skip(credentials))]
|
||||
|
||||
pub async fn run_credentials(
|
||||
path: &str,
|
||||
credentials: &Credentials,
|
||||
@@ -662,14 +661,15 @@ pub async fn run_credentials(
|
||||
.hooks
|
||||
.pre_launch
|
||||
.as_ref()
|
||||
.or(settings.hooks.pre_launch.as_ref());
|
||||
.or(settings.hooks.pre_launch.as_ref())
|
||||
.filter(|hook_command| !hook_command.is_empty());
|
||||
if let Some(hook) = pre_launch_hooks {
|
||||
// TODO: hook parameters
|
||||
let mut cmd = hook.split(' ');
|
||||
if let Some(command) = cmd.next() {
|
||||
let full_path = get_full_path(&profile.path).await?;
|
||||
let result = Command::new(command)
|
||||
.args(cmd.collect::<Vec<&str>>())
|
||||
.args(cmd)
|
||||
.current_dir(&full_path)
|
||||
.spawn()
|
||||
.map_err(|e| IOError::with_path(e, &full_path))?
|
||||
@@ -692,7 +692,12 @@ pub async fn run_credentials(
|
||||
.clone()
|
||||
.unwrap_or(settings.extra_launch_args);
|
||||
|
||||
let wrapper = profile.hooks.wrapper.clone().or(settings.hooks.wrapper);
|
||||
let wrapper = profile
|
||||
.hooks
|
||||
.wrapper
|
||||
.clone()
|
||||
.or(settings.hooks.wrapper)
|
||||
.filter(|hook_command| !hook_command.is_empty());
|
||||
|
||||
let memory = profile.memory.unwrap_or(settings.memory);
|
||||
let resolution =
|
||||
@@ -704,8 +709,12 @@ pub async fn run_credentials(
|
||||
.unwrap_or(settings.custom_env_vars);
|
||||
|
||||
// Post post exit hooks
|
||||
let post_exit_hook =
|
||||
profile.hooks.post_exit.clone().or(settings.hooks.post_exit);
|
||||
let post_exit_hook = profile
|
||||
.hooks
|
||||
.post_exit
|
||||
.clone()
|
||||
.or(settings.hooks.post_exit)
|
||||
.filter(|hook_command| !hook_command.is_empty());
|
||||
|
||||
// Any options.txt settings that we want set, add here
|
||||
let mut mc_set_options: Vec<(String, String)> = vec![];
|
||||
@@ -872,15 +881,12 @@ pub async fn create_mrpack_json(
|
||||
env.insert(EnvType::Client, SideType::Required);
|
||||
env.insert(EnvType::Server, SideType::Required);
|
||||
|
||||
let primary_file =
|
||||
if let Some(primary_file) = version.files.first() {
|
||||
primary_file
|
||||
} else {
|
||||
return Some(Err(crate::ErrorKind::OtherError(
|
||||
format!("No primary file found for mod at: {path}"),
|
||||
)
|
||||
.as_error()));
|
||||
};
|
||||
let Some(primary_file) = version.files.first() else {
|
||||
return Some(Err(crate::ErrorKind::OtherError(format!(
|
||||
"No primary file found for mod at: {path}"
|
||||
))
|
||||
.as_error()));
|
||||
};
|
||||
|
||||
let file_size = primary_file.size;
|
||||
let downloads = vec![primary_file.url.clone()];
|
||||
|
||||
@@ -255,7 +255,7 @@ async fn get_all_worlds_in_profile(
|
||||
AttachedWorldData::get_all_for_instance(profile_path, &state.pool)
|
||||
.await?;
|
||||
if !attached_data.is_empty() {
|
||||
for world in worlds.iter_mut() {
|
||||
for world in &mut worlds {
|
||||
if let Some(data) = attached_data
|
||||
.get(&(world.world_type(), world.world_id().to_owned()))
|
||||
{
|
||||
|
||||
@@ -139,9 +139,7 @@ pub async fn edit_loading(
|
||||
// increment refers to by what relative increment to the loading struct's total to update
|
||||
// message is the message to display on the loading bar- if None, use the loading bar's default one
|
||||
// By convention, fraction is the fraction of the progress bar that is filled
|
||||
#[allow(unused_variables)]
|
||||
#[tracing::instrument(level = "debug")]
|
||||
|
||||
pub fn emit_loading(
|
||||
key: &LoadingBarId,
|
||||
increment_frac: f64,
|
||||
@@ -149,22 +147,13 @@ pub fn emit_loading(
|
||||
) -> crate::Result<()> {
|
||||
let event_state = crate::EventState::get()?;
|
||||
|
||||
let mut loading_bar = match event_state.loading_bars.get_mut(&key.0) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
return Err(EventError::NoLoadingBar(key.0).into());
|
||||
}
|
||||
let Some(mut loading_bar) = event_state.loading_bars.get_mut(&key.0) else {
|
||||
return Err(EventError::NoLoadingBar(key.0).into());
|
||||
};
|
||||
|
||||
// Tick up loading bar
|
||||
loading_bar.current += increment_frac;
|
||||
let display_frac = loading_bar.current / loading_bar.total;
|
||||
let opt_display_frac = if display_frac >= 1.0 {
|
||||
None // by convention, when its done, we submit None
|
||||
// any further updates will be ignored (also sending None)
|
||||
} else {
|
||||
Some(display_frac)
|
||||
};
|
||||
|
||||
if f64::abs(display_frac - loading_bar.last_sent) > 0.005 {
|
||||
// Emit event to indicatif progress bar
|
||||
@@ -187,7 +176,12 @@ pub fn emit_loading(
|
||||
.emit(
|
||||
"loading",
|
||||
LoadingPayload {
|
||||
fraction: opt_display_frac,
|
||||
fraction: if display_frac >= 1.0 {
|
||||
None // by convention, when its done, we submit None
|
||||
// any further updates will be ignored (also sending None)
|
||||
} else {
|
||||
Some(display_frac)
|
||||
},
|
||||
message: message
|
||||
.unwrap_or(&loading_bar.message)
|
||||
.to_string(),
|
||||
@@ -197,6 +191,9 @@ pub fn emit_loading(
|
||||
)
|
||||
.map_err(EventError::from)?;
|
||||
|
||||
#[cfg(not(any(feature = "cli", feature = "tauri")))]
|
||||
let _ = message;
|
||||
|
||||
loading_bar.last_sent = display_frac;
|
||||
}
|
||||
|
||||
@@ -204,8 +201,6 @@ pub fn emit_loading(
|
||||
}
|
||||
|
||||
// emit_warning(message)
|
||||
#[allow(dead_code)]
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_warning(message: &str) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
@@ -227,8 +222,6 @@ pub async fn emit_warning(message: &str) -> crate::Result<()> {
|
||||
// emit_command(CommandPayload::Something { something })
|
||||
// ie: installing a pack, opening an .mrpack, etc
|
||||
// Generally used for url deep links and file opens that we want to handle in the frontend
|
||||
#[allow(dead_code)]
|
||||
#[allow(unused_variables)]
|
||||
pub async fn emit_command(command: CommandPayload) -> crate::Result<()> {
|
||||
tracing::debug!("Command: {}", serde_json::to_string(&command)?);
|
||||
#[cfg(feature = "tauri")]
|
||||
|
||||
@@ -87,9 +87,9 @@ pub fn get_lib_path(
|
||||
lib: &str,
|
||||
allow_not_exist: bool,
|
||||
) -> crate::Result<String> {
|
||||
let mut path = libraries_path.to_path_buf();
|
||||
|
||||
path.push(get_path_from_artifact(lib)?);
|
||||
let path = libraries_path
|
||||
.to_path_buf()
|
||||
.join(get_path_from_artifact(lib)?);
|
||||
|
||||
if !path.exists() && allow_not_exist {
|
||||
return Ok(path.to_string_lossy().to_string());
|
||||
|
||||
@@ -37,12 +37,7 @@ pub async fn download_minecraft(
|
||||
let assets_index =
|
||||
download_assets_index(st, version, Some(loading_bar), force).await?;
|
||||
|
||||
let amount = if version
|
||||
.processors
|
||||
.as_ref()
|
||||
.map(|x| !x.is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let amount = if version.processors.as_ref().is_some_and(|x| !x.is_empty()) {
|
||||
25.0
|
||||
} else {
|
||||
40.0
|
||||
|
||||
@@ -15,9 +15,10 @@ use daedalus as d;
|
||||
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
|
||||
use daedalus::modded::LoaderVersion;
|
||||
use rand::seq::SliceRandom; // AstralRinth
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use st::Profile;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -139,8 +140,7 @@ pub async fn get_java_version_from_profile(
|
||||
let key = version_info
|
||||
.java_version
|
||||
.as_ref()
|
||||
.map(|it| it.major_version)
|
||||
.unwrap_or(8);
|
||||
.map_or(8, |it| it.major_version);
|
||||
|
||||
let state = State::get().await?;
|
||||
|
||||
@@ -255,8 +255,7 @@ pub async fn install_minecraft(
|
||||
|
||||
let loader_version_id = loader_version.clone();
|
||||
crate::api::profile::edit(&profile.path, |prof| {
|
||||
prof.loader_version =
|
||||
loader_version_id.clone().map(|x| x.id.clone());
|
||||
prof.loader_version = loader_version_id.clone().map(|x| x.id);
|
||||
|
||||
async { Ok(()) }
|
||||
})
|
||||
@@ -281,8 +280,7 @@ pub async fn install_minecraft(
|
||||
let key = version_info
|
||||
.java_version
|
||||
.as_ref()
|
||||
.map(|it| it.major_version)
|
||||
.unwrap_or(8);
|
||||
.map_or(8, |it| it.major_version);
|
||||
let (java_version, set_java) = if let Some(java_version) =
|
||||
get_java_version_from_profile(profile, &version_info).await?
|
||||
{
|
||||
@@ -355,9 +353,11 @@ pub async fn install_minecraft(
|
||||
}
|
||||
}
|
||||
|
||||
let cp = wrap_ref_builder!(cp = processor.classpath.clone() => {
|
||||
cp.push(processor.jar.clone())
|
||||
});
|
||||
let cp = {
|
||||
let mut cp = processor.classpath.clone();
|
||||
cp.push(processor.jar.clone());
|
||||
cp
|
||||
};
|
||||
|
||||
let child = Command::new(&java_version.path)
|
||||
.arg("-cp")
|
||||
@@ -580,7 +580,9 @@ pub async fn launch_minecraft(
|
||||
let args = version_info.arguments.clone().unwrap_or_default();
|
||||
let mut command = match wrapper {
|
||||
Some(hook) => {
|
||||
wrap_ref_builder!(it = Command::new(hook) => {it.arg(&java_version.path)})
|
||||
let mut command = Command::new(hook);
|
||||
command.arg(&java_version.path);
|
||||
command
|
||||
}
|
||||
None => Command::new(&java_version.path),
|
||||
};
|
||||
@@ -629,8 +631,7 @@ pub async fn launch_minecraft(
|
||||
.as_ref()
|
||||
.and_then(|x| x.get(&LoggingSide::Client)),
|
||||
)?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
.into_iter(),
|
||||
)
|
||||
.arg(version_info.main_class.clone())
|
||||
.args(
|
||||
@@ -648,8 +649,7 @@ pub async fn launch_minecraft(
|
||||
&java_version.architecture,
|
||||
quick_play_type,
|
||||
)?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
.into_iter(),
|
||||
)
|
||||
.current_dir(instance_path.clone());
|
||||
|
||||
@@ -665,20 +665,35 @@ pub async fn launch_minecraft(
|
||||
|
||||
// Overwrites the minecraft options.txt file with the settings from the profile
|
||||
// Uses 'a:b' syntax which is not quite yaml
|
||||
use regex::Regex;
|
||||
|
||||
if !mc_set_options.is_empty() {
|
||||
let options_path = instance_path.join("options.txt");
|
||||
let mut options_string = String::new();
|
||||
if options_path.exists() {
|
||||
options_string = io::read_to_string(&options_path).await?;
|
||||
|
||||
let (mut options_string, input_encoding) = if options_path.exists() {
|
||||
io::read_any_encoding_to_string(&options_path).await?
|
||||
} else {
|
||||
(String::new(), encoding_rs::UTF_8)
|
||||
};
|
||||
|
||||
// UTF-16 encodings may be successfully detected and read, but we cannot encode
|
||||
// them back, and it's technically possible that the game client strongly expects
|
||||
// such encoding
|
||||
if input_encoding != input_encoding.output_encoding() {
|
||||
return Err(crate::ErrorKind::LauncherError(format!(
|
||||
"The instance options.txt file uses an unsupported encoding: {}. \
|
||||
Please either turn off instance options that need to modify this file, \
|
||||
or convert the file to an encoding that both the game and this app support, \
|
||||
such as UTF-8.",
|
||||
input_encoding.name()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
for (key, value) in mc_set_options {
|
||||
let re = Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?;
|
||||
// check if the regex exists in the file
|
||||
if !re.is_match(&options_string) {
|
||||
// The key was not found in the file, so append it
|
||||
options_string.push_str(&format!("\n{key}:{value}"));
|
||||
write!(&mut options_string, "\n{key}:{value}").unwrap();
|
||||
} else {
|
||||
let replaced_string = re
|
||||
.replace_all(&options_string, &format!("{key}:{value}"))
|
||||
@@ -687,7 +702,8 @@ pub async fn launch_minecraft(
|
||||
}
|
||||
}
|
||||
|
||||
io::write(&options_path, options_string).await?;
|
||||
io::write(&options_path, input_encoding.encode(&options_string).0)
|
||||
.await?;
|
||||
}
|
||||
|
||||
crate::api::profile::edit(&profile.path, |prof| {
|
||||
@@ -697,31 +713,6 @@ pub async fn launch_minecraft(
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut censor_strings = HashMap::new();
|
||||
let username = whoami::username();
|
||||
censor_strings
|
||||
.insert(format!("/{username}/"), "/{COMPUTER_USERNAME}/".to_string());
|
||||
censor_strings.insert(
|
||||
format!("\\{username}\\"),
|
||||
"\\{COMPUTER_USERNAME}\\".to_string(),
|
||||
);
|
||||
censor_strings.insert(
|
||||
credentials.access_token.clone(),
|
||||
"{MINECRAFT_ACCESS_TOKEN}".to_string(),
|
||||
);
|
||||
censor_strings.insert(
|
||||
credentials.username.clone(),
|
||||
"{MINECRAFT_USERNAME}".to_string(),
|
||||
);
|
||||
censor_strings.insert(
|
||||
credentials.id.as_simple().to_string(),
|
||||
"{MINECRAFT_UUID}".to_string(),
|
||||
);
|
||||
censor_strings.insert(
|
||||
credentials.id.as_hyphenated().to_string(),
|
||||
"{MINECRAFT_UUID}".to_string(),
|
||||
);
|
||||
|
||||
// If in tauri, and the 'minimize on launch' setting is enabled, minimize the window
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
|
||||
@@ -461,8 +461,7 @@ impl CacheValue {
|
||||
CacheValue::Team(members) => members
|
||||
.iter()
|
||||
.next()
|
||||
.map(|x| x.team_id.as_str())
|
||||
.unwrap_or(DEFAULT_ID)
|
||||
.map_or(DEFAULT_ID, |x| x.team_id.as_str())
|
||||
.to_string(),
|
||||
CacheValue::Organization(org) => org.id.clone(),
|
||||
CacheValue::File(file) => file.hash.clone(),
|
||||
@@ -556,7 +555,6 @@ macro_rules! impl_cache_methods {
|
||||
$(
|
||||
paste::paste! {
|
||||
#[tracing::instrument(skip(pool, fetch_semaphore))]
|
||||
#[allow(dead_code)]
|
||||
pub async fn [<get_ $variant:snake>](
|
||||
id: &str,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
@@ -568,7 +566,6 @@ macro_rules! impl_cache_methods {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(pool, fetch_semaphore))]
|
||||
#[allow(dead_code)]
|
||||
pub async fn [<get_ $variant:snake _many>](
|
||||
ids: &[&str],
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
@@ -597,7 +594,6 @@ macro_rules! impl_cache_method_singular {
|
||||
$(
|
||||
paste::paste! {
|
||||
#[tracing::instrument(skip(pool, fetch_semaphore))]
|
||||
#[allow(dead_code)]
|
||||
pub async fn [<get_ $variant:snake>] (
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
@@ -735,18 +731,13 @@ impl CachedEntry {
|
||||
|
||||
remaining_keys.retain(|x| {
|
||||
x != &&*row.id
|
||||
&& !row
|
||||
.alias
|
||||
.as_ref()
|
||||
.map(|y| {
|
||||
if type_.case_sensitive_alias().unwrap_or(true)
|
||||
{
|
||||
x == y
|
||||
} else {
|
||||
y.to_lowercase() == x.to_lowercase()
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
&& !row.alias.as_ref().is_some_and(|y| {
|
||||
if type_.case_sensitive_alias().unwrap_or(true) {
|
||||
x == y
|
||||
} else {
|
||||
y.to_lowercase() == x.to_lowercase()
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(data) = parsed_data {
|
||||
@@ -991,7 +982,7 @@ impl CachedEntry {
|
||||
let key = key.to_string();
|
||||
|
||||
if let Some(position) = teams.iter().position(|x| {
|
||||
x.first().map(|x| x.team_id == key).unwrap_or(false)
|
||||
x.first().is_some_and(|x| x.team_id == key)
|
||||
}) {
|
||||
let team = teams.remove(position);
|
||||
|
||||
|
||||
@@ -47,9 +47,8 @@ impl DirectoryInfo {
|
||||
))
|
||||
})?;
|
||||
|
||||
let config_dir = config_dir
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| settings_dir.clone());
|
||||
let config_dir =
|
||||
config_dir.map_or_else(|| settings_dir.clone(), PathBuf::from);
|
||||
|
||||
Ok(Self {
|
||||
settings_dir,
|
||||
@@ -198,8 +197,7 @@ impl DirectoryInfo {
|
||||
let move_dir = settings
|
||||
.custom_dir
|
||||
.as_ref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| app_dir.clone());
|
||||
.map_or_else(|| app_dir.clone(), PathBuf::from);
|
||||
|
||||
async fn is_dir_writeable(
|
||||
new_config_dir: &Path,
|
||||
@@ -225,7 +223,7 @@ impl DirectoryInfo {
|
||||
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list();
|
||||
|
||||
for disk in disks.iter() {
|
||||
for disk in &disks {
|
||||
if path.starts_with(disk.mount_point()) {
|
||||
return Ok(Some(disk.available_space()));
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ impl FriendsSocket {
|
||||
ServerToClientMessage::FriendRequest { from } => {
|
||||
let _ = emit_friend(FriendPayload::FriendRequest { from }).await;
|
||||
}
|
||||
ServerToClientMessage::FriendRequestRejected { .. } => todo!(),
|
||||
ServerToClientMessage::FriendRequestRejected { .. } => {}, // TODO
|
||||
|
||||
ServerToClientMessage::FriendSocketListening { .. } => {}, // TODO
|
||||
ServerToClientMessage::FriendSocketStoppedListening { .. } => {}, // TODO
|
||||
|
||||
@@ -29,9 +29,7 @@ where
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let old_launcher_root = if let Some(dir) = default_settings_dir() {
|
||||
dir
|
||||
} else {
|
||||
let Some(old_launcher_root) = default_settings_dir() else {
|
||||
return Ok(());
|
||||
};
|
||||
let old_launcher_root_str = old_launcher_root.to_string_lossy().to_string();
|
||||
@@ -177,12 +175,10 @@ where
|
||||
|
||||
let profile_path = entry.path().join("profile.json");
|
||||
|
||||
let profile = if let Ok(profile) =
|
||||
let Ok(profile) =
|
||||
read_json::<LegacyProfile>(&profile_path, &io_semaphore)
|
||||
.await
|
||||
{
|
||||
profile
|
||||
} else {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -285,7 +281,7 @@ where
|
||||
|
||||
TeamMember {
|
||||
team_id: x.team_id,
|
||||
user: user.clone(),
|
||||
user,
|
||||
is_owner: x.role == "Owner",
|
||||
role: x.role,
|
||||
ordering: x.ordering,
|
||||
|
||||
@@ -1177,12 +1177,10 @@ fn get_date_header(headers: &HeaderMap) -> DateTime<Utc> {
|
||||
.get(reqwest::header::DATE)
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.and_then(|x| DateTime::parse_from_rfc2822(x).ok())
|
||||
.map(|x| x.with_timezone(&Utc))
|
||||
.unwrap_or(Utc::now())
|
||||
.map_or(Utc::now(), |x| x.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
#[allow(clippy::format_collect)]
|
||||
fn generate_oauth_challenge() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
|
||||
@@ -692,7 +692,7 @@ impl Process {
|
||||
let mut cmd = hook.split(' ');
|
||||
if let Some(command) = cmd.next() {
|
||||
let mut command = Command::new(command);
|
||||
command.args(cmd.collect::<Vec<&str>>()).current_dir(
|
||||
command.args(cmd).current_dir(
|
||||
profile::get_full_path(&profile_path).await?,
|
||||
);
|
||||
command.spawn().map_err(IOError::from)?;
|
||||
|
||||
@@ -1022,8 +1022,10 @@ impl Profile {
|
||||
file.hash,
|
||||
file.project_type
|
||||
.filter(|x| *x != ProjectType::Mod)
|
||||
.map(|x| x.get_loaders().join("+"))
|
||||
.unwrap_or_else(|| profile.loader.as_str().to_string()),
|
||||
.map_or_else(
|
||||
|| profile.loader.as_str().to_string(),
|
||||
|x| x.get_loaders().join("+")
|
||||
),
|
||||
profile.game_version
|
||||
)
|
||||
}
|
||||
|
||||
@@ -247,9 +247,13 @@ pub struct WindowSize(pub u16, pub u16);
|
||||
|
||||
/// Game initialization hooks
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde_with::serde_as]
|
||||
pub struct Hooks {
|
||||
#[serde_as(as = "serde_with::NoneAsEmptyString")]
|
||||
pub pre_launch: Option<String>,
|
||||
#[serde_as(as = "serde_with::NoneAsEmptyString")]
|
||||
pub wrapper: Option<String>,
|
||||
#[serde_as(as = "serde_with::NoneAsEmptyString")]
|
||||
pub post_exit: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -80,10 +80,9 @@ pub async fn fetch_advanced(
|
||||
) -> crate::Result<Bytes> {
|
||||
let _permit = semaphore.0.acquire().await?;
|
||||
|
||||
let creds = if !header
|
||||
let creds = if header
|
||||
.as_ref()
|
||||
.map(|x| &*x.0.to_lowercase() == "authorization")
|
||||
.unwrap_or(false)
|
||||
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
|
||||
&& (url.starts_with("https://cdn.modrinth.com")
|
||||
|| url.starts_with(MODRINTH_API_URL)
|
||||
|| url.starts_with(MODRINTH_API_URL_V3))
|
||||
|
||||
@@ -35,7 +35,6 @@ impl IOError {
|
||||
}
|
||||
}
|
||||
|
||||
// dunce canonicalize
|
||||
pub fn canonicalize(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<std::path::PathBuf, IOError> {
|
||||
@@ -46,7 +45,6 @@ pub fn canonicalize(
|
||||
})
|
||||
}
|
||||
|
||||
// read_dir
|
||||
pub async fn read_dir(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<tokio::fs::ReadDir, IOError> {
|
||||
@@ -59,7 +57,6 @@ pub async fn read_dir(
|
||||
})
|
||||
}
|
||||
|
||||
// create_dir
|
||||
pub async fn create_dir(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
@@ -72,7 +69,6 @@ pub async fn create_dir(
|
||||
})
|
||||
}
|
||||
|
||||
// create_dir_all
|
||||
pub async fn create_dir_all(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
@@ -85,7 +81,6 @@ pub async fn create_dir_all(
|
||||
})
|
||||
}
|
||||
|
||||
// remove_dir_all
|
||||
pub async fn remove_dir_all(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
@@ -98,20 +93,37 @@ pub async fn remove_dir_all(
|
||||
})
|
||||
}
|
||||
|
||||
// read_to_string
|
||||
pub async fn read_to_string(
|
||||
/// Reads a text file to a string, automatically detecting its encoding and
|
||||
/// substituting any invalid characters with the Unicode replacement character.
|
||||
///
|
||||
/// This function is best suited for reading Minecraft instance files, whose
|
||||
/// encoding may vary depending on the platform, launchers, client versions
|
||||
/// (older Minecraft versions tended to rely on the system's default codepage
|
||||
/// more on Windows platforms), and mods used, while not being highly sensitive
|
||||
/// to occasional occurrences of mojibake or character replacements.
|
||||
pub async fn read_any_encoding_to_string(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<String, IOError> {
|
||||
) -> Result<(String, &'static encoding_rs::Encoding), IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})
|
||||
let file_bytes =
|
||||
tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
})?;
|
||||
|
||||
let file_encoding = {
|
||||
let mut encoding_detector = chardetng::EncodingDetector::new();
|
||||
encoding_detector.feed(&file_bytes, true);
|
||||
encoding_detector.guess(None, true)
|
||||
};
|
||||
|
||||
let (file_string, actual_file_encoding, _) =
|
||||
file_encoding.decode(&file_bytes);
|
||||
Ok((file_string.to_string(), actual_file_encoding))
|
||||
}
|
||||
|
||||
// read
|
||||
pub async fn read(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<Vec<u8>, IOError> {
|
||||
@@ -124,7 +136,6 @@ pub async fn read(
|
||||
})
|
||||
}
|
||||
|
||||
// write
|
||||
pub async fn write(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
data: impl AsRef<[u8]>,
|
||||
@@ -186,7 +197,6 @@ pub fn is_same_disk(old_dir: &Path, new_dir: &Path) -> Result<bool, IOError> {
|
||||
}
|
||||
}
|
||||
|
||||
// rename
|
||||
pub async fn rename_or_move(
|
||||
from: impl AsRef<std::path::Path>,
|
||||
to: impl AsRef<std::path::Path>,
|
||||
@@ -228,7 +238,6 @@ async fn move_recursive(from: &Path, to: &Path) -> Result<(), IOError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// copy
|
||||
pub async fn copy(
|
||||
from: impl AsRef<std::path::Path>,
|
||||
to: impl AsRef<std::path::Path>,
|
||||
@@ -243,7 +252,6 @@ pub async fn copy(
|
||||
})
|
||||
}
|
||||
|
||||
// remove file
|
||||
pub async fn remove_file(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
@@ -256,7 +264,6 @@ pub async fn remove_file(
|
||||
})
|
||||
}
|
||||
|
||||
// open file
|
||||
pub async fn open_file(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<tokio::fs::File, IOError> {
|
||||
@@ -269,7 +276,6 @@ pub async fn open_file(
|
||||
})
|
||||
}
|
||||
|
||||
// remove dir
|
||||
pub async fn remove_dir(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<(), IOError> {
|
||||
@@ -282,7 +288,6 @@ pub async fn remove_dir(
|
||||
})
|
||||
}
|
||||
|
||||
// metadata
|
||||
pub async fn metadata(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
) -> Result<std::fs::Metadata, IOError> {
|
||||
|
||||
@@ -227,13 +227,11 @@ async fn get_all_jre_path() -> HashSet<PathBuf> {
|
||||
paths.unwrap_or_else(|_| HashSet::new())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(dead_code)]
|
||||
pub const JAVA_BIN: &str = "javaw.exe";
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[allow(dead_code)]
|
||||
pub const JAVA_BIN: &str = "java";
|
||||
pub const JAVA_BIN: &str = if cfg!(target_os = "windows") {
|
||||
"javaw.exe"
|
||||
} else {
|
||||
"java"
|
||||
};
|
||||
|
||||
// For each example filepath in 'paths', perform check_java_at_filepath, checking each one concurrently
|
||||
// and returning a JavaVersion for every valid path that points to a java bin
|
||||
@@ -249,7 +247,7 @@ pub async fn check_java_at_filepaths(
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
jres.into_iter().flat_map(|x| x.ok()).flatten().collect()
|
||||
jres.into_iter().filter_map(|x| x.ok()).flatten().collect()
|
||||
}
|
||||
|
||||
// For example filepath 'path', attempt to resolve it and get a Java version at this path
|
||||
|
||||
@@ -5,15 +5,3 @@ pub mod jre;
|
||||
pub mod platform;
|
||||
pub mod utils; // AstralRinth
|
||||
pub mod server_ping;
|
||||
|
||||
/// Wrap a builder which uses a mut reference into one which outputs an owned value
|
||||
macro_rules! wrap_ref_builder {
|
||||
($id:ident = $init:expr => $transform:block) => {{
|
||||
let mut it = $init;
|
||||
{
|
||||
let $id = &mut it;
|
||||
$transform;
|
||||
}
|
||||
it
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "ariadne"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -13,3 +13,6 @@ rand.workspace = true
|
||||
either.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
serde_cbor.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="absolute right-8 top-8 size-8"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-cpu-icon lucide-cpu">
|
||||
<path d="M12 20v2" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M17 20v2" />
|
||||
<path d="M17 2v2" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M2 17h2" />
|
||||
<path d="M2 7h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="M20 17h2" />
|
||||
<path d="M20 7h2" />
|
||||
<path d="M7 20v2" />
|
||||
<path d="M7 2v2" />
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="8" y="8" width="8" height="8" rx="1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 527 B After Width: | Height: | Size: 648 B |
1
packages/assets/icons/database.svg
Normal file
1
packages/assets/icons/database.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database-icon lucide-database"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="absolute right-8 top-8 size-8"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 633 B |
1
packages/assets/icons/memory-stick.svg
Normal file
1
packages/assets/icons/memory-stick.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-memory-stick-icon lucide-memory-stick"><path d="M6 19v-3"/><path d="M10 19v-3"/><path d="M14 19v-3"/><path d="M18 19v-3"/><path d="M8 11V9"/><path d="M16 11V9"/><path d="M12 11V9"/><path d="M2 15h20"/><path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z"/></svg>
|
||||
|
After Width: | Height: | Size: 535 B |
@@ -77,6 +77,7 @@ import _CopyrightIcon from './icons/copyright.svg?component'
|
||||
import _CrownIcon from './icons/crown.svg?component'
|
||||
import _CurrencyIcon from './icons/currency.svg?component'
|
||||
import _DashboardIcon from './icons/dashboard.svg?component'
|
||||
import _DatabaseIcon from './icons/database.svg?component'
|
||||
import _DownloadIcon from './icons/download.svg?component'
|
||||
import _DropdownIcon from './icons/dropdown.svg?component'
|
||||
import _EditIcon from './icons/edit.svg?component'
|
||||
@@ -125,6 +126,7 @@ import _LogOutIcon from './icons/log-out.svg?component'
|
||||
import _MailIcon from './icons/mail.svg?component'
|
||||
import _ManageIcon from './icons/manage.svg?component'
|
||||
import _MaximizeIcon from './icons/maximize.svg?component'
|
||||
import _MemoryStickIcon from './icons/memory-stick.svg?component'
|
||||
import _MessageIcon from './icons/message.svg?component'
|
||||
import _MicrophoneIcon from './icons/microphone.svg?component'
|
||||
import _MinimizeIcon from './icons/minimize.svg?component'
|
||||
@@ -205,7 +207,6 @@ import _CubeIcon from './icons/cube.svg?component'
|
||||
import _CloudIcon from './icons/cloud.svg?component'
|
||||
import _CogIcon from './icons/cog.svg?component'
|
||||
import _CPUIcon from './icons/cpu.svg?component'
|
||||
import _DBIcon from './icons/db.svg?component'
|
||||
import _LoaderIcon from './icons/loader.svg?component'
|
||||
import _ImportIcon from './icons/import.svg?component'
|
||||
import _TimerIcon from './icons/timer.svg?component'
|
||||
@@ -311,6 +312,7 @@ export const CopyrightIcon = _CopyrightIcon
|
||||
export const CrownIcon = _CrownIcon
|
||||
export const CurrencyIcon = _CurrencyIcon
|
||||
export const DashboardIcon = _DashboardIcon
|
||||
export const DatabaseIcon = _DatabaseIcon
|
||||
export const DownloadIcon = _DownloadIcon
|
||||
export const DropdownIcon = _DropdownIcon
|
||||
export const EditIcon = _EditIcon
|
||||
@@ -360,6 +362,7 @@ export const LogOutIcon = _LogOutIcon
|
||||
export const MailIcon = _MailIcon
|
||||
export const ManageIcon = _ManageIcon
|
||||
export const MaximizeIcon = _MaximizeIcon
|
||||
export const MemoryStickIcon = _MemoryStickIcon
|
||||
export const MessageIcon = _MessageIcon
|
||||
export const MicrophoneIcon = _MicrophoneIcon
|
||||
export const MinimizeIcon = _MinimizeIcon
|
||||
@@ -450,7 +453,6 @@ export const CubeIcon = _CubeIcon
|
||||
export const CloudIcon = _CloudIcon
|
||||
export const CogIcon = _CogIcon
|
||||
export const CPUIcon = _CPUIcon
|
||||
export const DBIcon = _DBIcon
|
||||
export const LoaderIcon = _LoaderIcon
|
||||
export const ImportIcon = _ImportIcon
|
||||
export const CardIcon = _CardIcon
|
||||
|
||||
@@ -200,6 +200,8 @@ html {
|
||||
--color-platform-sponge: #f9e580;
|
||||
|
||||
--hover-brightness: 1.25;
|
||||
|
||||
--experimental-color-button-bg: #33363d;
|
||||
}
|
||||
|
||||
.oled-mode {
|
||||
@@ -257,7 +259,7 @@ html {
|
||||
}
|
||||
|
||||
.dark-experiments {
|
||||
--color-button-bg: #33363d;
|
||||
--color-button-bg: var(--experimental-color-button-bg);
|
||||
}
|
||||
|
||||
.dark-mode:not(.oled-mode),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "daedalus"
|
||||
version = "0.2.3"
|
||||
authors = ["Jai A <jai@modrinth.com>"]
|
||||
edition = "2024"
|
||||
edition.workspace = true
|
||||
license = "MIT"
|
||||
description = "Utilities for querying and parsing Minecraft metadata"
|
||||
repository = "https://github.com/modrinth/daedalus/"
|
||||
@@ -18,3 +18,6 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
thiserror.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
"name": "@modrinth/daedalus",
|
||||
"scripts": {
|
||||
"build": "cargo build --release",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
|
||||
"fix": "cargo fmt && cargo clippy --fix",
|
||||
"dev": "cargo run",
|
||||
"test": "cargo test"
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
|
||||
"test": "cargo nextest run --all-targets --no-fail-fast"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::minecraft::{
|
||||
Argument, ArgumentType, Library, VersionInfo, VersionType,
|
||||
};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -26,7 +26,6 @@ pub struct SidedDataEntry {
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
fn deserialize_date<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
@@ -34,7 +33,10 @@ where
|
||||
let s = String::deserialize(deserializer)?;
|
||||
|
||||
serde_json::from_str::<DateTime<Utc>>(&format!("\"{s}\""))
|
||||
.or_else(|_| Utc.datetime_from_str(&s, "%Y-%m-%dT%H:%M:%S%.9f"))
|
||||
.or_else(|_| {
|
||||
NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.9f")
|
||||
.map(|date| date.and_utc())
|
||||
})
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@stripe/stripe-js": "^7.3.1",
|
||||
"@vintl/unplugin": "^1.5.1",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"stripe": "^18.1.1",
|
||||
"tsconfig": "workspace:*",
|
||||
"typescript": "^5.4.5",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
120
packages/ui/src/components/base/ErrorInformationCard.vue
Normal file
120
packages/ui/src/components/base/ErrorInformationCard.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-8 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<component :is="icon" class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">{{ title }}</h1>
|
||||
</div>
|
||||
<div v-if="!description">
|
||||
<slot name="description" />
|
||||
</div>
|
||||
<p v-else class="text-lg text-secondary">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="errorDetails" class="my-4 w-full rounded-lg border border-divider bg-bg-raised">
|
||||
<div class="divide-y divide-divider">
|
||||
<div
|
||||
v-for="detail in errorDetails.filter((detail) => detail.type !== 'hidden')"
|
||||
:key="detail.label"
|
||||
class="px-4 py-3"
|
||||
>
|
||||
<div v-if="detail.type === 'inline'" class="flex items-center justify-between">
|
||||
<span class="font-medium text-secondary">{{ detail.label }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="rounded-lg bg-code-bg px-2 py-1 text-sm text-code-text">
|
||||
{{ detail.value }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detail.type === 'block'" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-secondary">{{ detail.label }}</span>
|
||||
</div>
|
||||
<div class="w-full overflow-hidden rounded-lg bg-code-bg p-3">
|
||||
<code
|
||||
class="block w-full overflow-x-auto break-words text-sm text-code-text whitespace-pre-wrap"
|
||||
>
|
||||
{{ detail.value }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex !w-full flex-row gap-4">
|
||||
<ButtonStyled
|
||||
v-if="action"
|
||||
size="large"
|
||||
:color="action.color || 'brand'"
|
||||
:disabled="action.disabled"
|
||||
@click="action.onClick"
|
||||
>
|
||||
<button class="!w-full">
|
||||
<component :is="action.icon" v-if="action.icon && !action.showAltIcon" class="size-4" />
|
||||
<component
|
||||
:is="action.altIcon"
|
||||
v-else-if="action.icon && action.showAltIcon"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled v-if="errorDetails" size="large" color="standard" @click="copyErrorInformation">
|
||||
<button class="!w-full">
|
||||
<CopyIcon v-if="!infoCopied" class="size-4" />
|
||||
<CheckIcon v-else class="size-4" />
|
||||
Copy Information
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import { CopyIcon, CheckIcon } from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
const infoCopied = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
icon: Component
|
||||
errorDetails?: {
|
||||
label?: string
|
||||
value?: string
|
||||
type?: 'inline' | 'block' | 'hidden'
|
||||
}[]
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
color?: 'brand' | 'standard' | 'red' | 'orange' | 'blue'
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
altIcon?: Component
|
||||
showAltIcon?: boolean
|
||||
}
|
||||
}>()
|
||||
|
||||
const copyErrorInformation = async () => {
|
||||
if (!props.errorDetails || props.errorDetails.length === 0) return
|
||||
|
||||
const formattedErrorInfo = props.errorDetails
|
||||
.filter((detail) => detail.label && detail.value)
|
||||
.map((detail) => `${detail.label}: ${detail.value}`)
|
||||
.join('\n\n')
|
||||
|
||||
await navigator.clipboard.writeText(formattedErrorInfo)
|
||||
infoCopied.value = true
|
||||
setTimeout(() => {
|
||||
infoCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
@@ -34,7 +34,7 @@
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ScrollablePanel v-if="search" class="h-[17rem]">
|
||||
<ScrollablePanel v-if="search">
|
||||
<Button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`option-${index}`"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="`radio-button-${index}`"
|
||||
class="p-0 py-2 px-2 border-0 flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
|
||||
class="p-0 py-2 px-2 border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
|
||||
:class="{
|
||||
'text-contrast font-medium bg-button-bg': selected === item,
|
||||
'text-contrast bg-button-bg': selected === item,
|
||||
'text-primary bg-transparent': selected !== item,
|
||||
}"
|
||||
@click="selected = item"
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
<div
|
||||
class="wrapper-wrapper"
|
||||
:class="{
|
||||
'top-fade': !scrollableAtTop && !props.disableScrolling,
|
||||
'bottom-fade': !scrollableAtBottom && !props.disableScrolling,
|
||||
'top-fade': !scrollableAtTop && !disableScrolling,
|
||||
'bottom-fade': !scrollableAtBottom && !disableScrolling,
|
||||
}"
|
||||
>
|
||||
<div ref="scrollablePane" class="scrollable-pane" @scroll="onScroll">
|
||||
<div
|
||||
ref="scrollablePane"
|
||||
:class="{
|
||||
'max-h-[19rem]': !disableScrolling,
|
||||
}"
|
||||
class="scrollable-pane"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,7 +24,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disableScrolling?: boolean
|
||||
}>(),
|
||||
|
||||
104
packages/ui/src/components/billing/AddPaymentMethod.vue
Normal file
104
packages/ui/src/components/billing/AddPaymentMethod.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { createStripeElements } from '@modrinth/utils'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import { loadStripe, type Stripe as StripsJs, type StripeElements } from '@stripe/stripe-js'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startLoading' | 'stopLoading'): void
|
||||
}>()
|
||||
|
||||
export type SetupIntentResponse = {
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
export type AddPaymentMethodProps = {
|
||||
publishableKey: string
|
||||
createSetupIntent: () => Promise<SetupIntentResponse>
|
||||
returnUrl: string
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
|
||||
const props = defineProps<AddPaymentMethodProps>()
|
||||
|
||||
const elementsLoaded = ref<0 | 1 | 2>(0)
|
||||
const stripe = ref<StripsJs>()
|
||||
const elements = ref<StripeElements>()
|
||||
const error = ref(false)
|
||||
|
||||
function handleError(error: Error) {
|
||||
props.onError(error)
|
||||
error.value = true
|
||||
}
|
||||
|
||||
async function reload(paymentMethods: Stripe.PaymentMethod[]) {
|
||||
try {
|
||||
elementsLoaded.value = 0
|
||||
error.value = false
|
||||
|
||||
const result = await props.createSetupIntent()
|
||||
|
||||
stripe.value = await loadStripe(props.publishableKey)
|
||||
const {
|
||||
elements: newElements,
|
||||
addressElement,
|
||||
paymentElement,
|
||||
} = createStripeElements(stripe.value, paymentMethods, {
|
||||
clientSecret: result.client_secret,
|
||||
})
|
||||
|
||||
elements.value = newElements
|
||||
paymentElement.on('ready', () => {
|
||||
elementsLoaded.value += 1
|
||||
})
|
||||
addressElement.on('ready', () => {
|
||||
elementsLoaded.value += 1
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<boolean> {
|
||||
emit('startLoading')
|
||||
|
||||
const result = await stripe.value.confirmSetup({
|
||||
elements: elements.value,
|
||||
confirmParams: {
|
||||
return_url: props.returnUrl,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(result)
|
||||
|
||||
const { error } = result
|
||||
|
||||
emit('stopLoading')
|
||||
if (error && error.type !== 'validation_error') {
|
||||
handleError(error.message)
|
||||
return false
|
||||
} else if (!error) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reload,
|
||||
submit,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[16rem] flex flex-col gap-2 justify-center items-center">
|
||||
<div v-show="elementsLoaded < 2">
|
||||
<ModalLoadingIndicator :error="error">
|
||||
Loading...
|
||||
<template #error> Error loading Stripe payment UI. </template>
|
||||
</ModalLoadingIndicator>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div id="address-element"></div>
|
||||
<div id="payment-element" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
72
packages/ui/src/components/billing/AddPaymentMethodModal.vue
Normal file
72
packages/ui/src/components/billing/AddPaymentMethodModal.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from '../index'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import AddPaymentMethod from './AddPaymentMethod.vue'
|
||||
import type { AddPaymentMethodProps } from './AddPaymentMethod.vue'
|
||||
import { commonMessages } from '../../utils'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const addPaymentMethod = useTemplateRef<InstanceType<typeof AddPaymentMethod>>('addPaymentMethod')
|
||||
|
||||
const props = defineProps<AddPaymentMethodProps>()
|
||||
const loading = ref(false)
|
||||
|
||||
async function open(paymentMethods: Stripe.PaymentMethod[]) {
|
||||
modal.value?.show()
|
||||
await nextTick()
|
||||
await addPaymentMethod.value?.reload(paymentMethods)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
addingPaymentMethod: {
|
||||
id: 'modal.add-payment-method.title',
|
||||
defaultMessage: 'Adding a payment method',
|
||||
},
|
||||
paymentMethodAdd: {
|
||||
id: 'modal.add-payment-method.action',
|
||||
defaultMessage: 'Add payment method',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: open,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.addingPaymentMethod) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="w-[40rem] max-w-full">
|
||||
<AddPaymentMethod
|
||||
ref="addPaymentMethod"
|
||||
:publishable-key="props.publishableKey"
|
||||
:return-url="props.returnUrl"
|
||||
:create-setup-intent="props.createSetupIntent"
|
||||
:on-error="props.onError"
|
||||
@start-loading="loading = true"
|
||||
@stop-loading="loading = false"
|
||||
/>
|
||||
<div class="input-group mt-auto pt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="loading" @click="addPaymentMethod.submit()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.paymentMethodAdd) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import { formatPrice } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { locale } = useVIntl()
|
||||
|
||||
export type BillingItem = {
|
||||
title: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
period?: string
|
||||
currency: string
|
||||
total: number
|
||||
billingItems: BillingItem[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const periodSuffix = computed(() => {
|
||||
return props.period ? ` / ${props.period}` : ''
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Accordion
|
||||
class="rounded-2xl overflow-hidden bg-bg"
|
||||
button-class="bg-transparent p-0 w-full p-4 active:scale-[0.98] transition-transform duration-100"
|
||||
>
|
||||
<template #title>
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-contrast font-bold">Total</div>
|
||||
<div class="text-right mr-1">
|
||||
<span class="text-primary font-bold">
|
||||
<template v-if="loading">
|
||||
<SpinnerIcon class="animate-spin size-4" />
|
||||
</template>
|
||||
<template v-else> {{ formatPrice(locale, total, currency) }} </template
|
||||
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 flex flex-col gap-4 bg-table-alternateRow">
|
||||
<div
|
||||
v-for="{ title, amount } in billingItems"
|
||||
:key="title"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="font-semibold">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<template v-if="loading">
|
||||
<SpinnerIcon class="animate-spin size-4" />
|
||||
</template>
|
||||
<template v-else> {{ formatPrice(locale, amount, currency) }} </template
|
||||
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { CardIcon, CurrencyIcon, PayPalIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import { commonMessages, paymentMethodMessages } from '../../utils'
|
||||
import type Stripe from 'stripe'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
defineProps<{
|
||||
method: Stripe.PaymentMethod
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="'type' in method">
|
||||
<CardIcon v-if="method.type === 'card'" class="size-[1.5em]" />
|
||||
<CurrencyIcon v-else-if="method.type === 'cashapp'" class="size-[1.5em]" />
|
||||
<PayPalIcon v-else-if="method.type === 'paypal'" class="size-[1.5em]" />
|
||||
<UnknownIcon v-else class="size-[1.5em]" />
|
||||
<span v-if="method.type === 'card' && 'card' in method && method.card">
|
||||
{{
|
||||
formatMessage(commonMessages.paymentMethodCardDisplay, {
|
||||
card_brand:
|
||||
formatMessage(paymentMethodMessages[method.card.brand]) ??
|
||||
formatMessage(paymentMethodMessages.unknown),
|
||||
last_four: method.card.last4,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template v-else>
|
||||
{{
|
||||
formatMessage(paymentMethodMessages[method.type]) ??
|
||||
formatMessage(paymentMethodMessages.unknown)
|
||||
}}
|
||||
</template>
|
||||
|
||||
<span v-if="method.type === 'cashapp' && 'cashapp' in method && method.cashapp">
|
||||
({{ method.cashapp.cashtag }})
|
||||
</span>
|
||||
<span v-else-if="method.type === 'paypal' && 'paypal' in method && method.paypal">
|
||||
({{ method.paypal.payer_email }})
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useTemplateRef, nextTick, watch } from 'vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
import { type MessageDescriptor, useVIntl, defineMessage } from '@vintl/vintl'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
XIcon,
|
||||
CheckCircleIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type {
|
||||
CreatePaymentIntentRequest,
|
||||
CreatePaymentIntentResponse,
|
||||
ServerBillingInterval,
|
||||
ServerPlan,
|
||||
ServerRegion,
|
||||
ServerStockRequest,
|
||||
UpdatePaymentIntentRequest,
|
||||
UpdatePaymentIntentResponse,
|
||||
} from '../../utils/billing'
|
||||
import { ButtonStyled } from '../index'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
import { commonMessages } from '../../utils'
|
||||
import RegionSelector from './ServersPurchase1Region.vue'
|
||||
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
|
||||
import ConfirmPurchase from './ServersPurchase3Review.vue'
|
||||
import { useStripe } from '../../composables/stripe'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type RegionPing = {
|
||||
region: string
|
||||
ping: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
publishableKey: string
|
||||
returnUrl: string
|
||||
paymentMethods: Stripe.PaymentMethod[]
|
||||
customer: Stripe.Customer
|
||||
currency: string
|
||||
pings: RegionPing[]
|
||||
regions: ServerRegion[]
|
||||
availableProducts: ServerPlan[]
|
||||
refreshPaymentMethods: () => Promise<void>
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
initiatePayment: (
|
||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
|
||||
onError: (err: Error) => void
|
||||
}>()
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const selectedPlan = ref<ServerPlan>()
|
||||
const selectedInterval = ref<ServerBillingInterval>('quarterly')
|
||||
const loading = ref(false)
|
||||
const selectedRegion = ref<string>()
|
||||
|
||||
const {
|
||||
initializeStripe,
|
||||
selectPaymentMethod,
|
||||
primaryPaymentMethodId,
|
||||
loadStripeElements,
|
||||
selectedPaymentMethod,
|
||||
inputtedPaymentMethod,
|
||||
createNewPaymentMethod,
|
||||
loadingElements,
|
||||
loadingElementsFailed,
|
||||
tax,
|
||||
total,
|
||||
paymentMethodLoading,
|
||||
reloadPaymentIntent,
|
||||
hasPaymentMethod,
|
||||
submitPayment,
|
||||
completingPurchase,
|
||||
} = useStripe(
|
||||
props.publishableKey,
|
||||
props.customer,
|
||||
props.paymentMethods,
|
||||
props.currency,
|
||||
selectedPlan,
|
||||
selectedInterval,
|
||||
selectedRegion,
|
||||
props.initiatePayment,
|
||||
props.onError,
|
||||
)
|
||||
|
||||
const customServer = ref<boolean>(false)
|
||||
const acceptedEula = ref<boolean>(false)
|
||||
const skipPaymentMethods = ref<boolean>(true)
|
||||
|
||||
type Step = 'region' | 'payment' | 'review'
|
||||
|
||||
const steps: Step[] = ['region', 'payment', 'review']
|
||||
|
||||
const titles: Record<Step, MessageDescriptor> = {
|
||||
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
|
||||
payment: defineMessage({
|
||||
id: 'servers.purchase.step.payment.title',
|
||||
defaultMessage: 'Payment method',
|
||||
}),
|
||||
review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }),
|
||||
}
|
||||
|
||||
const currentRegion = computed(() => {
|
||||
return props.regions.find((region) => region.shortcode === selectedRegion.value)
|
||||
})
|
||||
|
||||
const currentPing = computed(() => {
|
||||
return props.pings.find((ping) => ping.region === currentRegion.value?.shortcode)?.ping
|
||||
})
|
||||
|
||||
const currentStep = ref<Step>()
|
||||
|
||||
const currentStepIndex = computed(() => (currentStep.value ? steps.indexOf(currentStep.value) : -1))
|
||||
const previousStep = computed(() => {
|
||||
const step = currentStep.value ? steps[steps.indexOf(currentStep.value) - 1] : undefined
|
||||
if (step === 'payment' && skipPaymentMethods.value && primaryPaymentMethodId.value) {
|
||||
return 'region'
|
||||
}
|
||||
return step
|
||||
})
|
||||
const nextStep = computed(() =>
|
||||
currentStep.value ? steps[steps.indexOf(currentStep.value) + 1] : undefined,
|
||||
)
|
||||
|
||||
const canProceed = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 'region':
|
||||
return selectedRegion.value && selectedPlan.value && selectedInterval.value
|
||||
case 'payment':
|
||||
return selectedPaymentMethod.value || !loadingElements.value
|
||||
case 'review':
|
||||
return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
async function beforeProceed(step: string) {
|
||||
switch (step) {
|
||||
case 'region':
|
||||
return true
|
||||
case 'payment':
|
||||
await initializeStripe()
|
||||
|
||||
if (primaryPaymentMethodId.value && skipPaymentMethods.value) {
|
||||
const paymentMethod = await props.paymentMethods.find(
|
||||
(x) => x.id === primaryPaymentMethodId.value,
|
||||
)
|
||||
await selectPaymentMethod(paymentMethod)
|
||||
await setStep('review', true)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 'review':
|
||||
if (selectedPaymentMethod.value) {
|
||||
return true
|
||||
} else {
|
||||
const token = await createNewPaymentMethod()
|
||||
return !!token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function afterProceed(step: string) {
|
||||
switch (step) {
|
||||
case 'region':
|
||||
break
|
||||
case 'payment':
|
||||
await loadStripeElements()
|
||||
break
|
||||
case 'review':
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function setStep(step: Step | undefined, skipValidation = false) {
|
||||
if (!step) {
|
||||
await submitPayment(props.returnUrl)
|
||||
return
|
||||
}
|
||||
|
||||
if (!skipValidation && !canProceed.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (await beforeProceed(step)) {
|
||||
currentStep.value = step
|
||||
await nextTick()
|
||||
|
||||
await afterProceed(step)
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedPlan, () => {
|
||||
console.log(selectedPlan.value)
|
||||
})
|
||||
|
||||
function begin(interval: ServerBillingInterval, plan?: ServerPlan) {
|
||||
loading.value = false
|
||||
selectedPlan.value = plan
|
||||
selectedInterval.value = interval
|
||||
customServer.value = !selectedPlan.value
|
||||
selectedPaymentMethod.value = undefined
|
||||
currentStep.value = steps[0]
|
||||
skipPaymentMethods.value = true
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show: begin,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-1 font-bold text-secondary">
|
||||
<template v-for="(title, id, index) in titles" :key="id">
|
||||
<button
|
||||
v-if="index < currentStepIndex"
|
||||
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
|
||||
@click="setStep(id, true)"
|
||||
>
|
||||
{{ formatMessage(title) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
:class="{
|
||||
'text-contrast': index === currentStepIndex,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(title) }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < steps.length - 1"
|
||||
class="h-5 w-5 text-secondary"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-[40rem] max-w-full">
|
||||
<RegionSelector
|
||||
v-if="currentStep === 'region'"
|
||||
v-model:region="selectedRegion"
|
||||
v-model:plan="selectedPlan"
|
||||
:regions="regions"
|
||||
:pings="pings"
|
||||
:custom="customServer"
|
||||
:available-products="availableProducts"
|
||||
:fetch-stock="fetchStock"
|
||||
/>
|
||||
<PaymentMethodSelector
|
||||
v-else-if="currentStep === 'payment' && selectedPlan && selectedInterval"
|
||||
:payment-methods="paymentMethods"
|
||||
:selected="selectedPaymentMethod"
|
||||
:loading-elements="loadingElements"
|
||||
:loading-elements-failed="loadingElementsFailed"
|
||||
@select="selectPaymentMethod"
|
||||
/>
|
||||
<ConfirmPurchase
|
||||
v-else-if="
|
||||
currentStep === 'review' &&
|
||||
hasPaymentMethod &&
|
||||
currentRegion &&
|
||||
selectedInterval &&
|
||||
selectedPlan
|
||||
"
|
||||
v-model:interval="selectedInterval"
|
||||
v-model:accepted-eula="acceptedEula"
|
||||
:currency="currency"
|
||||
:plan="selectedPlan"
|
||||
:region="currentRegion"
|
||||
:ping="currentPing"
|
||||
:loading="paymentMethodLoading"
|
||||
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
|
||||
:tax="tax"
|
||||
:total="total"
|
||||
@change-payment-method="
|
||||
() => {
|
||||
skipPaymentMethods = false
|
||||
setStep('payment', true)
|
||||
}
|
||||
"
|
||||
@reload-payment-intent="reloadPaymentIntent"
|
||||
/>
|
||||
<div v-else>Something went wrong</div>
|
||||
<div
|
||||
v-show="
|
||||
selectedPaymentMethod === undefined &&
|
||||
currentStep === 'payment' &&
|
||||
selectedPlan &&
|
||||
selectedInterval
|
||||
"
|
||||
class="min-h-[16rem] flex flex-col gap-2 mt-2 p-4 bg-table-alternateRow rounded-xl justify-center items-center"
|
||||
>
|
||||
<div v-show="loadingElements">
|
||||
<ModalLoadingIndicator :error="loadingElementsFailed">
|
||||
Loading...
|
||||
<template #error> Error loading Stripe payment UI. </template>
|
||||
</ModalLoadingIndicator>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div id="address-element"></div>
|
||||
<div id="payment-element" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-between mt-4">
|
||||
<ButtonStyled>
|
||||
<button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
|
||||
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
|
||||
</button>
|
||||
<button v-else @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
currentStep === 'review' && !acceptedEula
|
||||
? 'You must accept the Minecraft EULA to proceed.'
|
||||
: undefined
|
||||
"
|
||||
:disabled="!canProceed"
|
||||
@click="setStep(nextStep)"
|
||||
>
|
||||
<template v-if="currentStep === 'review'">
|
||||
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
|
||||
<CheckCircleIcon v-else />
|
||||
Subscribe
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
37
packages/ui/src/components/billing/PaymentMethodOption.vue
Normal file
37
packages/ui/src/components/billing/PaymentMethodOption.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioButtonIcon, RadioButtonCheckedIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import type Stripe from 'stripe'
|
||||
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select'): void
|
||||
}>()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
item: Stripe.PaymentMethod | undefined
|
||||
selected: boolean
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center w-full gap-2 border-none p-3 text-primary rounded-xl transition-all duration-200 hover:bg-button-bg hover:brightness-[--hover-brightness] active:scale-[0.98] hover:cursor-pointer"
|
||||
:class="selected ? 'bg-button-bg' : 'bg-transparent'"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="selected" class="size-6 text-brand" />
|
||||
<RadioButtonIcon v-else class="size-6 text-secondary" />
|
||||
|
||||
<template v-if="item === undefined">
|
||||
<span>New payment method</span>
|
||||
</template>
|
||||
<FormattedPaymentMethod v-else-if="item" :method="item" />
|
||||
<SpinnerIcon v-if="loading" class="ml-auto size-4 text-secondary animate-spin" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -214,10 +214,17 @@
|
||||
{{ interval }}
|
||||
</span>
|
||||
<span
|
||||
v-if="interval === 'yearly'"
|
||||
v-if="interval === 'yearly' || interval === 'quarterly'"
|
||||
class="rounded-full bg-brand px-2 py-1 font-bold text-brand-inverted"
|
||||
>
|
||||
SAVE {{ calculateSavings(price.prices.intervals.monthly, rawPrice) }}%
|
||||
SAVE
|
||||
{{
|
||||
calculateSavings(
|
||||
price.prices.intervals.monthly,
|
||||
rawPrice,
|
||||
interval === 'quarterly' ? 3 : 12,
|
||||
)
|
||||
}}%
|
||||
</span>
|
||||
<span class="ml-auto text-lg" :class="{ 'text-secondary': selectedPlan !== interval }">
|
||||
{{ formatPrice(locale, rawPrice, price.currency_code) }}
|
||||
|
||||
246
packages/ui/src/components/billing/ServersPurchase1Region.vue
Normal file
246
packages/ui/src/components/billing/ServersPurchase1Region.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import ServersRegionButton from './ServersRegionButton.vue'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { onMounted, ref, computed, watch } from 'vue'
|
||||
import type { RegionPing } from './ModrinthServersPurchaseModal.vue'
|
||||
import type { ServerPlan, ServerRegion, ServerStockRequest } from '../../utils/billing'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import Slider from '../base/Slider.vue'
|
||||
import { SpinnerIcon, XIcon, InfoIcon } from '@modrinth/assets'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
regions: ServerRegion[]
|
||||
pings: RegionPing[]
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
custom: boolean
|
||||
availableProducts: ServerPlan[]
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
const checkingCustomStock = ref(false)
|
||||
const selectedPlan = defineModel<ServerPlan>('plan')
|
||||
const selectedRegion = defineModel<string>('region')
|
||||
|
||||
const regionOrder: string[] = ['us-vin', 'eu-lim']
|
||||
|
||||
const sortedRegions = computed(() => {
|
||||
return props.regions.slice().sort((a, b) => {
|
||||
return regionOrder.indexOf(a.shortcode) - regionOrder.indexOf(b.shortcode)
|
||||
})
|
||||
})
|
||||
|
||||
const selectedRam = ref<number>(-1)
|
||||
|
||||
const ramOptions = computed(() => {
|
||||
return props.availableProducts
|
||||
.map((product) => (product.metadata.ram ?? 0) / 1024)
|
||||
.filter((x) => x > 0)
|
||||
})
|
||||
|
||||
const minRam = computed(() => {
|
||||
return Math.min(...ramOptions.value)
|
||||
})
|
||||
const maxRam = computed(() => {
|
||||
return Math.max(...ramOptions.value)
|
||||
})
|
||||
|
||||
const lowestProduct = computed(() => {
|
||||
return (
|
||||
props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === minRam.value,
|
||||
) ?? props.availableProducts[0]
|
||||
)
|
||||
})
|
||||
|
||||
function updateRamStock(regionToCheck: string, newRam: number) {
|
||||
if (newRam > 0) {
|
||||
checkingCustomStock.value = true
|
||||
const plan = props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === newRam,
|
||||
)
|
||||
if (plan) {
|
||||
const region = sortedRegions.value.find((region) => region.shortcode === regionToCheck)
|
||||
if (region) {
|
||||
props
|
||||
.fetchStock(region, {
|
||||
cpu: plan.metadata.cpu ?? 0,
|
||||
memory_mb: plan.metadata.ram ?? 0,
|
||||
swap_mb: plan.metadata.swap ?? 0,
|
||||
storage_mb: plan.metadata.storage ?? 0,
|
||||
})
|
||||
.then((stock: number) => {
|
||||
if (stock > 0) {
|
||||
selectedPlan.value = plan
|
||||
} else {
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checkingCustomStock.value = false
|
||||
})
|
||||
} else {
|
||||
checkingCustomStock.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedRam, (newRam: number) => {
|
||||
if (props.custom && selectedRegion.value) {
|
||||
updateRamStock(selectedRegion.value, newRam)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedRegion, (newRegion: string | undefined) => {
|
||||
if (props.custom && newRegion) {
|
||||
updateRamStock(newRegion, selectedRam.value)
|
||||
}
|
||||
})
|
||||
|
||||
const currentStock = ref<{ [region: string]: number }>({})
|
||||
const bestPing = ref<string>()
|
||||
|
||||
const messages = defineMessages({
|
||||
prompt: {
|
||||
id: 'servers.region.prompt',
|
||||
defaultMessage: 'Where would you like your server to be located?',
|
||||
},
|
||||
regionUnsupported: {
|
||||
id: 'servers.region.region-unsupported',
|
||||
defaultMessage: `Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>`,
|
||||
},
|
||||
customPrompt: {
|
||||
id: 'servers.region.custom.prompt',
|
||||
defaultMessage: `How much RAM do you want your server to have?`,
|
||||
},
|
||||
})
|
||||
|
||||
async function updateStock() {
|
||||
currentStock.value = {}
|
||||
const capacityChecks = sortedRegions.value.map((region) =>
|
||||
props.fetchStock(
|
||||
region,
|
||||
selectedPlan.value
|
||||
? {
|
||||
cpu: selectedPlan.value?.metadata.cpu ?? 0,
|
||||
memory_mb: selectedPlan.value?.metadata.ram ?? 0,
|
||||
swap_mb: selectedPlan.value?.metadata.swap ?? 0,
|
||||
storage_mb: selectedPlan.value?.metadata.storage ?? 0,
|
||||
}
|
||||
: {
|
||||
cpu: lowestProduct.value.metadata.cpu ?? 0,
|
||||
memory_mb: lowestProduct.value.metadata.ram ?? 0,
|
||||
swap_mb: lowestProduct.value.metadata.swap ?? 0,
|
||||
storage_mb: lowestProduct.value.metadata.storage ?? 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
const results = await Promise.all(capacityChecks)
|
||||
results.forEach((result, index) => {
|
||||
currentStock.value[sortedRegions.value[index].shortcode] = result
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// auto select region with lowest ping
|
||||
loading.value = true
|
||||
bestPing.value =
|
||||
props.pings.length > 0
|
||||
? props.pings.reduce((acc, cur) => {
|
||||
return acc.ping < cur.ping ? acc : cur
|
||||
})?.region
|
||||
: undefined
|
||||
selectedRegion.value = undefined
|
||||
selectedRam.value = minRam.value
|
||||
checkingCustomStock.value = true
|
||||
updateStock().then(() => {
|
||||
const firstWithStock = sortedRegions.value.find(
|
||||
(region) => currentStock.value[region.shortcode] > 0,
|
||||
)
|
||||
let stockedRegion = selectedRegion.value
|
||||
if (!stockedRegion) {
|
||||
stockedRegion =
|
||||
bestPing.value && currentStock.value[bestPing.value] > 0
|
||||
? bestPing.value
|
||||
: firstWithStock?.shortcode
|
||||
}
|
||||
selectedRegion.value = stockedRegion
|
||||
if (props.custom && stockedRegion) {
|
||||
updateRamStock(stockedRegion, minRam.value)
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalLoadingIndicator v-if="loading" class="flex py-40 justify-center">
|
||||
Checking availability...
|
||||
</ModalLoadingIndicator>
|
||||
<template v-else>
|
||||
<h2 class="mt-0 mb-4 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.prompt) }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<ServersRegionButton
|
||||
v-for="region in sortedRegions"
|
||||
:key="region.shortcode"
|
||||
v-model="selectedRegion"
|
||||
:region="region"
|
||||
:out-of-stock="currentStock[region.shortcode] === 0"
|
||||
:ping="pings.find((p) => p.region === region.shortcode)?.ping"
|
||||
:best-ping="bestPing === region.shortcode"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 text-sm">
|
||||
<IntlFormatted :message-id="messages.regionUnsupported">
|
||||
<template #link="{ children }">
|
||||
<a
|
||||
class="text-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://surveys.modrinth.com/servers-region-waitlist"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<template v-if="custom">
|
||||
<h2 class="mt-4 mb-2 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.customPrompt) }}
|
||||
</h2>
|
||||
<div>
|
||||
<Slider v-model="selectedRam" :min="minRam" :max="maxRam" :step="2" unit="GB" />
|
||||
<div class="bg-bg rounded-xl p-4 mt-4 text-secondary">
|
||||
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
|
||||
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
|
||||
</div>
|
||||
<div v-else-if="selectedPlan">
|
||||
<ServersSpecs
|
||||
class="!flex-row justify-between"
|
||||
:ram="selectedPlan.metadata.ram ?? 0"
|
||||
:storage="selectedPlan.metadata.storage ?? 0"
|
||||
:cpus="selectedPlan.metadata.cpu ?? 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex gap-2 items-center">
|
||||
<XIcon class="size-5 shrink-0 text-red" /> Sorry, we don't have any plans available with
|
||||
{{ selectedRam }} GB RAM in this region.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<InfoIcon class="hidden sm:block shrink-0 mt-1" />
|
||||
<span class="text-sm text-secondary">
|
||||
Storage and shared CPU count are currently not configurable independently, and are based
|
||||
on the amount of RAM you select.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type Stripe from 'stripe'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import PaymentMethodOption from './PaymentMethodOption.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', paymentMethod: Stripe.PaymentMethod | undefined): void
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
paymentMethods: Stripe.PaymentMethod[]
|
||||
selected?: Stripe.PaymentMethod
|
||||
loadingElements: boolean
|
||||
loadingElementsFailed: boolean
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
prompt: {
|
||||
id: 'servers.purchase.step.payment.prompt',
|
||||
defaultMessage: 'Select a payment method',
|
||||
},
|
||||
description: {
|
||||
id: 'servers.purchase.step.payment.description',
|
||||
defaultMessage: `You won't be charged yet.`,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="mt-0 mb-1 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.prompt) }}
|
||||
</h2>
|
||||
<p class="mt-0 mb-4 text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<PaymentMethodOption
|
||||
v-for="method in paymentMethods"
|
||||
:key="method.id"
|
||||
:item="method"
|
||||
:selected="selected?.id === method.id"
|
||||
@select="emit('select', method)"
|
||||
/>
|
||||
<PaymentMethodOption
|
||||
:loading="false"
|
||||
:item="undefined"
|
||||
:selected="selected === undefined"
|
||||
@select="emit('select', undefined)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
263
packages/ui/src/components/billing/ServersPurchase3Review.vue
Normal file
263
packages/ui/src/components/billing/ServersPurchase3Review.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ServerBillingInterval, ServerPlan, ServerRegion } from '../../utils/billing'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
import { formatPrice, getPingLevel } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { regionOverrides } from '../../utils/regions'
|
||||
import {
|
||||
EditIcon,
|
||||
RightArrowIcon,
|
||||
SignalIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
RadioButtonIcon,
|
||||
RadioButtonCheckedIcon,
|
||||
ExternalIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type Stripe from 'stripe'
|
||||
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
import ExpandableInvoiceTotal from './ExpandableInvoiceTotal.vue'
|
||||
|
||||
const vintl = useVIntl()
|
||||
const { locale, formatMessage } = vintl
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'changePaymentMethod' | 'reloadPaymentIntent'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
plan: ServerPlan
|
||||
region: ServerRegion
|
||||
tax?: number
|
||||
total?: number
|
||||
currency: string
|
||||
ping?: number
|
||||
loading?: boolean
|
||||
selectedPaymentMethod: Stripe.PaymentMethod | undefined
|
||||
}>()
|
||||
|
||||
const interval = defineModel<ServerBillingInterval>('interval', { required: true })
|
||||
const acceptedEula = defineModel<boolean>('acceptedEula', { required: true })
|
||||
|
||||
const prices = computed(() => {
|
||||
return props.plan.prices.find((x) => x.currency_code === props.currency)
|
||||
})
|
||||
|
||||
const planName = computed(() => {
|
||||
if (!props.plan || !props.plan.metadata || props.plan.metadata.type !== 'pyro') return 'Unknown'
|
||||
const ram = props.plan.metadata.ram
|
||||
if (ram === 4096) return 'Small'
|
||||
if (ram === 6144) return 'Medium'
|
||||
if (ram === 8192) return 'Large'
|
||||
return 'Custom'
|
||||
})
|
||||
|
||||
const flag = computed(
|
||||
() =>
|
||||
regionOverrides[props.region.shortcode]?.flag ??
|
||||
`https://flagcdn.com/${props.region.country_code}.svg`,
|
||||
)
|
||||
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
|
||||
const title = computed(() =>
|
||||
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
|
||||
)
|
||||
const locationSubtitle = computed(() =>
|
||||
overrideTitle.value ? props.region.display_name : undefined,
|
||||
)
|
||||
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
|
||||
|
||||
const period = computed(() => {
|
||||
if (interval.value === 'monthly') return 'month'
|
||||
if (interval.value === 'quarterly') return '3 months'
|
||||
if (interval.value === 'yearly') return 'year'
|
||||
return '???'
|
||||
})
|
||||
|
||||
const monthsInInterval: Record<ServerBillingInterval, number> = {
|
||||
monthly: 1,
|
||||
quarterly: 3,
|
||||
yearly: 12,
|
||||
}
|
||||
|
||||
function setInterval(newInterval: ServerBillingInterval) {
|
||||
interval.value = newInterval
|
||||
emit('reloadPaymentIntent')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
|
||||
<div class="bg-table-alternateRow p-4 rounded-2xl">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<LazyUiServersModrinthServersIcon class="flex h-5 w-fit" />
|
||||
<TagItem>{{ planName }}</TagItem>
|
||||
</div>
|
||||
<div>
|
||||
<ServersSpecs
|
||||
v-if="plan.metadata && plan.metadata.ram && plan.metadata.storage && plan.metadata.cpu"
|
||||
class="!grid sm:grid-cols-2"
|
||||
:ram="plan.metadata.ram"
|
||||
:storage="plan.metadata.storage"
|
||||
:cpus="plan.metadata.cpu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-if="flag"
|
||||
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
|
||||
:src="flag"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="font-semibold">
|
||||
{{ title }}
|
||||
</span>
|
||||
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
|
||||
<template v-if="locationSubtitle">
|
||||
<span>
|
||||
{{ locationSubtitle }}
|
||||
</span>
|
||||
<span v-if="ping !== -1">•</span>
|
||||
</template>
|
||||
<template v-if="ping !== -1">
|
||||
<SignalIcon
|
||||
v-if="ping"
|
||||
aria-hidden="true"
|
||||
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
|
||||
stroke-width="3px"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<template v-if="ping"> {{ ping }}ms </template>
|
||||
<span v-else> Testing connection... </span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||
<button
|
||||
:class="
|
||||
interval === 'monthly'
|
||||
? 'bg-button-bg border-transparent'
|
||||
: 'bg-transparent border-button-border'
|
||||
"
|
||||
class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
|
||||
@click="setInterval('monthly')"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="interval === 'monthly'" class="size-6 text-brand" />
|
||||
<RadioButtonIcon v-else class="size-6 text-secondary" />
|
||||
<div class="flex flex-col items-start gap-1 font-medium text-primary">
|
||||
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'monthly' }"
|
||||
>Pay monthly</span
|
||||
>
|
||||
<span class="text-sm text-secondary flex items-center gap-1"
|
||||
>{{ formatPrice(locale, prices?.prices.intervals['monthly'], currency, true) }} /
|
||||
month</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
:class="
|
||||
interval === 'quarterly'
|
||||
? 'bg-button-bg border-transparent'
|
||||
: 'bg-transparent border-button-border'
|
||||
"
|
||||
class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
|
||||
@click="setInterval('quarterly')"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="interval === 'quarterly'" class="size-6 text-brand" />
|
||||
<RadioButtonIcon v-else class="size-6 text-secondary" />
|
||||
<div class="flex flex-col items-start gap-1 font-medium text-primary">
|
||||
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'quarterly' }"
|
||||
>Pay quarterly
|
||||
<span class="text-xs font-bold text-brand px-1.5 py-0.5 rounded-full bg-brand-highlight"
|
||||
>{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%</span
|
||||
></span
|
||||
>
|
||||
<span class="text-sm text-secondary flex items-center gap-1"
|
||||
>{{
|
||||
formatPrice(
|
||||
locale,
|
||||
(prices?.prices?.intervals?.['quarterly'] ?? 0) / monthsInInterval['quarterly'],
|
||||
currency,
|
||||
true,
|
||||
)
|
||||
}}
|
||||
/ month</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<ExpandableInvoiceTotal
|
||||
:period="period"
|
||||
:currency="currency"
|
||||
:loading="loading"
|
||||
:total="total ?? -1"
|
||||
:billing-items="
|
||||
total !== undefined && tax !== undefined
|
||||
? [
|
||||
{
|
||||
title: `Modrinth Servers (${planName})`,
|
||||
amount: total - tax,
|
||||
},
|
||||
{
|
||||
title: 'Tax',
|
||||
amount: tax,
|
||||
},
|
||||
]
|
||||
: []
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center pl-4 pr-2 py-3 bg-bg rounded-2xl gap-2 text-secondary">
|
||||
<template v-if="selectedPaymentMethod">
|
||||
<FormattedPaymentMethod :method="selectedPaymentMethod" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 text-red">
|
||||
<XIcon />
|
||||
No payment method selected
|
||||
</div>
|
||||
</template>
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button class="ml-auto" @click="emit('changePaymentMethod')">
|
||||
<template v-if="selectedPaymentMethod"> <EditIcon /> Change </template>
|
||||
<template v-else> Select payment method <RightArrowIcon /> </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 mt-4 text-sm text-secondary">
|
||||
<span class="font-semibold"
|
||||
>By clicking "Subscribe", you are purchasing a recurring subscription.</span
|
||||
>
|
||||
<br />
|
||||
You'll be charged
|
||||
<SpinnerIcon v-if="loading" class="animate-spin relative top-0.5 mx-2" /><template v-else>{{
|
||||
formatPrice(locale, total, currency)
|
||||
}}</template>
|
||||
every {{ period }} plus applicable taxes starting today, until you cancel. You can cancel
|
||||
anytime from your settings page.
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-1 text-sm">
|
||||
<Checkbox
|
||||
v-model="acceptedEula"
|
||||
label="I acknowledge that I have read and agree to the"
|
||||
description="I acknowledge that I have read and agree to the Minecraft EULA"
|
||||
/>
|
||||
<a
|
||||
href="https://www.minecraft.net/en-us/eula"
|
||||
target="_blank"
|
||||
class="text-brand underline hover:brightness-[--hover-brightness]"
|
||||
>Minecraft EULA<ExternalIcon class="size-3 shrink-0 ml-0.5 mb-0.5"
|
||||
/></a>
|
||||
</div>
|
||||
</template>
|
||||
90
packages/ui/src/components/billing/ServersRegionButton.vue
Normal file
90
packages/ui/src/components/billing/ServersRegionButton.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { getPingLevel } from '@modrinth/utils'
|
||||
import { SignalIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import type { ServerRegion } from '../../utils/billing'
|
||||
import { regionOverrides } from '../../utils/regions'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const currentRegion = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
region: ServerRegion
|
||||
ping?: number
|
||||
bestPing?: boolean
|
||||
outOfStock?: boolean
|
||||
}>()
|
||||
|
||||
const isCurrentRegion = computed(() => currentRegion.value === props.region.shortcode)
|
||||
const flag = computed(
|
||||
() =>
|
||||
regionOverrides[props.region.shortcode]?.flag ??
|
||||
`https://flagcdn.com/${props.region.country_code}.svg`,
|
||||
)
|
||||
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
|
||||
const title = computed(() =>
|
||||
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
|
||||
)
|
||||
const locationSubtitle = computed(() =>
|
||||
overrideTitle.value ? props.region.display_name : undefined,
|
||||
)
|
||||
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
|
||||
|
||||
function setRegion() {
|
||||
currentRegion.value = props.region.shortcode
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:disabled="outOfStock"
|
||||
class="rounded-2xl p-4 font-semibold transition-all border-2 border-solid flex flex-col items-center gap-3"
|
||||
:class="{
|
||||
'bg-button-bg border-transparent text-primary': !isCurrentRegion,
|
||||
'bg-brand-highlight border-brand text-contrast': isCurrentRegion,
|
||||
'opacity-50 cursor-not-allowed': outOfStock,
|
||||
'hover:text-contrast active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] ':
|
||||
!outOfStock,
|
||||
}"
|
||||
@click="setRegion"
|
||||
>
|
||||
<img
|
||||
v-if="flag"
|
||||
class="aspect-[16/10] max-w-16 w-full object-cover rounded-md border-1 border-solid"
|
||||
:class="[
|
||||
isCurrentRegion ? 'border-brand' : 'border-button-border',
|
||||
{ 'saturate-[0.25]': outOfStock },
|
||||
]"
|
||||
:src="flag"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex flex-col gap-1 items-center">
|
||||
<span class="flex items-center gap-1 flex-wrap justify-center">
|
||||
{{ title }} <span v-if="outOfStock" class="text-sm text-secondary">(Out of stock)</span>
|
||||
</span>
|
||||
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
|
||||
<template v-if="locationSubtitle">
|
||||
<span>
|
||||
{{ locationSubtitle }}
|
||||
</span>
|
||||
<span v-if="ping !== -1">•</span>
|
||||
</template>
|
||||
<template v-if="ping !== -1">
|
||||
<SignalIcon
|
||||
v-if="ping"
|
||||
aria-hidden="true"
|
||||
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
|
||||
stroke-width="3px"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<template v-if="ping"> {{ ping }}ms </template>
|
||||
<span v-else> Testing connection... </span>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
60
packages/ui/src/components/billing/ServersSpecs.vue
Normal file
60
packages/ui/src/components/billing/ServersSpecs.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import { MemoryStickIcon, DatabaseIcon, CPUIcon, SparklesIcon, UnknownIcon } from '@modrinth/assets'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click-bursting-link'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
ram: number
|
||||
storage: number
|
||||
cpus: number
|
||||
burstingLink?: string
|
||||
}>(),
|
||||
{
|
||||
burstingLink: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const formattedRam = computed(() => {
|
||||
return props.ram / 1024
|
||||
})
|
||||
|
||||
const formattedStorage = computed(() => {
|
||||
return props.storage / 1024
|
||||
})
|
||||
|
||||
const sharedCpus = computed(() => {
|
||||
return props.cpus / 2
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ul class="m-0 flex list-none flex-col gap-2 px-0 text-sm leading-normal text-secondary">
|
||||
<li class="flex items-center gap-2">
|
||||
<MemoryStickIcon class="h-5 w-5 shrink-0" /> {{ formattedRam }} GB RAM
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<DatabaseIcon class="h-5 w-5 shrink-0" /> {{ formattedStorage }} GB Storage
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<CPUIcon class="h-5 w-5 shrink-0" /> {{ sharedCpus }} Shared CPUs
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<SparklesIcon class="h-5 w-5 shrink-0" /> Bursts up to {{ cpus }} CPUs
|
||||
<AutoLink
|
||||
v-if="burstingLink"
|
||||
v-tooltip="
|
||||
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
||||
"
|
||||
class="flex"
|
||||
:to="burstingLink"
|
||||
@click="() => emit('click-bursting-link')"
|
||||
>
|
||||
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
||||
</AutoLink>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ export { default as DoubleIcon } from './base/DoubleIcon.vue'
|
||||
export { default as DropArea } from './base/DropArea.vue'
|
||||
export { default as DropdownSelect } from './base/DropdownSelect.vue'
|
||||
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
|
||||
export { default as ErrorInformationCard } from './base/ErrorInformationCard.vue'
|
||||
export { default as FileInput } from './base/FileInput.vue'
|
||||
export { default as FilterBar } from './base/FilterBar.vue'
|
||||
export type { FilterBarOption } from './base/FilterBar.vue'
|
||||
@@ -96,6 +97,8 @@ export { default as SearchSidebarFilter } from './search/SearchSidebarFilter.vue
|
||||
|
||||
// Billing
|
||||
export { default as PurchaseModal } from './billing/PurchaseModal.vue'
|
||||
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
|
||||
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue'
|
||||
|
||||
// Version
|
||||
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
|
||||
@@ -107,3 +110,4 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
|
||||
|
||||
// Servers
|
||||
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
|
||||
export { default as ServersSpecs } from './billing/ServersSpecs.vue'
|
||||
|
||||
35
packages/ui/src/components/modal/ModalLoadingIndicator.vue
Normal file
35
packages/ui/src/components/modal/ModalLoadingIndicator.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon, XCircleIcon } from '@modrinth/assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
error?: boolean
|
||||
}>(),
|
||||
{
|
||||
error: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex items-center gap-2 font-semibold" :class="error ? 'text-red' : 'animate-pulse'">
|
||||
<XCircleIcon v-if="error" class="w-6 h-6" />
|
||||
<SpinnerIcon v-else class="w-6 h-6 animate-spin" />
|
||||
<slot v-if="error" name="error" />
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
scale: 1;
|
||||
}
|
||||
50% {
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
403
packages/ui/src/composables/stripe.ts
Normal file
403
packages/ui/src/composables/stripe.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import type Stripe from 'stripe'
|
||||
import { type Stripe as StripeJs, loadStripe, type StripeElements } from '@stripe/stripe-js'
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import type { ContactOption } from '@stripe/stripe-js/dist/stripe-js/elements/address'
|
||||
import type {
|
||||
ServerPlan,
|
||||
BasePaymentIntentResponse,
|
||||
ChargeRequestType,
|
||||
CreatePaymentIntentRequest,
|
||||
CreatePaymentIntentResponse,
|
||||
PaymentRequestType,
|
||||
ServerBillingInterval,
|
||||
UpdatePaymentIntentRequest,
|
||||
UpdatePaymentIntentResponse,
|
||||
} from '../utils/billing.ts'
|
||||
|
||||
// export type CreateElements = (
|
||||
// paymentMethods: Stripe.PaymentMethod[],
|
||||
// options: StripeElementsOptionsMode,
|
||||
// ) => {
|
||||
// elements: StripeElements
|
||||
// paymentElement: StripePaymentElement
|
||||
// addressElement: StripeAddressElement
|
||||
// }
|
||||
|
||||
export const useStripe = (
|
||||
publishableKey: string,
|
||||
customer: Stripe.Customer,
|
||||
paymentMethods: Stripe.PaymentMethod[],
|
||||
currency: string,
|
||||
product: Ref<ServerPlan | undefined>,
|
||||
interval: Ref<ServerBillingInterval>,
|
||||
region: Ref<string | undefined>,
|
||||
initiatePayment: (
|
||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse>,
|
||||
onError: (err: Error) => void,
|
||||
) => {
|
||||
const stripe = ref<StripeJs | null>(null)
|
||||
|
||||
let elements: StripeElements | undefined = undefined
|
||||
const elementsLoaded = ref<0 | 1 | 2>(0)
|
||||
const loadingElementsFailed = ref<boolean>(false)
|
||||
|
||||
const paymentMethodLoading = ref(false)
|
||||
const loadingFailed = ref<string>()
|
||||
const paymentIntentId = ref<string>()
|
||||
const tax = ref<number>()
|
||||
const total = ref<number>()
|
||||
const confirmationToken = ref<string>()
|
||||
const submittingPayment = ref(false)
|
||||
const selectedPaymentMethod = ref<Stripe.PaymentMethod>()
|
||||
const inputtedPaymentMethod = ref<Stripe.PaymentMethod>()
|
||||
const clientSecret = ref<string>()
|
||||
const completingPurchase = ref<boolean>(false)
|
||||
|
||||
async function initialize() {
|
||||
stripe.value = await loadStripe(publishableKey)
|
||||
}
|
||||
|
||||
function createIntent(body: CreatePaymentIntentRequest): Promise<CreatePaymentIntentResponse> {
|
||||
return initiatePayment(body) as Promise<CreatePaymentIntentResponse>
|
||||
}
|
||||
|
||||
function updateIntent(body: UpdatePaymentIntentRequest): Promise<UpdatePaymentIntentResponse> {
|
||||
return initiatePayment(body) as Promise<UpdatePaymentIntentResponse>
|
||||
}
|
||||
|
||||
const planPrices = computed(() => {
|
||||
return product.value?.prices.find((x) => x.currency_code === currency)
|
||||
})
|
||||
|
||||
const createElements = (options) => {
|
||||
const styles = getComputedStyle(document.body)
|
||||
|
||||
if (!stripe.value) {
|
||||
throw new Error('Stripe API not yet loaded')
|
||||
}
|
||||
|
||||
elements = stripe.value.elements({
|
||||
appearance: {
|
||||
variables: {
|
||||
colorPrimary: styles.getPropertyValue('--color-brand'),
|
||||
colorBackground: styles.getPropertyValue('--experimental-color-button-bg'),
|
||||
colorText: styles.getPropertyValue('--color-base'),
|
||||
colorTextPlaceholder: styles.getPropertyValue('--color-secondary'),
|
||||
colorDanger: styles.getPropertyValue('--color-red'),
|
||||
fontFamily: styles.getPropertyValue('--font-standard'),
|
||||
spacingUnit: '0.25rem',
|
||||
borderRadius: '0.75rem',
|
||||
},
|
||||
},
|
||||
loader: 'never',
|
||||
...options,
|
||||
})
|
||||
|
||||
const paymentElement = elements.create('payment', {
|
||||
layout: {
|
||||
type: 'tabs',
|
||||
defaultCollapsed: false,
|
||||
},
|
||||
})
|
||||
paymentElement.mount('#payment-element')
|
||||
|
||||
const contacts: ContactOption[] = []
|
||||
|
||||
paymentMethods.forEach((method) => {
|
||||
const addr = method.billing_details?.address
|
||||
if (
|
||||
addr &&
|
||||
addr.line1 &&
|
||||
addr.city &&
|
||||
addr.postal_code &&
|
||||
addr.country &&
|
||||
addr.state &&
|
||||
method.billing_details.name
|
||||
) {
|
||||
contacts.push({
|
||||
address: {
|
||||
line1: addr.line1,
|
||||
line2: addr.line2 ?? undefined,
|
||||
city: addr.city,
|
||||
state: addr.state,
|
||||
postal_code: addr.postal_code,
|
||||
country: addr.country,
|
||||
},
|
||||
name: method.billing_details.name,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const addressElement = elements.create('address', {
|
||||
mode: 'billing',
|
||||
contacts: contacts.length > 0 ? contacts : undefined,
|
||||
})
|
||||
addressElement.mount('#address-element')
|
||||
|
||||
return { elements, paymentElement, addressElement }
|
||||
}
|
||||
|
||||
const primaryPaymentMethodId = computed<string | null>(() => {
|
||||
if (customer && customer.invoice_settings && customer.invoice_settings.default_payment_method) {
|
||||
const method = customer.invoice_settings.default_payment_method
|
||||
if (typeof method === 'string') {
|
||||
return method
|
||||
} else {
|
||||
return method.id
|
||||
}
|
||||
} else if (paymentMethods && paymentMethods[0] && paymentMethods[0].id) {
|
||||
return paymentMethods[0].id
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const loadStripeElements = async () => {
|
||||
loadingFailed.value = undefined
|
||||
try {
|
||||
if (!customer && primaryPaymentMethodId.value) {
|
||||
paymentMethodLoading.value = true
|
||||
await refreshPaymentIntent(primaryPaymentMethodId.value, false)
|
||||
paymentMethodLoading.value = false
|
||||
}
|
||||
|
||||
if (!selectedPaymentMethod.value) {
|
||||
elementsLoaded.value = 0
|
||||
|
||||
const {
|
||||
elements: newElements,
|
||||
addressElement,
|
||||
paymentElement,
|
||||
} = createElements({
|
||||
mode: 'payment',
|
||||
currency: currency.toLowerCase(),
|
||||
amount: product.value?.prices.find((x) => x.currency_code === currency)?.prices.intervals[
|
||||
interval.value
|
||||
],
|
||||
paymentMethodCreation: 'manual',
|
||||
setupFutureUsage: 'off_session',
|
||||
})
|
||||
|
||||
elements = newElements
|
||||
paymentElement.on('ready', () => {
|
||||
elementsLoaded.value += 1
|
||||
})
|
||||
addressElement.on('ready', () => {
|
||||
elementsLoaded.value += 1
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
loadingFailed.value = String(err)
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPaymentIntent(id: string, confirmation: boolean) {
|
||||
try {
|
||||
paymentMethodLoading.value = true
|
||||
if (!confirmation) {
|
||||
selectedPaymentMethod.value = paymentMethods.find((x) => x.id === id)
|
||||
}
|
||||
|
||||
const requestType: PaymentRequestType = confirmation
|
||||
? {
|
||||
type: 'confirmation_token',
|
||||
token: id,
|
||||
}
|
||||
: {
|
||||
type: 'payment_method',
|
||||
id: id,
|
||||
}
|
||||
|
||||
if (!product.value) {
|
||||
return handlePaymentError('No product selected')
|
||||
}
|
||||
|
||||
const charge: ChargeRequestType = {
|
||||
type: 'new',
|
||||
product_id: product.value?.id,
|
||||
interval: interval.value,
|
||||
}
|
||||
|
||||
let result: BasePaymentIntentResponse
|
||||
|
||||
if (paymentIntentId.value) {
|
||||
result = await updateIntent({
|
||||
...requestType,
|
||||
charge,
|
||||
existing_payment_intent: paymentIntentId.value,
|
||||
metadata: {
|
||||
type: 'pyro',
|
||||
server_region: region.value,
|
||||
source: {},
|
||||
},
|
||||
})
|
||||
console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
|
||||
} else {
|
||||
;({
|
||||
payment_intent_id: paymentIntentId.value,
|
||||
client_secret: clientSecret.value,
|
||||
...result
|
||||
} = await createIntent({
|
||||
...requestType,
|
||||
charge,
|
||||
metadata: {
|
||||
type: 'pyro',
|
||||
server_region: region.value,
|
||||
source: {},
|
||||
},
|
||||
}))
|
||||
console.log(`Created payment intent: ${interval.value} for ${result.total}`)
|
||||
}
|
||||
|
||||
tax.value = result.tax
|
||||
total.value = result.total
|
||||
|
||||
if (confirmation) {
|
||||
confirmationToken.value = id
|
||||
if (result.payment_method) {
|
||||
inputtedPaymentMethod.value = result.payment_method
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
handlePaymentError(err as string)
|
||||
}
|
||||
paymentMethodLoading.value = false
|
||||
}
|
||||
|
||||
async function createConfirmationToken() {
|
||||
if (!elements) {
|
||||
return handlePaymentError('No elements')
|
||||
}
|
||||
if (!stripe.value) {
|
||||
return handlePaymentError('No stripe')
|
||||
}
|
||||
|
||||
const { error, confirmationToken: confirmation } = await stripe.value.createConfirmationToken({
|
||||
elements,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
handlePaymentError(error.message ?? 'Unknown error creating confirmation token')
|
||||
return
|
||||
}
|
||||
|
||||
return confirmation.id
|
||||
}
|
||||
|
||||
function handlePaymentError(err: string | Error) {
|
||||
paymentMethodLoading.value = false
|
||||
completingPurchase.value = false
|
||||
onError(typeof err === 'string' ? new Error(err) : err)
|
||||
}
|
||||
|
||||
async function createNewPaymentMethod() {
|
||||
paymentMethodLoading.value = true
|
||||
|
||||
if (!elements) {
|
||||
return handlePaymentError('No elements')
|
||||
}
|
||||
|
||||
const { error: submitError } = await elements.submit()
|
||||
|
||||
if (submitError) {
|
||||
return handlePaymentError(submitError.message ?? 'Unknown error creating payment method')
|
||||
}
|
||||
|
||||
const token = await createConfirmationToken()
|
||||
if (!token) {
|
||||
return handlePaymentError('Failed to create confirmation token')
|
||||
}
|
||||
await refreshPaymentIntent(token, true)
|
||||
|
||||
if (!planPrices.value) {
|
||||
return handlePaymentError('No plan prices')
|
||||
}
|
||||
if (!total.value) {
|
||||
return handlePaymentError('No total amount')
|
||||
}
|
||||
|
||||
elements.update({ currency: planPrices.value.currency_code.toLowerCase(), amount: total.value })
|
||||
|
||||
elementsLoaded.value = 0
|
||||
confirmationToken.value = token
|
||||
paymentMethodLoading.value = false
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
async function selectPaymentMethod(paymentMethod: Stripe.PaymentMethod | undefined) {
|
||||
selectedPaymentMethod.value = paymentMethod
|
||||
if (paymentMethod === undefined) {
|
||||
await loadStripeElements()
|
||||
} else {
|
||||
refreshPaymentIntent(paymentMethod.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadingElements = computed(() => elementsLoaded.value < 2)
|
||||
|
||||
async function submitPayment(returnUrl: string) {
|
||||
completingPurchase.value = true
|
||||
const secert = clientSecret.value
|
||||
|
||||
if (!secert) {
|
||||
return handlePaymentError('No client secret')
|
||||
}
|
||||
|
||||
if (!stripe.value) {
|
||||
return handlePaymentError('No stripe')
|
||||
}
|
||||
|
||||
submittingPayment.value = true
|
||||
const { error } = await stripe.value.confirmPayment({
|
||||
clientSecret: secert,
|
||||
confirmParams: {
|
||||
confirmation_token: confirmationToken.value,
|
||||
return_url: `${returnUrl}?priceId=${product.value?.prices.find((x) => x.currency_code === currency)?.id}&plan=${interval.value}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
handlePaymentError(error.message ?? 'Unknown error submitting payment')
|
||||
return false
|
||||
}
|
||||
submittingPayment.value = false
|
||||
completingPurchase.value = false
|
||||
return true
|
||||
}
|
||||
|
||||
async function reloadPaymentIntent() {
|
||||
console.log('selected:', selectedPaymentMethod.value)
|
||||
console.log('token:', confirmationToken.value)
|
||||
if (selectedPaymentMethod.value) {
|
||||
await refreshPaymentIntent(selectedPaymentMethod.value.id, false)
|
||||
} else if (confirmationToken.value) {
|
||||
await refreshPaymentIntent(confirmationToken.value, true)
|
||||
} else {
|
||||
throw new Error('No payment method selected')
|
||||
}
|
||||
}
|
||||
|
||||
const hasPaymentMethod = computed(() => selectedPaymentMethod.value || confirmationToken.value)
|
||||
|
||||
return {
|
||||
initializeStripe: initialize,
|
||||
selectPaymentMethod,
|
||||
reloadPaymentIntent,
|
||||
primaryPaymentMethodId,
|
||||
selectedPaymentMethod,
|
||||
inputtedPaymentMethod,
|
||||
hasPaymentMethod,
|
||||
createNewPaymentMethod,
|
||||
loadingElements,
|
||||
loadingElementsFailed,
|
||||
paymentMethodLoading,
|
||||
loadStripeElements,
|
||||
tax,
|
||||
total,
|
||||
submitPayment,
|
||||
completingPurchase,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"button.back": {
|
||||
"defaultMessage": "Back"
|
||||
},
|
||||
"button.cancel": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
@@ -23,6 +26,9 @@
|
||||
"button.edit": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"button.next": {
|
||||
"defaultMessage": "Next"
|
||||
},
|
||||
"button.open-folder": {
|
||||
"defaultMessage": "Open folder"
|
||||
},
|
||||
@@ -173,6 +179,12 @@
|
||||
"label.visit-your-profile": {
|
||||
"defaultMessage": "Visit your profile"
|
||||
},
|
||||
"modal.add-payment-method.action": {
|
||||
"defaultMessage": "Add payment method"
|
||||
},
|
||||
"modal.add-payment-method.title": {
|
||||
"defaultMessage": "Adding a payment method"
|
||||
},
|
||||
"notification.error.title": {
|
||||
"defaultMessage": "An error occurred"
|
||||
},
|
||||
@@ -485,6 +497,36 @@
|
||||
"servers.notice.undismissable": {
|
||||
"defaultMessage": "Undismissable"
|
||||
},
|
||||
"servers.purchase.step.payment.description": {
|
||||
"defaultMessage": "You won't be charged yet."
|
||||
},
|
||||
"servers.purchase.step.payment.prompt": {
|
||||
"defaultMessage": "Select a payment method"
|
||||
},
|
||||
"servers.purchase.step.payment.title": {
|
||||
"defaultMessage": "Payment method"
|
||||
},
|
||||
"servers.purchase.step.region.title": {
|
||||
"defaultMessage": "Region"
|
||||
},
|
||||
"servers.purchase.step.review.title": {
|
||||
"defaultMessage": "Review"
|
||||
},
|
||||
"servers.region.custom.prompt": {
|
||||
"defaultMessage": "How much RAM do you want your server to have?"
|
||||
},
|
||||
"servers.region.europe": {
|
||||
"defaultMessage": "Europe"
|
||||
},
|
||||
"servers.region.north-america": {
|
||||
"defaultMessage": "North America"
|
||||
},
|
||||
"servers.region.prompt": {
|
||||
"defaultMessage": "Where would you like your server to be located?"
|
||||
},
|
||||
"servers.region.region-unsupported": {
|
||||
"defaultMessage": "Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>"
|
||||
},
|
||||
"settings.account.title": {
|
||||
"defaultMessage": "Account and security"
|
||||
},
|
||||
|
||||
102
packages/ui/src/utils/billing.ts
Normal file
102
packages/ui/src/utils/billing.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
export type ServerBillingInterval = 'monthly' | 'yearly' | 'quarterly'
|
||||
|
||||
export interface ServerPlan {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
metadata: {
|
||||
type: string
|
||||
ram?: number
|
||||
cpu?: number
|
||||
storage?: number
|
||||
swap?: number
|
||||
}
|
||||
prices: {
|
||||
id: string
|
||||
currency_code: string
|
||||
prices: {
|
||||
intervals: {
|
||||
monthly: number
|
||||
yearly: number
|
||||
}
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface ServerStockRequest {
|
||||
cpu?: number
|
||||
memory_mb?: number
|
||||
swap_mb?: number
|
||||
storage_mb?: number
|
||||
}
|
||||
|
||||
export interface ServerRegion {
|
||||
shortcode: string
|
||||
country_code: string
|
||||
display_name: string
|
||||
lat: number
|
||||
lon: number
|
||||
}
|
||||
|
||||
/*
|
||||
Request types
|
||||
*/
|
||||
export type PaymentMethodRequest = {
|
||||
type: 'payment_method'
|
||||
id: string
|
||||
}
|
||||
|
||||
export type ConfirmationTokenRequest = {
|
||||
type: 'confirmation_token'
|
||||
token: string
|
||||
}
|
||||
|
||||
export type PaymentRequestType = PaymentMethodRequest | ConfirmationTokenRequest
|
||||
|
||||
export type ChargeRequestType =
|
||||
| {
|
||||
type: 'existing'
|
||||
id: string
|
||||
}
|
||||
| {
|
||||
type: 'new'
|
||||
product_id: string
|
||||
interval?: ServerBillingInterval
|
||||
}
|
||||
|
||||
export type CreatePaymentIntentRequest = PaymentRequestType & {
|
||||
charge: ChargeRequestType
|
||||
metadata?: {
|
||||
type: 'pyro'
|
||||
server_name?: string
|
||||
server_region?: string
|
||||
source: {
|
||||
loader?: string
|
||||
game_version?: string
|
||||
loader_version?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdatePaymentIntentRequest = CreatePaymentIntentRequest & {
|
||||
existing_payment_intent: string
|
||||
}
|
||||
|
||||
/*
|
||||
Response types
|
||||
*/
|
||||
export type BasePaymentIntentResponse = {
|
||||
price_id: string
|
||||
tax: number
|
||||
total: number
|
||||
payment_method: Stripe.PaymentMethod
|
||||
}
|
||||
|
||||
export type UpdatePaymentIntentResponse = BasePaymentIntentResponse
|
||||
|
||||
export type CreatePaymentIntentResponse = BasePaymentIntentResponse & {
|
||||
payment_intent_id: string
|
||||
client_secret: string
|
||||
}
|
||||
@@ -17,6 +17,14 @@ export const commonMessages = defineMessages({
|
||||
id: 'button.continue',
|
||||
defaultMessage: 'Continue',
|
||||
},
|
||||
nextButton: {
|
||||
id: 'button.next',
|
||||
defaultMessage: 'Next',
|
||||
},
|
||||
backButton: {
|
||||
id: 'button.back',
|
||||
defaultMessage: 'Back',
|
||||
},
|
||||
copyIdButton: {
|
||||
id: 'button.copy-id',
|
||||
defaultMessage: 'Copy ID',
|
||||
@@ -205,6 +213,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.visit-your-profile',
|
||||
defaultMessage: 'Visit your profile',
|
||||
},
|
||||
paymentMethodCardDisplay: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_card_display',
|
||||
defaultMessage: '{card_brand} ending in {last_four}',
|
||||
},
|
||||
})
|
||||
|
||||
export const commonSettingsMessages = defineMessages({
|
||||
@@ -245,3 +257,51 @@ export const commonSettingsMessages = defineMessages({
|
||||
defaultMessage: 'Billing and subscriptions',
|
||||
},
|
||||
})
|
||||
|
||||
export const paymentMethodMessages = defineMessages({
|
||||
visa: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.visa',
|
||||
defaultMessage: 'Visa',
|
||||
},
|
||||
amex: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.amex',
|
||||
defaultMessage: 'American Express',
|
||||
},
|
||||
diners: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.diners',
|
||||
defaultMessage: 'Diners Club',
|
||||
},
|
||||
discover: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.discover',
|
||||
defaultMessage: 'Discover',
|
||||
},
|
||||
eftpos: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.eftpos',
|
||||
defaultMessage: 'EFTPOS',
|
||||
},
|
||||
jcb: { id: 'omorphia.component.purchase_modal.payment_method_type.jcb', defaultMessage: 'JCB' },
|
||||
mastercard: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.mastercard',
|
||||
defaultMessage: 'MasterCard',
|
||||
},
|
||||
unionpay: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.unionpay',
|
||||
defaultMessage: 'UnionPay',
|
||||
},
|
||||
paypal: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.paypal',
|
||||
defaultMessage: 'PayPal',
|
||||
},
|
||||
cashapp: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.cashapp',
|
||||
defaultMessage: 'Cash App',
|
||||
},
|
||||
amazon_pay: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.amazon_pay',
|
||||
defaultMessage: 'Amazon Pay',
|
||||
},
|
||||
unknown: {
|
||||
id: 'omorphia.component.purchase_modal.payment_method_type.unknown',
|
||||
defaultMessage: 'Unknown payment method',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ export const NOTICE_LEVELS: Record<
|
||||
},
|
||||
}
|
||||
|
||||
const DISMISSABLE = {
|
||||
export const DISMISSABLE = {
|
||||
name: defineMessage({
|
||||
id: 'servers.notice.dismissable',
|
||||
defaultMessage: 'Dismissable',
|
||||
@@ -57,7 +57,7 @@ const DISMISSABLE = {
|
||||
},
|
||||
}
|
||||
|
||||
const UNDISMISSABLE = {
|
||||
export const UNDISMISSABLE = {
|
||||
name: defineMessage({
|
||||
id: 'servers.notice.undismissable',
|
||||
defaultMessage: 'Undismissable',
|
||||
|
||||
16
packages/ui/src/utils/regions.ts
Normal file
16
packages/ui/src/utils/regions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineMessage, type MessageDescriptor } from '@vintl/vintl'
|
||||
|
||||
export const regionOverrides = {
|
||||
'us-vin': {
|
||||
name: defineMessage({ id: 'servers.region.north-america', defaultMessage: 'North America' }),
|
||||
flag: 'https://flagcdn.com/us.svg',
|
||||
},
|
||||
'eu-lim': {
|
||||
name: defineMessage({ id: 'servers.region.europe', defaultMessage: 'Europe' }),
|
||||
flag: 'https://flagcdn.com/eu.svg',
|
||||
},
|
||||
'de-fra': {
|
||||
name: defineMessage({ id: 'servers.region.europe', defaultMessage: 'Europe' }),
|
||||
flag: 'https://flagcdn.com/eu.svg',
|
||||
},
|
||||
} satisfies Record<string, { name?: MessageDescriptor; flag?: string }>
|
||||
@@ -5,5 +5,6 @@
|
||||
"compilerOptions": {
|
||||
"lib": ["esnext", "dom"],
|
||||
"noImplicitAny": false
|
||||
}
|
||||
},
|
||||
"types": ["@stripe/stripe-js"]
|
||||
}
|
||||
|
||||
@@ -61,23 +61,33 @@ export const getCurrency = (userCountry) => {
|
||||
return countryCurrency[userCountry] ?? 'USD'
|
||||
}
|
||||
|
||||
export const formatPrice = (locale, price, currency) => {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
export const formatPrice = (locale, price, currency, trimZeros = false) => {
|
||||
let formatter = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
})
|
||||
|
||||
const maxDigits = formatter.resolvedOptions().maximumFractionDigits
|
||||
|
||||
const convertedPrice = price / Math.pow(10, maxDigits)
|
||||
|
||||
let minimumFractionDigits = maxDigits
|
||||
|
||||
if (trimZeros && Number.isInteger(convertedPrice)) {
|
||||
minimumFractionDigits = 0
|
||||
}
|
||||
|
||||
formatter = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits,
|
||||
})
|
||||
return formatter.format(convertedPrice)
|
||||
}
|
||||
|
||||
export const calculateSavings = (monthlyPlan, annualPlan) => {
|
||||
const monthlyAnnualized = monthlyPlan * 12
|
||||
export const calculateSavings = (monthlyPlan, plan, months = 12) => {
|
||||
const monthlyAnnualized = monthlyPlan * months
|
||||
|
||||
return Math.floor(((monthlyAnnualized - annualPlan) / monthlyAnnualized) * 100)
|
||||
return Math.floor(((monthlyAnnualized - plan) / monthlyAnnualized) * 100)
|
||||
}
|
||||
|
||||
export const createStripeElements = (stripe, paymentMethods, options) => {
|
||||
@@ -87,13 +97,13 @@ export const createStripeElements = (stripe, paymentMethods, options) => {
|
||||
appearance: {
|
||||
variables: {
|
||||
colorPrimary: styles.getPropertyValue('--color-brand'),
|
||||
colorBackground: styles.getPropertyValue('--color-bg'),
|
||||
colorBackground: styles.getPropertyValue('--experimental-color-button-bg'),
|
||||
colorText: styles.getPropertyValue('--color-base'),
|
||||
colorTextPlaceholder: styles.getPropertyValue('--color-secondary'),
|
||||
colorDanger: styles.getPropertyValue('--color-red'),
|
||||
fontFamily: styles.getPropertyValue('--font-standard'),
|
||||
spacingUnit: '0.25rem',
|
||||
borderRadius: '1rem',
|
||||
borderRadius: '0.75rem',
|
||||
},
|
||||
},
|
||||
loader: 'never',
|
||||
|
||||
@@ -10,6 +10,58 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2025-06-16T11:00:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Rolled out hotfixes with the previous days' updates.
|
||||
- Failed subscriptions can now be cancelled.`,
|
||||
},
|
||||
{
|
||||
date: `2025-06-16T11:00:00-07:00`,
|
||||
product: 'servers',
|
||||
body: `### Improvements
|
||||
- Improved error handling.
|
||||
- Rolled out hotfixes with the previous days' updates.'`,
|
||||
},
|
||||
{
|
||||
date: `2025-06-15T16:25:00-07:00`,
|
||||
product: 'servers',
|
||||
body: `### Improvements
|
||||
- Fixed installing modpacks from search.
|
||||
- Fixed setting subdomains.`,
|
||||
},
|
||||
{
|
||||
date: `2025-06-15T14:30:00-07:00`,
|
||||
product: 'servers',
|
||||
body: `### Improvements
|
||||
- Fixed various issues with the panel loading improperly in certain cases.
|
||||
- Fixed CPU icon being smaller than the rest.
|
||||
- Server panel performance should be a little faster now.`,
|
||||
},
|
||||
{
|
||||
date: `2025-06-15T14:30:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Creator analytics charts will now show up to 15 projects in a tooltip instead of 5.
|
||||
- Made certain scrollable containers not have a fixed height, and allow them to be smaller if they have fewer items. (Contributed by [Erb3](https://github.com/modrinth/code/pull/2898))
|
||||
- Made organizations sort consistently alphabetically. (Contributed by [WorldWidePixel](https://github.com/modrinth/code/pull/3755))
|
||||
- Clarified the 'File too large' error message when uploading an image larger than 1MiB in the text editor. (Contributed by [IThundxr](https://github.com/modrinth/code/pull/3774))`,
|
||||
},
|
||||
{
|
||||
date: `2025-06-03T14:35:00-07:00`,
|
||||
product: 'servers',
|
||||
body: `### Added
|
||||
- Added support for servers in Europe.
|
||||
- Added server setup for new servers upon opening the panel for the first time.`,
|
||||
},
|
||||
{
|
||||
date: `2025-06-03T14:35:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Overhauled Modrinth Servers purchase flow.
|
||||
- Added the ability to donate creator rewards to charity.`,
|
||||
},
|
||||
{
|
||||
date: `2025-05-08T09:00:00-07:00`,
|
||||
product: 'servers',
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './projects'
|
||||
export * from './types'
|
||||
export * from './users'
|
||||
export * from './utils'
|
||||
export * from './servers'
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"highlight.js": "^11.9.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"ofetch": "^1.3.4",
|
||||
"xss": "^1.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/utils/servers/errors/index.ts
Normal file
3
packages/utils/servers/errors/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './modrinth-servers-multi-error'
|
||||
export * from './modrinth-servers-fetch-error'
|
||||
export * from './modrinth-server-error'
|
||||
59
packages/utils/servers/errors/modrinth-server-error.ts
Normal file
59
packages/utils/servers/errors/modrinth-server-error.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { FetchError } from 'ofetch'
|
||||
import { V1ErrorInfo } from '../types'
|
||||
|
||||
export class ModrinthServerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number,
|
||||
public readonly originalError?: Error,
|
||||
public readonly module?: string,
|
||||
public readonly v1Error?: V1ErrorInfo,
|
||||
) {
|
||||
let errorMessage = message
|
||||
let method = 'GET'
|
||||
let path = ''
|
||||
|
||||
if (originalError instanceof FetchError) {
|
||||
const matches = message.match(/\[([A-Z]+)\]\s+"([^"]+)":/)
|
||||
if (matches) {
|
||||
method = matches[1]
|
||||
path = matches[2].replace(/https?:\/\/[^/]+\/[^/]+\/v\d+\//, '')
|
||||
}
|
||||
|
||||
const statusMessage = (() => {
|
||||
if (!statusCode) return 'Unknown Error'
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return 'Bad Request'
|
||||
case 401:
|
||||
return 'Unauthorized'
|
||||
case 403:
|
||||
return 'Forbidden'
|
||||
case 404:
|
||||
return 'Not Found'
|
||||
case 408:
|
||||
return 'Request Timeout'
|
||||
case 429:
|
||||
return 'Too Many Requests'
|
||||
case 500:
|
||||
return 'Internal Server Error'
|
||||
case 502:
|
||||
return 'Bad Gateway'
|
||||
case 503:
|
||||
return 'Service Unavailable'
|
||||
case 504:
|
||||
return 'Gateway Timeout'
|
||||
default:
|
||||
return `HTTP ${statusCode}`
|
||||
}
|
||||
})()
|
||||
|
||||
errorMessage = `[${method}] ${statusMessage} (${statusCode}) while fetching ${path}${module ? ` in ${module}` : ''}`
|
||||
} else {
|
||||
errorMessage = `${message}${statusCode ? ` (${statusCode})` : ''}${module ? ` in ${module}` : ''}`
|
||||
}
|
||||
|
||||
super(errorMessage)
|
||||
this.name = 'ModrinthServersFetchError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class ModrinthServersFetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public originalError?: Error,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ModrinthFetchError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export class ModrinthServersMultiError extends Error {
|
||||
public readonly errors: Map<string, Error> = new Map()
|
||||
public readonly timestamp: number = Date.now()
|
||||
|
||||
constructor(message?: string) {
|
||||
super(message || 'Multiple errors occurred')
|
||||
this.name = 'MultipleErrors'
|
||||
}
|
||||
|
||||
addError(module: string, error: Error) {
|
||||
this.errors.set(module, error)
|
||||
this.message = this.buildErrorMessage()
|
||||
}
|
||||
|
||||
hasErrors() {
|
||||
return this.errors.size > 0
|
||||
}
|
||||
|
||||
private buildErrorMessage(): string {
|
||||
return (
|
||||
Array.from(this.errors.entries())
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.map(([_module, error]) => error.message)
|
||||
.join('\n')
|
||||
)
|
||||
}
|
||||
}
|
||||
2
packages/utils/servers/index.ts
Normal file
2
packages/utils/servers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './errors'
|
||||
export * from './types'
|
||||
19
packages/utils/servers/types/api.ts
Normal file
19
packages/utils/servers/types/api.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ModrinthServerError } from '../errors'
|
||||
|
||||
export interface V1ErrorInfo {
|
||||
context?: string
|
||||
error: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface JWTAuth {
|
||||
url: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface ModuleError {
|
||||
error: ModrinthServerError
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type ModuleName = 'general' | 'content' | 'backups' | 'network' | 'startup' | 'ws' | 'fs'
|
||||
28
packages/utils/servers/types/backup.ts
Normal file
28
packages/utils/servers/types/backup.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { WSBackupTask, WSBackupState } from './websocket'
|
||||
|
||||
export interface Backup {
|
||||
id: string
|
||||
name: string
|
||||
created_at: string
|
||||
locked: boolean
|
||||
automated: boolean
|
||||
interrupted: boolean
|
||||
ongoing: boolean
|
||||
task: {
|
||||
[K in WSBackupTask]?: {
|
||||
progress: number
|
||||
state: WSBackupState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AutoBackupSettings {
|
||||
enabled: boolean
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface ServerBackup {
|
||||
id: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
59
packages/utils/servers/types/common.ts
Normal file
59
packages/utils/servers/types/common.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Project } from '../../types'
|
||||
import { Allocation } from './server'
|
||||
import { ServerBackup } from './backup'
|
||||
import { Mod } from './content'
|
||||
|
||||
export type ServerNotice = {
|
||||
id: number
|
||||
message: string
|
||||
title?: string
|
||||
level: 'info' | 'warn' | 'critical' | 'survey'
|
||||
dismissable: boolean
|
||||
announce_at: string
|
||||
expires: string
|
||||
assigned: {
|
||||
kind: 'server' | 'node'
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
dismissed_by: {
|
||||
server: string
|
||||
dismissed_on: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
server_id: string
|
||||
name: string
|
||||
status: string
|
||||
net: {
|
||||
ip: string
|
||||
port: number
|
||||
domain: string
|
||||
allocations: Allocation[]
|
||||
}
|
||||
game: string
|
||||
loader: string | null
|
||||
loader_version: string | null
|
||||
mc_version: string | null
|
||||
backup_quota: number
|
||||
used_backup_quota: number
|
||||
backups: ServerBackup[]
|
||||
mods: Mod[]
|
||||
project: Project | null
|
||||
suspension_reason: string | null
|
||||
image: string | null
|
||||
upstream?: {
|
||||
kind: 'modpack'
|
||||
project_id: string
|
||||
version_id: string
|
||||
}
|
||||
motd: string
|
||||
flows: {
|
||||
intro?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface Servers {
|
||||
servers: Server[]
|
||||
}
|
||||
13
packages/utils/servers/types/content.ts
Normal file
13
packages/utils/servers/types/content.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface Mod {
|
||||
filename: string
|
||||
project_id: string | undefined
|
||||
version_id: string | undefined
|
||||
name: string | undefined
|
||||
version_number: string | undefined
|
||||
icon_url: string | undefined
|
||||
owner: string | undefined
|
||||
disabled: boolean
|
||||
installing: boolean
|
||||
}
|
||||
|
||||
export type ContentType = 'mod' | 'plugin'
|
||||
33
packages/utils/servers/types/filesystem.ts
Normal file
33
packages/utils/servers/types/filesystem.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FSQueuedOp, FilesystemOp } from './websocket'
|
||||
import { JWTAuth } from './api'
|
||||
|
||||
export interface DirectoryItem {
|
||||
name: string
|
||||
type: 'directory' | 'file'
|
||||
count?: number
|
||||
modified: number
|
||||
created: number
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface FileUploadQuery {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
promise: Promise<any>
|
||||
onProgress: (
|
||||
callback: (progress: { loaded: number; total: number; progress: number }) => void,
|
||||
) => void
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export interface DirectoryResponse {
|
||||
items: DirectoryItem[]
|
||||
total: number
|
||||
current?: number
|
||||
}
|
||||
|
||||
export interface FSModule {
|
||||
auth: JWTAuth
|
||||
ops: FilesystemOp[]
|
||||
queuedOps: FSQueuedOp[]
|
||||
opsQueuedForModification: string[]
|
||||
}
|
||||
8
packages/utils/servers/types/index.ts
Normal file
8
packages/utils/servers/types/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './api'
|
||||
export * from './content'
|
||||
export * from './server'
|
||||
export * from './backup'
|
||||
export * from './filesystem'
|
||||
export * from './websocket'
|
||||
export * from './stats'
|
||||
export * from './common'
|
||||
76
packages/utils/servers/types/server.ts
Normal file
76
packages/utils/servers/types/server.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Project } from '../../types'
|
||||
import type { ServerNotice } from './common'
|
||||
|
||||
export interface ServerGeneral {
|
||||
server_id: string
|
||||
name: string
|
||||
net: {
|
||||
ip: string
|
||||
port: number
|
||||
domain: string
|
||||
}
|
||||
game: string
|
||||
backup_quota: number
|
||||
used_backup_quota: number
|
||||
status: string
|
||||
suspension_reason: 'moderated' | 'paymentfailed' | 'cancelled' | 'upgrading' | 'other' | string
|
||||
loader: string
|
||||
loader_version: string
|
||||
mc_version: string
|
||||
upstream: {
|
||||
kind: 'modpack' | 'mod' | 'resourcepack'
|
||||
version_id: string
|
||||
project_id: string
|
||||
} | null
|
||||
motd?: string
|
||||
image?: string
|
||||
project?: Project
|
||||
sftp_username: string
|
||||
sftp_password: string
|
||||
sftp_host: string
|
||||
datacenter?: string
|
||||
notices?: ServerNotice[]
|
||||
node: {
|
||||
token: string
|
||||
instance: string
|
||||
}
|
||||
flows?: {
|
||||
intro?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface Allocation {
|
||||
port: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Startup {
|
||||
invocation: string
|
||||
original_invocation: string
|
||||
jdk_version: 'lts8' | 'lts11' | 'lts17' | 'lts21'
|
||||
jdk_build: 'corretto' | 'temurin' | 'graal'
|
||||
}
|
||||
|
||||
export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
|
||||
export type JDKVersion = 'lts8' | 'lts11' | 'lts17' | 'lts21'
|
||||
export type JDKBuild = 'corretto' | 'temurin' | 'graal'
|
||||
|
||||
export type Loaders =
|
||||
| 'Fabric'
|
||||
| 'Quilt'
|
||||
| 'Forge'
|
||||
| 'NeoForge'
|
||||
| 'Paper'
|
||||
| 'Spigot'
|
||||
| 'Bukkit'
|
||||
| 'Vanilla'
|
||||
| 'Purpur'
|
||||
|
||||
export type ServerState =
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'restarting'
|
||||
| 'stopping'
|
||||
| 'stopped'
|
||||
| 'crashed'
|
||||
| 'unknown'
|
||||
20
packages/utils/servers/types/stats.ts
Normal file
20
packages/utils/servers/types/stats.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface Stats {
|
||||
current: {
|
||||
cpu_percent: number
|
||||
ram_usage_bytes: number
|
||||
ram_total_bytes: number
|
||||
storage_usage_bytes: number
|
||||
storage_total_bytes: number
|
||||
}
|
||||
past: {
|
||||
cpu_percent: number
|
||||
ram_usage_bytes: number
|
||||
ram_total_bytes: number
|
||||
storage_usage_bytes: number
|
||||
storage_total_bytes: number
|
||||
}
|
||||
graph: {
|
||||
cpu: number[]
|
||||
ram: number[]
|
||||
}
|
||||
}
|
||||
124
packages/utils/servers/types/websocket.ts
Normal file
124
packages/utils/servers/types/websocket.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Stats } from './stats'
|
||||
import type { ServerState } from './server'
|
||||
|
||||
export interface WSAuth {
|
||||
url: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface WSLogEvent {
|
||||
event: 'log'
|
||||
message: string
|
||||
}
|
||||
|
||||
type CurrentStats = Stats['current']
|
||||
|
||||
export interface WSStatsEvent extends CurrentStats {
|
||||
event: 'stats'
|
||||
}
|
||||
|
||||
export interface WSAuthExpiringEvent {
|
||||
event: 'auth-expiring'
|
||||
}
|
||||
|
||||
export interface WSPowerStateEvent {
|
||||
event: 'power-state'
|
||||
state: ServerState
|
||||
// if state "crashed"
|
||||
oom_killed?: boolean
|
||||
exit_code?: number
|
||||
}
|
||||
|
||||
export interface WSAuthIncorrectEvent {
|
||||
event: 'auth-incorrect'
|
||||
}
|
||||
|
||||
export interface WSInstallationResultOkEvent {
|
||||
event: 'installation-result'
|
||||
result: 'ok'
|
||||
}
|
||||
|
||||
export interface WSInstallationResultErrEvent {
|
||||
event: 'installation-result'
|
||||
result: 'err'
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type WSInstallationResultEvent = WSInstallationResultOkEvent | WSInstallationResultErrEvent
|
||||
|
||||
export interface WSAuthOkEvent {
|
||||
event: 'auth-ok'
|
||||
}
|
||||
|
||||
export interface WSUptimeEvent {
|
||||
event: 'uptime'
|
||||
uptime: number // seconds
|
||||
}
|
||||
|
||||
export interface WSNewModEvent {
|
||||
event: 'new-mod'
|
||||
}
|
||||
|
||||
export type WSBackupTask = 'file' | 'create' | 'restore'
|
||||
export type WSBackupState = 'ongoing' | 'done' | 'failed' | 'cancelled' | 'unchanged'
|
||||
|
||||
export interface WSBackupProgressEvent {
|
||||
event: 'backup-progress'
|
||||
task: WSBackupTask
|
||||
id: string
|
||||
progress: number // percentage
|
||||
state: WSBackupState
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
export type FSQueuedOpUnarchive = {
|
||||
op: 'unarchive'
|
||||
src: string
|
||||
}
|
||||
|
||||
export type FSQueuedOp = FSQueuedOpUnarchive
|
||||
|
||||
export type FSOpUnarchive = {
|
||||
op: 'unarchive'
|
||||
progress: number // Note: 1 does not mean it's done
|
||||
id: string // UUID
|
||||
|
||||
mime: string
|
||||
src: string
|
||||
state:
|
||||
| 'queued'
|
||||
| 'ongoing'
|
||||
| 'cancelled'
|
||||
| 'done'
|
||||
| 'failed-corrupted'
|
||||
| 'failed-invalid-path'
|
||||
| 'failed-cf-no-serverpack'
|
||||
| 'failed-cf-not-available'
|
||||
| 'failed-not-reachable'
|
||||
|
||||
current_file: string | null
|
||||
failed_path?: string
|
||||
bytes_processed: number
|
||||
files_processed: number
|
||||
started: string
|
||||
}
|
||||
|
||||
export type FilesystemOp = FSOpUnarchive
|
||||
|
||||
export interface WSFilesystemOpsEvent {
|
||||
event: 'filesystem-ops'
|
||||
all: FilesystemOp[]
|
||||
}
|
||||
|
||||
export type WSEvent =
|
||||
| WSLogEvent
|
||||
| WSStatsEvent
|
||||
| WSPowerStateEvent
|
||||
| WSAuthExpiringEvent
|
||||
| WSAuthIncorrectEvent
|
||||
| WSInstallationResultEvent
|
||||
| WSAuthOkEvent
|
||||
| WSUptimeEvent
|
||||
| WSNewModEvent
|
||||
| WSBackupProgressEvent
|
||||
| WSFilesystemOpsEvent
|
||||
@@ -294,22 +294,3 @@ export type Report = {
|
||||
created: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export type ServerNotice = {
|
||||
id: number
|
||||
message: string
|
||||
title?: string
|
||||
level: 'info' | 'warn' | 'critical' | 'survey'
|
||||
dismissable: boolean
|
||||
announce_at: string
|
||||
expires: string
|
||||
assigned: {
|
||||
kind: 'server' | 'node'
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
dismissed_by: {
|
||||
server: string
|
||||
dismissed_on: string
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -177,8 +177,15 @@ export const formatCategory = (name) => {
|
||||
return 'Colored Lighting'
|
||||
} else if (name === 'optifine') {
|
||||
return 'OptiFine'
|
||||
} else if (name === 'bta-babric') {
|
||||
return 'BTA (Babric)'
|
||||
} else if (name === 'legacy-fabric') {
|
||||
return 'Legacy Fabric'
|
||||
} else if (name === 'java-agent') {
|
||||
return 'Java Agent'
|
||||
} else if (name === 'nilloader') {
|
||||
return 'NilLoader'
|
||||
}
|
||||
|
||||
return capitalizeString(name)
|
||||
}
|
||||
|
||||
@@ -341,3 +348,17 @@ export const getArrayOrString = (x: string[] | string): string[] => {
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
export function getPingLevel(ping: number) {
|
||||
if (ping < 120) {
|
||||
return 5
|
||||
} else if (ping < 200) {
|
||||
return 4
|
||||
} else if (ping < 300) {
|
||||
return 3
|
||||
} else if (ping < 400) {
|
||||
return 2
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user