You've already forked AstralRinth
forked from didirus/AstralRinth
Reworked app update flow (#3960)
* Make theseus capable of logging messages from the `log` crate * Move update checking entirely into JS and open a modal if an update is available * Fix formatjs on Windows and run formatjs * Add in the buttons and body * Fix lint * Show update size in modal * Fix update not being rechecked if the update modal was directly dismissed * Slight UI tweaks * Fix lint * Implement skipping the update * Implement the Update Now button * Implement updating at next exit * Turn download progress into an error bar on failure * Restore 5 minute update check instead of 30 seconds * Fix PendingUpdateData being seen as a unit struct * Fix lint * Make CI also lint updater code * feat: create AppearingProgressBar component * feat: polish update available modal * feat: add error handling * Open changelog with tauri-plugin-opener * Run intl:extract * Update completion toasts (#3978) * Use single LAUNCHER_USER_AGENT constant for all user agents * Fix build on Mac * Request the update size with HEAD instead of GET * UI tweaks * lint * Fix lint * fix: hide modal header & add "Hide update reminder" button w/ tooltip * Run intl:extract * fix: lint issues * fix: merge issues * notifications.js no longer exists * Add metered network checking * Add a timeout to macOS is_network_metered * Fix tauri.conf.json * vibe debugging * Set a dispatch queue * Have a popup that asks you if you'd like to disable automatic file downloads if you're on a metered network * Move UpdateModal to modal package * Fix lint * Add a toggle for automatic downloads * Fix type Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com> Signed-off-by: Josiah Glosson <soujournme@gmail.com> * Redo updating UI and experience * lint * fix unlistener issue * remove unneeded translation keys * Fix expose issue * temp disable cranelift, tweak some messages * change version back * Clean up App.vue * move toast to top right * update reload icon * Fixed the bug!!!!!!!!!!!! * improve messages * intl:extract * Add liquid glass icon file * not you! * use dependency injection * lint on apple icon * Fix imports, move download size to button * change update check back to 5 mins * lint + move to providers * intl:extract --------- Signed-off-by: Cal H. <hendersoncal117@gmail.com> Signed-off-by: Josiah Glosson <soujournme@gmail.com> Co-authored-by: Calum <calum@modrinth.com> Co-authored-by: Prospector <prospectordev@gmail.com> Co-authored-by: Cal H. <hendersoncal117@gmail.com> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
This commit is contained in:
@@ -41,7 +41,7 @@
|
||||
{
|
||||
"name": "display_claims!: serde_json::Value",
|
||||
"ordinal": 7,
|
||||
"type_info": "Null"
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates\n FROM settings\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -142,6 +142,21 @@
|
||||
"name": "toggle_sidebar",
|
||||
"ordinal": 27,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "skipped_update",
|
||||
"ordinal": 28,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pending_update_toast_for_version",
|
||||
"ordinal": 29,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "auto_download_updates",
|
||||
"ordinal": 30,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -175,8 +190,11 @@
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca"
|
||||
"hash": "7dc83d7ffa3d583fc5ffaf13811a8dab4d0b9ded6200f827b9de7ac32e5318d5"
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28\n ",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 28
|
||||
"Right": 31
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c"
|
||||
"hash": "eb95fac3043d0ffd10caef69cc469474cc5c0d36cc0698c4cc0852da81fed158"
|
||||
}
|
||||
@@ -80,7 +80,7 @@ tokio = { workspace = true, features = [
|
||||
"macros",
|
||||
"process",
|
||||
] }
|
||||
tokio-util = { workspace = true, features = ["compat", "io", "io-util"] }
|
||||
tokio-util = { workspace = true, features = ["compat", "io", "io-util", "time"] }
|
||||
async-recursion.workspace = true
|
||||
fs4 = { workspace = true, features = ["tokio"] }
|
||||
async-walkdir.workspace = true
|
||||
@@ -109,12 +109,19 @@ sqlx = { workspace = true, features = [
|
||||
] }
|
||||
|
||||
quartz_nbt = { workspace = true, features = ["serde"] }
|
||||
|
||||
hickory-resolver.workspace = true
|
||||
zbus.workspace = true
|
||||
|
||||
ariadne.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg.workspace = true
|
||||
windows = { workspace = true, features = ["Networking_Connectivity"] }
|
||||
windows-core.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cidre = { workspace = true, features = ["nw", "blocks"] }
|
||||
|
||||
[build-dependencies]
|
||||
dotenvy.workspace = true
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN skipped_update TEXT NULL;
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN pending_update_toast_for_version TEXT NULL;
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN auto_download_updates INT NULL;
|
||||
@@ -37,7 +37,7 @@ pub mod prelude {
|
||||
settings,
|
||||
util::{
|
||||
io::{IOError, canonicalize},
|
||||
network::tcp_listen_any_loopback,
|
||||
network::{is_network_metered, tcp_listen_any_loopback},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,6 +166,13 @@ pub enum ErrorKind {
|
||||
|
||||
#[error("RPC error: {0}")]
|
||||
RpcError(String),
|
||||
|
||||
#[cfg(windows)]
|
||||
#[error("Windows error: {0}")]
|
||||
WindowsError(#[from] windows_core::Error),
|
||||
|
||||
#[error("zbus error: {0}")]
|
||||
ZbusError(#[from] zbus::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -176,7 +176,6 @@ pub enum LoadingBarType {
|
||||
import_location: PathBuf,
|
||||
profile_name: String,
|
||||
},
|
||||
CheckingForUpdates,
|
||||
LauncherUpdate {
|
||||
version: String,
|
||||
current_version: String,
|
||||
|
||||
@@ -25,3 +25,9 @@ pub use event::{
|
||||
};
|
||||
pub use logger::start_logger;
|
||||
pub use state::State;
|
||||
|
||||
pub const LAUNCHER_USER_AGENT: &str = concat!(
|
||||
"modrinth/theseus/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (support@modrinth.com)"
|
||||
);
|
||||
|
||||
@@ -25,12 +25,11 @@ pub fn start_logger() -> Option<()> {
|
||||
.unwrap_or_else(|_| {
|
||||
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
|
||||
});
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(filter)
|
||||
.with(tracing_error::ErrorLayer::default());
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("setting default subscriber failed");
|
||||
.with(tracing_error::ErrorLayer::default())
|
||||
.init();
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -76,7 +75,7 @@ pub fn start_logger() -> Option<()> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("theseus=info"));
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(file)
|
||||
@@ -84,10 +83,8 @@ pub fn start_logger() -> Option<()> {
|
||||
.with_timer(ChronoLocal::rfc_3339()),
|
||||
)
|
||||
.with(filter)
|
||||
.with(tracing_error::ErrorLayer::default());
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("Setting default subscriber failed");
|
||||
.with(tracing_error::ErrorLayer::default())
|
||||
.init();
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::ErrorKind;
|
||||
use crate::LAUNCHER_USER_AGENT;
|
||||
use crate::data::ModrinthCredentials;
|
||||
use crate::event::FriendPayload;
|
||||
use crate::event::emit::emit_friend;
|
||||
@@ -82,13 +83,9 @@ impl FriendsSocket {
|
||||
)
|
||||
.into_client_request()?;
|
||||
|
||||
let user_agent = format!(
|
||||
"modrinth/theseus/{} (support@modrinth.com)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"User-Agent",
|
||||
HeaderValue::from_str(&user_agent).unwrap(),
|
||||
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
|
||||
);
|
||||
|
||||
let res = connect_async(request).await;
|
||||
|
||||
@@ -38,6 +38,10 @@ pub struct Settings {
|
||||
|
||||
pub developer_mode: bool,
|
||||
pub feature_flags: HashMap<FeatureFlag, bool>,
|
||||
|
||||
pub skipped_update: Option<String>,
|
||||
pub pending_update_toast_for_version: Option<String>,
|
||||
pub auto_download_updates: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)]
|
||||
@@ -63,7 +67,8 @@ impl Settings {
|
||||
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
|
||||
mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,
|
||||
hook_pre_launch, hook_wrapper, hook_post_exit,
|
||||
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar
|
||||
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,
|
||||
skipped_update, pending_update_toast_for_version, auto_download_updates
|
||||
FROM settings
|
||||
"
|
||||
)
|
||||
@@ -117,6 +122,10 @@ impl Settings {
|
||||
.as_ref()
|
||||
.and_then(|x| serde_json::from_str(x).ok())
|
||||
.unwrap_or_default(),
|
||||
skipped_update: res.skipped_update,
|
||||
pending_update_toast_for_version: res
|
||||
.pending_update_toast_for_version,
|
||||
auto_download_updates: res.auto_download_updates.map(|x| x == 1),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,7 +179,11 @@ impl Settings {
|
||||
|
||||
toggle_sidebar = $26,
|
||||
feature_flags = $27,
|
||||
hide_nametag_skins_page = $28
|
||||
hide_nametag_skins_page = $28,
|
||||
|
||||
skipped_update = $29,
|
||||
pending_update_toast_for_version = $30,
|
||||
auto_download_updates = $31
|
||||
",
|
||||
max_concurrent_writes,
|
||||
max_concurrent_downloads,
|
||||
@@ -199,7 +212,10 @@ impl Settings {
|
||||
self.migrated,
|
||||
self.toggle_sidebar,
|
||||
feature_flags,
|
||||
self.hide_nametag_skins_page
|
||||
self.hide_nametag_skins_page,
|
||||
self.skipped_update,
|
||||
self.pending_update_toast_for_version,
|
||||
self.auto_download_updates,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Functions for fetching information from the Internet
|
||||
use super::io::{self, IOError};
|
||||
use crate::ErrorKind;
|
||||
use crate::LAUNCHER_USER_AGENT;
|
||||
use crate::event::LoadingBarId;
|
||||
use crate::event::emit::emit_loading;
|
||||
use bytes::Bytes;
|
||||
@@ -20,11 +21,8 @@ pub struct FetchSemaphore(pub Semaphore);
|
||||
|
||||
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
let header = reqwest::header::HeaderValue::from_str(&format!(
|
||||
"modrinth/theseus/{} (support@modrinth.com)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.unwrap();
|
||||
let header =
|
||||
reqwest::header::HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap();
|
||||
headers.insert(reqwest::header::USER_AGENT, header);
|
||||
reqwest::Client::builder()
|
||||
.tcp_keepalive(Some(time::Duration::from_secs(10)))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::Result;
|
||||
use std::io;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use tokio::net::TcpListener;
|
||||
@@ -15,3 +16,78 @@ pub async fn tcp_listen_any_loopback() -> io::Result<TcpListener> {
|
||||
|
||||
TcpListener::bind(ANY_LOOPBACK_SOCKET).await
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
use windows::Networking::Connectivity::{
|
||||
NetworkCostType, NetworkInformation,
|
||||
};
|
||||
|
||||
let cost_type = NetworkInformation::GetInternetConnectionProfile()?
|
||||
.GetConnectionCost()?
|
||||
.NetworkCostType()?;
|
||||
Ok(matches!(
|
||||
cost_type,
|
||||
NetworkCostType::Fixed | NetworkCostType::Variable
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
use crate::ErrorKind;
|
||||
use cidre::dispatch::Queue;
|
||||
use cidre::nw::PathMonitor;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::future::FutureExt;
|
||||
|
||||
let (sender, mut receiver) = mpsc::channel(1);
|
||||
|
||||
let queue = Queue::new();
|
||||
let mut monitor = PathMonitor::new();
|
||||
monitor.set_queue(&queue);
|
||||
monitor.set_update_handler(move |path| {
|
||||
let _ = sender.try_send(path.is_constrained() || path.is_expensive());
|
||||
});
|
||||
|
||||
monitor.start();
|
||||
let result = receiver
|
||||
.recv()
|
||||
.timeout(Duration::from_millis(100))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
monitor.cancel();
|
||||
|
||||
result.ok_or_else(|| {
|
||||
ErrorKind::OtherError(
|
||||
"NWPathMonitor didn't provide an NWPath in time".to_string(),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
// Thanks to https://github.com/Hakanbaban53/rclone-manager for showing how to do this
|
||||
use zbus::{Connection, Proxy};
|
||||
|
||||
let connection = Connection::system().await?;
|
||||
let proxy = Proxy::new(
|
||||
&connection,
|
||||
"org.freedesktop.NetworkManager",
|
||||
"/org/freedesktop/NetworkManager",
|
||||
"org.freedesktop.NetworkManager",
|
||||
)
|
||||
.await?;
|
||||
let metered = proxy.get_property("Metered").await?;
|
||||
Ok(matches!(metered, 1 | 3))
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
|
||||
pub async fn is_network_metered() -> Result<bool> {
|
||||
tracing::warn!(
|
||||
"is_network_metered called on unsupported platform. Assuming unmetered."
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ import _RadioButtonIcon from './icons/radio-button.svg?component'
|
||||
import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
|
||||
import _ReceiptTextIcon from './icons/receipt-text.svg?component'
|
||||
import _RedoIcon from './icons/redo.svg?component'
|
||||
import _RefreshCwIcon from './icons/refresh-cw.svg?component'
|
||||
import _ReplyIcon from './icons/reply.svg?component'
|
||||
import _ReportIcon from './icons/report.svg?component'
|
||||
import _RestoreIcon from './icons/restore.svg?component'
|
||||
@@ -335,6 +336,7 @@ export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
|
||||
export const RadioButtonIcon = _RadioButtonIcon
|
||||
export const ReceiptTextIcon = _ReceiptTextIcon
|
||||
export const RedoIcon = _RedoIcon
|
||||
export const RefreshCwIcon = _RefreshCwIcon
|
||||
export const ReplyIcon = _ReplyIcon
|
||||
export const ReportIcon = _ReportIcon
|
||||
export const RestoreIcon = _RestoreIcon
|
||||
|
||||
1
packages/assets/icons/refresh-cw.svg
Normal file
1
packages/assets/icons/refresh-cw.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-refresh-cw-icon lucide-refresh-cw"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
||||
|
After Width: | Height: | Size: 411 B |
140
packages/ui/src/components/base/AppearingProgressBar.vue
Normal file
140
packages/ui/src/components/base/AppearingProgressBar.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="isVisible" class="w-full">
|
||||
<div class="mb-2 flex justify-between text-sm">
|
||||
<Transition name="phrase-fade" mode="out-in">
|
||||
<span :key="currentPhrase" class="text-md font-semibold">{{ currentPhrase }}</span>
|
||||
</Transition>
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-secondary">{{ Math.round(progress) }}%</span>
|
||||
<span class="text-xs text-secondary"
|
||||
>{{ formatBytes(currentValue) }} / {{ formatBytes(maxValue) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-divider">
|
||||
<div
|
||||
class="h-2 animate-pulse bg-brand rounded-full transition-all duration-300 ease-out"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
maxValue: number
|
||||
currentValue: number
|
||||
tips?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tips: () => [
|
||||
'Removing Herobrine...',
|
||||
'Feeding parrots...',
|
||||
'Teaching villagers new trades...',
|
||||
'Convincing creepers to be friendly...',
|
||||
'Polishing diamonds...',
|
||||
'Training wolves to fetch...',
|
||||
'Building pixel art...',
|
||||
'Explaining redstone to beginners...',
|
||||
'Collecting all the cats...',
|
||||
'Negotiating with endermen...',
|
||||
'Planting suspicious stew ingredients...',
|
||||
'Calibrating TNT blast radius...',
|
||||
'Teaching chickens to fly...',
|
||||
'Sorting inventory alphabetically...',
|
||||
'Convincing iron golems to smile...',
|
||||
],
|
||||
})
|
||||
|
||||
const currentPhrase = ref('')
|
||||
const usedPhrases = ref(new Set<number>())
|
||||
let phraseInterval: NodeJS.Timeout | null = null
|
||||
|
||||
const progress = computed(() => {
|
||||
if (props.maxValue === 0) return 0
|
||||
return Math.min((props.currentValue / props.maxValue) * 100, 100)
|
||||
})
|
||||
|
||||
const isVisible = computed(() => props.maxValue > 0 && props.currentValue >= 0)
|
||||
|
||||
function getNextPhrase() {
|
||||
if (usedPhrases.value.size >= props.tips.length) {
|
||||
const currentPhraseIndex = props.tips.indexOf(currentPhrase.value)
|
||||
usedPhrases.value.clear()
|
||||
if (currentPhraseIndex !== -1) {
|
||||
usedPhrases.value.add(currentPhraseIndex)
|
||||
}
|
||||
}
|
||||
const availableIndices = props.tips
|
||||
.map((_, index) => index)
|
||||
.filter((index) => !usedPhrases.value.has(index))
|
||||
|
||||
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
|
||||
usedPhrases.value.add(randomIndex)
|
||||
|
||||
return props.tips[randomIndex]
|
||||
}
|
||||
|
||||
function startPhraseRotation() {
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval)
|
||||
}
|
||||
|
||||
currentPhrase.value = getNextPhrase()
|
||||
phraseInterval = setInterval(() => {
|
||||
currentPhrase.value = getNextPhrase()
|
||||
}, 4500)
|
||||
}
|
||||
|
||||
function stopPhraseRotation() {
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval)
|
||||
phraseInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(isVisible, (newVisible) => {
|
||||
if (newVisible) {
|
||||
startPhraseRotation()
|
||||
} else {
|
||||
stopPhraseRotation()
|
||||
usedPhrases.value.clear()
|
||||
}
|
||||
})
|
||||
|
||||
watch(progress, (newProgress) => {
|
||||
if (newProgress >= 100) {
|
||||
stopPhraseRotation()
|
||||
currentPhrase.value = 'Installing modpack...'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPhraseRotation()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.phrase-fade-enter-active,
|
||||
.phrase-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.phrase-fade-enter-from,
|
||||
.phrase-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
121
packages/ui/src/components/base/JoinedButtons.vue
Normal file
121
packages/ui/src/components/base/JoinedButtons.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="joined-buttons">
|
||||
<ButtonStyled :color="color">
|
||||
<button :disabled="disabled" @click="handlePrimaryAction">
|
||||
<component :is="primaryAction.icon" v-if="primaryAction.icon" aria-hidden="true" />
|
||||
{{ primaryAction.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="dropdownActions.length > 0" :color="color">
|
||||
<OverflowMenu class="btn-dropdown-animation" :options="dropdownOptions" :disabled="disabled">
|
||||
<DropdownIcon />
|
||||
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
|
||||
<component :is="action.icon" v-if="action.icon" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { ButtonStyled, OverflowMenu } from '../index'
|
||||
|
||||
// TODO: This should be moved to a shared types file.
|
||||
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
|
||||
export interface JoinedButtonAction {
|
||||
id: string
|
||||
label: string
|
||||
icon?: Component
|
||||
action: () => void
|
||||
color?: Colors
|
||||
hoverFilled?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
actions: JoinedButtonAction[]
|
||||
color?: Colors
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'standard',
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const primaryAction = computed(() => props.actions[0])
|
||||
|
||||
const dropdownActions = computed(() => props.actions.slice(1))
|
||||
|
||||
const colorMap: Record<
|
||||
Colors,
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'highlight'
|
||||
| 'primary'
|
||||
| 'danger'
|
||||
| 'secondary'
|
||||
| undefined
|
||||
> = {
|
||||
standard: 'secondary',
|
||||
brand: 'primary',
|
||||
red: 'red',
|
||||
orange: 'orange',
|
||||
green: 'green',
|
||||
blue: 'blue',
|
||||
purple: 'purple',
|
||||
}
|
||||
|
||||
const dropdownOptions = computed(() =>
|
||||
dropdownActions.value.map((action) => ({
|
||||
id: action.id,
|
||||
color: action.color ? colorMap[action.color] : undefined,
|
||||
action: action.action,
|
||||
hoverFilled: action.hoverFilled ?? true,
|
||||
})),
|
||||
)
|
||||
|
||||
function handlePrimaryAction() {
|
||||
if (primaryAction.value && !props.disabled) {
|
||||
primaryAction.value.action()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.joined-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn:first-child) {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn:last-child) {
|
||||
border-top-right-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn:not(:last-child)) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.btn-dropdown-animation {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
</style>
|
||||
58
packages/ui/src/components/base/ProgressSpinner.vue
Normal file
58
packages/ui/src/components/base/ProgressSpinner.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
progress: number
|
||||
max?: number
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
},
|
||||
)
|
||||
|
||||
const percent = computed(() => props.progress / props.max)
|
||||
</script>
|
||||
<template>
|
||||
<span class="relative flex items-center justify-center">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="absolute"
|
||||
>
|
||||
<circle opacity="0.25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
</svg>
|
||||
<svg
|
||||
:style="{ '--_progress': `${percent * 100}%` }"
|
||||
width="24"
|
||||
height="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="absolute progress-circle"
|
||||
>
|
||||
<circle opacity="0.75" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@property --_progress {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
.progress-circle {
|
||||
transition: --_progress 0.125s ease-in-out;
|
||||
mask-image: conic-gradient(
|
||||
black 0%,
|
||||
black var(--_progress),
|
||||
transparent calc(var(--_progress) + 1%),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
// Base content
|
||||
export { default as Accordion } from './base/Accordion.vue'
|
||||
export { default as Admonition } from './base/Admonition.vue'
|
||||
export { default as AppearingProgressBar } from './base/AppearingProgressBar.vue'
|
||||
export { default as AutoLink } from './base/AutoLink.vue'
|
||||
export { default as Avatar } from './base/Avatar.vue'
|
||||
export { default as Badge } from './base/Badge.vue'
|
||||
@@ -23,6 +24,8 @@ export type { FilterBarOption } from './base/FilterBar.vue'
|
||||
export { default as FilterBar } from './base/FilterBar.vue'
|
||||
export { default as HeadingLink } from './base/HeadingLink.vue'
|
||||
export { default as IconSelect } from './base/IconSelect.vue'
|
||||
export type { JoinedButtonAction } from './base/JoinedButtons.vue'
|
||||
export { default as JoinedButtons } from './base/JoinedButtons.vue'
|
||||
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './base/ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
|
||||
@@ -34,6 +37,7 @@ export { default as Pagination } from './base/Pagination.vue'
|
||||
export { default as PopoutMenu } from './base/PopoutMenu.vue'
|
||||
export { default as PreviewSelectButton } from './base/PreviewSelectButton.vue'
|
||||
export { default as ProgressBar } from './base/ProgressBar.vue'
|
||||
export { default as ProgressSpinner } from './base/ProgressSpinner.vue'
|
||||
export { default as ProjectCard } from './base/ProjectCard.vue'
|
||||
export { default as RadialHeader } from './base/RadialHeader.vue'
|
||||
export { default as RadioButtons } from './base/RadioButtons.vue'
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<div class="modal-container experimental-styles-within" :class="{ shown: visible }">
|
||||
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
|
||||
<div
|
||||
v-if="!hideHeader"
|
||||
data-tauri-drag-region
|
||||
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-divider max-w-full"
|
||||
>
|
||||
@@ -61,6 +62,7 @@ const props = withDefaults(
|
||||
closeOnClickOutside?: boolean
|
||||
warnOnClose?: boolean
|
||||
header?: string
|
||||
hideHeader?: boolean
|
||||
onHide?: () => void
|
||||
onShow?: () => void
|
||||
}>(),
|
||||
@@ -72,6 +74,7 @@ const props = withDefaults(
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
header: undefined,
|
||||
hideHeader: false,
|
||||
onHide: () => {},
|
||||
onShow: () => {},
|
||||
},
|
||||
@@ -135,7 +138,7 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (props.closeOnEsc && event.key === 'Escape') {
|
||||
if (props.closeOnEsc && event.key === 'Escape' && props.closable) {
|
||||
hide()
|
||||
mouseX.value = window.innerWidth / 2
|
||||
mouseY.value = window.innerHeight / 2
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"button.cancel": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
"button.close": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"button.continue": {
|
||||
"defaultMessage": "Continue"
|
||||
},
|
||||
|
||||
@@ -25,6 +25,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
closeButton: {
|
||||
id: 'button.close',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
changesSavedLabel: {
|
||||
id: 'label.changes-saved',
|
||||
defaultMessage: 'Changes saved',
|
||||
|
||||
Reference in New Issue
Block a user