mirror of
https://github.com/modrinth/code.git
synced 2026-09-02 13:05:50 +00:00
Merge branch 'main' into boris/dev-1205-trace-rules
This commit is contained in:
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-12
@@ -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"
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::emit_instance;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::{EditInstance, State};
|
||||
use crate::util::fetch::{sha1_async, write};
|
||||
use crate::util::io;
|
||||
use bytes::Bytes;
|
||||
use std::fs::File as StdFile;
|
||||
use std::io::{BufRead, BufReader, Cursor, Seek};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const INSTANCE_ICON_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const INSTANCE_ICON_MAX_DIMENSION: u32 = 512;
|
||||
const INSTANCE_ICON_MAX_SOURCE_DIMENSION: u32 = 8_192;
|
||||
const INSTANCE_ICON_MAX_DECODE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
enum LegacyIconAction {
|
||||
Keep,
|
||||
Normalize,
|
||||
Remove,
|
||||
}
|
||||
|
||||
pub async fn edit_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<&Path>,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let icon_path = if let Some(icon_path) = icon_path {
|
||||
Some(
|
||||
cache_icon_from_path(icon_path, &state)
|
||||
.await?
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
apply_instance_icon(instance_id, icon_path, &state).await
|
||||
}
|
||||
|
||||
pub(crate) async fn cache_icon(
|
||||
bytes: Bytes,
|
||||
state: &State,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let bytes = tokio::task::spawn_blocking(move || {
|
||||
if looks_like_svg(&bytes) {
|
||||
return Err(svg_not_supported_error());
|
||||
}
|
||||
|
||||
normalize_raster(Cursor::new(bytes))
|
||||
})
|
||||
.await??;
|
||||
|
||||
write_cached_icon(bytes, state).await
|
||||
}
|
||||
|
||||
pub(crate) async fn cache_icon_from_path(
|
||||
icon_path: &Path,
|
||||
state: &State,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let icon_path = icon_path.to_path_buf();
|
||||
let bytes = tokio::task::spawn_blocking(move || {
|
||||
let file = StdFile::open(&icon_path).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not open instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let looks_like_svg = {
|
||||
let bytes = reader.fill_buf().map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not inspect instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
looks_like_svg(bytes)
|
||||
};
|
||||
if has_svg_extension(&icon_path) || looks_like_svg {
|
||||
return Err(svg_not_supported_error());
|
||||
}
|
||||
|
||||
normalize_raster(reader)
|
||||
})
|
||||
.await??;
|
||||
|
||||
write_cached_icon(bytes, state).await
|
||||
}
|
||||
|
||||
pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let instances = instance_rows::list_instances(&state.pool).await?;
|
||||
|
||||
for instance in instances {
|
||||
let Some(icon_path) = instance.icon_path.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let action = match inspect_legacy_icon(Path::new(icon_path)) {
|
||||
Ok(action) => action,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
instance_id = instance.id,
|
||||
icon_path,
|
||||
error = %error,
|
||||
"Failed to inspect legacy instance icon"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match action {
|
||||
LegacyIconAction::Keep => {}
|
||||
LegacyIconAction::Normalize => {
|
||||
if let Err(error) =
|
||||
edit_icon(&instance.id, Some(Path::new(icon_path))).await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id = instance.id,
|
||||
icon_path,
|
||||
error = %error,
|
||||
"Failed to normalize legacy instance icon"
|
||||
);
|
||||
}
|
||||
}
|
||||
LegacyIconAction::Remove => {
|
||||
if let Err(error) =
|
||||
apply_instance_icon(&instance.id, None, &state).await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id = instance.id,
|
||||
icon_path,
|
||||
error = %error,
|
||||
"Failed to remove legacy SVG instance icon"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_instance_icon(
|
||||
instance_id: &str,
|
||||
icon_path: Option<String>,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let instance =
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
crate::state::edit_instance(
|
||||
instance_id,
|
||||
EditInstance {
|
||||
icon_path: Some(icon_path.clone()),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(error) = super::shared::sync_shared_instance_icon(
|
||||
instance_id,
|
||||
icon_path.as_deref(),
|
||||
state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
instance_id,
|
||||
error = %error,
|
||||
"Failed to sync shared instance icon"
|
||||
);
|
||||
}
|
||||
|
||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_cached_icon(
|
||||
bytes: Bytes,
|
||||
state: &State,
|
||||
) -> crate::Result<PathBuf> {
|
||||
if bytes.len() >= INSTANCE_ICON_MAX_BYTES {
|
||||
return Err(icon_too_large_error());
|
||||
}
|
||||
|
||||
let hash = sha1_async(bytes.clone()).await?;
|
||||
let path = state
|
||||
.directories
|
||||
.caches_dir()
|
||||
.join("icons")
|
||||
.join(format!("{hash}.png"));
|
||||
write(&path, &bytes, &state.io_semaphore).await?;
|
||||
|
||||
Ok(io::canonicalize(path)?)
|
||||
}
|
||||
|
||||
fn normalize_raster<R>(reader: R) -> crate::Result<Bytes>
|
||||
where
|
||||
R: BufRead + Seek,
|
||||
{
|
||||
let mut reader = image::ImageReader::new(reader)
|
||||
.with_guessed_format()
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not identify instance icon format: {error}"
|
||||
))
|
||||
})?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(INSTANCE_ICON_MAX_SOURCE_DIMENSION);
|
||||
limits.max_image_height = Some(INSTANCE_ICON_MAX_SOURCE_DIMENSION);
|
||||
limits.max_alloc = Some(INSTANCE_ICON_MAX_DECODE_BYTES);
|
||||
reader.limits(limits);
|
||||
|
||||
let image = reader.decode().map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not decode instance icon: {error}"
|
||||
))
|
||||
})?;
|
||||
let image = if image.width() > INSTANCE_ICON_MAX_DIMENSION
|
||||
|| image.height() > INSTANCE_ICON_MAX_DIMENSION
|
||||
{
|
||||
image.resize(
|
||||
INSTANCE_ICON_MAX_DIMENSION,
|
||||
INSTANCE_ICON_MAX_DIMENSION,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
} else {
|
||||
image
|
||||
};
|
||||
let mut normalized = Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image.to_rgba8())
|
||||
.write_to(&mut normalized, image::ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not encode instance icon as PNG: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
validate_normalized_icon(normalized.into_inner())
|
||||
}
|
||||
|
||||
fn inspect_legacy_icon(icon_path: &Path) -> crate::Result<LegacyIconAction> {
|
||||
let metadata = std::fs::metadata(icon_path).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not inspect instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
let file = StdFile::open(icon_path).map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not open instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let bytes = reader.fill_buf().map_err(|error| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Could not inspect instance icon {}: {error}",
|
||||
icon_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if has_svg_extension(icon_path) || looks_like_svg(bytes) {
|
||||
return Ok(LegacyIconAction::Remove);
|
||||
}
|
||||
|
||||
if metadata.len() < INSTANCE_ICON_MAX_BYTES as u64
|
||||
&& image::guess_format(bytes).ok() == Some(image::ImageFormat::Png)
|
||||
{
|
||||
return Ok(LegacyIconAction::Keep);
|
||||
}
|
||||
|
||||
Ok(LegacyIconAction::Normalize)
|
||||
}
|
||||
|
||||
fn validate_normalized_icon(normalized: Vec<u8>) -> crate::Result<Bytes> {
|
||||
if normalized.len() >= INSTANCE_ICON_MAX_BYTES {
|
||||
return Err(icon_too_large_error());
|
||||
}
|
||||
|
||||
Ok(Bytes::from(normalized))
|
||||
}
|
||||
|
||||
fn looks_like_svg(bytes: &[u8]) -> bool {
|
||||
if image::guess_format(bytes).is_ok() {
|
||||
return false;
|
||||
}
|
||||
|
||||
bytes[..bytes.len().min(1_024)]
|
||||
.windows(4)
|
||||
.any(|window| window.eq_ignore_ascii_case(b"<svg"))
|
||||
}
|
||||
|
||||
fn has_svg_extension(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("svg"))
|
||||
}
|
||||
|
||||
fn icon_too_large_error() -> crate::Error {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance icons must be smaller than {INSTANCE_ICON_MAX_BYTES} bytes"
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn svg_not_supported_error() -> crate::Error {
|
||||
crate::ErrorKind::InputError(
|
||||
"SVG instance icons are not supported".to_string(),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
@@ -5,8 +5,6 @@ use crate::state::{
|
||||
CreateInstance, EditInstance, InstanceLink, InstanceMetadata, ModLoader,
|
||||
State,
|
||||
};
|
||||
use crate::util::io;
|
||||
use std::path::Path;
|
||||
|
||||
#[tracing::instrument]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -73,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 qu’un 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."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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<'_>,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+252
-28
@@ -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 +1,2 @@
|
||||
export { default as UserBadges } from './UserBadges.vue'
|
||||
export { default as UserPageHeader } from './UserPageHeader.vue'
|
||||
|
||||
@@ -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
-3
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4931,9 +4931,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Tokens de acceso personal"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Perfil público"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sesiones"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5099,9 +5099,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "Jetons d'accès personnel"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "Profil public"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "Sessions"
|
||||
},
|
||||
|
||||
@@ -1127,9 +1127,6 @@
|
||||
"settings.pats.title": {
|
||||
"defaultMessage": "מפתחות גישה אישיים"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"defaultMessage": "פרופיל ציבורי"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"defaultMessage": "חיבורים פעילים"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user