Merge branch 'main' into boris/dev-1205-trace-rules

This commit is contained in:
aecsocket
2026-07-30 05:15:25 +01:00
336 changed files with 19006 additions and 9411 deletions
+4
View File
@@ -24,6 +24,8 @@ import { LabrinthAttributionInternalModule } from './labrinth/attribution/intern
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthBlockedUsersInternalModule } from './labrinth/blocked-users/internal'
import { LabrinthBlockedUsersV3Module } from './labrinth/blocked-users/v3'
import { LabrinthCampaignInternalModule } from './labrinth/campaign/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthContentV3Module } from './labrinth/content/v3'
@@ -100,6 +102,8 @@ export const MODULE_REGISTRY = {
labrinth_auth_v2: LabrinthAuthV2Module,
labrinth_attribution_internal: LabrinthAttributionInternalModule,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_blocked_users_internal: LabrinthBlockedUsersInternalModule,
labrinth_blocked_users_v3: LabrinthBlockedUsersV3Module,
labrinth_campaign_internal: LabrinthCampaignInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_content_v3: LabrinthContentV3Module,
@@ -0,0 +1,25 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthBlockedUsersInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_blocked_users_internal'
}
/**
* Check whether one user has blocked another.
*/
public async getStatus(
userId: string,
targetId: string,
): Promise<Labrinth.BlockedUsers.Internal.BlockStatus> {
return this.client.request<Labrinth.BlockedUsers.Internal.BlockStatus>(
`/block/${encodeURIComponent(userId)}/${encodeURIComponent(targetId)}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
}
@@ -0,0 +1,45 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthBlockedUsersV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_blocked_users_v3'
}
/**
* List the users blocked by the authenticated user.
*/
public async list(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
return this.client.request<Labrinth.BlockedUsers.v3.BlockedUserId[]>('/blocks', {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
/**
* Block a user.
*
* @param idOrUsername - The target user's ID or username
*/
public async block(idOrUsername: string): Promise<void> {
return this.client.request(`/block/${encodeURIComponent(idOrUsername)}`, {
api: 'labrinth',
version: 3,
method: 'POST',
})
}
/**
* Unblock a user.
*
* @param idOrUsername - The target user's ID or username
*/
public async unblock(idOrUsername: string): Promise<void> {
return this.client.request(`/block/${encodeURIComponent(idOrUsername)}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}
@@ -1,5 +1,4 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle } from '../../../types/upload'
import type { Labrinth } from '../types'
export class LabrinthOAuthInternalModule extends AbstractModule {
@@ -45,14 +44,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)
}
/**
@@ -109,14 +109,14 @@ export class LabrinthOAuthInternalModule extends AbstractModule {
* @param id - The OAuth client ID
* @param file - The icon file
* @param ext - The file extension (e.g. 'png', 'jpeg')
* @returns UploadHandle for progress tracking and cancellation
*/
public uploadAppIcon(id: string, file: File | Blob, ext: string): UploadHandle<void> {
return this.client.upload<void>(`/oauth/app/${id}/icon`, {
public async uploadAppIcon(id: string, file: File | Blob, ext: string): Promise<void> {
return this.client.request(`/oauth/app/${id}/icon`, {
api: 'labrinth',
version: 'internal',
file,
method: 'PATCH',
params: { ext },
body: file,
})
}
@@ -1663,6 +1663,8 @@ export namespace Labrinth {
allow_friend_requests?: boolean
moderation_notes?: Common.ModerationNote | null
github_id?: number
discord_id?: string
steam_id?: string
}
export type SearchUser = {
@@ -1689,6 +1691,18 @@ export namespace Labrinth {
}
}
export namespace BlockedUsers {
export namespace Internal {
export type BlockStatus = {
blocked: boolean
}
}
export namespace v3 {
export type BlockedUserId = string
}
}
export namespace ServerPing {
export namespace Internal {
export type MinecraftJavaPingRequest = {
@@ -168,7 +168,7 @@ export class LabrinthUsersV2Module extends AbstractModule {
*/
public async patch(
idOrUsername: string,
data: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'role'>>,
data: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'bio' | 'role' | 'username'>>,
): Promise<void> {
return this.client.request(`/user/${idOrUsername}`, {
api: 'labrinth',
@@ -177,4 +177,34 @@ export class LabrinthUsersV2Module extends AbstractModule {
body: data,
})
}
/**
* Change a user's avatar.
*
* @param idOrUsername - The user's ID or username
* @param file - Image file to upload
* @param ext - File extension (e.g., 'png', 'jpeg', 'gif', 'webp')
*/
public async changeIcon(idOrUsername: string, file: Blob, ext: string): Promise<void> {
return this.client.request(`/user/${idOrUsername}/icon`, {
api: 'labrinth',
version: 2,
method: 'PATCH',
params: { ext },
body: file,
})
}
/**
* Delete a user's avatar.
*
* @param idOrUsername - The user's ID or username
*/
public async deleteIcon(idOrUsername: string): Promise<void> {
return this.client.request(`/user/${idOrUsername}/icon`, {
api: 'labrinth',
version: 2,
method: 'DELETE',
})
}
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE install_jobs\n\t\tSET instance_id = ?, state = ?, modified = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "15b4f72d367d329690f5daddafbdf0e51a285e35404053d62492e8df5fc7132f"
}
+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 -56
View File
@@ -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,66 +71,17 @@ 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?;
let instance =
instance_rows::get_instance_display_info(instance_id, &state.pool)
.await?;
crate::install::runner::cancel_jobs_for_instance_deletion(
instance_id,
&state,
)
.await?;
crate::state::remove_instance(instance_id, &state).await?;
if let Some(instance) = instance {
@@ -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> {
@@ -836,11 +836,29 @@ async fn config_bundle_bytes(
entries: &BTreeMap<String, Vec<u8>>,
) -> crate::Result<Vec<u8>> {
if entries.len() > MAX_CONFIG_BUNDLE_ENTRIES {
return Err(crate::ErrorKind::InputError(
"Shared instance config bundle contains too many entries"
.to_string(),
)
.into());
let mut folder_entry_counts = HashMap::new();
for path in entries.keys() {
if let Some((folder, _)) = path.split_once('/') {
*folder_entry_counts.entry(folder).or_insert(0_usize) += 1;
}
}
if let Some((folder, count)) = folder_entry_counts
.into_iter()
.filter(|(_, count)| *count > MAX_CONFIG_BUNDLE_ENTRIES)
.max_by_key(|(_, count)| *count)
{
return Err(crate::ErrorKind::InputError(format!(
"The \"{folder}\" config folder has too many files to share ({count}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}). Select fewer files from this folder."
))
.into());
}
return Err(crate::ErrorKind::InputError(format!(
"Too many config files were selected to share ({}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}). Select fewer files.",
entries.len()
))
.into());
}
let mut total_size = 0_u64;
for bytes in entries.values() {
@@ -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
}
+212 -1
View File
@@ -1,7 +1,9 @@
use crate::State;
use crate::util::fetch::fetch_json;
use crate::util::fetch::{fetch_advanced, fetch_advanced_bytes, fetch_json};
use bytes::Bytes;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SearchUser {
@@ -30,3 +32,212 @@ pub async fn search_user(query: &str) -> crate::Result<Vec<SearchUser>> {
)
.await
}
#[tracing::instrument]
pub async fn get_user_profile(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!("{}user/{}", env!("MODRINTH_API_URL_V3"), user_id),
None,
None,
Some("/v3/user/:id"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_user_projects(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!("{}user/{}/projects", env!("MODRINTH_API_URL"), user_id),
None,
None,
Some("/v2/user/:id/projects"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_user_organizations(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!(
"{}user/{}/organizations",
env!("MODRINTH_API_URL_V3"),
user_id
),
None,
None,
Some("/v3/user/:id/organizations"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_user_collections(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!(
"{}user/{}/collections",
env!("MODRINTH_API_URL_V3"),
user_id
),
None,
None,
Some("/v3/user/:id/collections"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument(skip(patch))]
pub async fn patch_user(user_id: &str, patch: Value) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_advanced(
Method::PATCH,
&format!("{}user/{}", env!("MODRINTH_API_URL"), user_id),
None,
Some(patch),
None,
None,
None,
Some("/v2/user/:id"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
#[tracing::instrument(skip(image))]
pub async fn change_user_avatar(
user_id: &str,
image: Bytes,
extension: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
let extension = urlencoding::encode(extension);
fetch_advanced_bytes(
Method::PATCH,
&format!(
"{}user/{}/icon?ext={}",
env!("MODRINTH_API_URL"),
user_id,
extension
),
image,
Some(("Content-Type", "application/octet-stream")),
Some("/v2/user/:id/icon"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn delete_user_avatar(user_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_advanced(
Method::DELETE,
&format!("{}user/{}/icon", env!("MODRINTH_API_URL"), user_id),
None,
None,
None,
None,
None,
Some("/v2/user/:id/icon"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn block_user(user_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_advanced(
Method::POST,
&format!("{}block/{}", env!("MODRINTH_API_URL_V3"), user_id),
None,
None,
None,
None,
None,
Some("/v3/block/:id"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn unblock_user(user_id: &str) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_advanced(
Method::DELETE,
&format!("{}block/{}", env!("MODRINTH_API_URL_V3"), user_id),
None,
None,
None,
None,
None,
Some("/v3/block/:id"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
#[tracing::instrument]
pub async fn get_blocked_users() -> crate::Result<Vec<String>> {
let state = State::get().await?;
fetch_json(
Method::GET,
&format!("{}blocks", env!("MODRINTH_API_URL_V3")),
None,
None,
Some("/v3/blocks"),
&state.api_semaphore,
&state.pool,
)
.await
}
+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),
+14 -5
View File
@@ -202,11 +202,20 @@ pub async fn emit_install_job(
{
use tauri::Emitter;
let event_state = crate::EventState::get()?;
event_state
.app
.emit("install_job", snapshot)
.map_err(crate::event::EventError::from)?;
let result: crate::Result<()> = (|| {
let event_state = crate::EventState::get()?;
event_state
.app
.emit("install_job", snapshot)
.map_err(crate::event::EventError::from)?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!(
"Failed to emit install job {} update: {error}",
snapshot.job_id
);
}
}
Ok(())
+114 -37
View File
@@ -262,29 +262,83 @@ async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> {
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
let jobs = store::list_interrupted_candidates(state).await?;
for mut job in jobs {
if job.state.display.is_none() {
job.state.display = display_from_request(&job.state);
for job in jobs {
let job_id = job.id;
if let Err(error) = recover_interrupted_job(job, state).await {
tracing::error!(
"Error recovering interrupted install job {job_id}: {error}"
);
}
let interrupted_phase = job.state.progress.phase;
job.state.record_event(InstallJobEventKind::Interrupted {
reason: InstallInterruptReason::AppClosed,
phase: interrupted_phase,
});
job.state.progress.phase = InstallPhaseId::RollingBack;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
job.state.error = Some(InstallErrorView::from_message(
"app_closed",
interrupted_phase,
"App closed while install was running",
));
}
job.state
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
if let Err(error) = apply_cleanup(&job.state, state).await {
Ok(())
}
async fn recover_interrupted_job(
mut job: store::InstallJobRecord,
state: &State,
) -> crate::Result<()> {
if job.state.display.is_none() {
job.state.display = display_from_request(&job.state);
}
if let Some(instance_id) = target_instance_id(&job.state.target)
&& instance_rows::get_instance_by_id(instance_id, &state.pool)
.await?
.is_none()
{
let canceled_phase = job.state.progress.phase;
job.state.error = Some(InstallErrorView::from_message(
"canceled",
canceled_phase,
"Install canceled because the instance was deleted",
));
job.state.record_event(InstallJobEventKind::JobCanceled {
phase: canceled_phase,
});
if let Some(record) = store::finish_active(
job.id,
InstallJobStatus::Canceled,
&job.state,
state,
)
.await?
{
store::dismiss(job.id, state).await?;
clear_staging_dir(&job.state).await;
emit_install_job(&record.snapshot()).await?;
}
return Ok(());
}
let interrupted_phase = job.state.progress.phase;
job.state.record_event(InstallJobEventKind::Interrupted {
reason: InstallInterruptReason::AppClosed,
phase: interrupted_phase,
});
job.state.progress.phase = InstallPhaseId::RollingBack;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
job.state.error = Some(InstallErrorView::from_message(
"app_closed",
interrupted_phase,
"App closed while install was running",
));
job.state
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
let cleanup_succeeded = match apply_cleanup(&job.state, state).await {
Ok(()) => {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job.state);
true
}
Err(error) => {
tracing::error!(
"Error cleaning up interrupted install job {}: {error}",
job.id
@@ -298,20 +352,19 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
} else {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
false
}
clear_deleted_new_instance_id(&mut job.state);
};
let record = store::update_status(
job.id,
InstallJobStatus::Interrupted,
&job.state,
state,
)
.await?;
if job.state.rollback_error.is_none() {
if let Some(record) = store::finish_active(
job.id,
InstallJobStatus::Interrupted,
&job.state,
state,
)
.await?
{
if cleanup_succeeded {
clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
@@ -320,6 +373,13 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
Ok(())
}
fn target_instance_id(target: &InstallTarget) -> Option<&str> {
match target {
InstallTarget::NewInstance { instance_id } => instance_id.as_deref(),
InstallTarget::ExistingInstance { instance_id } => Some(instance_id),
}
}
fn clear_deleted_new_instance_id(job_state: &mut InstallJobState) {
if matches!(job_state.cleanup, InstallCleanup::DeleteNewInstance { .. }) {
job_state.target = InstallTarget::NewInstance { instance_id: None };
@@ -383,10 +443,20 @@ pub async fn apply_cleanup(
match &job_state.cleanup {
InstallCleanup::DeleteNewInstance { instance_id } => {
if let Some(instance_id) = instance_id {
let _ = crate::state::remove_instance(instance_id, state).await;
let _ =
if crate::state::get_instance(instance_id, &state.pool)
.await?
.is_some()
{
crate::state::remove_instance(instance_id, state).await?;
}
if let Err(error) =
emit_instance(instance_id, InstancePayloadType::Removed)
.await;
.await
{
tracing::warn!(
"Failed to emit removed instance {instance_id}: {error}"
);
}
}
}
InstallCleanup::RestoreExistingInstance { instance_id } => {
@@ -410,7 +480,14 @@ pub async fn apply_cleanup(
)
.await?;
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
if let Err(error) =
emit_instance(instance_id, InstancePayloadType::Edited)
.await
{
tracing::warn!(
"Failed to emit restored instance {instance_id}: {error}"
);
}
}
}
}
+292 -114
View File
@@ -163,19 +163,57 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
job.state.progress.phase = InstallPhaseId::PreparingInstance;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
prepare_initial_instance(&mut job.state, &state).await?;
if let Err(error) = prepare_initial_instance(&mut job.state, &state).await {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job.state, &state).await
{
tracing::error!(
"Error cleaning up install job {job_id} retry preparation: {cleanup_error}"
);
}
return Err(error);
}
job.state.record_event(InstallJobEventKind::JobQueued {
kind: job.state.request.kind(),
});
let record = store::update_status(
let record = match store::update_status(
job_id,
InstallJobStatus::Queued,
&job.state,
&state,
)
.await?;
lock_existing_instance_if_needed(&job.state, &state).await?;
.await
{
Ok(record) => record,
Err(error) => {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job.state, &state).await
{
tracing::error!(
"Error cleaning up unqueued install job {job_id}: {cleanup_error}"
);
}
return Err(error);
}
};
if let Err(error) =
lock_existing_instance_if_needed(&job.state, &state).await
{
let error_view = install_error_view(
job.state.progress.phase,
&error,
job.state.context.clone(),
);
if let Err(terminal_error) =
terminalize_failed_job(job_id, job.state, error_view, &state).await
{
tracing::error!(
"Failed to terminalize retried install job {job_id}: {terminal_error}"
);
}
return Err(error);
}
emit_install_job(&record.snapshot()).await?;
spawn_job(job_id);
@@ -206,31 +244,50 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
match recovery::apply_cleanup(&job.state, &state).await {
Ok(()) => job
.state
.record_event(InstallJobEventKind::RollbackCompleted),
Err(error) => {
job.state.rollback_error = Some(InstallErrorView::from_error(
"rollback_error",
InstallPhaseId::RollingBack,
&error,
None,
));
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
}
}
clear_deleted_new_instance_id(&mut job.state);
let record = store::update_status(
let status_updated = store::update_status_if(
job_id,
InstallJobStatus::Canceled,
InstallJobStatus::Queued,
InstallJobStatus::Running,
&job.state,
&state,
)
.await?;
if job.state.rollback_error.is_none() {
.await?
.is_some();
if !status_updated {
return Err(crate::ErrorKind::InputError(
"Install job is no longer queued".to_string(),
)
.into());
}
let cleanup_succeeded =
match recovery::apply_cleanup(&job.state, &state).await {
Ok(()) => {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job.state);
true
}
Err(error) => {
job.state.rollback_error = Some(InstallErrorView::from_error(
"rollback_error",
InstallPhaseId::RollingBack,
&error,
None,
));
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
false
}
};
let status = if cleanup_succeeded {
InstallJobStatus::Canceled
} else {
InstallJobStatus::Failed
};
let record =
store::update_status(job_id, status, &job.state, &state).await?;
if cleanup_succeeded {
recovery::clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
@@ -238,6 +295,39 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
Ok(record.snapshot())
}
pub(crate) async fn cancel_jobs_for_instance_deletion(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
for mut job in store::list_active_for_instance(instance_id, state).await? {
let canceled_phase = job.state.progress.phase;
job.state.error = Some(InstallErrorView::from_message(
"canceled",
canceled_phase,
"Install canceled because the instance was deleted",
));
job.state.record_event(InstallJobEventKind::JobCanceled {
phase: canceled_phase,
});
let Some(record) = store::finish_active(
job.id,
InstallJobStatus::Canceled,
&job.state,
state,
)
.await?
else {
continue;
};
store::dismiss(job.id, state).await?;
emit_install_job(&record.snapshot()).await?;
}
Ok(())
}
pub async fn dismiss_job(job_id: Uuid) -> crate::Result<()> {
let state = State::get().await?;
store::dismiss(job_id, &state).await
@@ -247,10 +337,53 @@ async fn start(request: InstallRequest) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let id = Uuid::new_v4();
let mut job_state = InstallJobState::new(request);
prepare_initial_instance(&mut job_state, &state).await?;
let record =
store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?;
lock_existing_instance_if_needed(&job_state, &state).await?;
if let Err(error) = prepare_initial_instance(&mut job_state, &state).await {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error cleaning up install job preparation: {cleanup_error}"
);
}
return Err(error);
}
let record = match store::insert(
id,
&job_state,
InstallJobStatus::Queued,
&state,
)
.await
{
Ok(record) => record,
Err(error) => {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error cleaning up untracked install job {id}: {cleanup_error}"
);
}
return Err(error);
}
};
if let Err(error) =
lock_existing_instance_if_needed(&job_state, &state).await
{
let error_view = install_error_view(
job_state.progress.phase,
&error,
job_state.context.clone(),
);
if let Err(terminal_error) =
terminalize_failed_job(id, job_state, error_view, &state).await
{
tracing::error!(
"Failed to terminalize install job {id} after setup error: {terminal_error}"
);
}
return Err(error);
}
emit_install_job(&record.snapshot()).await?;
spawn_job(id);
Ok(record.snapshot())
@@ -370,9 +503,9 @@ async fn prepare_initial_instance(
metadata.instance.icon_path,
);
let instance_id = metadata.instance.id;
set_instance_id(job_state, instance_id.clone());
attach_pending_shared_instance(&instance_id, &data, state).await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
set_instance_id(job_state, instance_id);
}
InstallRequest::ImportInstance {
instance_folder, ..
@@ -433,9 +566,14 @@ async fn prepare_initial_instance(
fn spawn_job(job_id: Uuid) {
tokio::spawn(async move {
if let Err(error) = Box::pin(run_job(job_id)).await {
tracing::error!(
"Install job {job_id} failed to update state: {error}"
);
let failure = error.to_string();
tracing::error!("Install job {job_id} terminated: {failure}");
if let Err(error) = terminalize_stranded_job(job_id, failure).await
{
tracing::error!(
"Failed to terminalize stranded install job {job_id}: {error}"
);
}
}
});
}
@@ -457,34 +595,44 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
let mut job_state = job.state.clone();
job_state.record_event(InstallJobEventKind::JobStarted);
let record = store::update_status(
let Some(record) = store::update_status_if(
job_id,
InstallJobStatus::Queued,
InstallJobStatus::Running,
&job_state,
&state,
)
.await?;
.await?
else {
return Ok(());
};
emit_install_job(&record.snapshot()).await?;
let result = Box::pin(run_request(job_id, &mut job_state, &state)).await;
if let Ok(record) = store::get_required(job_id, &state).await {
let status = record.status;
job_state = record.state;
if status != InstallJobStatus::Running {
return Ok(());
}
}
let result = match result {
Ok(instance_id) => {
if let Some(instance_id) = instance_id {
set_instance_id(&mut job_state, instance_id);
}
finalize_existing_instance_success(&job_state, &state).await
Ok(Some(instance_id)) => {
set_instance_id(&mut job_state, instance_id.clone());
Ok(instance_id)
}
Ok(None) => Err(crate::ErrorKind::InputError(
"Install job completed without an instance id".to_string(),
)
.into()),
Err(error) => Err(error),
};
match result {
Ok(()) => {
Ok(instance_id) => {
job_state.record_event(InstallJobEventKind::JobSucceeded {
instance_id: current_instance_id(&job_state),
instance_id: Some(instance_id.clone()),
});
job_state.progress.phase = InstallPhaseId::Finalizing;
job_state.progress.progress = None;
@@ -492,71 +640,119 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
job_state.error = None;
job_state.rollback_error = None;
job_state.context = None;
let record = store::update_status(
job_id,
InstallJobStatus::Succeeded,
&job_state,
&state,
)
.await?;
recovery::clear_staging_dir(&job_state).await;
emit_install_job(&record.snapshot()).await?;
if let Some(record) =
store::complete_success(job_id, &job_state, &state).await?
{
recovery::clear_staging_dir(&job_state).await;
if let Err(error) =
emit_instance(&instance_id, InstancePayloadType::Edited)
.await
{
tracing::warn!(
"Failed to emit completed instance {instance_id}: {error}"
);
}
emit_install_job(&record.snapshot()).await?;
}
}
Err(error) => {
let failed_phase = job_state.progress.phase;
tracing::error!("Install job {job_id} failed: {error}");
let error_view = install_error_view(
failed_phase,
job_state.progress.phase,
&error,
job_state.context.clone(),
);
job_state.record_event(InstallJobEventKind::Failed {
phase: failed_phase,
code: error_view.code.clone(),
message: error_view.message.clone(),
});
job_state.error = Some(error_view);
job_state.progress.phase = InstallPhaseId::RollingBack;
job_state.progress.progress = None;
job_state.progress.details = InstallPhaseDetails::Empty;
job_state.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job_state.cleanup.clone(),
});
if let Err(rollback_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error rolling back failed install job {job_id}: {rollback_error}"
);
job_state.rollback_error = Some(install_error_view(
InstallPhaseId::RollingBack,
&rollback_error,
None,
));
job_state.record_event(InstallJobEventKind::RollbackFailed {
message: rollback_error.to_string(),
});
} else {
job_state.record_event(InstallJobEventKind::RollbackCompleted);
}
clear_deleted_new_instance_id(&mut job_state);
let record = store::update_status(
job_id,
InstallJobStatus::Failed,
&job_state,
&state,
)
.await?;
if job_state.rollback_error.is_none() {
recovery::clear_staging_dir(&job_state).await;
}
emit_install_job(&record.snapshot()).await?;
return Err(error);
terminalize_failed_job(job_id, job_state, error_view, &state)
.await?;
}
}
Ok(())
}
async fn terminalize_stranded_job(
job_id: Uuid,
message: String,
) -> crate::Result<()> {
let state = State::get().await?;
let job = store::get_required(job_id, &state).await?;
if !matches!(
job.status,
InstallJobStatus::Queued | InstallJobStatus::Running
) {
return Ok(());
}
let error_view = InstallErrorView::from_message(
"install_worker_terminated",
job.state.progress.phase,
message,
);
terminalize_failed_job(job_id, job.state, error_view, &state).await
}
async fn terminalize_failed_job(
job_id: Uuid,
mut job_state: InstallJobState,
error_view: InstallErrorView,
state: &State,
) -> crate::Result<()> {
let failed_phase = job_state.progress.phase;
job_state.record_event(InstallJobEventKind::Failed {
phase: failed_phase,
code: error_view.code.clone(),
message: error_view.message.clone(),
});
job_state.error = Some(error_view);
job_state.rollback_error = None;
job_state.progress.phase = InstallPhaseId::RollingBack;
job_state.progress.progress = None;
job_state.progress.details = InstallPhaseDetails::Empty;
job_state.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job_state.cleanup.clone(),
});
let cleanup_succeeded = match recovery::apply_cleanup(&job_state, state)
.await
{
Ok(()) => {
job_state.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job_state);
true
}
Err(rollback_error) => {
tracing::error!(
"Error rolling back failed install job {job_id}: {rollback_error}"
);
job_state.rollback_error = Some(install_error_view(
InstallPhaseId::RollingBack,
&rollback_error,
None,
));
job_state.record_event(InstallJobEventKind::RollbackFailed {
message: rollback_error.to_string(),
});
false
}
};
if let Some(record) = store::finish_active(
job_id,
InstallJobStatus::Failed,
&job_state,
state,
)
.await?
{
if cleanup_succeeded {
recovery::clear_staging_dir(&job_state).await;
}
emit_install_job(&record.snapshot()).await?;
}
Ok(())
}
async fn run_request(
job_id: Uuid,
job_state: &mut InstallJobState,
@@ -1176,25 +1372,6 @@ async fn lock_existing_instance(
Ok(())
}
async fn finalize_existing_instance_success(
job_state: &InstallJobState,
state: &State,
) -> crate::Result<()> {
if let InstallCleanup::RestoreExistingInstance { instance_id } =
&job_state.cleanup
{
crate::state::instances::commands::set_instance_install_stage(
instance_id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
Ok(())
}
pub(super) async fn update_progress(
job_id: Uuid,
job_state: &mut InstallJobState,
@@ -1293,6 +1470,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 => {
+194 -9
View File
@@ -171,7 +171,7 @@ pub async fn list(
.await?
};
rows.into_iter().map(row_to_record).collect()
Ok(deserialize_rows(rows))
}
pub async fn list_interrupted_candidates(
@@ -198,7 +198,18 @@ pub async fn list_interrupted_candidates(
.fetch_all(&app_state.pool)
.await?;
rows.into_iter().map(row_to_record).collect()
Ok(deserialize_rows(rows))
}
pub async fn list_active_for_instance(
instance_id: &str,
app_state: &State,
) -> crate::Result<Vec<InstallJobRecord>> {
Ok(list_interrupted_candidates(app_state)
.await?
.into_iter()
.filter(|job| job.instance_id.as_deref() == Some(instance_id))
.collect())
}
pub async fn update_state(
@@ -212,20 +223,30 @@ pub async fn update_state(
let id_value = id.to_string();
let modified = now.timestamp();
sqlx::query!(
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, state = ?, modified = ?
WHERE id = ?
SET
instance_id = (SELECT id FROM instances WHERE id = ?),
state = ?,
modified = ?
WHERE id = ? AND status IN ('queued', 'running')
",
instance_id,
json,
modified,
id_value,
)
.bind(instance_id)
.bind(json)
.bind(modified)
.bind(id_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::ErrorKind::InputError(format!(
"Install job {id} is no longer active"
))
.into());
}
get_required(id, app_state).await
}
@@ -262,6 +283,153 @@ pub async fn update_status(
get_required(id, app_state).await
}
pub async fn update_status_if(
id: Uuid,
expected_status: InstallJobStatus,
status: InstallJobStatus,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let now = Utc::now();
let finished = status.is_finished().then_some(now.timestamp());
let json = serde_json::to_string(state)?;
let status_value = status.as_str();
let expected_status_value = expected_status.as_str();
let instance_id = instance_id(state);
let id_value = id.to_string();
let modified = now.timestamp();
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
WHERE id = ? AND status = ?
",
)
.bind(instance_id)
.bind(status_value)
.bind(json)
.bind(modified)
.bind(finished)
.bind(id_value)
.bind(expected_status_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
get_required(id, app_state).await.map(Some)
}
pub async fn finish_active(
id: Uuid,
status: InstallJobStatus,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let now = Utc::now();
let finished = now.timestamp();
let json = serde_json::to_string(state)?;
let status_value = status.as_str();
let instance_id = instance_id(state);
let id_value = id.to_string();
let modified = finished;
let result = sqlx::query(
"
UPDATE install_jobs
SET
instance_id = (SELECT id FROM instances WHERE id = ?),
status = ?,
state = ?,
modified = ?,
finished = ?
WHERE id = ? AND status IN ('queued', 'running')
",
)
.bind(instance_id)
.bind(status_value)
.bind(json)
.bind(modified)
.bind(finished)
.bind(id_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
get_required(id, app_state).await.map(Some)
}
pub async fn complete_success(
id: Uuid,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let Some(instance_id) = instance_id(state) else {
return Err(crate::ErrorKind::InputError(
"Install job is missing its instance id".to_string(),
)
.into());
};
let now = Utc::now().timestamp();
let json = serde_json::to_string(state)?;
let id_value = id.to_string();
let mut transaction = app_state.pool.begin().await?;
let job_result = sqlx::query(
"
UPDATE install_jobs
SET
instance_id = (SELECT id FROM instances WHERE id = ?),
status = 'succeeded',
state = ?,
modified = ?,
finished = ?
WHERE id = ? AND status = 'running'
",
)
.bind(&instance_id)
.bind(json)
.bind(now)
.bind(now)
.bind(id_value)
.execute(&mut *transaction)
.await?;
if job_result.rows_affected() == 0 {
transaction.rollback().await?;
return Ok(None);
}
let instance_result = sqlx::query(
"
UPDATE instances
SET install_stage = 'installed', modified = ?
WHERE id = ?
",
)
.bind(now)
.bind(&instance_id)
.execute(&mut *transaction)
.await?;
if instance_result.rows_affected() == 0 {
transaction.rollback().await?;
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
.into());
}
transaction.commit().await?;
get_required(id, app_state).await.map(Some)
}
pub async fn dismiss(id: Uuid, app_state: &State) -> crate::Result<()> {
let id = id.to_string();
let modified = Utc::now().timestamp();
@@ -308,6 +476,23 @@ fn row_to_record(row: InstallJobRow) -> crate::Result<InstallJobRecord> {
})
}
fn deserialize_rows(rows: Vec<InstallJobRow>) -> Vec<InstallJobRecord> {
rows.into_iter()
.filter_map(|row| {
let id = row.id.clone();
match row_to_record(row) {
Ok(record) => Some(record),
Err(error) => {
tracing::error!(
"Failed to deserialize install job {id}: {error}"
);
None
}
}
})
.collect()
}
fn instance_id(state: &InstallJobState) -> Option<String> {
match &state.target {
super::model::InstallTarget::NewInstance { instance_id } => {
+9 -7
View File
@@ -603,19 +603,21 @@ pub async fn install_minecraft_with_reporter(
let protocol_version = read_protocol_version_from_jar(client_path).await?;
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
crate::state::instances::commands::set_applied_content_set_protocol_version(
&instance.id,
protocol_version,
&state.pool,
)
.await?;
if reporter.is_none() {
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
}
if let Some(loading_bar) = &loading_bar {
emit_loading(loading_bar, 1.0, Some("Finished installing"))?;
}
@@ -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};
@@ -55,8 +55,12 @@ pub(crate) async fn create_instance(
None
};
let icon_path =
resolve_icon_path(input.icon_path.as_deref(), state).await?;
let icon_path = resolve_icon_path(
input.icon_path.as_deref(),
matches!(&input.link, InstanceLink::SharedInstance { .. }),
state,
)
.await?;
let now = Utc::now();
let instance_id = format!("local:{}", Uuid::new_v4());
let content_set_id = format!("content-set:{}", Uuid::new_v4());
@@ -168,16 +172,15 @@ async fn path_available(
async fn resolve_icon_path(
icon_path: Option<&str>,
ignore_missing_remote_icon: bool,
state: &State,
) -> crate::Result<Option<String>> {
let Some(icon) = icon_path else {
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 = match fetch::fetch(
icon,
None,
None,
@@ -185,25 +188,40 @@ async fn resolve_icon_path(
&state.fetch_semaphore,
&state.pool,
)
.await?;
let name = icon.rsplit('/').next().unwrap_or("icon").to_string();
(fetched, name)
.await
{
Ok(bytes) => bytes,
Err(error)
if ignore_missing_remote_icon && is_not_found_error(&error) =>
{
return Ok(None);
}
Err(error) => return Err(error),
};
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()))
}
fn is_not_found_error(error: &crate::Error) -> bool {
match error.raw.as_ref() {
crate::ErrorKind::FetchError(error) => {
error.status() == Some(reqwest::StatusCode::NOT_FOUND)
}
crate::ErrorKind::LabrinthError(error) => {
error.status == Some(reqwest::StatusCode::NOT_FOUND.as_u16())
}
_ => false,
}
}
fn content_source_kind(link: &InstanceLink) -> ContentSourceKind {
match link {
InstanceLink::Unmanaged => ContentSourceKind::Local,
+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(),
+5 -1
View File
@@ -53,7 +53,6 @@ pub struct Settings {
pub enum FeatureFlag {
PagePath,
ProjectBackground,
WorldsTab,
WorldsInHome,
ServerRamAsBytesAlwaysOn,
AlwaysShowAppControls,
@@ -65,6 +64,8 @@ pub enum FeatureFlag {
ShowInstancePlayTime,
SkipNonEssentialWarnings,
AdvancedFiltersCollapsed,
AlwaysShowCopyDetails,
HideInstalledModpacks,
}
impl Settings {
@@ -328,6 +329,7 @@ pub enum Theme {
Dark,
Light,
Oled,
Retro,
System,
}
@@ -337,6 +339,7 @@ impl Theme {
Theme::Dark => "dark",
Theme::Light => "light",
Theme::Oled => "oled",
Theme::Retro => "retro",
Theme::System => "system",
}
}
@@ -346,6 +349,7 @@ impl Theme {
"dark" => Theme::Dark,
"light" => Theme::Light,
"oled" => Theme::Oled,
"retro" => Theme::Retro,
"system" => Theme::System,
_ => Theme::Dark,
}
+39 -30
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};
@@ -429,6 +428,7 @@ pub async fn fetch_with_client_progress(
sha1,
None,
None,
None,
download_meta,
None,
uri_path,
@@ -494,6 +494,35 @@ pub async fn fetch_advanced(
.await
}
#[tracing::instrument(skip(body, semaphore))]
#[allow(clippy::too_many_arguments)]
pub async fn fetch_advanced_bytes(
method: Method,
url: &str,
body: Bytes,
header: Option<(&str, &str)>,
uri_path: Option<&'static str>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Bytes> {
fetch_advanced_with_client_and_progress(
method,
url,
None,
None,
Some(body),
header,
None,
None,
uri_path,
semaphore,
exec,
&INSECURE_REQWEST_CLIENT,
None,
)
.await
}
#[tracing::instrument(skip(json_body, semaphore, progress))]
#[allow(clippy::too_many_arguments)]
pub async fn fetch_advanced_with_progress(
@@ -514,6 +543,7 @@ pub async fn fetch_advanced_with_progress(
url,
sha1,
json_body,
None,
header,
download_meta,
loading_bar,
@@ -547,6 +577,7 @@ pub async fn fetch_advanced_with_client(
url,
sha1,
json_body,
None,
header,
download_meta,
loading_bar,
@@ -559,13 +590,16 @@ pub async fn fetch_advanced_with_client(
.await
}
#[tracing::instrument(skip(json_body, semaphore, client, progress))]
#[tracing::instrument(skip(
json_body, bytes_body, semaphore, client, progress
))]
#[allow(clippy::too_many_arguments)]
async fn fetch_advanced_with_client_and_progress(
method: Method,
url: &str,
sha1: Option<&str>,
json_body: Option<serde_json::Value>,
bytes_body: Option<Bytes>,
header: Option<(&str, &str)>,
download_meta: Option<&DownloadMeta>,
loading_bar: Option<(&LoadingBarId, f64)>,
@@ -612,6 +646,8 @@ async fn fetch_advanced_with_client_and_progress(
if let Some(body) = json_body.clone() {
req = req.json(&body);
} else if let Some(body) = bytes_body.clone() {
req = req.body(body);
}
if let Some(header) = header {
@@ -907,33 +943,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()
+2 -2
View File
@@ -66,7 +66,6 @@ import _BugIcon from './icons/bug.svg?component'
import _CalendarIcon from './icons/calendar.svg?component'
import _CalendarArrowDownIcon from './icons/calendar-arrow-down.svg?component'
import _CardIcon from './icons/card.svg?component'
import _ChangeSkinIcon from './icons/change-skin.svg?component'
import _ChartIcon from './icons/chart.svg?component'
import _ChartAreaIcon from './icons/chart-area.svg?component'
import _ChartColumnBigIcon from './icons/chart-column-big.svg?component'
@@ -244,6 +243,7 @@ import _ShareIcon from './icons/share.svg?component'
import _ShieldIcon from './icons/shield.svg?component'
import _ShieldAlertIcon from './icons/shield-alert.svg?component'
import _ShieldCheckIcon from './icons/shield-check.svg?component'
import _ShirtIcon from './icons/shirt.svg?component'
import _SignalIcon from './icons/signal.svg?component'
import _SignatureIcon from './icons/signature.svg?component'
import _SkullIcon from './icons/skull.svg?component'
@@ -498,7 +498,6 @@ export const BugIcon = _BugIcon
export const CalendarIcon = _CalendarIcon
export const CalendarArrowDownIcon = _CalendarArrowDownIcon
export const CardIcon = _CardIcon
export const ChangeSkinIcon = _ChangeSkinIcon
export const ChartIcon = _ChartIcon
export const ChartAreaIcon = _ChartAreaIcon
export const ChartColumnBigIcon = _ChartColumnBigIcon
@@ -676,6 +675,7 @@ export const ShareIcon = _ShareIcon
export const ShieldIcon = _ShieldIcon
export const ShieldAlertIcon = _ShieldAlertIcon
export const ShieldCheckIcon = _ShieldCheckIcon
export const ShirtIcon = _ShirtIcon
export const SignalIcon = _SignalIcon
export const SignatureIcon = _SignatureIcon
export const SkullIcon = _SkullIcon
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" transform="scale(-1 1)" viewBox="0 0 49.915 52.72">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M15.71 31.484v19.07h18.63v-19.07l6.538 6.539 6.871-6.872-11.203-11.733H14.122L2.166 31.375l6.827 6.827z"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.993" d="M24.872 19.548v-6.44"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M24.704 13.202a5.518 5.518 0 0 1-5.518-5.518 5.518 5.518 0 0 1 5.518-5.518 5.518 5.518 0 0 1 5.518 5.518"/>
</svg>

Before

Width:  |  Height:  |  Size: 695 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-shirt"
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"
>
<path d="M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z" />
</svg>

After

Width:  |  Height:  |  Size: 478 B

+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;
}
+115
View File
@@ -10,6 +10,121 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-29T21:32:06+00:00`,
product: 'app',
version: '0.17.3',
body: `## Added
- Added button to create a new instance on the Library page.
## Changed
- Added a toggle to hide modpacks that are already installed.
- Added tooltip to installation settings button in installed modpack card.
## Fixed
- Improved error when shared instances reach a config file limit.
- When pushing updates to shared instances, will now scroll to the top when entering a sub-page.
- Fixed instance installations getting stuck when the instance is deleted.
- Fixed error when failing to fetch a shared instance icon.
- Installing content from Discover content will now install the latest that matches the game version and loader filters, not the absolute latest.`,
},
{
date: `2026-07-29T21:32:06+00:00`,
product: 'web',
body: `## Changed
- Incomplete current day revenue hides that day's line segment instead of showing a dip to \$0.
- Updated the modal for creating OAuth applications.
## Fixed
- Fixed dependencies in project download modal could give dependency with wrong Minecraft version.
- Fixed PATS page new generated tokens invalidating in the same session.`,
},
{
date: `2026-07-28T23:36:10+00:00`,
product: 'web',
body: `## Changed
- Added message to legacy moderation threads showing that there may be undocumented moderation history.
## Fixed
- Fixed OAuth application icon upload not working.
- Fixed moderation messages not showing.`,
},
{
date: `2026-07-28T23:36:10+00:00`,
product: 'app',
version: '0.17.2',
body: `## Fixed
- Fixed broken shared instances invite management table.`,
},
{
date: `2026-07-28T20:40:11+00:00`,
product: 'app',
version: '0.17.1',
body: `## Added
- Added user pages.
- Added a banner for users of a shared instance which let's them know that they need to review an update to play the instance - alongside the existing checks when you click Play.
- Added a way to see and manage the invite links you have created for a shared instance in the Sharing tab of the Instance Settings modal.
- Added a way to see where a shared instance content is coming from when in the Install to play modal, either the linked modpack or if it was added on top. Content which is a part of the linked modpack will show the modpack information underneath it's name.
- Added user blocking
- You can block users on their profile page.
- You can block users when reporting a shared instance. This prevents the user from sending you invites to shared instances and Modrinth Hosting server panels.
- You can manage who you've blocked in the new Social settings in the app's Settings menu, or on the Modrinth website.
- Added the ability to edit your Modrinth profile in the app's Settings menu.
- Added the ability to adjust the amount of quick instances shown in the sidebar by dragging the divider up and down.
- Added an option to always show "Copy details" on the installation job notifications, rather than just on failed and interrupted instance install jobs.
- Clicking on friends in the friends list will take you to their profile page.
## Changed
- More than three instances now show up in the left sidebar's quick instance selection area for larger window sizes.
- Updated Modrinth App logo to just use the standard Modrinth logo to save space.
- Updated the design of the back/forward buttons.
- Limited shared instances to 50 users.
- Changed the expiry date picker in the shared instance invite edit modal to be a dropdown of common dates, rather than a complicated date picker. You can still use the fine-grained date picker by choosing "Custom"
- Instance icons must now be smaller than 4MB - any existing instances will have their icons compressed to 512x512px size to conform to the new limit. This will break any instances which have .GIF icons.
- Split up the App Settings modal into categories.
- Moved out behavioural settings into it's own subpage, rather than being in Appearance settings.
- Updated "Advanced" toggle filter design to be the same as the other filters, just with only an exclude button as the primary action.
- Re-aligned the traffic light buttons on macOS with the top bar.
- Updated translations. Want to help translate the Modrinth App? [Click here](https://translate.modrinth.com)
- **Modrinth Hosting:** Updated translations. Want to help translate Modrinth Hosting? [Click here](https://translate.modrinth.com)
## Fixed
- **Modrinth Hosting:** Fixed issue where when browsing content for your server panel, when visiting a project page and going back to search, it would reset the page back to one.
- Fixed issue where when browsing content for an instance, when visiting a project page and going back to search, it would reset the page back to one.
- Refactored how breadcrumbs work in the app, this should solve many issues you might have encountered using the back and forward navigation buttons in the app header.
- Fixed error spam when the shared instances API is not accessible, it will cleanly provide feedback that the app cannot connect.`,
},
{
date: `2026-07-28T20:40:11+00:00`,
product: 'web',
body: `## Added
- You can now block users on their profile page. This prevents the user from sending you invites to shared instances and Modrinth Hosting server panels.
- Added new social settings page, where you can manage users you have blocked and in the future set who can send you friend requests and invitations to shared instances and Modrinth Hosting server panels.
## Changed
- Cleaned up the layout of and renamed the Public profile settings page to Profile settings.
- Updated "Advanced" toggle filter design to be the same as the other filters, just with only an exclude button as the primary action.
- Updated translations. Want to help translate Modrinth's website? [Click here](https://translate.modrinth.com)
## Fixed
- Fixed profile pictures still being deleted after resetting a pending removal in Profile settings.
- Fixed shared instance reports not showing up in the dashboard's Reports page.
- Fixed shared instance report emails saying "Unknown" rather than the shared instance's name.
- Fixed the settings page for Authorized apps being broken.
- Broken Retro theme colors
- Improved vertical alignment of status indicators in notifications.`,
},
{
date: `2026-07-28T20:40:11+00:00`,
product: 'hosting',
body: `## Changed
- Updated translations. Want to help translate Modrinth Hosting? [Click here](https://translate.modrinth.com)
## Fixed
- Fixed issue where when browsing content for your server panel, when visiting a project page and going back to search, it would reset the page back to one.`,
},
{
date: `2026-07-26T19:06:47+00:00`,
product: 'web',
+46 -5
View File
@@ -1,77 +1,118 @@
import type { KeybindListener } from '../types/keybinds'
import type { Labrinth } from '@modrinth/api-client'
const copyProjectLink = async (
project: Labrinth.Projects.v2.Project,
permalink: boolean,
relative: boolean,
page: boolean,
) => {
let url = ``
if (relative) {
url += `${globalThis.location.origin}`
} else {
url += `https://modrinth.com`
}
if (permalink) {
url += `/project/${project.id}`
} else {
url += `/${project.project_type}/${project.slug}`
}
if (page) {
url += `/${globalThis.location.pathname.split('/').slice(3).join('/')}`
}
await navigator.clipboard.writeText(url)
}
const keybinds: { [id: string]: KeybindListener } = {
'next-stage': {
keybind: 'ArrowRight',
description: 'Go to next stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoNext(),
},
'previous-stage': {
keybind: 'ArrowLeft',
description: 'Go to previous stage',
scope: 'checklist',
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoBack(),
},
'generate-message': {
keybind: 'Ctrl+Shift+E',
description: 'Generate moderation message',
scope: 'checklist',
action: (ctx) => ctx.actions.tryGenerateMessage(),
},
'toggle-collapse': {
keybind: 'Shift+C',
description: 'Toggle collapse/expand',
scope: 'checklist',
action: (ctx) => ctx.actions.tryToggleCollapse(),
},
'reset-progress': {
keybind: 'Ctrl+Shift+R',
description: 'Reset moderation progress',
scope: 'checklist',
action: (ctx) => ctx.actions.tryResetProgress(),
},
'skip-project': {
keybind: 'Ctrl+Shift+S',
description: 'Skip to next project',
scope: 'checklist',
enabled: (ctx) => ctx.state.futureProjectCount > 0 && !ctx.state.isDone,
action: (ctx) => ctx.actions.trySkipProject(),
},
'copy-permalink': {
keybind: 'Ctrl+Alt+C',
description: 'Copy permalink',
action: (ctx) => ctx.actions.tryCopyLink(true, false, false),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, false, false),
},
'copy-relative-permalink': {
keybind: 'Ctrl+Alt+R',
description: 'Copy relative permalink',
action: (ctx) => ctx.actions.tryCopyLink(true, true, false),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, true, false),
},
'copy-page-permalink': {
keybind: 'Shift+Ctrl+Alt+C',
description: 'Copy permalink with page',
action: (ctx) => ctx.actions.tryCopyLink(true, false, true),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, false, true),
},
'copy-page-relative-permalink': {
keybind: 'Shift+Ctrl+Alt+R',
description: 'Copy relative permalink with page',
action: (ctx) => ctx.actions.tryCopyLink(true, true, true),
scope: 'project',
action: async (ctx) => copyProjectLink(ctx.project, true, true, true),
},
'copy-id': {
keybind: 'Ctrl+Alt+D',
description: 'Copy Project ID',
action: (ctx) => ctx.actions.tryCopyId(),
scope: 'project',
action: async (ctx) => await navigator.clipboard.writeText(ctx.project.id),
},
'approve-project': {
keybind: 'Shift+Alt+A',
description: 'Approve project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryApprove(),
},
'withhold-project': {
keybind: 'Shift+Alt+W',
description: 'Withhold project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryWithhold(),
},
'reject-project': {
keybind: 'Shift+Alt+R',
description: 'Reject project',
scope: 'checklist',
action: (ctx) => ctx.actions.tryReject(),
},
}
+33
View File
@@ -0,0 +1,33 @@
import type { EnumSettingDefinition, ToggleSettingDefinition } from '../types/settings.ts'
const settings = {
General: {
ChecklistPosition: {
type: 'enum',
id: 'checklist-position',
title: 'Checklist Position',
description: 'Where the checklist should be displayed on the page',
entries: [
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
],
default: 'right',
} as EnumSettingDefinition,
ProjectKeybinds: {
type: 'toggle',
id: 'project-keybinds',
title: 'Enable Project Keybinds',
description: 'Weather certain keybinds should work without the checklist visible.',
default: false,
} as ToggleSettingDefinition,
PrivateMessageHighlight: {
type: 'toggle',
id: 'private-message-highlight',
title: 'Highlight Private Messages',
description: 'Whether private messages should be highlighted in the chat.',
default: true,
} as ToggleSettingDefinition,
},
} as const
export default settings
@@ -0,0 +1,82 @@
import {
type KeybindDefinition,
type KeybindListener,
matchesKeybind,
type ModerationContext,
normalizeKeybind,
} from '../types/keybinds.ts'
import keybinds from '../data/keybinds.ts'
function normalizeKeybinds(
keybind: KeybindDefinition | KeybindDefinition[] | string | string[],
): KeybindDefinition[] {
return Array.isArray(keybind) ? keybind.map(normalizeKeybind) : [normalizeKeybind(keybind)]
}
export type KeybindListenerWithDefault = KeybindListener & {
keybind: KeybindDefinition[]
defaultKeybind: KeybindDefinition[]
}
export class Keybinds {
private readonly configured: { [id: string]: KeybindDefinition[] } = {}
constructor(keybinds: { [id: string]: KeybindDefinition[] }) {
this.configured = keybinds
}
*[Symbol.iterator](): IterableIterator<[string, KeybindListenerWithDefault]> {
for (const [id, keybind] of Object.entries(keybinds)) {
yield [
id,
{
...keybind,
keybind: this.configured[id] ?? normalizeKeybinds(keybind.keybind),
defaultKeybind: normalizeKeybinds(keybind.keybind),
},
]
}
}
set(id: string, keybind: KeybindDefinition | KeybindDefinition[] | string | string[]): void {
this.configured[id] = normalizeKeybinds(keybind)
}
handle(event: KeyboardEvent, ctx: ModerationContext): boolean {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line')
) {
return false
}
for (const [id, keybind] of Object.entries(keybinds)) {
if (ctx.scope !== keybind.scope) {
continue
}
if (keybind.enabled && !keybind.enabled(ctx as any)) {
continue
}
const definitions = this.configured[id] ?? normalizeKeybinds(keybind.keybind)
const matches = definitions.some((def) => matchesKeybind(event, def))
if (matches) {
keybind.action(ctx as any)
const shouldPrevent = definitions.some((def) => def.preventDefault !== false)
if (shouldPrevent) {
event.preventDefault()
}
return true
}
}
return false
}
}
@@ -0,0 +1,25 @@
import type { SettingDefinitionBase } from '../types/settings.ts'
export class Settings {
private readonly settings: { [id: string]: any }
private readonly onChange: () => void
constructor(
settings: { [id: string]: any } | undefined = undefined,
onChange: () => void = () => {},
) {
this.settings = settings || {}
this.onChange = onChange
}
get<T>(definition: SettingDefinitionBase<T>): T {
return this.settings[definition.id] ?? definition.default
}
set<T>(definition: SettingDefinitionBase<T>, value?: T): void {
const previous = this.settings[definition.id] ?? definition.default
this.settings[definition.id] = value
definition.onChange?.(previous, value ?? definition.default)
this.onChange()
}
}
+4
View File
@@ -1,5 +1,6 @@
export { default as checklist, stages, useStages } from './data/checklist'
export { default as keybinds } from './data/keybinds'
export { default as moderationSettings } from './data/settings'
export { default as nags } from './data/nags'
export * from './data/nags/index'
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
@@ -11,6 +12,7 @@ export {
export * from './locales'
export * from './types/actions'
export * from './types/keybinds'
export * from './types/settings'
export * from './types/messages'
export * from './types/nags'
export * from './types/node'
@@ -19,3 +21,5 @@ export * from './types/quick-reply'
export * from './types/reports'
export * from './types/stage'
export * from './utils'
export * from './handles/keybinds'
export * from './handles/settings'
@@ -201,7 +201,7 @@
"defaultMessage": "Link-Einstellungen ansehen"
},
"nags.settings.permissions.title": {
"defaultMessage": "Berechtigung-Dashboard besuchen"
"defaultMessage": "Berechtigungs-Dashboard besuchen"
},
"nags.settings.tags.title": {
"defaultMessage": "Tag-Einstellungen ansehen"
@@ -129,7 +129,7 @@
"defaultMessage": "Bei einigen deiner Bilder fehlt der Alt-Text, der besonders für sehbehinderte Nutzer wichtig ist, um die Barrierefreiheit zu gewährleisten."
},
"nags.missing-alt-text.title": {
"defaultMessage": "Alternativen Bild-Text Hinzufügen"
"defaultMessage": "Alternativen Bild-Text hinzufügen"
},
"nags.misused-discord-link-description": {
"defaultMessage": "Discord-Einladungen können nicht für andere Link-Typen verwendet werden. Bitte füge deinen Discord-Link nur in das Feld „Discord-Einladungslink“ ein."
@@ -201,13 +201,13 @@
"defaultMessage": "Linkeinstellungen ansehen"
},
"nags.settings.permissions.title": {
"defaultMessage": "Berechtigung-Dashboard besuchen"
"defaultMessage": "Berechtigungs-Dashboard besuchen"
},
"nags.settings.tags.title": {
"defaultMessage": "Tageinstellungen ansehen"
},
"nags.settings.title": {
"defaultMessage": "Einstellungen aufrufen"
"defaultMessage": "Allgemeine Einstellungen aufrufen"
},
"nags.summary-same-as-title.description": {
"defaultMessage": "Deine Zusammenfassung darf nicht identisch mit dem Namen deines Projekts sein. Es ist wichtig, eine informative und ansprechende Zusammenfassung zu erstellen."
@@ -240,13 +240,13 @@
"defaultMessage": "Wähle passende Sprachen aus"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "Du hast {tagCount, plural, one {# Tag} other {# Tags}} ausgewählt. Bitte reduziere die Anzahl auf {maxTagCount} oder weniger, damit dein Server in relevanten Suchen erscheint."
"defaultMessage": "Du hast {tagCount, plural, one {# Tag} other {# Tags}} ausgewählt. Bitte reduziere die Anzahl auf {maxTagCount} oder weniger, damit dein Server in relevanten Suchergebnissen erscheint."
},
"nags.too-many-tags-server.title": {
"defaultMessage": "Wähle passende Tags"
},
"nags.too-many-tags.description": {
"defaultMessage": "Du hast {tagCount, plural, one {# Tag} other {# Tags}} ausgewählt. Reduziere die Anzahl auf {maxTagCount} oder weniger, damit dein Projekt in den relevanten Suchergebnissen erscheint."
"defaultMessage": "Du hast {tagCount, plural, one {# Tag} other {# Tags}} ausgewählt. Reduziere die Anzahl auf {maxTagCount} oder weniger, damit dein Projekt in relevanten Suchergebnissen erscheint."
},
"nags.too-many-tags.title": {
"defaultMessage": "Wähle passende Tags aus"
@@ -144,7 +144,7 @@
"defaultMessage": "Examinez et traitez toutes les remarques de l’équipe de modération avant de soumettre à nouveau."
},
"nags.moderator-feedback.title": {
"defaultMessage": "Retour de revue"
"defaultMessage": "Retours de revue"
},
"nags.multiple-resolution-tags.description": {
"defaultMessage": "Vous avez sélectionné {count, plural, one {# tag de résolution} other {# tags de résolution}} ({tags}). Les packs de ressources ne devraient généralement avoir quun seul tag de résolution correspondant à leur résolution principale."
@@ -171,13 +171,13 @@
"defaultMessage": "Supprimer une région"
},
"nags.select-language.description": {
"defaultMessage": "Lisyez la ou les langues prises en charge par votre serveur."
"defaultMessage": "Listez la ou les langues prises en charge par votre serveur."
},
"nags.select-language.title": {
"defaultMessage": "Sélectionner une langue"
},
"nags.select-license.description": {
"defaultMessage": "Sélectionnez la licence votre {type} est distribué."
"defaultMessage": "Sélectionnez la licence sous laquelle votre {type} est distribué."
},
"nags.select-license.title": {
"defaultMessage": "Sélectionner une licence"
@@ -273,7 +273,7 @@
"defaultMessage": "Vérifier les liens externes"
},
"nags.versions.title": {
"defaultMessage": "Voir les versions"
"defaultMessage": "Voir la page des versions"
},
"nags.visit-links-settings.title": {
"defaultMessage": "Accéder aux paramètres des liens"
@@ -90,7 +90,7 @@
"defaultMessage": "Identieke links opschonen"
},
"nags.image-heavy-description.description": {
"defaultMessage": "Uw beschrijving moet voldoende platte tekst of alternatieve afbeeldingstekst bevatten, zodat deze ook toegankelijk is voor mensen die een schermlezer gebruiken of een trage internetverbinding hebben."
"defaultMessage": "Je beschrijving moet voldoende normale tekst of alternatieve tekst bij afbeeldingen bevatten, zodat deze toegankelijk blijft voor mensen die een schermlezer gebruiken of een trage internetverbinding hebben."
},
"nags.image-heavy-description.title": {
"defaultMessage": "Zorg voor toegankelijkheid"
@@ -120,7 +120,7 @@
"defaultMessage": "Verkort koppen"
},
"nags.minecraft-title-clause.description": {
"defaultMessage": "Projecten mogen niet de merknaam van Minecraft gebruiken en \"Minecraft\" mag geen belangrijk onderdeel van de naam zijn."
"defaultMessage": "Projecten mogen geen gebruik maken van de huisstijl van Minecraft en mogen het woord \"Minecraft\" niet als een belangrijk onderdeel van de naam bevatten."
},
"nags.minecraft-title-clause.title": {
"defaultMessage": "Voorkom merkinbreuk"
@@ -132,7 +132,7 @@
"defaultMessage": "Alt-tekst voor afbeelding toevoegen"
},
"nags.misused-discord-link-description": {
"defaultMessage": "Discord-uitnodigingen kunnen niet worden gebruikt voor andere linktypen. Plaats uw Discord-link alleen in het veld 'Discord-uitnodigingslink'."
"defaultMessage": "Discord-uitnodigingen kunnen niet worden gebruikt voor andere soorten links. Plaats je Discord-link alleen in het veld 'Discord-uitnodigingslink'."
},
"nags.misused-discord-link.title": {
"defaultMessage": "Verplaats Discord uitnodiging"
@@ -147,7 +147,7 @@
"defaultMessage": "Feedback beoordelen"
},
"nags.multiple-resolution-tags.description": {
"defaultMessage": "Je hebt {count, plural, one {# resolution tag} other {# resolution tags}} ({tags}) geselecteerd. Resource packs moeten typisch maar een resolutietag hebben dat hun primaire resolutie matcht."
"defaultMessage": "Je hebt {count, plural, one {# resolutietag} other {# resolutietags}} ({tags}) geselecteerd. Bronpakketten zouden normaal slechts één resolutietag moeten hebben die overeenkomt met hun primaire resolutie."
},
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Selecteer juiste resolutie"
@@ -255,7 +255,7 @@
"defaultMessage": "Er is minstens één afbeelding in de galerij vereist om de inhoud van je {type} te tonen."
},
"nags.upload-gallery-image.resourcepack-type": {
"defaultMessage": "resource packs, met uitzondering van audio- of lokalisatiepakketten. Als dit op jouw pakket van toepassing is, selecteer dan de juiste tag"
"defaultMessage": "bronpakket, met uitzondering van audio- of lokalisatiepakketten. Als dit op jouw pakket van toepassing is, selecteer dan de juiste tag"
},
"nags.upload-gallery-image.title": {
"defaultMessage": "Upload een galerijafbeelding"
@@ -267,7 +267,7 @@
"defaultMessage": "Upload een versie"
},
"nags.verify-external-links.description": {
"defaultMessage": "Sommige van uw externe links gebruiken mogelijk domeinen die niet geschikt zijn voor dat type link."
"defaultMessage": "Sommige van je externe links gebruiken mogelijk domeinnamen die niet geschikt zijn voor dat soort links."
},
"nags.verify-external-links.title": {
"defaultMessage": "Verifieer externe links"
@@ -261,7 +261,7 @@
"defaultMessage": "Ladda upp en galleribild"
},
"nags.upload-version.description": {
"defaultMessage": "Åt minstone en version krävs för att ett projekt ska kunna skickas in för granskning."
"defaultMessage": "Åtminstone en version krävs för att ett projekt ska kunna skickas in för granskning."
},
"nags.upload-version.title": {
"defaultMessage": "Ladda upp en version"
@@ -24,13 +24,13 @@
"defaultMessage": "Додати деталі ліцензії"
},
"nags.add-links-server.description": {
"defaultMessage": "Додайте будь-які доречні посилання, націлені за межі Modrinth, наприклад вебсайт, крамниця або запрошення Discord."
"defaultMessage": "Додайте будь-які доречні посилання поза Modrinth, як-от на вебсайт, крамницю або запрошення до Discord."
},
"nags.add-links-server.title": {
"defaultMessage": "Додайте зовнішні посилання"
},
"nags.add-links.description": {
"defaultMessage": "Додайте будь-які доречні посилання, націлені за межі Modrinth, наприклад на вихідний код, систему відстеження проблем чи запрошення Discord."
"defaultMessage": "Додайте будь-які доречні посилання, націлені за межі Modrinth, наприклад, на вихідний код, систему відстеження проблем чи запрошення Discord."
},
"nags.add-links.title": {
"defaultMessage": "Додайте зовнішні посилання"
@@ -78,7 +78,7 @@
"defaultMessage": "Відвідайте сторінку галереї"
},
"nags.gpl-license-source-required.description": {
"defaultMessage": "Ваш {type} використовує ліцензію, яка вимагає відкритий вихідний код. Надайте посилання на вихідний код або файли для кожної додаткової версії або використайте іншу ліцензію."
"defaultMessage": "Ваш {type} використовує ліцензію, що вимагає відкритости вихідного коду. Надайте посилання на вихідний код (або файли з кодом) для кожної версії, або змініть ліцензію."
},
"nags.gpl-license-source-required.title": {
"defaultMessage": "Надайте вихідний код"
@@ -87,7 +87,7 @@
"defaultMessage": "Деякі з ваших посилань, схоже, однакові. Кожне посилання слід вводити лише один раз й у відповідне для нього поле."
},
"nags.identical-links.title": {
"defaultMessage": "Очистити однакові посилання"
"defaultMessage": "Прибрати однакові посилання"
},
"nags.image-heavy-description.description": {
"defaultMessage": "Ваш опис повинен містити достатню кількість простого тексту або альтернативного тексту зображень, щоби він був зрозумілим для тих, хто використовує читачі екрану або має повільне з’єднання з інтернетом."
@@ -17,6 +17,12 @@
"nags.add-java-address.title": {
"defaultMessage": "Thêm địa chỉ Java"
},
"nags.add-license-details.description": {
"defaultMessage": "Thêm một URL hợp lệ và tên hoặc mã định danh SPDX cho giấy phép tuỳ chỉnh của bạn."
},
"nags.add-license-details.title": {
"defaultMessage": "Thông tin giấy phép"
},
"nags.add-links-server.description": {
"defaultMessage": "Thêm bất kỳ liên kết nào có liên quan đến các trang web bên ngoài Modrinth, chẳng hạn như trang web, cửa hàng hoặc lời mời tham gia Discord."
},
@@ -29,8 +35,14 @@
"nags.add-links.title": {
"defaultMessage": "Thêm liên kết ngoài"
},
"nags.all-languages.description": {
"defaultMessage": "Bạn đã chọn tất cả ngôn ngữ có sẵn. Xin hãy chỉ chọn những ngôn ngữ máy chủ của bạn hỗ trợ tích cực."
},
"nags.all-languages.title": {
"defaultMessage": "Chọn ngôn ngữ chính xác"
},
"nags.all-tags-selected.description": {
"defaultMessage": "Bạn đã chọn tất cả {totalAvailableTags, plural, one {# thẻ có sẵn} other {# thẻ có sẵn}}. Điều này làm mất đi mục đích của thẻ, vốn được dùng để giúp người dùng tìm thấy các dự án phù hợp. Vui lòng chỉ chọn những thẻ thực sự liên quan đến dự án của bạn."
"defaultMessage": "Bạn đã chọn tất cả {totalAvailableTags} thẻ có sẵn. Điều này làm mất đi mục đích của thẻ, vốn được dùng để giúp người dùng tìm thấy các dự án phù hợp. Vui lòng chỉ chọn những thẻ thực sự liên quan đến dự án của bạn."
},
"nags.all-tags-selected.title": {
"defaultMessage": "Chọn các thẻ chính xác"
@@ -140,6 +152,12 @@
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Chọn độ phân giải đúng"
},
"nags.review-permissions.description": {
"defaultMessage": "Chắc chắn rằng bạn đã cung cấp bằng chứng về quyền của bạn để phân phối bất kì nội dung ngoài nào trong Modpack của bạn."
},
"nags.review-permissions.title": {
"defaultMessage": "Xem lại quyền bên ngoài"
},
"nags.select-compatibility.description": {
"defaultMessage": "Chọn các phiên bản mà máy chủ của bạn hỗ trợ, chọn một Modpack hoặc tải lên Modpack của riêng bạn."
},
@@ -182,6 +200,9 @@
"nags.settings.links.title": {
"defaultMessage": "Truy cập cài đặt liên kết"
},
"nags.settings.permissions.title": {
"defaultMessage": "Truy cập bảng quản lý quyền"
},
"nags.settings.tags.title": {
"defaultMessage": "Truy cập cài đặt thẻ"
},
@@ -201,7 +222,7 @@
"defaultMessage": "Làm gọn phần tóm tắt"
},
"nags.summary-too-short.description": {
"defaultMessage": "Phần tóm tắt của bạn hiện có {length, plural, one {# ký tự} other {# ký tự}}. Khuyến nghị nên có ít nhất {minChars, plural, one {# ký tự} other {# ký tự}} để tạo ra một bản tóm tắt đầy đủ thông tin và hấp dẫn."
"defaultMessage": "Phần tóm tắt của bạn hiện có {length} ký tự. Khuyến nghị nên có ít nhất {minChars} ký tự để tạo ra một bản tóm tắt đầy đủ thông tin và hấp dẫn."
},
"nags.summary-too-short.title": {
"defaultMessage": "Mở rộng phần tóm tắt"
@@ -212,11 +233,17 @@
"nags.title-contains-technical-info.title": {
"defaultMessage": "Làm gọn tên dự án"
},
"nags.too-many-languages.description": {
"defaultMessage": "Bạn đã chọn {languageCount} ngôn ngữ. Xin hãy chỉ chọn những ngôn ngữ máy chủ của bạn hỗ trợ tích cực."
},
"nags.too-many-languages.title": {
"defaultMessage": "Chọn ngôn ngữ chính xác"
},
"nags.too-many-tags-server.description": {
"defaultMessage": "Bạn đã chọn {tagCount, plural, one {# tag} other {# tags}}. vui lòng giảm {maxTagCount} hoặc ít hơn để đảm bảo máy chủ của bạn xuất hiện trong kết quả tìm kiếm có liên quan."
},
"nags.too-many-tags-server.title": {
"defaultMessage": "Chọn thẻ"
"defaultMessage": "Chọn thẻ chính xác"
},
"nags.too-many-tags.description": {
"defaultMessage": "Bạn đã chọn {tagCount, plural, one {# thẻ} other {# thẻ}}. Hãy cân nhắc giảm xuống còn {maxTagCount} thẻ hoặc ít hơn để đảm bảo dự án của bạn xuất hiện trong các kết quả tìm kiếm phù hợp."
+21 -60
View File
@@ -14,17 +14,6 @@ export interface ModerationActions {
tryReject: () => void
tryWithhold: () => void
tryEditMessage: () => void
tryToggleAction: (actionIndex: number) => void
trySelectDropdownOption: (actionIndex: number, optionIndex: number) => void
tryToggleChip: (actionIndex: number, chipIndex: number) => void
tryFocusNextAction: () => void
tryFocusPreviousAction: () => void
tryActivateFocusedAction: () => void
tryCopyLink: (permalink: boolean, relative: boolean, page: boolean) => void
tryCopyId: () => void
}
export interface ModerationState {
@@ -41,17 +30,22 @@ export interface ModerationState {
futureProjectCount: number
visibleActionsCount: number
focusedActionIndex: number | null
focusedActionType: 'button' | 'toggle' | 'dropdown' | 'multi-select' | null
}
export interface ModerationContext {
export type ModerationProjectContext = {
project: Labrinth.Projects.v2.Project
scope: 'project'
}
export type ModerationChecklistContext = {
project: Labrinth.Projects.v2.Project
scope: 'checklist'
state: ModerationState
actions: ModerationActions
}
export type ModerationContext = ModerationProjectContext | ModerationChecklistContext
export interface KeybindDefinition {
key: string
ctrl?: boolean
@@ -61,13 +55,22 @@ export interface KeybindDefinition {
preventDefault?: boolean
}
export interface KeybindListener {
export type BaseKeybindListener<T> = {
keybind: KeybindDefinition | KeybindDefinition[] | string | string[]
description: string
enabled?: (ctx: ModerationContext) => boolean
action: (ctx: ModerationContext) => void
scope: 'project' | 'checklist'
enabled?: (ctx: T) => boolean
action: (ctx: T) => void
}
export type KeybindProjectListener = BaseKeybindListener<ModerationProjectContext> & {
scope: 'project'
}
export type KeybindChecklistListener = BaseKeybindListener<ModerationChecklistContext> & {
scope: 'checklist'
}
export type KeybindListener = KeybindProjectListener | KeybindChecklistListener
export function parseKeybind(keybindString: string): KeybindDefinition {
const parts = keybindString.split('+').map((p) => p.trim().toLowerCase())
@@ -106,45 +109,3 @@ export function toKeybindDefinition(event: KeyboardEvent): KeybindDefinition {
preventDefault: true,
}
}
export function handleKeybind(
event: KeyboardEvent,
ctx: ModerationContext,
keybinds: KeybindListener[],
): boolean {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target as HTMLElement)?.closest('.cm-editor') ||
(event.target as HTMLElement)?.classList?.contains('cm-content') ||
(event.target as HTMLElement)?.classList?.contains('cm-line') ||
document.getElementById('moderation-checklist-keybinds-modal')
) {
return false
}
for (const keybind of keybinds) {
if (keybind.enabled && !keybind.enabled(ctx)) {
continue
}
const keybindDefs = Array.isArray(keybind.keybind)
? keybind.keybind.map(normalizeKeybind)
: [normalizeKeybind(keybind.keybind)]
const matches = keybindDefs.some((def) => matchesKeybind(event, def))
if (matches) {
keybind.action(ctx)
const shouldPrevent = keybindDefs.some((def) => def.preventDefault !== false)
if (shouldPrevent) {
event.preventDefault()
}
return true
}
}
return false
}
+29
View File
@@ -0,0 +1,29 @@
export interface SettingDefinitionBase<T> {
type: string
id: string
title: string
description: string
default: T
onChange?: (previous: T | undefined, current: T) => void
}
export interface ToggleSettingDefinition extends SettingDefinitionBase<boolean> {
type: 'toggle'
}
export interface EnumSettingDefinition extends SettingDefinitionBase<string> {
type: 'enum'
entries: {
label: string
value: string
}[]
}
export interface StringSettingDefinition extends SettingDefinitionBase<string> {
type: 'string'
}
export type SettingDefinition =
| ToggleSettingDefinition
| EnumSettingDefinition
| StringSettingDefinition
+67 -2
View File
@@ -4,7 +4,65 @@ use std::time::Duration;
pub const DEFAULT_API_URL: &str = "https://api.neverbounce.com";
pub const SINGLE_CHECK_PATH: &str = "/v4/single/check";
pub const TIMEOUT: Duration = Duration::from_secs(10);
pub const TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReqwestErrorReason {
Builder,
Redirect,
Status(reqwest::StatusCode),
Timeout,
Request,
Connect,
Body,
Decode,
Unknown,
}
impl ReqwestErrorReason {
#[must_use]
pub fn is_transient(&self) -> bool {
match self {
Self::Status(status) => {
status.is_server_error()
|| matches!(
*status,
reqwest::StatusCode::REQUEST_TIMEOUT
| reqwest::StatusCode::TOO_MANY_REQUESTS
)
}
Self::Timeout | Self::Request | Self::Connect | Self::Body => true,
Self::Builder | Self::Redirect | Self::Decode | Self::Unknown => {
false
}
}
}
}
impl From<&reqwest::Error> for ReqwestErrorReason {
fn from(error: &reqwest::Error) -> Self {
if error.is_timeout() {
Self::Timeout
} else if error.is_connect() {
Self::Connect
} else if error.is_builder() {
Self::Builder
} else if error.is_redirect() {
Self::Redirect
} else if error.is_status() {
error.status().map_or(Self::Unknown, Self::Status)
} else if error.is_request() {
Self::Request
} else if error.is_body() {
Self::Body
} else if error.is_decode() {
Self::Decode
} else {
Self::Unknown
}
}
}
/// Authentication and email parameters for a single verification.
///
@@ -71,6 +129,13 @@ pub enum ResponseStatus {
Unrecognized(String),
}
impl ResponseStatus {
#[must_use]
pub fn is_transient(&self) -> bool {
matches!(self, Self::TemporarilyUnavailable | Self::ThrottleTriggered)
}
}
impl<'de> Deserialize<'de> for ResponseStatus {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
@@ -201,7 +266,7 @@ struct SingleCheckRequest<'a> {
/// This endpoint should only be called in response to an action such as a form
/// submission. Existing lists and databases must use NeverBounce's bulk API.
/// Both the server verification timeout and the complete HTTP request timeout
/// are ten seconds.
/// are five seconds.
pub async fn single_check(
client: &Client,
params: &SingleCheckParams<'_>,
+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>
@@ -1,6 +1,8 @@
<template>
<div class="mx-auto flex flex-col items-center p-6 text-center">
<component :is="illustration" v-if="illustration" class="h-[200px] w-auto" />
<slot name="illustration">
<component :is="illustration" v-if="illustration" class="h-[200px] w-auto" />
</slot>
<div class="flex flex-col items-center gap-1.5">
<span class="text-2xl font-semibold text-contrast">
<slot name="heading">{{ heading }}</slot>
@@ -17,6 +17,7 @@ const props = defineProps<{
ariaLabel?: string
belowModal?: boolean
hideWhenModalOpen?: boolean
inline?: boolean
}>()
const INTERCOM_BUBBLE_GAP = 8
@@ -24,6 +25,7 @@ const INTERCOM_BUBBLE_GAP = 8
const barEl = ref<HTMLElement | null>(null)
const toolbarEl = ref<HTMLElement | null>(null)
const compact = ref(false)
const attentionRequested = ref(false)
const { stackCount } = useModalStack()
const pageContext = injectPageContext(null)
@@ -75,6 +77,7 @@ function updateIntercomBubbleClearance() {
if (
typeof window === 'undefined' ||
props.inline ||
!shown.value ||
stackCount.value > 0 ||
!barEl.value ||
@@ -105,7 +108,7 @@ function updateIntercomBubbleClearance() {
function updateBodyState(isShown = shown.value) {
if (typeof document === 'undefined') return
if (isShown) {
if (isShown && !props.inline) {
visibleFloatingActionBars.add(floatingActionBarId)
} else {
visibleFloatingActionBars.delete(floatingActionBarId)
@@ -149,10 +152,10 @@ watch(
)
watch(
shown,
[shown, () => props.inline],
async (isShown) => {
await nextTick()
updateBodyState(isShown)
updateBodyState(isShown[0])
scheduleIntercomBubbleClearanceUpdate()
},
{ immediate: true },
@@ -187,24 +190,44 @@ onUnmounted(() => {
if (typeof document === 'undefined') return
updateFloatingActionBarBodyClass()
})
async function nudge(): Promise<void> {
attentionRequested.value = false
await nextTick()
attentionRequested.value = true
}
defineExpose({ nudge })
defineOptions({
inheritAttrs: false,
})
</script>
<template>
<Teleport to="body">
<Teleport to="body" :disabled="inline">
<Transition name="floating-action-bar" appear>
<div
v-if="shown"
v-bind="$attrs"
ref="barEl"
class="floating-action-bar drop-shadow-2xl fixed p-4 bottom-0"
:style="barStyle"
class="floating-action-bar drop-shadow-2xl"
:class="inline ? 'floating-action-bar--inline z-10' : 'fixed bottom-0 p-4'"
:style="inline ? undefined : barStyle"
aria-live="polite"
>
<div
ref="toolbarEl"
role="toolbar"
:aria-label="ariaLabel"
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto md:max-w-[60vw] px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
:class="{ 'bar-compact': compact }"
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
:class="[
{
'bar-compact': compact,
'floating-action-bar-attention': attentionRequested,
},
inline ? 'w-full' : 'mx-auto md:max-w-[60vw]',
]"
@animationend="attentionRequested = false"
>
<slot />
</div>
@@ -220,6 +243,31 @@ onUnmounted(() => {
transition: bottom 0.25s ease-in-out;
}
.floating-action-bar--inline {
left: auto;
right: auto;
}
.floating-action-bar-attention {
animation: floating-action-bar-attention 300ms ease-in-out;
}
@keyframes floating-action-bar-attention {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-0.4rem);
}
50% {
transform: translateX(0.4rem);
}
75% {
transform: translateX(-0.2rem);
}
}
.floating-action-bar-enter-active {
transition:
transform 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96),
@@ -243,14 +291,20 @@ onUnmounted(() => {
}
@media (any-hover: none) and (max-width: 640px) {
.floating-action-bar {
.floating-action-bar:not(.floating-action-bar--inline) {
bottom: var(--size-mobile-navbar-height);
}
.expanded-mobile-nav .floating-action-bar {
.expanded-mobile-nav .floating-action-bar:not(.floating-action-bar--inline) {
bottom: var(--size-mobile-navbar-height-expanded);
}
}
@media (prefers-reduced-motion: reduce) {
.floating-action-bar-attention {
animation: none;
}
}
</style>
<style>
@@ -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() {
@@ -1,7 +1,7 @@
<script setup lang="ts" generic="T">
import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { isEqual } from 'es-toolkit'
import { type Component, computed } from 'vue'
import { type Component, computed, ref } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils'
@@ -24,6 +24,7 @@ const props = withDefaults(
saveLabel?: MessageDescriptor | string
savingLabel?: MessageDescriptor | string
saveIcon?: Component
inline?: boolean
}>(),
{
canReset: true,
@@ -36,6 +37,7 @@ const props = withDefaults(
saveLabel: () => commonMessages.saveButton,
savingLabel: () => commonMessages.savingButton,
saveIcon: SaveIcon,
inline: false,
},
)
@@ -46,10 +48,18 @@ const shown = computed(() =>
function localizeIfPossible(message: MessageDescriptor | string) {
return typeof message === 'string' ? message : formatMessage(message)
}
const actionBar = ref<InstanceType<typeof FloatingActionBar> | null>(null)
function nudge(): void {
void actionBar.value?.nudge()
}
defineExpose({ nudge })
</script>
<template>
<FloatingActionBar :shown="shown">
<FloatingActionBar ref="actionBar" :shown="shown" :inline="inline">
<p class="m-0 font-semibold text-sm md:text-base">{{ localizeIfPossible(text) }}</p>
<div class="ml-auto flex gap-2">
<ButtonStyled v-if="canReset" type="transparent">
-184
View File
@@ -1,184 +0,0 @@
<template>
<div v-if="shown">
<div
:class="{ shown: actuallyShown }"
class="tauri-overlay"
data-tauri-drag-region
@click="() => (closable ? hide() : {})"
/>
<div
:class="{
shown: actuallyShown,
noblur: props.noblur,
}"
class="modal-overlay"
@click="() => (closable ? hide() : {})"
/>
<div class="modal-container" :class="{ shown: actuallyShown }">
<div class="modal-body">
<div v-if="props.header" class="header">
<h1>{{ props.header }}</h1>
<button v-if="closable" class="btn icon-only transparent" @click="hide">
<XIcon />
</button>
</div>
<div class="content">
<slot />
</div>
</div>
</div>
</div>
<div v-else></div>
</template>
<script setup>
import { XIcon } from '@modrinth/assets'
import { ref } from 'vue'
const props = defineProps({
header: {
type: String,
default: null,
},
noblur: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: true,
},
onHide: {
type: Function,
default() {
return () => {}
},
},
})
const shown = ref(false)
const actuallyShown = ref(false)
function show() {
shown.value = true
setTimeout(() => {
actuallyShown.value = true
}, 50)
}
function hide() {
props.onHide?.()
actuallyShown.value = false
setTimeout(() => {
shown.value = false
}, 300)
}
defineExpose({
show,
hide,
})
</script>
<style lang="scss" scoped>
.tauri-overlay {
position: fixed;
visibility: hidden;
top: 0;
left: 0;
width: 100%;
height: 100px;
z-index: 20;
&.shown {
opacity: 1;
visibility: visible;
}
}
.modal-overlay {
visibility: hidden;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 19;
transition: all 0.3s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
&.shown {
opacity: 1;
visibility: visible;
background: hsla(0, 0%, 0%, 0.5);
backdrop-filter: blur(3px);
}
&.noblur {
backdrop-filter: none;
}
}
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 21;
visibility: hidden;
pointer-events: none;
&.shown {
visibility: visible;
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
.modal-body {
position: fixed;
box-shadow: var(--shadow-raised), var(--shadow-inset);
border-radius: var(--radius-lg);
background-color: var(--color-raised-bg);
max-height: calc(100% - 2 * var(--gap-lg));
overflow-y: visible;
width: 600px;
pointer-events: auto;
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
padding: var(--gap-md) var(--gap-lg);
h1 {
font-weight: bold;
font-size: 1.25rem;
}
}
transform: translateY(50vh);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--gap-lg));
}
}
}
</style>
+34 -3
View File
@@ -5,7 +5,8 @@
:class="{ shown: visible }"
class="tauri-overlay"
data-tauri-drag-region
@click="() => (closeOnClickOutside && closable ? hide() : {})"
@pointerdown="onTauriOverlayPointerDown"
@click="onTauriOverlayClick"
/>
<div
:class="[
@@ -175,6 +176,7 @@ const props = withDefaults(
onHide?: () => void
onAfterHide?: () => void
onShow?: () => void
beforeHide?: () => boolean
mergeHeader?: boolean
scrollable?: boolean
maxContentHeight?: string
@@ -202,6 +204,7 @@ const props = withDefaults(
onHide: () => {},
onAfterHide: () => {},
onShow: () => {},
beforeHide: undefined,
mergeHeader: false,
// TODO: migrate all modals to use scrollable and remove this prop
scrollable: false,
@@ -216,6 +219,30 @@ const props = withDefaults(
const effectiveNoblur = computed(() => props.noblur ?? modalBehavior?.noblur.value ?? false)
const TAURI_DRAG_THRESHOLD_PX = 4
let tauriPointerScreen: { x: number; y: number } | null = null
function onTauriOverlayPointerDown(event: PointerEvent) {
if (event.button !== 0) {
return
}
tauriPointerScreen = { x: event.screenX, y: event.screenY }
}
function onTauriOverlayClick(event: MouseEvent) {
const start = tauriPointerScreen
tauriPointerScreen = null
if (
start &&
Math.hypot(event.screenX - start.x, event.screenY - start.y) >= TAURI_DRAG_THRESHOLD_PX
) {
return
}
if (props.closeOnClickOutside && props.closable && !props.disableClose) {
hide()
}
}
const computedFade = computed(() => {
if (props.fade) return props.fade
if (props.danger) return 'danger'
@@ -279,9 +306,12 @@ function show(event?: MouseEvent) {
}, 50)
}
function hide() {
function hide(): boolean {
if (props.disableClose) {
return
return false
}
if (props.beforeHide?.() === false) {
return false
}
props.onHide?.()
resetMousePosition()
@@ -302,6 +332,7 @@ function hide() {
hideTimeout = null
nextTick(() => props.onAfterHide?.())
}, 300)
return true
}
async function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
@@ -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
@@ -27,6 +28,9 @@ const props = withDefaults(
closable?: boolean
onHide?: () => void
onShow?: () => void
beforeHide?: () => boolean
beforeTabChange?: (fromIndex: number, toIndex: number) => boolean
floatingActionBarShown?: boolean
}>(),
{
header: undefined,
@@ -35,6 +39,9 @@ const props = withDefaults(
closable: true,
onHide: undefined,
onShow: undefined,
beforeHide: undefined,
beforeTabChange: undefined,
floatingActionBarShown: false,
},
)
@@ -46,9 +53,18 @@ const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
useScrollIndicator(scrollContainer)
const sidebarScrollContainer = ref<HTMLElement | null>(null)
const {
showTopFade: showSidebarTopFade,
showBottomFade: showSidebarBottomFade,
checkScrollState: checkSidebarScrollState,
} = useScrollIndicator(sidebarScrollContainer)
const modal = ref<InstanceType<typeof NewModal> | null>(null)
function setTab(index: number) {
if (index === selectedTab.value) return
if (props.beforeTabChange?.(selectedTab.value, index) === false) return
selectedTab.value = index
nextTick(() => forceCheck())
}
@@ -57,8 +73,13 @@ function show(event?: MouseEvent) {
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
function hide(): boolean {
return modal.value?.hide() ?? false
}
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 })
@@ -72,6 +93,7 @@ defineExpose({ show, hide, selectedTab, setTab })
:closable="closable"
:on-hide="onHide"
:on-show="onShow"
:before-hide="beforeHide"
no-padding
>
<template v-if="$slots.title" #title>
@@ -79,32 +101,74 @@ defineExpose({ show, hide, selectedTab, setTab })
</template>
<div class="grid grid-cols-[auto_1fr] p-6 pb-3 pr-0">
<div
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
class="flex min-w-[200px] max-h-[min(65vh,600px)] flex-col border-0 border-r-[1px] border-solid border-divider pr-4"
>
<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"
<div class="relative min-h-0 flex-1">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-4"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-4"
leave-to-class="opacity-0 max-h-0"
>
{{ formatMessage(tab.badge) }}
</span>
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
</component>
<div
v-if="showSidebarTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-4 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="sidebarScrollContainer"
class="flex h-full flex-col gap-1 overflow-y-auto"
@scroll="checkSidebarScrollState"
>
<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.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>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-16"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-16"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showSidebarBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-16 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<slot name="footer" />
</div>
<div class="relative">
<div class="relative min-h-[min(65vh,600px)]">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
@@ -121,7 +185,8 @@ defineExpose({ show, hide, selectedTab, setTab })
<div
ref="scrollContainer"
class="overflow-y-auto px-6 pb-6 h-screen max-h-[min(65vh,600px)]"
class="absolute inset-0 overflow-y-auto px-6"
:class="floatingActionBarShown ? 'pb-24' : 'pb-6'"
@scroll="checkScrollState"
>
<Suspense>
@@ -145,6 +210,12 @@ defineExpose({ show, hide, selectedTab, setTab })
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-16 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
<div class="pointer-events-none absolute bottom-3 left-6 right-6 z-20">
<div class="pointer-events-auto">
<slot name="floating-action-bar" />
</div>
</div>
</div>
</div>
</NewModal>
@@ -1,6 +1,5 @@
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as Modal } from './Modal.vue'
export { default as NewModal } from './NewModal.vue'
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
export { default as OpenInAppModal } from './OpenInAppModal.vue'
@@ -52,6 +52,7 @@
:progress-current="progressItem.progressCurrent"
:progress-total="progressItem.progressTotal"
:actions="progressItem.buttons"
:dismissible="progressItem.dismissible"
@dismiss="handleProgressItemDismiss(item, progressItem)"
@action="(index) => handleProgressItemAction(progressItem, index)"
/>
@@ -49,7 +49,7 @@
</template>
</template>
</p>
<ButtonStyled size="small" type="transparent" circular>
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
@@ -96,7 +96,7 @@
{{ entityLabel }}
</p>
<div class="col-start-2 row-start-1 justify-self-end">
<ButtonStyled size="small" type="transparent" circular>
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
@@ -213,6 +213,7 @@ const props = withDefaults(
progressCurrent?: number
progressTotal?: number
actions?: PopupNotificationButton[]
dismissible?: boolean
}>(),
{
actionLoading: null,
@@ -224,6 +225,7 @@ const props = withDefaults(
showProgress: true,
wrapText: false,
progressType: 'percentage',
dismissible: true,
},
)
@@ -25,7 +25,7 @@
:key="`member-${member.id}`"
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
:to="userLink(member.user.username)"
:target="linkTarget ?? null"
:target="resolveLinkTarget(userLinkTarget)"
>
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col">
@@ -36,7 +36,7 @@
v-tooltip="formatMessage(messages.owner)"
class="text-brand-orange"
/>
<ExternalIcon v-if="linkTarget === '_blank'" />
<ExternalIcon v-if="resolveLinkTarget(userLinkTarget) === '_blank'" />
</span>
<span class="text-sm font-normal text-secondary">{{ member.role }}</span>
</div>
@@ -79,8 +79,13 @@ const props = defineProps<{
orgLink: (slug: string) => string
userLink: (username: string) => string
linkTarget?: string
userLinkTarget?: string | null
}>()
function resolveLinkTarget(target: string | null | undefined): string | null {
return target === undefined ? (props.linkTarget ?? null) : target
}
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
// The rest of the members should be sorted by role, then by name
const sortedMembers = computed(() => {
@@ -19,7 +19,7 @@
:option="option"
:included="included(option)"
:excluded="excluded(option)"
:supports-negative-filter="supportsNegativeFilter"
:supports="supports"
@toggle="(o) => emit('toggle', o)"
@toggle-exclude="(o) => emit('toggleExclude', o)"
>
@@ -44,13 +44,13 @@
import { DropdownIcon } from '@modrinth/assets'
import { ref } from 'vue'
import type { FilterOption } from '../../utils/search'
import type { FilterMode, FilterOption } from '../../utils/search'
import SearchFilterOption from './SearchFilterOption.vue'
defineProps<{
groupName: string
options: FilterOption[]
supportsNegativeFilter: boolean
supports: FilterMode[]
included: (option: FilterOption) => boolean
excluded: (option: FilterOption) => boolean
}>()
@@ -1,13 +1,13 @@
<template>
<div class="search-filter-option group flex gap-1 items-center">
<button
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`"
@click="() => emit('toggle', option)"
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand [&>svg.ban-icon]:hover:text-red [&>svg.ban-icon]:focus-visible:text-red'}`"
@click="() => emit(primaryAction === 'exclude' ? 'toggleExclude' : 'toggle', option)"
>
<slot> </slot>
<BanIcon
v-if="excluded"
:class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
v-if="excluded || primaryAction === 'exclude'"
:class="`filter-action-icon ban-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
aria-hidden="true"
/>
<CheckIcon
@@ -17,12 +17,12 @@
/>
</button>
<div
v-if="supportsNegativeFilter && !excluded"
v-if="showExcludeButton"
class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents"
:class="{ 'opacity-0': included }"
></div>
<button
v-if="supportsNegativeFilter && !excluded"
v-if="showExcludeButton"
v-tooltip="formatMessage(messages.excludeTooltip)"
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]"
@click="() => emit('toggleExclude', option)"
@@ -34,22 +34,30 @@
<script setup lang="ts">
import { BanIcon, CheckIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption } from '../../utils/search'
import type { FilterMode, FilterOption } from '../../utils/search'
withDefaults(
const props = withDefaults(
defineProps<{
option: FilterOption
included: boolean
excluded: boolean
supportsNegativeFilter?: boolean
supports?: FilterMode[]
}>(),
{
supportsNegativeFilter: false,
supports: () => ['include'],
},
)
const supportsInclude = computed(() => props.supports.includes('include'))
const supportsExclude = computed(() => props.supports.includes('exclude'))
const primaryAction = computed<FilterMode>(() => (supportsInclude.value ? 'include' : 'exclude'))
const showExcludeButton = computed(
() => supportsInclude.value && supportsExclude.value && !props.excluded,
)
const { formatMessage } = useVIntl()
const emit = defineEmits<{
@@ -71,105 +71,82 @@
</template>
<template v-else #default>
<slot name="prefix" />
<div
v-if="filterType.display === 'toggle'"
:class="innerPanelClass ? innerPanelClass : ''"
class="flex flex-col gap-3"
>
<label
v-for="option in filterType.options"
:key="`${filterType.id}-toggle-${option.id}`"
class="flex cursor-pointer items-center justify-between text-secondary gap-3 font-semibold"
>
<span class="text-sm">{{ option.formatted_name ?? option.id }}</span>
<Toggle
:model-value="isExcluded(option)"
small
class="shrink-0"
@update:model-value="toggleNegativeFilter(option)"
/>
</label>
</div>
<template v-if="filterType.display !== 'toggle'">
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports-negative-filter="filterType.supports_negative_filter"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports="filterType.supports"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports="filterType.supports"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{
showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore)
}}
</span>
</button>
</div>
</ScrollablePanel>
</template>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{ showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore) }}
</span>
</button>
</div>
</ScrollablePanel>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox
v-for="group in filterType.toggle_groups"
@@ -213,7 +190,6 @@ import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Toggle from '../base/Toggle.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'
@@ -76,7 +76,7 @@ const backupCreator = computed(() => {
const creatorProfileLink = computed(() =>
backupCreator.value && backupCreator.value.id !== 'support'
? `https://modrinth.com/user/${encodeURIComponent(backupCreator.value.username)}`
? `/user/${encodeURIComponent(backupCreator.value.username)}`
: undefined,
)
@@ -224,8 +224,6 @@ const creatorAvatarSrc = computed(() =>
<template v-else-if="backupCreator">
<AutoLink
:to="creatorProfileLink"
:target="creatorProfileLink ? '_blank' : undefined"
:rel="creatorProfileLink ? 'noopener noreferrer' : undefined"
class="group flex min-w-0 items-center gap-1.5"
:class="creatorProfileLink ? 'text-secondary hover:underline' : 'text-primary'"
>
@@ -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>
@@ -0,0 +1,315 @@
<template>
<PageHeader :title="user.username" :summary="summary">
<template #leading>
<Avatar
:src="user.avatar_url"
:alt="user.username"
:size="isModrinthUser ? '64px' : '96px'"
:tint-by="user.username"
circle
/>
</template>
<template v-if="isOfficialAccount || showAffiliateBadge" #badges>
<PageHeaderBadgeItem
v-if="isOfficialAccount"
:icon="BadgeCheckIcon"
:icon-props="{ fill: 'var(--color-brand-highlight)' }"
:tooltip="formatMessage(messages.officialAccount)"
class="border-brand-highlight bg-brand-highlight text-brand"
>
{{ formatMessage(messages.officialAccount) }}
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-if="showAffiliateBadge"
:icon="AffiliateIcon"
class="border-brand-highlight bg-brand-highlight text-brand"
>
{{ formatMessage(messages.affiliateLabel) }}
</PageHeaderBadgeItem>
</template>
<template v-if="$slots.summary" #summary>
<slot name="summary" />
</template>
<template v-if="!isModrinthUser" #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataNumberItem
:icon="BoxIcon"
:value="projectsCount"
:label="formatMessage(messages.profileProjectCountLabel, { count: projectsCount })"
/>
<PageHeaderMetadataNumberItem
:icon="DownloadIcon"
:value="downloads"
:label="formatMessage(messages.profileDownloadCountLabel, { count: downloads })"
:tooltip="downloadsTooltip"
/>
<PageHeaderMetadataTimeItem
:icon="CalendarIcon"
:date="user.created"
:label="formatMessage(messages.profileJoinedLabel)"
:tooltip="joinedTooltip"
/>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled v-if="isSelf" size="large">
<AutoLink :to="editProfileLink">
<EditIcon />
{{ formatMessage(commonMessages.editButton) }}
</AutoLink>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="moreActions"
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AffiliateIcon,
BadgeCheckIcon,
BanIcon,
BoxIcon,
CalendarIcon,
ChartIcon,
ClipboardCopyIcon,
CurrencyIcon,
DownloadIcon,
EditIcon,
InfoIcon,
MoreVerticalIcon,
ReportIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import PageHeader from '#ui/components/base/page-header/index.vue'
import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.vue'
import PageHeaderMetadataNumberItem from '#ui/components/base/page-header/metadata/page-header-metadata-number-item.vue'
import PageHeaderMetadataTimeItem from '#ui/components/base/page-header/metadata/page-header-metadata-time-item.vue'
import PageHeaderActions from '#ui/components/base/page-header/page-header-actions.vue'
import PageHeaderBadgeItem from '#ui/components/base/page-header/page-header-badge-item.vue'
import type { Item as TeleportOverflowMenuItem } from '#ui/components/base/TeleportOverflowMenu.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { defineMessages, useFormatDateTime, useFormatNumber, useVIntl } from '#ui/composables'
import type { AuthUser } from '#ui/providers/auth'
import { commonMessages } from '#ui/utils'
const messages = defineMessages({
affiliateLabel: {
id: 'profile.label.affiliate',
defaultMessage: 'Affiliate',
},
analyticsButton: {
id: 'profile.button.analytics',
defaultMessage: 'View user analytics',
},
billingButton: {
id: 'profile.button.billing',
defaultMessage: 'Manage user billing',
},
blockButton: {
id: 'profile.button.block',
defaultMessage: 'Block',
},
unblockButton: {
id: 'profile.button.unblock',
defaultMessage: 'Unblock',
},
editRoleButton: {
id: 'profile.button.edit-role',
defaultMessage: 'Edit role',
},
infoButton: {
id: 'profile.button.info',
defaultMessage: 'View user details',
},
officialAccount: {
id: 'profile.official-account',
defaultMessage: 'Official Modrinth account',
},
profileJoinedLabel: {
id: 'profile.label.joined',
defaultMessage: 'Joined',
},
profileProjectCountLabel: {
id: 'profile.label.project-count',
defaultMessage: '{count, plural, one {project} other {projects}}',
},
profileDownloadCountLabel: {
id: 'profile.label.download-count',
defaultMessage: '{count, plural, one {download} other {downloads}}',
},
profileManageProjectsButton: {
id: 'profile.button.manage-projects',
defaultMessage: 'Manage projects',
},
removeAffiliateButton: {
id: 'profile.button.remove-affiliate',
defaultMessage: 'Remove as affiliate',
},
setAffiliateButton: {
id: 'profile.button.set-affiliate',
defaultMessage: 'Set as affiliate',
},
})
const props = withDefaults(
defineProps<{
user: Labrinth.Users.v3.User
summary?: string | null
authUser?: AuthUser | null
editProfileLink?: string | (() => void)
isModrinthUser?: boolean
isOfficialAccount?: boolean
showAffiliateBadge?: boolean
isAffiliate?: boolean
isSelf?: boolean
isAdmin?: boolean
isStaff?: boolean
showStaffActions?: boolean
isBlocked?: boolean
projectsCount?: number
downloads?: number
}>(),
{
summary: null,
authUser: null,
editProfileLink: '/settings/profile',
isModrinthUser: false,
isOfficialAccount: false,
showAffiliateBadge: false,
isAffiliate: false,
isSelf: false,
isAdmin: false,
isStaff: false,
showStaffActions: false,
isBlocked: false,
projectsCount: 0,
downloads: 0,
},
)
const emit = defineEmits<{
manageProjects: []
report: []
block: []
copyId: []
copyPermalink: []
openBilling: []
toggleAffiliate: []
openInfo: []
openAnalytics: []
editRole: []
}>()
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const downloadsTooltip = computed(() => formatNumber(props.downloads))
const joinedTooltip = computed(() => formatDateTime(props.user.created))
const moreActions = computed<TeleportOverflowMenuItem[]>(() => [
{
id: 'manage-projects',
label: formatMessage(messages.profileManageProjectsButton),
icon: BoxIcon,
action: () => emit('manageProjects'),
shown: props.isSelf,
},
{
divider: true,
shown: props.isSelf,
},
{
id: 'report',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
action: () => emit('report'),
color: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
id: 'block',
label: formatMessage(props.isBlocked ? messages.unblockButton : messages.blockButton),
icon: BanIcon,
action: () => emit('block'),
color: 'red',
shown: props.authUser?.id !== props.user.id,
},
{
id: 'copy-id',
label: formatMessage(commonMessages.copyIdButton),
icon: ClipboardCopyIcon,
action: () => emit('copyId'),
},
{
id: 'copy-permalink',
label: formatMessage(commonMessages.copyPermalinkButton),
icon: ClipboardCopyIcon,
action: () => emit('copyPermalink'),
},
{
divider: true,
shown: props.showStaffActions && (props.isAdmin || props.isStaff),
},
{
id: 'open-billing',
label: formatMessage(messages.billingButton),
icon: CurrencyIcon,
action: () => emit('openBilling'),
shown: props.showStaffActions && props.isStaff,
},
{
id: 'toggle-affiliate',
label: props.isAffiliate
? formatMessage(messages.removeAffiliateButton)
: formatMessage(messages.setAffiliateButton),
icon: AffiliateIcon,
action: () => emit('toggleAffiliate'),
shown: props.showStaffActions && props.isAdmin,
remainOnClick: true,
color: props.isAffiliate ? 'red' : 'orange',
},
{
id: 'open-info',
label: formatMessage(messages.infoButton),
icon: InfoIcon,
action: () => emit('openInfo'),
shown: props.showStaffActions && props.isStaff,
},
{
id: 'open-analytics',
label: formatMessage(messages.analyticsButton),
icon: ChartIcon,
action: () => emit('openAnalytics'),
shown: props.showStaffActions && props.isAdmin,
},
{
id: 'edit-role',
label: formatMessage(messages.editRoleButton),
icon: EditIcon,
action: () => emit('editRole'),
shown: props.showStaffActions && props.isAdmin,
},
])
</script>
+1
View File
@@ -1 +1,2 @@
export { default as UserBadges } from './UserBadges.vue'
export { default as UserPageHeader } from './UserPageHeader.vue'
+1
View File
@@ -4,4 +4,5 @@ export * from './shared/content-tab'
export * from './shared/files-tab'
export * from './shared/installation-settings'
export * from './shared/server-settings'
export * from './shared/user-profile'
export * from './wrapped'
@@ -194,16 +194,17 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
}
const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? [])
const effectiveCurrentFilters = computed(() =>
isServerType.value ? serverCurrentFilters.value : currentFilters.value,
)
watch(
[
query,
maxResults,
options.projectType,
currentSortType,
serverCurrentSortType,
currentFilters,
serverCurrentFilters,
effectiveCurrentSortType,
effectiveCurrentFilters,
overriddenProvidedFilterTypes,
providedFiltersOrEmpty,
],
@@ -107,7 +107,11 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
</template>
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value && ctx.variant !== 'web'" />
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<NavTabs
v-if="ctx.showProjectTypeTabs.value"
:links="ctx.selectableProjectTypes.value"
:replace="ctx.variant === 'app'"
/>
<StyledInput
v-model="ctx.query.value"
@@ -280,9 +284,7 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
name: result.organization == null ? result.author : result.organization,
link:
result.organization_id == null
? ctx.variant === 'web'
? `/user/${result.author_id ?? result.author}`
: `https://modrinth.com/user/${result.author_id ?? result.author}`
? `/user/${encodeURIComponent(result.author_id ?? result.author)}`
: ctx.variant === 'web'
? `/organization/${result.organization_id}`
: `https://modrinth.com/organization/${result.organization_id}`,
@@ -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"
@@ -21,7 +21,7 @@ import OverflowMenu, {
import TagTagItem from '#ui/components/base/TagTagItem.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { useRelativeTime } from '#ui/composables/how-ago'
import { useVIntl } from '#ui/composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import type {
@@ -33,6 +33,13 @@ import type {
const { formatMessage } = useVIntl()
const messages = defineMessages({
installationSettingsTooltip: {
id: 'content.modpack-card.installation-settings',
defaultMessage: 'Installation settings',
},
})
interface Props {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
@@ -117,7 +124,7 @@ onUnmounted(() => {
<template>
<div
ref="containerRef"
class="@container flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md"
class="@container flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md border border-solid border-surface-4"
:class="{ 'opacity-50': disabled }"
>
<div class="flex flex-wrap items-start justify-between gap-4">
@@ -218,7 +225,10 @@ onUnmounted(() => {
</ButtonStyled>
<ButtonStyled v-if="hasSettingsListener" type="outlined" circular>
<button @click="emit('settings')">
<button
v-tooltip="formatMessage(messages.installationSettingsTooltip)"
@click="emit('settings')"
>
<Settings2Icon />
</button>
</ButtonStyled>
@@ -244,7 +254,7 @@ onUnmounted(() => {
</template>
<template #settings>
<Settings2Icon class="size-5" />
{{ formatMessage(commonMessages.settingsLabel) }}
{{ formatMessage(messages.installationSettingsTooltip) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
@@ -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
@@ -260,7 +262,16 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
owner: item.owner
? {
...item.owner,
link: `https://modrinth.com/${item.owner.type}/${item.owner.id}`,
link:
item.owner.type === 'user'
? `/user/${encodeURIComponent(item.owner.id)}`
: `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 } : {}),
@@ -290,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> = {}
@@ -332,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
@@ -0,0 +1,2 @@
export { default as UserProfilePageLayout } from './layout.vue'
export * from './providers'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
export * from './user-profile'
@@ -0,0 +1,25 @@
import type { Labrinth } from '@modrinth/api-client'
import { createContext } from '#ui/providers/create-context'
export interface UserProfileContext {
getUser: (userId: string) => Promise<Labrinth.Users.v3.User>
getProjects: (userId: string) => Promise<Labrinth.Projects.v2.Project[]>
getOrganizations: (userId: string) => Promise<Labrinth.Organizations.v3.Organization[]>
getCollections: (userId: string) => Promise<Labrinth.Collections.Collection[]>
patchUser: (
userId: string,
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'role'>>,
) => Promise<void>
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
blockUser: (userId: string) => Promise<void>
unblockUser: (userId: string) => Promise<void>
}
export const blockedUsersQueryKey = (userId?: string | null) =>
['blocked-users', userId ?? null] as const
export const [injectUserProfile, provideUserProfile] = createContext<UserProfileContext>(
'UserProfilePageLayout',
'userProfileContext',
)
@@ -0,0 +1,87 @@
import type { Labrinth } from '@modrinth/api-client'
type ProjectSorting = 'publish_time' | 'queue_time' | 'downloads'
type ProjectStatusPriority = { order: number; sort: ProjectSorting }
const projectStatusPriority: Record<Labrinth.Projects.v2.ProjectStatus, ProjectStatusPriority> = {
approved: { order: 1, sort: 'downloads' },
scheduled: { order: 1, sort: 'downloads' },
archived: { order: 2, sort: 'downloads' },
unlisted: { order: 3, sort: 'downloads' },
private: { order: 4, sort: 'downloads' },
processing: { order: 5, sort: 'queue_time' },
withheld: { order: 6, sort: 'publish_time' },
rejected: { order: 7, sort: 'publish_time' },
draft: { order: 8, sort: 'publish_time' },
unknown: { order: 9, sort: 'publish_time' },
}
function getProjectSortValue(
project: Labrinth.Projects.v2.Project,
sorting: ProjectSorting,
): number {
switch (sorting) {
case 'publish_time':
return new Date(project.published).getTime()
case 'queue_time':
return new Date(project.queued || project.published).getTime()
case 'downloads':
return project.downloads
}
}
export function projectUserSorting(
first: Labrinth.Projects.v2.Project,
second: Labrinth.Projects.v2.Project,
): number {
const firstPriority = projectStatusPriority[first.status] ?? projectStatusPriority.unknown
const secondPriority = projectStatusPriority[second.status] ?? projectStatusPriority.unknown
if (firstPriority.order !== secondPriority.order) {
return firstPriority.order - secondPriority.order
}
if (firstPriority.sort !== secondPriority.sort) {
return 0
}
return (
getProjectSortValue(second, secondPriority.sort) -
getProjectSortValue(first, firstPriority.sort)
)
}
export function resolveProjectType(
project: Labrinth.Projects.v2.Project,
loaders: Labrinth.Tags.v2.Loader[],
): string {
if (project.project_type !== 'mod') {
return project.project_type
}
const projectLoaders = new Set(project.loaders)
const supportsType = (type: string) =>
loaders.some(
(loader) => projectLoaders.has(loader.name) && loader.supported_project_types.includes(type),
)
if (supportsType('datapack')) return 'datapack'
if (supportsType('plugin')) return 'plugin'
return 'mod'
}
const PRIDE_26_MIDAS_DURATION_MS = 30 * 24 * 60 * 60 * 1000
export function hasPride26Badge(user?: Labrinth.Users.v3.User | null): boolean {
return user?.campaigns?.pride_26?.has_badge === true
}
export function hasActivePride26Midas(
user?: Labrinth.Users.v3.User | null,
now = Date.now(),
): boolean {
const campaign = user?.campaigns?.pride_26
if (!campaign?.has_midas) return false
const donatedAt = Date.parse(campaign.last_donated_at)
return Number.isFinite(donatedAt) && donatedAt + PRIDE_26_MIDAS_DURATION_MS > now
}
@@ -0,0 +1,364 @@
<template>
<EmptyState
v-if="!auth.user.value"
type="empty"
class="[&>div:last-child]:!mt-6"
:heading="formatMessage(messages.signInRequiredTitle)"
:description="formatMessage(messages.signInRequiredDescription)"
>
<template #illustration>
<div class="relative mb-4 h-[200px]">
<img :src="ThinkingRinthbot" alt="" class="h-full w-auto object-contain" />
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-bg-raised to-transparent"
/>
</div>
</template>
<template #actions>
<ButtonStyled color="brand" size="large">
<button type="button" @click="requestSignIn">
<LogInIcon aria-hidden="true" />
{{ formatMessage(commonMessages.signInButton) }}
</button>
</ButtonStyled>
</template>
</EmptyState>
<div v-else class="flex flex-col gap-4">
<p class="m-0 text-secondary" :class="{ 'order-last': disclaimerPosition === 'bottom' }">
<IntlFormatted :message-id="messages.description">
<template #profile-link="{ children }">
<RouterLink v-slot="{ href, navigate }" :to="profilePath" custom>
<a :href="href" class="text-link" @click="handleProfileLinkClick($event, navigate)">
<component :is="() => children" />
</a>
</RouterLink>
</template>
<template #docs-link="{ children }">
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<hr
v-if="disclaimerPosition === 'top'"
class="m-0 h-px w-full border-none bg-divider"
aria-hidden="true"
/>
<section class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.profilePicture) }}
</h2>
<div class="flex items-center gap-4">
<Avatar :src="displayedAvatarUrl" size="md" circle :alt="auth.user.value.username" />
<div class="flex flex-col gap-2">
<ButtonStyled>
<FileInput
:max-size="262144"
:show-icon="true"
class="button-like !shadow-none"
:prompt="formatMessage(commonMessages.uploadImageButton)"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
</ButtonStyled>
<ButtonStyled v-if="avatarUrl && !pendingAvatarDeletion">
<button type="button" class="!shadow-none" @click="removePreviewImage">
<TrashIcon aria-hidden="true" />
{{ formatMessage(commonMessages.removeImageButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="avatarFile || pendingAvatarDeletion">
<button type="button" class="!shadow-none" @click="resetAvatar">
<UndoIcon aria-hidden="true" />
{{ formatMessage(commonMessages.resetButton) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.usernameLabel) }}
</h2>
<div class="flex items-center gap-2">
<StyledInput
id="username-field"
v-model="current.username"
class="w-full max-w-md"
:error="current.username.length > 39"
/>
<span
v-if="current.username.length >= 30"
class="shrink-0 text-secondary"
:class="{ 'text-red': current.username.length > 39 }"
>
{{ current.username.length }}/39
</span>
</div>
<p class="m-0 text-secondary">
{{ formatMessage(messages.usernameDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.bioTitle) }}
</h2>
<StyledInput
id="bio-field"
v-model="current.bio"
multiline
:error="current.bio.length > 160"
/>
<div class="text-secondary" :class="{ 'text-red': current.bio.length > 160 }">
{{ current.bio.length }}/160
</div>
<p class="m-0 text-secondary">
{{ formatMessage(messages.bioDescription) }}
</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { LogInIcon, ThinkingRinthbot, TrashIcon, UndoIcon, UploadIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import FileInput from '#ui/components/base/FileInput.vue'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { defineMessages, useVIntl } from '#ui/composables'
import { type AuthUser, injectAuth, injectNotificationManager } from '#ui/providers'
import { commonMessages } from '#ui/utils'
type ProfileFields = {
username: string
bio: string
}
const props = withDefaults(
defineProps<{
patchUser: (userId: string, patch: Partial<ProfileFields>) => Promise<void>
changeAvatar: (userId: string, file: Blob, extension: string) => Promise<void>
deleteAvatar: (userId: string) => Promise<void>
getAuthenticatedUser: () => Promise<AuthUser>
disclaimerPosition?: 'top' | 'bottom'
}>(),
{
disclaimerPosition: 'top',
},
)
const emit = defineEmits<{
profileLinkClick: [event: MouseEvent]
}>()
const auth = injectAuth()
const notificationManager = injectNotificationManager()
const { formatMessage } = useVIntl()
const activeUserId = ref<string | null>(null)
const original = ref<ProfileFields>({ username: '', bio: '' })
const current = ref<ProfileFields>({ username: '', bio: '' })
const avatarUrl = ref<string | null>(null)
const avatarFile = shallowRef<File | null>(null)
const previewImageUrl = ref<string | null>(null)
const pendingAvatarDeletion = ref(false)
const saving = ref(false)
const displayedAvatarUrl = computed(() => {
if (previewImageUrl.value) return previewImageUrl.value
if (pendingAvatarDeletion.value) return null
return avatarUrl.value
})
const profilePath = computed(
() => `/user/${encodeURIComponent(auth.user.value?.username ?? current.value.username)}`,
)
const originalState = computed(() => ({
...original.value,
avatarChanged: false,
}))
const modifiedState = computed(() => ({
...current.value,
avatarChanged: Boolean(avatarFile.value || pendingAvatarDeletion.value),
}))
const hasChanges = computed(
() =>
current.value.username !== original.value.username ||
current.value.bio !== original.value.bio ||
Boolean(avatarFile.value || pendingAvatarDeletion.value),
)
watch(
() => auth.user.value,
(user) => {
if (!user || user.id !== activeUserId.value) {
syncFromUser(user)
}
},
{ immediate: true },
)
function syncFromUser(user: AuthUser | null): void {
revokePreviewImage()
activeUserId.value = user?.id ?? null
original.value = {
username: user?.username ?? '',
bio: user?.bio ?? '',
}
current.value = { ...original.value }
avatarUrl.value = user?.avatar_url ?? null
avatarFile.value = null
pendingAvatarDeletion.value = false
}
function revokePreviewImage(): void {
if (previewImageUrl.value) {
URL.revokeObjectURL(previewImageUrl.value)
previewImageUrl.value = null
}
}
function showPreviewImage(files: File[]): void {
const file = files[0]
if (!file) return
revokePreviewImage()
avatarFile.value = file
previewImageUrl.value = URL.createObjectURL(file)
pendingAvatarDeletion.value = false
}
function removePreviewImage(): void {
revokePreviewImage()
avatarFile.value = null
pendingAvatarDeletion.value = true
}
function resetAvatar(): void {
revokePreviewImage()
avatarFile.value = null
pendingAvatarDeletion.value = false
}
function reset(): void {
current.value = { ...original.value }
resetAvatar()
}
async function requestSignIn(): Promise<void> {
await auth.requestSignIn('')
}
function handleProfileLinkClick(
event: MouseEvent,
navigate: (event?: MouseEvent) => unknown,
): void {
emit('profileLinkClick', event)
if (!event.defaultPrevented) {
navigate(event)
}
}
async function save(): Promise<void> {
const user = auth.user.value
if (!user || saving.value) return
saving.value = true
try {
const patch: Partial<ProfileFields> = {}
if (current.value.username !== original.value.username) {
patch.username = current.value.username
}
if (current.value.bio !== original.value.bio) {
patch.bio = current.value.bio
}
if (Object.keys(patch).length > 0) {
await props.patchUser(user.id, patch)
}
if (pendingAvatarDeletion.value) {
await props.deleteAvatar(user.id)
} else if (avatarFile.value) {
const extension = avatarFile.value.type.split('/').at(-1)
if (!extension) throw new Error('The selected image does not have a valid file type.')
await props.changeAvatar(user.id, avatarFile.value, extension)
}
const refreshedUser = await props.getAuthenticatedUser()
auth.user.value = refreshedUser
syncFromUser(refreshedUser)
} catch {
notificationManager.addNotification({
type: 'error',
title: formatMessage(messages.saveError),
text: formatMessage(messages.saveErrorDescription),
})
} finally {
saving.value = false
}
}
onBeforeUnmount(revokePreviewImage)
defineExpose({
originalState,
modifiedState,
saving,
hasChanges,
reset,
save,
})
const messages = defineMessages({
description: {
id: 'settings.profile.public-information.description',
defaultMessage:
'Your profile information is publicly <profile-link>viewable on Modrinth</profile-link> and through the <docs-link>Modrinth API</docs-link>.',
},
profilePicture: {
id: 'settings.profile.profile-picture.title',
defaultMessage: 'Profile picture',
},
usernameDescription: {
id: 'settings.profile.username.description',
defaultMessage: 'A unique case-insensitive name to identify your profile.',
},
bioTitle: {
id: 'settings.profile.bio.title',
defaultMessage: 'Bio',
},
bioDescription: {
id: 'settings.profile.bio.description',
defaultMessage: 'A short description to tell everyone a little bit about you.',
},
signInRequiredTitle: {
id: 'settings.profile.sign-in-required.title',
defaultMessage: 'Modrinth account required',
},
signInRequiredDescription: {
id: 'settings.profile.sign-in-required.description',
defaultMessage: 'Sign in with a Modrinth account to customize your public profile.',
},
saveError: {
id: 'settings.profile.save-error',
defaultMessage: 'Failed to update profile',
},
saveErrorDescription: {
id: 'settings.profile.save-error-description',
defaultMessage: 'An error occurred while updating your profile. Please try again.',
},
})
</script>
@@ -0,0 +1,447 @@
<template>
<EmptyState
v-if="!auth.user.value"
type="empty"
class="[&>div:last-child]:!mt-6"
:heading="formatMessage(messages.signInRequiredTitle)"
:description="formatMessage(messages.signInRequiredDescription)"
>
<template #illustration>
<div class="relative mb-4 h-[200px]">
<img :src="ThinkingRinthbot" alt="" class="h-full w-auto object-contain" />
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-bg-raised to-transparent"
/>
</div>
</template>
<template #actions>
<ButtonStyled color="brand" size="large">
<button type="button" @click="requestSignIn">
<LogInIcon aria-hidden="true" />
{{ formatMessage(commonMessages.signInButton) }}
</button>
</ButtonStyled>
</template>
</EmptyState>
<div v-else class="flex flex-col gap-8">
<section class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.friendRequestsTitle) }}
</h2>
<Chips
v-model="friendRequestSource"
:items="friendRequestSourceOptions"
:format-label="formatInteractionSource"
:disabled-items="friendRequestSourceOptions"
:disabled-tooltip="formatMessage(messages.comingSoon)"
:capitalize="false"
:aria-label="formatMessage(messages.friendRequestsTitle)"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.friendRequestsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.sharedInstanceInvitesTitle) }}
</h2>
<Chips
v-model="sharedInstanceInviteSource"
:items="sharedInstanceInviteSourceOptions"
:format-label="formatInteractionSource"
:disabled-items="sharedInstanceInviteSourceOptions"
:disabled-tooltip="formatMessage(messages.comingSoon)"
:capitalize="false"
:aria-label="formatMessage(messages.sharedInstanceInvitesTitle)"
/>
<p class="m-0 text-secondary">
{{ formatMessage(messages.sharedInstanceInvitesDescription) }}
</p>
</div>
</section>
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.blockedUsersTitle) }}
</h2>
<p class="m-0 text-secondary">
{{ formatMessage(messages.blockedUsersDescription) }}
</p>
<ul class="m-0 flex list-disc flex-col gap-1 pl-5 text-secondary">
<li>{{ formatMessage(messages.friendRequestsRestriction) }}</li>
<li>{{ formatMessage(messages.sharedInstancesRestriction) }}</li>
<li>{{ formatMessage(messages.hostingRestriction) }}</li>
</ul>
</div>
<div class="relative overflow-hidden rounded-2xl border border-solid border-surface-4">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-3"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-3"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-3 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="blockedUsersTable"
class="max-h-[20.5rem] overflow-y-auto"
@scroll="checkScrollState"
>
<Table
class="!rounded-none !border-0"
:columns="columns"
:data="blockedUsers"
row-key="id"
>
<template #empty-state>
<div class="flex h-40 items-center justify-center px-4 text-center text-secondary">
<div v-if="isLoading" class="flex items-center gap-2">
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
{{ formatMessage(messages.loadingBlockedUsers) }}
</div>
<div v-else-if="loadError" class="flex flex-col items-center gap-3">
<span>{{ formatMessage(messages.loadError) }}</span>
<ButtonStyled type="outlined">
<button type="button" @click="retry">
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
</div>
<span v-else>{{ formatMessage(messages.noBlockedUsers) }}</span>
</div>
</template>
<template #cell-user="{ row }">
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="row.avatar_url"
:alt="formatMessage(messages.userAvatarAlt, { username: row.username })"
:tint-by="row.username"
size="32px"
circle
no-shadow
/>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-contrast">
{{ row.name ?? row.username }}
</span>
<span v-if="row.name" class="truncate text-sm text-secondary">
{{ row.username }}
</span>
</div>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button
type="button"
:disabled="unblockingUserId !== null"
:aria-label="
formatMessage(messages.unblockUserAriaLabel, {
username: row.username,
})
"
@click="unblock(row)"
>
<SpinnerIcon
v-if="unblockingUserId === row.id"
class="animate-spin"
aria-hidden="true"
/>
{{ formatMessage(messages.unblockButton) }}
</button>
</ButtonStyled>
</div>
</template>
</Table>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-3"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-3"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-3 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</section>
</div>
</template>
<script setup lang="ts">
// TODO this will be moved in with the rest of the xplat settings.
import type { Labrinth } from '@modrinth/api-client'
import { LogInIcon, SpinnerIcon, ThinkingRinthbot } from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Chips from '#ui/components/base/Chips.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import Table, { type TableColumn } from '#ui/components/base/Table.vue'
import { defineMessages, useScrollIndicator, useVIntl } from '#ui/composables'
import { injectAuth, injectNotificationManager } from '#ui/providers'
import { commonMessages } from '#ui/utils'
import { blockedUsersQueryKey } from '../shared/user-profile/providers'
type BlockedUserTableColumn = 'user' | 'actions'
type BlockedUser = Labrinth.Users.v2.User & Record<BlockedUserTableColumn, unknown>
type FriendRequestSource = 'everyone' | 'mutuals' | 'no-one'
type SharedInstanceInviteSource = 'everyone' | 'friends' | 'no-one'
const props = defineProps<{
getBlockedUsers: () => Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]>
getUsers: (userIds: string[]) => Promise<Labrinth.Users.v2.User[]>
unblockUser: (userId: string) => Promise<void>
}>()
const auth = injectAuth()
const notificationManager = injectNotificationManager()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const blockedUsersTable = ref<HTMLElement | null>(null)
const unblockingUserId = ref<string | null>(null)
const friendRequestSource = ref<FriendRequestSource>('everyone')
const sharedInstanceInviteSource = ref<SharedInstanceInviteSource>('everyone')
const friendRequestSourceOptions: FriendRequestSource[] = ['everyone', 'mutuals', 'no-one']
const sharedInstanceInviteSourceOptions: SharedInstanceInviteSource[] = [
'everyone',
'friends',
'no-one',
]
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(blockedUsersTable)
function formatInteractionSource(source: FriendRequestSource | SharedInstanceInviteSource): string {
switch (source) {
case 'everyone':
return formatMessage(messages.everyone)
case 'mutuals':
return formatMessage(messages.friendsOfFriends)
case 'friends':
return formatMessage(messages.friends)
case 'no-one':
return formatMessage(messages.noOne)
}
}
const columns = computed<TableColumn<BlockedUserTableColumn>[]>(() => [
{
key: 'user',
label: formatMessage(messages.userColumn),
},
{
key: 'actions',
label: formatMessage(messages.actionsColumn),
align: 'right',
width: '8rem',
},
])
const blockedUserIdsQuery = useQuery({
queryKey: computed(() => blockedUsersQueryKey(auth.user.value?.id)),
queryFn: props.getBlockedUsers,
enabled: computed(() => Boolean(auth.user.value)),
staleTime: 30_000,
})
const blockedUserIds = computed(() => blockedUserIdsQuery.data.value ?? [])
const blockedUserProfilesQueryKey = computed(
() => ['blocked-user-profiles', auth.user.value?.id ?? null, blockedUserIds.value] as const,
)
const blockedUserProfilesQuery = useQuery({
queryKey: blockedUserProfilesQueryKey,
queryFn: () => props.getUsers(blockedUserIds.value),
enabled: computed(() => Boolean(auth.user.value && blockedUserIds.value.length)),
staleTime: 30_000,
})
const blockedUsers = computed<BlockedUser[]>(() => {
const profilesById = new Map(
(blockedUserProfilesQuery.data.value ?? []).map((user) => [user.id, user]),
)
return blockedUserIds.value
.map((userId) => profilesById.get(userId))
.filter((user): user is Labrinth.Users.v2.User => Boolean(user))
.map((user) => ({
...user,
user: user.username,
actions: null,
}))
})
const isLoading = computed(
() =>
Boolean(auth.user.value) &&
(blockedUserIdsQuery.isPending.value ||
(blockedUserIds.value.length > 0 && blockedUserProfilesQuery.isPending.value)),
)
const loadError = computed(
() => blockedUserIdsQuery.error.value ?? blockedUserProfilesQuery.error.value,
)
async function retry(): Promise<void> {
await blockedUserIdsQuery.refetch()
if (blockedUserIds.value.length > 0) {
await blockedUserProfilesQuery.refetch()
}
}
async function requestSignIn(): Promise<void> {
await auth.requestSignIn('')
}
async function unblock(user: BlockedUser): Promise<void> {
if (unblockingUserId.value) return
unblockingUserId.value = user.id
try {
await props.unblockUser(user.id)
const remainingIds = blockedUserIds.value.filter((userId) => userId !== user.id)
const remainingUsers = blockedUsers.value.filter((blockedUser) => blockedUser.id !== user.id)
queryClient.setQueryData(
['blocked-user-profiles', auth.user.value?.id ?? null, remainingIds],
remainingUsers,
)
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
blockedUsersQueryKey(auth.user.value?.id),
remainingIds,
)
} catch {
notificationManager.addNotification({
type: 'error',
title: formatMessage(messages.unblockError),
text: formatMessage(messages.unblockErrorDescription),
})
} finally {
unblockingUserId.value = null
}
}
const messages = defineMessages({
friendRequestsTitle: {
id: 'settings.social.friend-requests.title',
defaultMessage: 'Friend requests',
},
friendRequestsDescription: {
id: 'settings.social.friend-requests.description',
defaultMessage: 'Control who can send you friend requests on Modrinth.',
},
sharedInstanceInvitesTitle: {
id: 'settings.social.shared-instance-invites.title',
defaultMessage: 'Invitations',
},
sharedInstanceInvitesDescription: {
id: 'settings.social.shared-instance-invites.description',
defaultMessage:
'Control who can send you invites to shared instances and Modrinth Hosting panels.',
},
everyone: {
id: 'settings.social.interaction-source.everyone',
defaultMessage: 'Everyone',
},
friendsOfFriends: {
id: 'settings.social.interaction-source.friends-of-friends',
defaultMessage: 'Friends of friends',
},
friends: {
id: 'settings.social.interaction-source.friends',
defaultMessage: 'Friends',
},
noOne: {
id: 'settings.social.interaction-source.no-one',
defaultMessage: 'No one',
},
comingSoon: {
id: 'settings.social.interaction-source.coming-soon',
defaultMessage: 'Coming soon!',
},
blockedUsersTitle: {
id: 'settings.social.blocked-users.title',
defaultMessage: 'Blocked users',
},
blockedUsersDescription: {
id: 'settings.social.blocked-users.description',
defaultMessage: 'These are the users you have blocked on Modrinth. They cannot:',
},
friendRequestsRestriction: {
id: 'settings.social.blocked-users.restriction.friend-requests',
defaultMessage: 'Send you friend requests',
},
sharedInstancesRestriction: {
id: 'settings.social.blocked-users.restriction.shared-instances',
defaultMessage: 'Invite you to shared instances',
},
hostingRestriction: {
id: 'settings.social.blocked-users.restriction.hosting',
defaultMessage: 'Invite you to manage a Modrinth Hosting server.',
},
userColumn: {
id: 'settings.social.blocked-users.column.user',
defaultMessage: 'User',
},
actionsColumn: {
id: 'settings.social.blocked-users.column.actions',
defaultMessage: 'Actions',
},
unblockButton: {
id: 'settings.social.blocked-users.unblock',
defaultMessage: 'Unblock',
},
unblockUserAriaLabel: {
id: 'settings.social.blocked-users.unblock-user',
defaultMessage: 'Unblock {username}',
},
loadingBlockedUsers: {
id: 'settings.social.blocked-users.loading',
defaultMessage: 'Loading blocked users…',
},
noBlockedUsers: {
id: 'settings.social.blocked-users.empty',
defaultMessage: "You haven't blocked anyone.",
},
signInRequiredTitle: {
id: 'settings.social.sign-in-required.title',
defaultMessage: 'Modrinth account required',
},
signInRequiredDescription: {
id: 'settings.social.sign-in-required.description',
defaultMessage:
'You can control who can interact with you, and manage blocked users with a Modrinth Account',
},
loadError: {
id: 'settings.social.blocked-users.load-error',
defaultMessage: 'Blocked users could not be loaded.',
},
userAvatarAlt: {
id: 'settings.social.blocked-users.user-avatar',
defaultMessage: "{username}'s avatar",
},
unblockError: {
id: 'settings.social.blocked-users.unblock-error',
defaultMessage: 'Failed to unblock user',
},
unblockErrorDescription: {
id: 'settings.social.blocked-users.unblock-error-description',
defaultMessage: 'An error occurred while unblocking this user. Please try again.',
},
})
</script>
+2
View File
@@ -1,3 +1,5 @@
export { default as AccountProfileSettings } from './AccountProfileSettings.vue'
export { default as AccountSocialSettings } from './AccountSocialSettings.vue'
export { default as ServersManageAccessPage } from './hosting/manage/[id]/access/access.vue'
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
-4
View File
@@ -4076,9 +4076,6 @@
"settings.pats.title": {
"defaultMessage": "Osobní přístupové tokeny"
},
"settings.profile.title": {
"defaultMessage": "Veřejný profil"
},
"settings.sessions.title": {
"defaultMessage": "Relace"
},
@@ -4560,4 +4557,3 @@
"defaultMessage": "Typ"
}
}
File diff suppressed because it is too large Load Diff
-4
View File
@@ -5105,9 +5105,6 @@
"settings.pats.title": {
"defaultMessage": "Persöhnlich Zugangstoken"
},
"settings.profile.title": {
"defaultMessage": "Öffentliches Profil"
},
"settings.sessions.title": {
"defaultMessage": "Sitzungen"
},
@@ -5940,4 +5937,3 @@
"defaultMessage": "Typ"
}
}
+10 -14
View File
@@ -9,7 +9,7 @@
"defaultMessage": "Neuen Partnercode erstellen"
},
"affiliate.create.title.description": {
"defaultMessage": "Gib deinem Partnerlink einen Namen, damit du weist, von wo Leute kommen!"
"defaultMessage": "Gib deinem Partnerlink einen Namen, damit du weißt, von wo Leute kommen!"
},
"affiliate.create.title.label": {
"defaultMessage": "Titel des Partnerlinks"
@@ -114,7 +114,7 @@
"defaultMessage": "{count, plural, one {# Projekt} other {# Projekte}} ausgewählt"
},
"browse.selected-projects-leave-modal.admonition-body": {
"defaultMessage": "Du hast {count, plural, one {# Projekt} other {# Projekte}} zur Installation ausgewählt. Installiere {count, plural, one {es} other {sie}} jetzt oder gehe ohne installation zurück."
"defaultMessage": "Du hast {count, plural, one {# Projekt} other {# Projekte}} zur Installation ausgewählt. Installiere {count, plural, one {es} other {sie}} jetzt oder gehe zurück, ohne diese zu Installieren."
},
"browse.selected-projects-leave-modal.admonition-header": {
"defaultMessage": "Ausgewählte Projekte noch nicht installiert"
@@ -303,7 +303,7 @@
"defaultMessage": "Entfolgen"
},
"button.unlink-modpack": {
"defaultMessage": "Version wechseln"
"defaultMessage": "Modpack trennen"
},
"button.update": {
"defaultMessage": "Aktualisieren"
@@ -1224,7 +1224,7 @@
"defaultMessage": "Nein"
},
"external-project-license-status.permanent-no": {
"defaultMessage": "Permanentes nein"
"defaultMessage": "Permanentes Nein"
},
"external-project-license-status.unidentified": {
"defaultMessage": "Unbekannt"
@@ -1875,10 +1875,10 @@
"defaultMessage": "Spielversion auswählen"
},
"installation-settings.aria.select-loader-version": {
"defaultMessage": "{loader}version auswählen"
"defaultMessage": "{loader}-Version auswählen"
},
"installation-settings.aria.select-platform": {
"defaultMessage": "Platform wählen"
"defaultMessage": "Plattform wählen"
},
"installation-settings.confirm-version-change": {
"defaultMessage": "Bestätigen"
@@ -1932,7 +1932,7 @@
"defaultMessage": "Serverprojekt"
},
"installation-settings.loader-version": {
"defaultMessage": "{loader}version"
"defaultMessage": "{loader}-Version"
},
"installation-settings.platform-lock-tooltip": {
"defaultMessage": "Du musst deinen Server zurücksetzen, um den Loader zu wechseln."
@@ -2136,10 +2136,10 @@
"defaultMessage": "Version auswählen"
},
"instances.updater-modal.incompatible-update.description": {
"defaultMessage": "{version} ist nicht als kompatibel mit dieser Installation markiert. Sie wird sich villeicht unerwartet verhalten."
"defaultMessage": "{version} ist nicht als mit dieser Installation kompatibel markiert. Es kann sein, dass sie nicht startet oder sich unerwartet verhält."
},
"instances.updater-modal.incompatible-update.header": {
"defaultMessage": "Auf eine kompatible Version aktualisieren?"
"defaultMessage": "Auf eine inkompatible Version aktualisieren?"
},
"instances.updater-modal.incompatible-update.proceed": {
"defaultMessage": "Trotzdem aktualisieren"
@@ -3480,7 +3480,7 @@
"defaultMessage": "Dein Projekt hat mehrere Umgebungen"
},
"project.settings.environment.notice.review-options.description": {
"defaultMessage": "Wir haben kürzlich das Umgebungssystem auf Modrinth überarbeitet, und neue Optionen sind jetzt verfügbar. Bitte stelle sicher, dass unten die richtige Option ausgewählt ist, und klicke anschließend auf Überprüfen!"
"defaultMessage": "Wir haben kürzlich das Umgebungssystem auf Modrinth überarbeitet, und neue Optionen sind jetzt verfügbar. Bitte stelle sicher, dass unten die richtige Option ausgewählt ist, und klicke anschließend auf Bestätigen!"
},
"project.settings.environment.notice.review-options.title": {
"defaultMessage": "Bitte überprüfe die untenstehenden Optionen"
@@ -5105,9 +5105,6 @@
"settings.pats.title": {
"defaultMessage": "Persönliche Zugangstoken"
},
"settings.profile.title": {
"defaultMessage": "Öffentliches Profil"
},
"settings.sessions.title": {
"defaultMessage": "Sitzungen"
},
@@ -5940,4 +5937,3 @@
"defaultMessage": "Typ"
}
}
+302 -2
View File
@@ -566,6 +566,9 @@
"content.inline-backup.world-label": {
"defaultMessage": "world"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Installation settings"
},
"content.page-layout.additional-content": {
"defaultMessage": "Additional content"
},
@@ -2240,6 +2243,9 @@
"label.details": {
"defaultMessage": "Details"
},
"label.discover-content": {
"defaultMessage": "Discover content"
},
"label.done": {
"defaultMessage": "Done"
},
@@ -2318,6 +2324,9 @@
"label.password": {
"defaultMessage": "Password"
},
"label.permissions": {
"defaultMessage": "Permissions"
},
"label.plan-custom": {
"defaultMessage": "Custom"
},
@@ -2849,9 +2858,162 @@
"payment-method.visa": {
"defaultMessage": "Visa"
},
"profile.bio.fallback.creator": {
"defaultMessage": "A Modrinth creator."
},
"profile.bio.fallback.user": {
"defaultMessage": "A Modrinth user."
},
"profile.block-user.admonition-body": {
"defaultMessage": "{username} will not be able to send you friend requests, invite you to shared instances or invite you to Modrinth Hosting servers."
},
"profile.block-user.admonition-title": {
"defaultMessage": "Are you sure you want to block this user?"
},
"profile.block-user.error-description": {
"defaultMessage": "An error occurred while blocking this user. Please try again."
},
"profile.block-user.error-title": {
"defaultMessage": "Failed to block user"
},
"profile.block-user.success-description": {
"defaultMessage": "{username} has been blocked."
},
"profile.block-user.success-title": {
"defaultMessage": "User blocked"
},
"profile.block-user.title": {
"defaultMessage": "Block {username}"
},
"profile.button.analytics": {
"defaultMessage": "View user analytics"
},
"profile.button.billing": {
"defaultMessage": "Manage user billing"
},
"profile.button.block": {
"defaultMessage": "Block"
},
"profile.button.create-collection": {
"defaultMessage": "Create a collection"
},
"profile.button.create-project": {
"defaultMessage": "Create a project"
},
"profile.button.edit-role": {
"defaultMessage": "Edit role"
},
"profile.button.info": {
"defaultMessage": "View user details"
},
"profile.button.manage-projects": {
"defaultMessage": "Manage projects"
},
"profile.button.remove-affiliate": {
"defaultMessage": "Remove as affiliate"
},
"profile.button.set-affiliate": {
"defaultMessage": "Set as affiliate"
},
"profile.button.unblock": {
"defaultMessage": "Unblock"
},
"profile.collection.projects-count": {
"defaultMessage": "{count, plural, one {# project} other {# projects}}"
},
"profile.details.label.auth-providers": {
"defaultMessage": "Auth providers"
},
"profile.details.label.email-verified": {
"defaultMessage": "Email verified"
},
"profile.details.label.has-password": {
"defaultMessage": "Has password"
},
"profile.details.label.has-totp": {
"defaultMessage": "Has TOTP"
},
"profile.details.label.payment-methods": {
"defaultMessage": "Payment methods"
},
"profile.details.title": {
"defaultMessage": "User details"
},
"profile.details.tooltip.email-not-verified": {
"defaultMessage": "Email not verified"
},
"profile.details.tooltip.email-verified": {
"defaultMessage": "Email verified"
},
"profile.error.load-description": {
"defaultMessage": "The user profile could not be loaded."
},
"profile.error.not-found": {
"defaultMessage": "User not found"
},
"profile.label.affiliate": {
"defaultMessage": "Affiliate"
},
"profile.label.badges": {
"defaultMessage": "Badges"
},
"profile.label.collection": {
"defaultMessage": "Collection"
},
"profile.label.download-count": {
"defaultMessage": "{count, plural, one {download} other {downloads}}"
},
"profile.label.joined": {
"defaultMessage": "Joined"
},
"profile.label.no-collections": {
"defaultMessage": "This user has no collections!"
},
"profile.label.no-collections-auth-description": {
"defaultMessage": "You don't have any collections yet."
},
"profile.label.no-projects": {
"defaultMessage": "This user has no projects!"
},
"profile.label.no-projects-auth-description": {
"defaultMessage": "You don't have any projects yet."
},
"profile.label.organizations": {
"defaultMessage": "Organizations"
},
"profile.label.project-count": {
"defaultMessage": "{count, plural, one {project} other {projects}}"
},
"profile.label.saving": {
"defaultMessage": "Saving..."
},
"profile.official-account": {
"defaultMessage": "Official Modrinth account"
},
"profile.official-account.bio": {
"defaultMessage": "The official user account of Modrinth. Get support at <support-link></support-link> or via email at <email></email>"
},
"profile.role.select-placeholder": {
"defaultMessage": "Select a role"
},
"profile.role.update-error-description": {
"defaultMessage": "An error occurred while updating the user role. Please try again."
},
"profile.role.update-error-title": {
"defaultMessage": "Failed to update role"
},
"profile.unblock-user.error-description": {
"defaultMessage": "An error occurred while unblocking this user. Please try again."
},
"profile.unblock-user.error-title": {
"defaultMessage": "Failed to unblock user"
},
"profile.unblock-user.success-description": {
"defaultMessage": "{username} has been unblocked."
},
"profile.unblock-user.success-title": {
"defaultMessage": "User unblocked"
},
"project-card.date.published.tooltip": {
"defaultMessage": "Published {date}"
},
@@ -2879,6 +3041,9 @@
"project-type.all": {
"defaultMessage": "All"
},
"project-type.collection.plural": {
"defaultMessage": "Collections"
},
"project-type.datapack.capital": {
"defaultMessage": "{count, plural, one {Data Pack} other {Data Packs}}"
},
@@ -5105,12 +5270,120 @@
"settings.pats.title": {
"defaultMessage": "Personal access tokens"
},
"settings.profile.title": {
"defaultMessage": "Public profile"
"settings.profile.bio.description": {
"defaultMessage": "A short description to tell everyone a little bit about you."
},
"settings.profile.bio.title": {
"defaultMessage": "Bio"
},
"settings.profile.navigation-title": {
"defaultMessage": "Profile"
},
"settings.profile.profile-picture.title": {
"defaultMessage": "Profile picture"
},
"settings.profile.public-information.description": {
"defaultMessage": "Your profile information is publicly <profile-link>viewable on Modrinth</profile-link> and through the <docs-link>Modrinth API</docs-link>."
},
"settings.profile.save-error": {
"defaultMessage": "Failed to update profile"
},
"settings.profile.save-error-description": {
"defaultMessage": "An error occurred while updating your profile. Please try again."
},
"settings.profile.sign-in-required.description": {
"defaultMessage": "Sign in with a Modrinth account to customize your public profile."
},
"settings.profile.sign-in-required.title": {
"defaultMessage": "Modrinth account required"
},
"settings.profile.username.description": {
"defaultMessage": "A unique case-insensitive name to identify your profile."
},
"settings.sessions.title": {
"defaultMessage": "Sessions"
},
"settings.social.blocked-users.column.actions": {
"defaultMessage": "Actions"
},
"settings.social.blocked-users.column.user": {
"defaultMessage": "User"
},
"settings.social.blocked-users.description": {
"defaultMessage": "These are the users you have blocked on Modrinth. They cannot:"
},
"settings.social.blocked-users.empty": {
"defaultMessage": "You haven't blocked anyone."
},
"settings.social.blocked-users.load-error": {
"defaultMessage": "Blocked users could not be loaded."
},
"settings.social.blocked-users.loading": {
"defaultMessage": "Loading blocked users…"
},
"settings.social.blocked-users.restriction.friend-requests": {
"defaultMessage": "Send you friend requests"
},
"settings.social.blocked-users.restriction.hosting": {
"defaultMessage": "Invite you to manage a Modrinth Hosting server."
},
"settings.social.blocked-users.restriction.shared-instances": {
"defaultMessage": "Invite you to shared instances"
},
"settings.social.blocked-users.title": {
"defaultMessage": "Blocked users"
},
"settings.social.blocked-users.unblock": {
"defaultMessage": "Unblock"
},
"settings.social.blocked-users.unblock-error": {
"defaultMessage": "Failed to unblock user"
},
"settings.social.blocked-users.unblock-error-description": {
"defaultMessage": "An error occurred while unblocking this user. Please try again."
},
"settings.social.blocked-users.unblock-user": {
"defaultMessage": "Unblock {username}"
},
"settings.social.blocked-users.user-avatar": {
"defaultMessage": "{username}'s avatar"
},
"settings.social.friend-requests.description": {
"defaultMessage": "Control who can send you friend requests on Modrinth."
},
"settings.social.friend-requests.title": {
"defaultMessage": "Friend requests"
},
"settings.social.interaction-source.coming-soon": {
"defaultMessage": "Coming soon!"
},
"settings.social.interaction-source.everyone": {
"defaultMessage": "Everyone"
},
"settings.social.interaction-source.friends": {
"defaultMessage": "Friends"
},
"settings.social.interaction-source.friends-of-friends": {
"defaultMessage": "Friends of friends"
},
"settings.social.interaction-source.no-one": {
"defaultMessage": "No one"
},
"settings.social.shared-instance-invites.description": {
"defaultMessage": "Control who can send you invites to shared instances and Modrinth Hosting panels."
},
"settings.social.shared-instance-invites.title": {
"defaultMessage": "Invitations"
},
"settings.social.sign-in-required.description": {
"defaultMessage": "You can control who can interact with you, and manage blocked users with a Modrinth Account"
},
"settings.social.sign-in-required.title": {
"defaultMessage": "Modrinth account required"
},
"settings.social.title": {
"defaultMessage": "Social"
},
"sharing.invite-players-modal.add": {
"defaultMessage": "Add"
},
@@ -5120,6 +5393,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"
},
@@ -5129,12 +5405,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"
},
@@ -5027,9 +5027,6 @@
"settings.pats.title": {
"defaultMessage": "Tokens de acceso personal"
},
"settings.profile.title": {
"defaultMessage": "Perfil público"
},
"settings.sessions.title": {
"defaultMessage": "Sesiones"
},
@@ -5787,4 +5784,3 @@
"defaultMessage": "Tipo"
}
}
-3
View File
@@ -4931,9 +4931,6 @@
"settings.pats.title": {
"defaultMessage": "Tokens de acceso personal"
},
"settings.profile.title": {
"defaultMessage": "Perfil público"
},
"settings.sessions.title": {
"defaultMessage": "Sesiones"
},
-4
View File
@@ -560,9 +560,6 @@
"settings.language.title": {
"defaultMessage": "Kieli"
},
"settings.profile.title": {
"defaultMessage": "Julkinen profiili"
},
"settings.sessions.title": {
"defaultMessage": "Istunnot"
},
@@ -570,4 +567,3 @@
"defaultMessage": "Sinulla on tallentamattomia muutoksia."
}
}
@@ -2294,9 +2294,6 @@
"settings.pats.title": {
"defaultMessage": "Mga personal na access token"
},
"settings.profile.title": {
"defaultMessage": "Pampublikong profile"
},
"settings.sessions.title": {
"defaultMessage": "Mga sesyon"
},
@@ -2769,4 +2766,3 @@
"defaultMessage": "Uri"
}
}
-3
View File
@@ -5099,9 +5099,6 @@
"settings.pats.title": {
"defaultMessage": "Jetons d'accès personnel"
},
"settings.profile.title": {
"defaultMessage": "Profil public"
},
"settings.sessions.title": {
"defaultMessage": "Sessions"
},
-3
View File
@@ -1127,9 +1127,6 @@
"settings.pats.title": {
"defaultMessage": "מפתחות גישה אישיים"
},
"settings.profile.title": {
"defaultMessage": "פרופיל ציבורי"
},
"settings.sessions.title": {
"defaultMessage": "חיבורים פעילים"
},
+98 -8
View File
@@ -341,6 +341,9 @@
"content.confirm-deletion.admonition-header": {
"defaultMessage": "Törlési figyelmeztetés"
},
"content.confirm-deletion.delete-button": {
"defaultMessage": "{count, number} {itemType} törlése"
},
"content.confirm-modpack-update.admonition-body": {
"defaultMessage": "{action, select, downgrade {A verzió visszaváltása} other {A frissítés}} kompatibilitási problémákat okozhat. A modcsomaghoz hozzáadott modok vagy tartalmak megmaradnak, de előfordulhat, hogy nem lesznek kompatibilisek az új verzióval."
},
@@ -417,7 +420,10 @@
"defaultMessage": "Letiltva"
},
"content.diff-modal.diff-type.updated": {
"defaultMessage": "Frissítve"
"defaultMessage": "Frissítve:"
},
"content.diff-modal.file-count": {
"defaultMessage": "{count} fájl"
},
"content.diff-modal.removed-count": {
"defaultMessage": "{count} eltávolítva"
@@ -1401,7 +1407,7 @@
"defaultMessage": "A tartalom engedélyezése nem sikerült"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Tartalom frissítése sikertelen"
"defaultMessage": "A tartalom frissítése sikertelen"
},
"hosting.content.failed-to-install": {
"defaultMessage": "A tartalom telepítése nem sikerült"
@@ -1662,7 +1668,7 @@
"defaultMessage": "Projekt letöltése"
},
"instances.content-install.incompatible-tooltip": {
"defaultMessage": "Ez az példány olyan betöltőt vagy játékverziót használ, amelyet ez a projekt nem támogat."
"defaultMessage": "Ez a játékpéldány olyan betöltőt vagy játékverziót használ, amelyet ez a projekt nem támogat."
},
"instances.content-install.install-button": {
"defaultMessage": "Letöltés"
@@ -2570,6 +2576,9 @@
"project.about.compatibility.platforms": {
"defaultMessage": "Platformok"
},
"project.about.compatibility.platforms-plural": {
"defaultMessage": "{count, plural, one {Platform} other {Platformok}}"
},
"project.about.compatibility.title": {
"defaultMessage": "Kompatibilitás"
},
@@ -3122,6 +3131,9 @@
"project.stats.downloads-label": {
"defaultMessage": "letöltés"
},
"project.stats.followers-label": {
"defaultMessage": "{count} követő"
},
"project.versions.channel.alpha.symbol": {
"defaultMessage": "A"
},
@@ -3338,6 +3350,9 @@
"servers.access-page.activity-log-filter.action.modpack-unlinked": {
"defaultMessage": "Leválasztott modcsomag"
},
"servers.access-page.activity-log-filter.action.server-restarted": {
"defaultMessage": "Újraindított szerver"
},
"servers.access-page.activity-log-filter.instances": {
"defaultMessage": "Játékpéldányok"
},
@@ -3686,8 +3701,11 @@
"servers.busy.installing": {
"defaultMessage": "A szerver települ"
},
"servers.grant-access-modal.add-as-friend": {
"defaultMessage": "Barátkérelem küldése is"
},
"servers.grant-access-modal.already-member-tooltip": {
"defaultMessage": "Ez a felhasználó már meg lett meghíva"
"defaultMessage": "Ez a felhasználó már meg lett hívva"
},
"servers.grant-access-modal.cancel": {
"defaultMessage": "Mégse"
@@ -3726,7 +3744,7 @@
"defaultMessage": "Modrinth felhasználónév"
},
"servers.grant-access-modal.target.no-suggestions": {
"defaultMessage": "Nem találtunk megfelelő felhasználókat."
"defaultMessage": "Nem található a keresésnek megfelelő felhasználó."
},
"servers.grant-access-modal.target.placeholder": {
"defaultMessage": "Modrinth felhasználónév megadása"
@@ -3836,6 +3854,9 @@
"servers.listing.notice.subscription-cancelled-payment-failed": {
"defaultMessage": "Előfizetésed fizetési hiba miatt töröltük."
},
"servers.listing.owner-avatar-alt": {
"defaultMessage": "{username} avatárja"
},
"servers.listing.server-icon-alt": {
"defaultMessage": "Szerverikon"
},
@@ -3914,6 +3935,9 @@
"servers.medal-listing.notice.suspended-with-reason": {
"defaultMessage": "A szerveredet felfüggesztettük: {reason}. Kérlek, frissítsd a számlázási adataidat, vagy további információkért vedd fel a kapcsolatot a Modrinth ügyfélszolgálatával."
},
"servers.medal-listing.owner-avatar-alt": {
"defaultMessage": "{username} avatárja"
},
"servers.medal-listing.server-icon-alt": {
"defaultMessage": "Szerverikon"
},
@@ -4034,6 +4058,9 @@
"servers.region.western-europe": {
"defaultMessage": "Nyugat-Európa"
},
"servers.remove-access-modal.user-avatar-alt": {
"defaultMessage": "{username} avatárja"
},
"servers.setup.onboarding.installation-failed.text": {
"defaultMessage": "A telepítés során váratlan hiba történt. Kérlek, próbáld újra később."
},
@@ -4172,12 +4199,75 @@
"settings.pats.title": {
"defaultMessage": "Személyes hozzáférési tokenek"
},
"settings.profile.title": {
"defaultMessage": "Nyilvános profil"
},
"settings.sessions.title": {
"defaultMessage": "Munkamenetek"
},
"sharing.invite-players-modal.add": {
"defaultMessage": "Hozzáadás"
},
"sharing.invite-players-modal.added": {
"defaultMessage": "Hozzáadva"
},
"sharing.invite-players-modal.already-invited": {
"defaultMessage": "Ez a felhasználó már meg lett hívva."
},
"sharing.invite-players-modal.avatar-alt": {
"defaultMessage": "{username} avatárja"
},
"sharing.invite-players-modal.cancel": {
"defaultMessage": "Mégse"
},
"sharing.invite-players-modal.cancel-button": {
"defaultMessage": "Mégse"
},
"sharing.invite-players-modal.edit-invite-link": {
"defaultMessage": "Meghívólink szerkesztése."
},
"sharing.invite-players-modal.edit-invite-link-title": {
"defaultMessage": "Meghívólink szerkesztése"
},
"sharing.invite-players-modal.expiry-label": {
"defaultMessage": "Lejárati dátum"
},
"sharing.invite-players-modal.friends-heading": {
"defaultMessage": "A barátaid {count}"
},
"sharing.invite-players-modal.invite": {
"defaultMessage": "Meghívás"
},
"sharing.invite-players-modal.invite-expiry-description": {
"defaultMessage": "A meghívólink lejár {duration}on belül."
},
"sharing.invite-players-modal.invite-link-heading": {
"defaultMessage": "Vagy használj egy meghívólinket"
},
"sharing.invite-players-modal.link-copied-text": {
"defaultMessage": "A meghívólink a vágólapra lett másolva."
},
"sharing.invite-players-modal.link-copied-title": {
"defaultMessage": "Link kimásolva"
},
"sharing.invite-players-modal.no-search-results": {
"defaultMessage": "Nem található a keresésnek megfelelő felhasználó."
},
"sharing.invite-players-modal.requested": {
"defaultMessage": "Kérelem elküldve"
},
"sharing.invite-players-modal.requested-tooltip": {
"defaultMessage": "{username} felhasználónak először el kell fogadnia a barátkérelmet"
},
"sharing.invite-players-modal.save-button": {
"defaultMessage": "Mentés"
},
"sharing.invite-players-modal.search-placeholder": {
"defaultMessage": "Modrinth felhasználónév megadása"
},
"sharing.invite-players-modal.searching": {
"defaultMessage": "Keresés..."
},
"sharing.invite-players-modal.update-invite-link-failed-title": {
"defaultMessage": "A meghívólink frissítése sikertelen"
},
"tag.category.128x": {
"defaultMessage": "128x"
},
-4
View File
@@ -2567,9 +2567,6 @@
"settings.pats.title": {
"defaultMessage": "Token akses pribadi"
},
"settings.profile.title": {
"defaultMessage": "Profil publik"
},
"settings.sessions.title": {
"defaultMessage": "Sesi"
},
@@ -2988,4 +2985,3 @@
"defaultMessage": "Anda memiliki perubahan yang belum tersimpan."
}
}

Some files were not shown because too many files have changed in this diff Show More