Merge branch 'main' into prospector/app-layout-cleanup

This commit is contained in:
Prospector
2026-07-28 11:22:27 -07:00
committed by GitHub
150 changed files with 3830 additions and 1606 deletions
@@ -45,14 +45,15 @@ export class LabrinthOAuthInternalModule extends AbstractModule {
* @returns Promise resolving to an array of OAuth clients
*/
public async getApps(ids: string[]): Promise<Labrinth.OAuth.Internal.OAuthClient[]> {
return this.client.request<Labrinth.OAuth.Internal.OAuthClient[]>(
`/oauth/apps?ids=${encodeURIComponent(JSON.stringify(ids))}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
if (ids.length === 0) {
return []
}
// bulk `/oauth/apps` is broken on backend, fetch by id instead
// TODO: Remove this once the backend is fixed
const results = await Promise.all(ids.map((id) => this.getApp(id).catch(() => null)))
return results.filter((app): app is Labrinth.OAuth.Internal.OAuthClient => app !== null)
}
/**
+1
View File
@@ -47,6 +47,7 @@ governor = { workspace = true }
heck = { workspace = true }
hickory-resolver = { workspace = true }
httpdate = { workspace = true }
image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] }
indicatif = { workspace = true, optional = true }
itertools = { workspace = true }
modrinth-content-management = { workspace = true }
+16 -9
View File
@@ -4,6 +4,7 @@ mod content;
mod content_set_diff;
mod export_mrpack;
mod get;
mod icon;
mod install;
mod lifecycle;
mod paths;
@@ -21,9 +22,13 @@ pub use self::export_mrpack::{
create_mrpack_json, export_mrpack, get_pack_export_candidates,
};
pub use self::get::{get, get_many, list};
pub use self::icon::edit_icon;
pub(crate) use self::icon::{
cache_icon, cache_icon_from_path, migrate_legacy_icons,
};
pub use self::install::get_optimal_jre_key;
pub(crate) use self::lifecycle::create;
pub use self::lifecycle::{edit, edit_icon, remove};
pub use self::lifecycle::{edit, remove};
pub use self::paths::{get_full_path, get_mod_full_path};
pub use self::projects::{
InstallProjectWithDependenciesRequest, add_project_from_path,
@@ -42,17 +47,19 @@ pub(crate) use self::shared::{
};
pub use self::shared::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
SharedInstanceJoinType, SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
SharedInstanceInvite, SharedInstanceInviteInstallPreview,
SharedInstanceInviteLink, SharedInstanceJoinType,
SharedInstancePublishPreview, SharedInstanceUpdateDiff,
SharedInstanceUpdateDiffType, SharedInstanceUpdatePreview,
SharedInstanceUser, SharedInstanceUsers,
accept_pending_shared_instance_invite,
accept_shared_instance_invite_for_install,
can_active_user_use_shared_instances, create_shared_instance_invite_link,
decline_pending_shared_instance_invite,
get_shared_instance_install_preview, get_shared_instance_publish_preview,
get_shared_instance_update_preview, get_shared_instance_users,
install_shared_instance, invite_shared_instance_users,
publish_shared_instance, remove_shared_instance_users,
get_shared_instance_install_preview, get_shared_instance_invites,
get_shared_instance_publish_preview, get_shared_instance_update_preview,
get_shared_instance_users, install_shared_instance,
invite_shared_instance_users, publish_shared_instance,
remove_shared_instance_users, revoke_shared_instance_invite,
unlink_shared_instance, unpublish_shared_instance, update_shared_instance,
};
+318
View File
@@ -0,0 +1,318 @@
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::instances::adapters::sqlite::instance_rows;
use crate::state::{EditInstance, State};
use crate::util::fetch::{sha1_async, write};
use crate::util::io;
use bytes::Bytes;
use std::fs::File as StdFile;
use std::io::{BufRead, BufReader, Cursor, Seek};
use std::path::{Path, PathBuf};
const INSTANCE_ICON_MAX_BYTES: usize = 4 * 1024 * 1024;
const INSTANCE_ICON_MAX_DIMENSION: u32 = 512;
const INSTANCE_ICON_MAX_SOURCE_DIMENSION: u32 = 8_192;
const INSTANCE_ICON_MAX_DECODE_BYTES: u64 = 64 * 1024 * 1024;
enum LegacyIconAction {
Keep,
Normalize,
Remove,
}
pub async fn edit_icon(
instance_id: &str,
icon_path: Option<&Path>,
) -> crate::Result<()> {
let state = State::get().await?;
let icon_path = if let Some(icon_path) = icon_path {
Some(
cache_icon_from_path(icon_path, &state)
.await?
.to_string_lossy()
.to_string(),
)
} else {
None
};
apply_instance_icon(instance_id, icon_path, &state).await
}
pub(crate) async fn cache_icon(
bytes: Bytes,
state: &State,
) -> crate::Result<PathBuf> {
let bytes = tokio::task::spawn_blocking(move || {
if looks_like_svg(&bytes) {
return Err(svg_not_supported_error());
}
normalize_raster(Cursor::new(bytes))
})
.await??;
write_cached_icon(bytes, state).await
}
pub(crate) async fn cache_icon_from_path(
icon_path: &Path,
state: &State,
) -> crate::Result<PathBuf> {
let icon_path = icon_path.to_path_buf();
let bytes = tokio::task::spawn_blocking(move || {
let file = StdFile::open(&icon_path).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not open instance icon {}: {error}",
icon_path.display()
))
})?;
let mut reader = BufReader::new(file);
let looks_like_svg = {
let bytes = reader.fill_buf().map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not inspect instance icon {}: {error}",
icon_path.display()
))
})?;
looks_like_svg(bytes)
};
if has_svg_extension(&icon_path) || looks_like_svg {
return Err(svg_not_supported_error());
}
normalize_raster(reader)
})
.await??;
write_cached_icon(bytes, state).await
}
pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
let state = State::get().await?;
let instances = instance_rows::list_instances(&state.pool).await?;
for instance in instances {
let Some(icon_path) = instance.icon_path.as_deref() else {
continue;
};
let action = match inspect_legacy_icon(Path::new(icon_path)) {
Ok(action) => action,
Err(error) => {
tracing::warn!(
instance_id = instance.id,
icon_path,
error = %error,
"Failed to inspect legacy instance icon"
);
continue;
}
};
match action {
LegacyIconAction::Keep => {}
LegacyIconAction::Normalize => {
if let Err(error) =
edit_icon(&instance.id, Some(Path::new(icon_path))).await
{
tracing::warn!(
instance_id = instance.id,
icon_path,
error = %error,
"Failed to normalize legacy instance icon"
);
}
}
LegacyIconAction::Remove => {
if let Err(error) =
apply_instance_icon(&instance.id, None, &state).await
{
tracing::warn!(
instance_id = instance.id,
icon_path,
error = %error,
"Failed to remove legacy SVG instance icon"
);
}
}
}
}
Ok(())
}
async fn apply_instance_icon(
instance_id: &str,
icon_path: Option<String>,
state: &State,
) -> crate::Result<()> {
let instance =
instance_rows::get_instance_display_info(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
crate::state::edit_instance(
instance_id,
EditInstance {
icon_path: Some(icon_path.clone()),
..EditInstance::default()
},
&state.pool,
)
.await?;
if let Err(error) = super::shared::sync_shared_instance_icon(
instance_id,
icon_path.as_deref(),
state,
)
.await
{
tracing::warn!(
instance_id,
error = %error,
"Failed to sync shared instance icon"
);
}
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
Ok(())
}
async fn write_cached_icon(
bytes: Bytes,
state: &State,
) -> crate::Result<PathBuf> {
if bytes.len() >= INSTANCE_ICON_MAX_BYTES {
return Err(icon_too_large_error());
}
let hash = sha1_async(bytes.clone()).await?;
let path = state
.directories
.caches_dir()
.join("icons")
.join(format!("{hash}.png"));
write(&path, &bytes, &state.io_semaphore).await?;
Ok(io::canonicalize(path)?)
}
fn normalize_raster<R>(reader: R) -> crate::Result<Bytes>
where
R: BufRead + Seek,
{
let mut reader = image::ImageReader::new(reader)
.with_guessed_format()
.map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not identify instance icon format: {error}"
))
})?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(INSTANCE_ICON_MAX_SOURCE_DIMENSION);
limits.max_image_height = Some(INSTANCE_ICON_MAX_SOURCE_DIMENSION);
limits.max_alloc = Some(INSTANCE_ICON_MAX_DECODE_BYTES);
reader.limits(limits);
let image = reader.decode().map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not decode instance icon: {error}"
))
})?;
let image = if image.width() > INSTANCE_ICON_MAX_DIMENSION
|| image.height() > INSTANCE_ICON_MAX_DIMENSION
{
image.resize(
INSTANCE_ICON_MAX_DIMENSION,
INSTANCE_ICON_MAX_DIMENSION,
image::imageops::FilterType::Lanczos3,
)
} else {
image
};
let mut normalized = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image.to_rgba8())
.write_to(&mut normalized, image::ImageFormat::Png)
.map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not encode instance icon as PNG: {error}"
))
})?;
validate_normalized_icon(normalized.into_inner())
}
fn inspect_legacy_icon(icon_path: &Path) -> crate::Result<LegacyIconAction> {
let metadata = std::fs::metadata(icon_path).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not inspect instance icon {}: {error}",
icon_path.display()
))
})?;
let file = StdFile::open(icon_path).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not open instance icon {}: {error}",
icon_path.display()
))
})?;
let mut reader = BufReader::new(file);
let bytes = reader.fill_buf().map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not inspect instance icon {}: {error}",
icon_path.display()
))
})?;
if has_svg_extension(icon_path) || looks_like_svg(bytes) {
return Ok(LegacyIconAction::Remove);
}
if metadata.len() < INSTANCE_ICON_MAX_BYTES as u64
&& image::guess_format(bytes).ok() == Some(image::ImageFormat::Png)
{
return Ok(LegacyIconAction::Keep);
}
Ok(LegacyIconAction::Normalize)
}
fn validate_normalized_icon(normalized: Vec<u8>) -> crate::Result<Bytes> {
if normalized.len() >= INSTANCE_ICON_MAX_BYTES {
return Err(icon_too_large_error());
}
Ok(Bytes::from(normalized))
}
fn looks_like_svg(bytes: &[u8]) -> bool {
if image::guess_format(bytes).is_ok() {
return false;
}
bytes[..bytes.len().min(1_024)]
.windows(4)
.any(|window| window.eq_ignore_ascii_case(b"<svg"))
}
fn has_svg_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("svg"))
}
fn icon_too_large_error() -> crate::Error {
crate::ErrorKind::InputError(format!(
"Instance icons must be smaller than {INSTANCE_ICON_MAX_BYTES} bytes"
))
.into()
}
fn svg_not_supported_error() -> crate::Error {
crate::ErrorKind::InputError(
"SVG instance icons are not supported".to_string(),
)
.into()
}
@@ -5,8 +5,6 @@ use crate::state::{
CreateInstance, EditInstance, InstanceLink, InstanceMetadata, ModLoader,
State,
};
use crate::util::io;
use std::path::Path;
#[tracing::instrument]
#[allow(clippy::too_many_arguments)]
@@ -73,60 +71,6 @@ pub async fn edit(
Ok(instance)
}
pub async fn edit_icon(
instance_id: &str,
icon_path: Option<&Path>,
) -> crate::Result<()> {
let state = State::get().await?;
let instance =
instance_rows::get_instance_display_info(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let icon_path = if let Some(icon) = icon_path {
let bytes = io::read(icon).await?;
let file = crate::util::fetch::write_cached_icon(
&icon.to_string_lossy(),
&state.directories.caches_dir(),
bytes::Bytes::from(bytes),
&state.io_semaphore,
)
.await?;
Some(file.to_string_lossy().to_string())
} else {
None
};
crate::state::edit_instance(
instance_id,
EditInstance {
icon_path: Some(icon_path.clone()),
..EditInstance::default()
},
&state.pool,
)
.await?;
if let Err(error) = super::shared::sync_shared_instance_icon(
instance_id,
icon_path.as_deref(),
&state,
)
.await
{
tracing::warn!(
instance_id,
error = %error,
"Failed to sync shared instance icon"
);
}
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
Ok(())
}
#[tracing::instrument]
pub async fn remove(instance_id: &str) -> crate::Result<()> {
let state = State::get().await?;
@@ -73,6 +73,14 @@ pub(super) struct CreateInstanceInviteResponse {
pub(super) id: String,
}
#[derive(Clone, Debug, Deserialize)]
pub(super) struct InstanceInviteResponse {
pub(super) id: String,
pub(super) expiration: DateTime<Utc>,
pub(super) max_uses: i32,
pub(super) uses: i32,
}
#[derive(Clone, Debug, Deserialize)]
pub(super) struct BlacklistStatusResponse {
pub(super) blacklisted: bool,
@@ -502,6 +510,20 @@ pub(super) async fn delete_remote_invite(
.await
}
pub(super) async fn get_remote_invites(
shared_instance_id: &str,
state: &State,
) -> crate::Result<Vec<InstanceInviteResponse>> {
request_json(
"get_instance_invites",
Method::GET,
&format!("/instances/{shared_instance_id}/invites"),
None,
state,
)
.await
}
pub(super) async fn get_shared_instance_invite_info(
invite_id: &str,
state: &State,
@@ -691,9 +713,17 @@ where
let body = match response.text().await {
Ok(body) => body,
Err(error) if strip_response_url => {
return Err(error.without_url().into());
return Err(crate::ErrorKind::SharedInstancesApiError(
error.without_url().to_string(),
)
.into());
}
Err(error) => {
return Err(crate::ErrorKind::SharedInstancesApiError(
error.to_string(),
)
.into());
}
Err(error) => return Err(error.into()),
};
serde_json::from_str::<T>(&body).map_err(|error| {
tracing::warn!(
@@ -707,7 +737,7 @@ where
error_column = error.column(),
"Shared instances API returned an invalid JSON response"
);
crate::ErrorKind::OtherError(format!(
crate::ErrorKind::SharedInstancesApiError(format!(
"Shared instances API request {operation} {method} {log_path} returned invalid JSON with status {status}"
))
.into()
@@ -848,7 +878,12 @@ pub(super) async fn send_bytes_request_to_url(
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.body(body)
.send()
.await?;
.await
.map_err(|error| {
crate::ErrorKind::SharedInstancesApiError(
error.without_url().to_string(),
)
})?;
if response.status().is_success() {
let request_id = response_request_id(&response);
@@ -938,9 +973,17 @@ async fn send_request_with_auth_and_log_path(
let response = match request.send().await {
Ok(response) => response,
Err(error) if path != log_path => {
return Err(error.without_url().into());
return Err(crate::ErrorKind::SharedInstancesApiError(
error.without_url().to_string(),
)
.into());
}
Err(error) => {
return Err(crate::ErrorKind::SharedInstancesApiError(
error.to_string(),
)
.into());
}
Err(error) => return Err(error.into()),
};
if response.status().is_success() {
let request_id = response_request_id(&response);
@@ -973,10 +1016,14 @@ pub(super) async fn shared_instances_request_error<T>(
request_id = request_id.as_deref().unwrap_or("none"),
"Shared instances API request failed"
);
Err(crate::ErrorKind::OtherError(format!(
let message = format!(
"Shared instances API request {operation} {method} {path} failed with status {status}"
))
.into())
);
if status.is_server_error() {
return Err(crate::ErrorKind::SharedInstancesApiError(message).into());
}
Err(crate::ErrorKind::OtherError(message).into())
}
pub(super) fn response_request_id(
@@ -244,13 +244,7 @@ pub(crate) async fn check_shared_instance_availability_before_launch(
let availability =
match get_remote_instance_access(&attachment.id, state).await {
Ok(availability) => availability,
Err(error)
if matches!(
error.raw.as_ref(),
crate::ErrorKind::NoCredentialsError
| crate::ErrorKind::FetchError(_)
) =>
{
Err(error) => {
tracing::warn!(
instance_id,
shared_instance_id = %attachment.id,
@@ -259,7 +253,6 @@ pub(crate) async fn check_shared_instance_availability_before_launch(
);
return Ok(());
}
Err(error) => return Err(error),
};
if let SharedInstanceRemoteResponse::Unavailable(reason) = availability {
@@ -124,6 +124,44 @@ pub async fn create_shared_instance_invite_link(
})
}
#[tracing::instrument]
pub async fn get_shared_instance_invites(
instance_id: &str,
) -> crate::Result<Vec<SharedInstanceInvite>> {
let state = State::get().await?;
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
return Ok(Vec::new());
};
ensure_owner(&attachment)?;
Ok(get_remote_invites(&attachment.id, &state)
.await?
.into_iter()
.map(|invite| SharedInstanceInvite {
id: invite.id,
expiration: invite.expiration,
max_uses: invite.max_uses,
uses: invite.uses,
})
.collect())
}
#[tracing::instrument(skip(invite_id))]
pub async fn revoke_shared_instance_invite(
instance_id: &str,
invite_id: String,
) -> crate::Result<()> {
let state = State::get().await?;
let _shared_instance_lock = state.lock_shared_instance(instance_id).await;
let Some(attachment) = shared_attachment(instance_id, &state).await? else {
return Ok(());
};
ensure_owner(&attachment)?;
delete_remote_invite(&attachment.id, &invite_id, &state).await?;
emit_instance(instance_id, InstancePayloadType::Edited).await
}
#[tracing::instrument]
pub async fn remove_shared_instance_users(
instance_id: &str,
@@ -110,8 +110,9 @@ pub use self::install::{
};
pub use self::invites::{
accept_pending_shared_instance_invite, create_shared_instance_invite_link,
decline_pending_shared_instance_invite, get_shared_instance_users,
invite_shared_instance_users, remove_shared_instance_users,
decline_pending_shared_instance_invite, get_shared_instance_invites,
get_shared_instance_users, invite_shared_instance_users,
remove_shared_instance_users, revoke_shared_instance_invite,
};
pub use self::publish::{
get_shared_instance_publish_preview, publish_shared_instance,
@@ -119,10 +120,11 @@ pub use self::publish::{
};
pub use self::types::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
SharedInstanceJoinType, SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
SharedInstanceInvite, SharedInstanceInviteInstallPreview,
SharedInstanceInviteLink, SharedInstanceJoinType,
SharedInstancePublishPreview, SharedInstanceUpdateDiff,
SharedInstanceUpdateDiffType, SharedInstanceUpdatePreview,
SharedInstanceUser, SharedInstanceUsers,
};
pub async fn can_active_user_use_shared_instances() -> crate::Result<bool> {
@@ -116,6 +116,15 @@ pub struct SharedInstanceInviteLink {
pub max_uses: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceInvite {
pub id: String,
pub expiration: DateTime<Utc>,
pub max_uses: i32,
pub uses: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SharedInstanceInviteInstallPreview {
@@ -7,10 +7,7 @@ use crate::{
install::{InstallPhaseDetails, InstallProgressReporter},
prelude::ModLoader,
state::{AppliedContentSetPatch, EditInstance, InstanceInstallStage},
util::{
fetch::{fetch, write_cached_icon},
io,
},
util::{fetch::fetch, io},
};
use super::{finish_import, recache_icon};
@@ -90,18 +87,8 @@ pub async fn import_curseforge(
&state.pool,
)
.await?;
let filename = thumbnail_url.rsplit('/').next_back();
if let Some(filename) = filename {
icon = Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
);
}
icon =
Some(crate::api::instance::cache_icon(icon_bytes, &state).await?);
}
// base mod loader is always None for vanilla
+3 -12
View File
@@ -346,19 +346,10 @@ pub async fn recache_icon(
) -> crate::Result<Option<PathBuf>> {
let state = crate::State::get().await?;
let bytes = tokio::fs::read(&icon_path).await;
if let Ok(bytes) = bytes {
let bytes = bytes::Bytes::from(bytes);
let cache_dir = &state.directories.caches_dir();
let semaphore = &state.io_semaphore;
if tokio::fs::try_exists(&icon_path).await.unwrap_or(false) {
Ok(Some(
fetch::write_cached_icon(
&icon_path.to_string_lossy(),
cache_dir,
bytes,
semaphore,
)
.await?,
crate::api::instance::cache_icon_from_path(&icon_path, &state)
.await?,
))
} else {
// could not find icon (for instance, prism default icon, etc)
+2 -16
View File
@@ -10,7 +10,7 @@ use crate::state::{
};
use crate::util::fetch::{
DownloadMeta, DownloadReason, FetchProgressFn, fetch,
fetch_advanced_with_progress, sha1_file_async, write_cached_icon,
fetch_advanced_with_progress, sha1_file_async,
};
use path_util::SafeRelativeUtf8UnixPathBuf;
use reqwest::Method;
@@ -415,21 +415,7 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
)
.await?;
let filename = icon_url.rsplit('/').next();
if let Some(filename) = filename {
Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
)
} else {
None
}
Some(crate::api::instance::cache_icon(icon_bytes, &state).await?)
} else {
None
}
+3
View File
@@ -124,6 +124,9 @@ pub enum ErrorKind {
#[error("Shared instance unavailable: {0}")]
SharedInstanceUnavailable(SharedInstanceUnavailableReason),
#[error("Shared instances API request failed: {0}")]
SharedInstancesApiError(String),
#[error("Join handle error: {0}")]
JoinError(#[from] tokio::task::JoinError),
+1
View File
@@ -1433,6 +1433,7 @@ fn install_error_code(
ErrorKind::SharedInstanceUnavailable(_) => {
"shared_instance_unavailable"
}
ErrorKind::SharedInstancesApiError(_) => "shared_instances_api_error",
ErrorKind::InputError(_) => match phase {
PreparingInstance | Finalizing => "instance_error",
ResolvingPack | DownloadingPackFile | ReadingPackManifest => {
@@ -8,7 +8,7 @@ use crate::state::{
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
State,
};
use crate::util::fetch::{self, write_cached_icon};
use crate::util::fetch;
use crate::util::io;
use chrono::Utc;
use serde::{Deserialize, Serialize};
@@ -174,10 +174,8 @@ async fn resolve_icon_path(
return Ok(None);
};
let (bytes, file_name) = if icon.starts_with("https://")
|| icon.starts_with("http://")
{
let fetched = fetch::fetch(
let file = if icon.starts_with("https://") || icon.starts_with("http://") {
let bytes = fetch::fetch(
icon,
None,
None,
@@ -186,21 +184,15 @@ async fn resolve_icon_path(
&state.pool,
)
.await?;
let name = icon.rsplit('/').next().unwrap_or("icon").to_string();
(fetched, name)
crate::api::instance::cache_icon(bytes, state).await?
} else {
let data = io::read(state.directories.caches_dir().join(icon)).await?;
(bytes::Bytes::from(data), icon.to_string())
crate::api::instance::cache_icon_from_path(
&state.directories.caches_dir().join(icon),
state,
)
.await?
};
let file = write_cached_icon(
&file_name,
&state.directories.caches_dir(),
bytes,
&state.io_semaphore,
)
.await?;
Ok(Some(file.to_string_lossy().to_string()))
}
+4
View File
@@ -150,6 +150,10 @@ impl State {
)
.await;
if let Err(e) = crate::api::instance::migrate_legacy_icons().await {
tracing::error!("Error migrating legacy instance icons: {e}");
}
let res = tokio::try_join!(
state.discord_rpc.clear_to_default(true),
instances::refresh_all_instances(),
+3
View File
@@ -328,6 +328,7 @@ pub enum Theme {
Dark,
Light,
Oled,
Retro,
System,
}
@@ -337,6 +338,7 @@ impl Theme {
Theme::Dark => "dark",
Theme::Light => "light",
Theme::Oled => "oled",
Theme::Retro => "retro",
Theme::System => "system",
}
}
@@ -346,6 +348,7 @@ impl Theme {
"dark" => Theme::Dark,
"light" => Theme::Light,
"oled" => Theme::Oled,
"retro" => Theme::Retro,
"system" => Theme::System,
_ => Theme::Dark,
}
+1 -29
View File
@@ -14,10 +14,9 @@ use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::ffi::OsStr;
use std::future::Future;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::pin::Pin;
use std::sync::{Arc, LazyLock};
use std::time::{self, Duration, Instant, SystemTime};
@@ -907,33 +906,6 @@ pub async fn copy(
Ok(())
}
// Writes a icon to the cache and returns the absolute path of the icon within the cache directory
#[tracing::instrument(skip(bytes, semaphore))]
pub async fn write_cached_icon(
icon_path: &str,
cache_dir: &Path,
bytes: Bytes,
semaphore: &IoSemaphore,
) -> crate::Result<PathBuf> {
let hash = sha1_async(bytes.clone()).await?;
let path = cache_dir
.join("icons")
.join(cached_icon_file_name(icon_path, &hash));
write(&path, &bytes, semaphore).await?;
let path = io::canonicalize(path)?;
Ok(path)
}
fn cached_icon_file_name(icon_path: &str, hash: &str) -> String {
let path = icon_path.split(['?', '#']).next().unwrap_or(icon_path);
match Path::new(path).extension().and_then(OsStr::to_str) {
Some(extension) => format!("{hash}.{extension}"),
None => hash.to_string(),
}
}
pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
let hash = tokio::task::spawn_blocking(move || {
sha1_smol::Sha1::from(bytes).hexdigest()
+33
View File
@@ -422,6 +422,39 @@ html {
}
.retro-mode {
@extend .dark-mode;
--surface-1: #191917;
--surface-2: rgb(22, 22, 21);
--surface-2-5: #3a3c3e;
--surface-3: #232421;
--surface-4: #3a3b38;
--surface-5: #5a5c58;
--color-button-bg: #3a3b38;
--color-base: #c3c4b3;
--color-secondary: #9b9e98;
--color-contrast: #e6e2d1;
--color-brand: #4d9227;
--color-brand-highlight: #25421e;
--color-accent-contrast: #ffffff;
--color-ad: var(--color-brand-highlight);
--color-ad-raised: var(--color-brand);
--color-ad-contrast: black;
--color-ad-highlight: var(--color-brand);
--color-red: rgb(232, 32, 13);
--color-orange: rgb(232, 141, 13);
--color-green: rgb(60, 219, 54);
--color-blue: rgb(9, 159, 239);
--color-purple: rgb(139, 129, 230);
--color-gray: #718096;
--color-red-highlight: rgba(232, 32, 13, 0.25);
--color-orange-highlight: rgba(232, 141, 13, 0.25);
--color-green-highlight: rgba(60, 219, 54, 0.25);
--color-blue-highlight: rgba(9, 159, 239, 0.25);
--color-purple-highlight: rgba(139, 129, 230, 0.25);
--color-gray-highlight: rgba(113, 128, 150, 0.25);
--brand-gradient-strong-bg: #3a3b38;
}
+13 -3
View File
@@ -4,7 +4,7 @@
:title="formatMessage(copiedMessage)"
@click="copyText"
>
<span>{{ text }}</span>
<span>{{ displayText ?? text }}</span>
<CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else />
</button>
@@ -12,7 +12,7 @@
<script setup lang="ts">
import { CheckIcon, ClipboardCopyIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { onBeforeUnmount, ref } from 'vue'
import { defineMessage, useVIntl } from '../../composables/i18n'
@@ -22,12 +22,22 @@ const copiedMessage = defineMessage({
})
const { formatMessage } = useVIntl()
const props = defineProps<{ text: string }>()
const props = defineProps<{
text: string
displayText?: string
}>()
const copied = ref(false)
let copiedResetTimeout: ReturnType<typeof setTimeout> | undefined
async function copyText() {
await navigator.clipboard.writeText(props.text)
copied.value = true
clearTimeout(copiedResetTimeout)
copiedResetTimeout = setTimeout(() => {
copied.value = false
}, 2000)
}
onBeforeUnmount(() => clearTimeout(copiedResetTimeout))
</script>
@@ -142,6 +142,7 @@ const props = withDefaults(
min?: number
max?: number
step?: number
clamp?: boolean
disabled?: boolean
readonly?: boolean
error?: boolean
@@ -159,6 +160,7 @@ const props = withDefaults(
type: 'text',
size: 'standard',
variant: 'filled',
clamp: false,
disabled: false,
readonly: false,
error: false,
@@ -189,12 +191,22 @@ defineExpose({
function onInput(event: Event) {
const target = event.target as HTMLInputElement | HTMLTextAreaElement
model.value =
props.type === 'number' && !props.multiline
? target.value === ''
? undefined
: Number(target.value)
: target.value
if (props.type !== 'number' || props.multiline) {
model.value = target.value
return
}
if (target.value === '') {
model.value = undefined
return
}
let value = Number(target.value)
if (props.clamp) {
if (props.min !== undefined) value = Math.max(props.min, value)
if (props.max !== undefined) value = Math.min(props.max, value)
target.value = String(value)
}
model.value = value
}
function clear() {
@@ -9,6 +9,7 @@ import { useScrollIndicator } from '../../composables/scroll-indicator'
import NewModal from './NewModal.vue'
export interface Tab {
name: MessageDescriptor
category?: MessageDescriptor
icon: Component
content?: Component
href?: string
@@ -61,6 +62,11 @@ function hide() {
modal.value?.hide()
}
function startsCategory(index: number) {
const category = visibleTabs.value[index]?.category
return !!category && category.id !== visibleTabs.value[index - 1]?.category?.id
}
defineExpose({ show, hide, selectedTab, setTab })
</script>
<template>
@@ -81,26 +87,32 @@ defineExpose({ show, hide, selectedTab, setTab })
<div
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
>
<component
:is="tab.href ? 'a' : 'button'"
v-for="(tab, index) in visibleTabs"
:key="index"
:href="tab.href ?? undefined"
:target="tab.href ? '_blank' : undefined"
:rel="tab.href ? 'noopener noreferrer' : undefined"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="!tab.href && setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
<template v-for="(tab, index) in visibleTabs" :key="index">
<div
v-if="startsCategory(index) && tab.category"
class="px-4 pb-1 pt-2 text-xs font-bold uppercase tracking-wide text-secondary"
>
{{ formatMessage(tab.badge) }}
</span>
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
</component>
{{ formatMessage(tab.category) }}
</div>
<component
:is="tab.href ? 'a' : 'button'"
:href="tab.href ?? undefined"
:target="tab.href ? '_blank' : undefined"
:rel="tab.href ? 'noopener noreferrer' : undefined"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="!tab.href && setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
</component>
</template>
<slot name="footer" />
</div>
@@ -135,6 +135,7 @@
ref="inviteLinkEditor"
:link-expires-at="linkExpiresAt"
:link-max-uses="linkMaxUses"
:link-max-uses-limit="linkMaxUsesLimit"
:update-invite-link="updateInviteLink"
/>
</template>
@@ -169,6 +170,7 @@ const props = withDefaults(
link?: string
linkExpiresAt?: string | Date | null
linkMaxUses?: number
linkMaxUsesLimit?: number
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
friendsLabel?: string
searchPlaceholder?: string
@@ -188,6 +190,7 @@ const props = withDefaults(
suggestions: () => [],
canInvite: true,
linkMaxUses: 10,
linkMaxUsesLimit: 2147483647,
},
)
@@ -1,19 +1,62 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="30rem">
<NewModal ref="modal" :header="formatMessage(messages.title)" width="420px" max-width="420px">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span>
<DatePicker
v-model="expiry"
<Combobox
:model-value="selectedExpiryPreset"
:options="expiryDropdownOptions"
:display-value="expiryPickerLabel"
:disabled="saving"
:min-date="minimumExpiry"
:max-date="maximumExpiry"
date-format="Y-m-d H:i"
alt-format="F j, Y at h:i K"
enable-time
wrapper-class="w-full"
input-class="w-full"
/>
:dropdown-min-width="customExpiryOpen ? '20rem' : undefined"
:dropdown-class="customExpiryOpen ? 'bg-transparent border-0 -mt-1 pb-2 shadow-none' : ''"
@open="handleExpiryPickerOpen"
@close="handleExpiryPickerClose"
@select="selectExpiryPreset"
>
<template #dropdown-footer>
<div
v-if="customExpiryOpen"
class="flex flex-col rounded-2xl border border-solid border-surface-5 bg-surface-3 p-1"
>
<DatePicker
v-model="customExpiry"
:min-date="minimumExpiry"
:max-date="maximumExpiry"
:default-view-date="customExpiry || minimumExpiry"
date-format="Y-m-d H:i"
enable-time
calendar-only
wrapper-class="w-full"
calendar-class="!border-none"
/>
<div class="flex justify-end gap-2 p-3 pt-1">
<ButtonStyled type="outlined">
<button type="button" @click="cancelCustomExpiry">
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
type="button"
:disabled="!canApplyCustomExpiry"
@click="applyCustomExpiry"
>
{{ formatMessage(messages.apply) }}
</button>
</ButtonStyled>
</div>
</div>
<button
v-else
type="button"
class="flex w-full cursor-pointer items-center border-0 border-t border-solid border-surface-5 bg-transparent px-4 py-3 text-left text-base font-semibold leading-tight text-primary transition-colors hover:bg-surface-5"
@click.stop="openCustomExpiry"
>
{{ formatMessage(messages.customExpiry) }}
</button>
</template>
</Combobox>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span>
@@ -21,9 +64,10 @@
v-model="maxUses"
type="number"
:min="1"
:max="2147483647"
:max="maximumUses"
:step="1"
:disabled="saving"
:disabled="saving || maximumUses === 0"
clamp
/>
</div>
</div>
@@ -49,29 +93,58 @@
<script setup lang="ts">
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectNotificationManager } from '../../../providers'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import DatePicker from '../../base/DatePicker.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
import type { InviteLinkSettings } from './types'
const props = defineProps<{
linkExpiresAt?: string | Date | null
linkMaxUses: number
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
}>()
const EXPIRY_PRESET_DURATIONS = {
one_hour: 3_600_000,
six_hours: 6 * 3_600_000,
twelve_hours: 12 * 3_600_000,
one_day: 86_400_000,
three_days: 3 * 86_400_000,
seven_days: 7 * 86_400_000,
} as const
const MINIMUM_EXPIRY_DURATION = EXPIRY_PRESET_DURATIONS.one_hour
const MAXIMUM_EXPIRY_DURATION = EXPIRY_PRESET_DURATIONS.seven_days
const EXPIRY_PRESET_MATCH_TOLERANCE = 2 * 60_000
type ExpiryPreset = keyof typeof EXPIRY_PRESET_DURATIONS
const props = withDefaults(
defineProps<{
linkExpiresAt?: string | Date | null
linkMaxUses: number
linkMaxUsesLimit?: number
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
}>(),
{
linkMaxUsesLimit: 2147483647,
},
)
const { formatMessage } = useVIntl()
const notificationManager = injectNotificationManager(null)
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const expiry = ref('')
const expiryMode = ref<'preset' | 'custom'>('preset')
const expiryPreset = ref<ExpiryPreset>('seven_days')
const expiryReferenceTime = ref(Date.now())
const customExpiry = ref('')
const customExpiryOpen = ref(false)
const maxUses = ref<number>()
const minimumExpiry = ref(new Date())
const maximumExpiry = ref(new Date())
const saving = ref(false)
const maximumUses = computed(() => Math.max(0, Math.floor(props.linkMaxUsesLimit)))
const formatExpiryDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const messages = defineMessages({
title: {
@@ -86,10 +159,46 @@ const messages = defineMessages({
id: 'sharing.invite-players-modal.max-uses-label',
defaultMessage: 'Maximum uses',
},
inOneHour: {
id: 'sharing.invite-players-modal.expiry-in-one-hour',
defaultMessage: 'In 1 hour',
},
inSixHours: {
id: 'sharing.invite-players-modal.expiry-in-six-hours',
defaultMessage: 'In 6 hours',
},
inTwelveHours: {
id: 'sharing.invite-players-modal.expiry-in-twelve-hours',
defaultMessage: 'In 12 hours',
},
inOneDay: {
id: 'sharing.invite-players-modal.expiry-in-one-day',
defaultMessage: 'In 1 day',
},
inThreeDays: {
id: 'sharing.invite-players-modal.expiry-in-three-days',
defaultMessage: 'In 3 days',
},
inSevenDays: {
id: 'sharing.invite-players-modal.expiry-in-seven-days',
defaultMessage: 'In 7 days',
},
customExpiry: {
id: 'sharing.invite-players-modal.custom-expiry',
defaultMessage: 'Custom...',
},
customExpiryValue: {
id: 'sharing.invite-players-modal.custom-expiry-value',
defaultMessage: 'Custom: {date}',
},
cancel: {
id: 'sharing.invite-players-modal.cancel-button',
defaultMessage: 'Cancel',
},
apply: {
id: 'sharing.invite-players-modal.apply-button',
defaultMessage: 'Apply',
},
save: {
id: 'sharing.invite-players-modal.save-button',
defaultMessage: 'Save',
@@ -100,6 +209,35 @@ const messages = defineMessages({
},
})
const expiryOptions = computed<ComboboxOption<ExpiryPreset>[]>(() => [
{ value: 'one_hour', label: formatMessage(messages.inOneHour) },
{ value: 'six_hours', label: formatMessage(messages.inSixHours) },
{ value: 'twelve_hours', label: formatMessage(messages.inTwelveHours) },
{ value: 'one_day', label: formatMessage(messages.inOneDay) },
{ value: 'three_days', label: formatMessage(messages.inThreeDays) },
{ value: 'seven_days', label: formatMessage(messages.inSevenDays) },
])
const expiryDropdownOptions = computed(() => (customExpiryOpen.value ? [] : expiryOptions.value))
const selectedExpiryPreset = computed(() =>
expiryMode.value === 'preset' ? expiryPreset.value : undefined,
)
const expiryPickerLabel = computed(() => {
if (expiryMode.value === 'preset') {
return (
expiryOptions.value.find((option) => option.value === expiryPreset.value)?.label ??
formatMessage(messages.inSevenDays)
)
}
const date = parseLocalDate(expiry.value)
return date
? formatMessage(messages.customExpiryValue, { date: formatExpiryDate(date) })
: formatMessage(messages.customExpiry)
})
const canApplyCustomExpiry = computed(() => {
const date = parseLocalDate(customExpiry.value)
return !!date && date >= minimumExpiry.value && date <= maximumExpiry.value
})
const canSave = computed(() => {
const date = parseLocalDate(expiry.value)
return (
@@ -109,7 +247,7 @@ const canSave = computed(() => {
date <= maximumExpiry.value &&
Number.isInteger(maxUses.value ?? 0) &&
(maxUses.value ?? 0) > 0 &&
(maxUses.value ?? 0) <= 2147483647
(maxUses.value ?? 0) <= maximumUses.value
)
})
@@ -126,13 +264,48 @@ function parseLocalDate(value: string) {
return Number.isNaN(date.getTime()) ? null : date
}
function roundDownToMinute(timestamp: number) {
const date = new Date(timestamp)
date.setSeconds(0, 0)
return date
}
function roundUpToMinute(timestamp: number) {
const date = roundDownToMinute(timestamp)
if (date.getTime() < timestamp) date.setMinutes(date.getMinutes() + 1)
return date
}
function expiryForPreset(preset: ExpiryPreset) {
const expiryTimestamp = expiryReferenceTime.value + EXPIRY_PRESET_DURATIONS[preset]
const date = roundDownToMinute(expiryTimestamp)
if (date < minimumExpiry.value) return minimumExpiry.value
if (date > maximumExpiry.value) return maximumExpiry.value
return date
}
function matchingExpiryPreset(date: Date) {
const duration = date.getTime() - expiryReferenceTime.value
let closestPreset: ExpiryPreset | null = null
let closestDifference = Number.POSITIVE_INFINITY
for (const [preset, presetDuration] of Object.entries(EXPIRY_PRESET_DURATIONS) as Array<
[ExpiryPreset, number]
>) {
const difference = Math.abs(duration - presetDuration)
if (difference < closestDifference) {
closestPreset = preset
closestDifference = difference
}
}
return closestDifference <= EXPIRY_PRESET_MATCH_TOLERANCE ? closestPreset : null
}
function show() {
const now = new Date()
minimumExpiry.value = new Date(now.getTime() + 3_600_000)
minimumExpiry.value.setSeconds(0, 0)
minimumExpiry.value.setMinutes(minimumExpiry.value.getMinutes() + 1)
maximumExpiry.value = new Date(now.getTime() + 7 * 86_400_000)
maximumExpiry.value.setSeconds(0, 0)
expiryReferenceTime.value = Date.now()
minimumExpiry.value = roundUpToMinute(expiryReferenceTime.value + MINIMUM_EXPIRY_DURATION)
maximumExpiry.value = roundDownToMinute(expiryReferenceTime.value + MAXIMUM_EXPIRY_DURATION)
const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value
const date =
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
@@ -141,16 +314,63 @@ function show() {
? maximumExpiry.value
: currentExpiry
expiry.value = formatLocalDate(date)
maxUses.value = props.linkMaxUses
const matchingPreset = matchingExpiryPreset(date)
expiryMode.value = matchingPreset ? 'preset' : 'custom'
if (matchingPreset) expiryPreset.value = matchingPreset
customExpiry.value = expiry.value
customExpiryOpen.value = false
maxUses.value = Math.min(props.linkMaxUses, maximumUses.value)
modal.value?.show()
}
function selectExpiryPreset(option: ComboboxOption<ExpiryPreset>) {
expiryMode.value = 'preset'
expiryPreset.value = option.value
expiry.value = formatLocalDate(expiryForPreset(option.value))
}
function handleExpiryPickerOpen() {
customExpiryOpen.value = false
}
function handleExpiryPickerClose() {
customExpiryOpen.value = false
customExpiry.value = expiry.value
}
function openCustomExpiry() {
customExpiry.value = expiry.value
customExpiryOpen.value = true
}
function cancelCustomExpiry() {
customExpiry.value = expiry.value
customExpiryOpen.value = false
}
function closeExpiryPicker(event: Event) {
const target = event.target
if (!(target instanceof HTMLElement)) return
target
.closest('[role="listbox"], [role="menu"]')
?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
function applyCustomExpiry(event: MouseEvent) {
const date = parseLocalDate(customExpiry.value)
if (!canApplyCustomExpiry.value || !date) return
expiryMode.value = 'custom'
expiry.value = formatLocalDate(date)
closeExpiryPicker(event)
}
async function save() {
const date = parseLocalDate(expiry.value)
if (!canSave.value || !date || !props.updateInviteLink) return
const clampedMaxUses = Math.min(maxUses.value ?? 1, maximumUses.value)
saving.value = true
try {
await props.updateInviteLink({ expiresAt: date, maxUses: maxUses.value ?? 1 })
await props.updateInviteLink({ expiresAt: date, maxUses: clampedMaxUses })
modal.value?.hide()
} catch (error) {
notificationManager?.addNotification({
@@ -163,5 +383,9 @@ async function save() {
}
}
watch([maxUses, maximumUses], ([uses, limit]) => {
if (uses !== undefined && uses > limit) maxUses.value = limit
})
defineExpose({ show })
</script>
@@ -29,6 +29,7 @@ import type {
ContentCardProject,
ContentCardVersion,
ContentOwner,
ContentSource,
} from '../types'
const { formatMessage } = useVIntl()
@@ -46,6 +47,7 @@ interface Props {
version?: ContentCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
source?: ContentSource
enabled?: boolean
installing?: boolean
hasUpdate?: boolean
@@ -68,6 +70,7 @@ const props = withDefaults(defineProps<Props>(), {
version: undefined,
versionLink: undefined,
owner: undefined,
source: undefined,
enabled: undefined,
installing: false,
hasUpdate: false,
@@ -196,8 +199,32 @@ const deleteHovered = ref(false)
</div>
<div class="flex min-w-0 items-center gap-1">
<template v-if="source">
<AutoLink
:target="
typeof source.link === 'string' && source.link.startsWith('http')
? '_blank'
: undefined
"
:to="source.link"
class="flex min-w-0 items-center gap-1 !decoration-secondary"
:class="{ 'hover:underline': source.link }"
>
<Avatar
:src="source.project.icon_url"
:alt="source.project.title"
:tint-by="source.project.id"
size="1.25rem"
no-shadow
class="shrink-0 rounded-md"
/>
<span class="truncate text-sm leading-5 text-secondary">
{{ source.project.title }}
</span>
</AutoLink>
</template>
<AutoLink
v-if="owner"
v-else-if="owner"
:target="
typeof owner.link === 'string' && owner.link.startsWith('http')
? '_blank'
@@ -264,6 +264,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:version="item.version"
:version-link="item.versionLink"
:owner="item.owner"
:source="item.source"
:enabled="item.enabled"
:installing="item.installing"
:has-update="item.hasUpdate"
@@ -327,6 +328,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:version="item.version"
:version-link="item.versionLink"
:owner="item.owner"
:source="item.source"
:enabled="item.enabled"
:installing="item.installing"
:has-update="item.hasUpdate"
@@ -20,6 +20,7 @@ import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowM
import StyledInput from '#ui/components/base/StyledInput.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectPageContext } from '#ui/providers/page-context'
import {
commonMessages,
commonProjectTypeCategoryMessages,
@@ -28,11 +29,12 @@ import {
} from '#ui/utils/common-messages'
import { getClientWarningType, isClientOnlyEnvironment } from '../../composables/content-filtering'
import type { ContentCardTableItem, ContentItem } from '../../types'
import type { ContentCardProject, ContentCardTableItem, ContentItem } from '../../types'
import ContentCardTable from '../ContentCardTable.vue'
import ContentSelectionBar from '../ContentSelectionBar.vue'
const { formatMessage } = useVIntl()
const pageContext = injectPageContext(null)
interface Props {
header?: string
@@ -266,6 +268,12 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
: `https://modrinth.com/organization/${item.owner.id}`,
}
: undefined,
source: item.source
? {
...item.source,
link: item.source.link ?? sourceProjectLink(item.source.project),
}
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
installing: item.installing === true,
toggleDisabled: props.actionDisabled,
@@ -293,7 +301,7 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
})),
)
const externalItemIds = computed(
() => new Set(items.value.filter((item) => item.external).map((item) => item.id)),
() => new Set(items.value.filter((item) => item.external && !item.source).map((item) => item.id)),
)
const externalSlicerUrls = computed(() => {
const urls: Record<string, string> = {}
@@ -335,6 +343,12 @@ function itemDisplayName(item: ContentItem) {
return item.project?.title ?? item.file_name
}
function sourceProjectLink(project: ContentCardProject) {
const projectId = project.slug ?? project.id
const url = `https://modrinth.com/modpack/${encodeURIComponent(projectId)}`
return pageContext ? () => pageContext.openExternalUrl(url) : url
}
function handleEnabledChange(id: string, value: boolean) {
if (props.actionDisabled) return
const item = items.value.find((item) => item.id === id)
@@ -21,6 +21,11 @@ export interface ContentOwner {
link?: string | RouteLocationRaw | (() => void)
}
export interface ContentSource {
project: ContentCardProject
link?: string | RouteLocationRaw | (() => void)
}
export type ClientWarningType = 'retained' | 'depends' | 'environment'
export type ContentSourceKind =
@@ -44,6 +49,7 @@ export interface ContentCardTableItem {
version?: ContentCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
source?: ContentSource
enabled?: boolean
disabled?: boolean
disabledTooltip?: string | null
@@ -402,7 +402,11 @@ import {
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { UserBadge } from '@modrinth/utils'
import {
isModrinthUser as checkIsModrinthUser,
isOfficialAccount as checkIsOfficialAccount,
UserBadge,
} from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -716,8 +720,8 @@ const earliestProjectByType = computed(() => {
return earliest
})
const isModrinthUser = computed(() => user.value?.id === '2REoufqX')
const isOfficialAccount = computed(() => isModrinthUser.value || user.value?.id === 'GVFjtWTf')
const isModrinthUser = computed(() => checkIsModrinthUser(user.value?.id))
const isOfficialAccount = computed(() => checkIsOfficialAccount(user.value?.id))
const isSelf = computed(() => auth.user.value?.id === user.value?.id)
const isAdminViewing = computed(() => auth.user.value?.role === 'admin')
const isStaffViewing = computed(
+30
View File
@@ -2321,6 +2321,9 @@
"label.password": {
"defaultMessage": "Password"
},
"label.permissions": {
"defaultMessage": "Permissions"
},
"label.plan-custom": {
"defaultMessage": "Custom"
},
@@ -5240,6 +5243,9 @@
"sharing.invite-players-modal.already-invited": {
"defaultMessage": "This user has already been invited."
},
"sharing.invite-players-modal.apply-button": {
"defaultMessage": "Apply"
},
"sharing.invite-players-modal.avatar-alt": {
"defaultMessage": "{username}'s avatar"
},
@@ -5249,12 +5255,36 @@
"sharing.invite-players-modal.cancel-button": {
"defaultMessage": "Cancel"
},
"sharing.invite-players-modal.custom-expiry": {
"defaultMessage": "Custom..."
},
"sharing.invite-players-modal.custom-expiry-value": {
"defaultMessage": "Custom: {date}"
},
"sharing.invite-players-modal.edit-invite-link": {
"defaultMessage": "Edit invite link."
},
"sharing.invite-players-modal.edit-invite-link-title": {
"defaultMessage": "Edit invite link"
},
"sharing.invite-players-modal.expiry-in-one-day": {
"defaultMessage": "In 1 day"
},
"sharing.invite-players-modal.expiry-in-one-hour": {
"defaultMessage": "In 1 hour"
},
"sharing.invite-players-modal.expiry-in-seven-days": {
"defaultMessage": "In 7 days"
},
"sharing.invite-players-modal.expiry-in-six-hours": {
"defaultMessage": "In 6 hours"
},
"sharing.invite-players-modal.expiry-in-three-days": {
"defaultMessage": "In 3 days"
},
"sharing.invite-players-modal.expiry-in-twelve-hours": {
"defaultMessage": "In 12 hours"
},
"sharing.invite-players-modal.expiry-label": {
"defaultMessage": "Expiry date"
},
@@ -232,46 +232,55 @@ export const ManyTabs: StoryObj = {
const tabs = [
{
name: { id: 'general', defaultMessage: 'General' },
category: { id: 'display-category', defaultMessage: 'Display' },
icon: InfoIcon,
content: makeTabContent('General'),
},
{
name: { id: 'appearance', defaultMessage: 'Appearance' },
category: { id: 'display-category', defaultMessage: 'Display' },
icon: PaintbrushIcon,
content: makeTabContent('Appearance'),
},
{
name: { id: 'language', defaultMessage: 'Language' },
category: { id: 'display-category', defaultMessage: 'Display' },
icon: LanguagesIcon,
content: makeTabContent('Language'),
},
{
name: { id: 'privacy', defaultMessage: 'Privacy' },
category: { id: 'account-category', defaultMessage: 'Account' },
icon: ShieldIcon,
content: makeTabContent('Privacy'),
},
{
name: { id: 'java', defaultMessage: 'Java and memory' },
category: { id: 'instances-category', defaultMessage: 'Instances' },
icon: CoffeeIcon,
content: makeTabContent('Java and memory'),
},
{
name: { id: 'instances', defaultMessage: 'Default instance options' },
category: { id: 'instances-category', defaultMessage: 'Instances' },
icon: GameIcon,
content: makeTabContent('Default instance options'),
},
{
name: { id: 'resources', defaultMessage: 'Resource management' },
category: { id: 'instances-category', defaultMessage: 'Instances' },
icon: GaugeIcon,
content: makeTabContent('Resource management'),
},
{
name: { id: 'window', defaultMessage: 'Window' },
category: { id: 'advanced-category', defaultMessage: 'Advanced' },
icon: MonitorIcon,
content: makeTabContent('Window'),
},
{
name: { id: 'hooks', defaultMessage: 'Launch hooks' },
category: { id: 'advanced-category', defaultMessage: 'Advanced' },
icon: WrenchIcon,
content: makeTabContent('Launch hooks'),
},
+4
View File
@@ -291,6 +291,10 @@ export const commonMessages = defineMessages({
id: 'label.scopes',
defaultMessage: 'Scopes',
},
permissionsLabel: {
id: 'label.permissions',
defaultMessage: 'Permissions',
},
searchLabel: {
id: 'label.search',
defaultMessage: 'Search',
+14
View File
@@ -13,3 +13,17 @@ export const isAdmin = (user) => {
}
export const STAFF_ROLES = ['moderator', 'admin']
export const MODRINTH_USER_ID = '2REoufqX'
export const AUTOMOD_USER_ID = ''
export const MODRINTH_ARCHIVES_USER_ID = 'GVFjtWTf'
export const OFFICIAL_ACCOUNT_IDS = [MODRINTH_USER_ID, AUTOMOD_USER_ID, MODRINTH_ARCHIVES_USER_ID]
export const isModrinthUser = (userId) => {
return userId === MODRINTH_USER_ID
}
export const isOfficialAccount = (userId) => {
return OFFICIAL_ACCOUNT_IDS.includes(userId)
}