You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit 'b9d90aa6356c88c8d661c04ab84194cf08ea0198' into feature-clean
This commit is contained in:
@@ -5,6 +5,7 @@ use crate::{
|
||||
state::{MemorySettings, WindowSize},
|
||||
util::{io::IOError, platform::classpath_separator},
|
||||
};
|
||||
use daedalus::minecraft::LoggingConfiguration;
|
||||
use daedalus::{
|
||||
get_path_from_artifact,
|
||||
minecraft::{Argument, ArgumentValue, Library, VersionType},
|
||||
@@ -104,11 +105,13 @@ pub fn get_jvm_arguments(
|
||||
arguments: Option<&[Argument]>,
|
||||
natives_path: &Path,
|
||||
libraries_path: &Path,
|
||||
log_configs_path: &Path,
|
||||
class_paths: &str,
|
||||
version_name: &str,
|
||||
memory: MemorySettings,
|
||||
custom_args: Vec<String>,
|
||||
java_arch: &str,
|
||||
log_config: Option<&LoggingConfiguration>,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
let mut parsed_arguments = Vec::new();
|
||||
|
||||
@@ -143,6 +146,12 @@ pub fn get_jvm_arguments(
|
||||
parsed_arguments.push(class_paths.to_string());
|
||||
}
|
||||
parsed_arguments.push(format!("-Xmx{}M", memory.maximum));
|
||||
if let Some(LoggingConfiguration::Log4j2Xml { argument, file }) = log_config
|
||||
{
|
||||
let full_path = log_configs_path.join(&file.id);
|
||||
let full_path = full_path.to_string_lossy();
|
||||
parsed_arguments.push(argument.replace("${path}", &full_path));
|
||||
}
|
||||
for arg in custom_args {
|
||||
if !arg.is_empty() {
|
||||
parsed_arguments.push(arg);
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
state::State,
|
||||
util::{fetch::*, io, platform::OsExt},
|
||||
};
|
||||
use daedalus::minecraft::{LoggingConfiguration, LoggingSide};
|
||||
use daedalus::{
|
||||
self as d,
|
||||
minecraft::{
|
||||
@@ -48,7 +49,8 @@ pub async fn download_minecraft(
|
||||
|
||||
tokio::try_join! {
|
||||
// Total loading sums to 90/60
|
||||
download_client(st, version, Some(loading_bar), force), // 10
|
||||
download_client(st, version, Some(loading_bar), force), // 9
|
||||
download_log_config(st, version, Some(loading_bar), force),
|
||||
download_assets(st, version.assets == "legacy", &assets_index, Some(loading_bar), amount, force), // 40
|
||||
download_libraries(st, version.libraries.as_slice(), &version.id, Some(loading_bar), amount, java_arch, force, minecraft_updated) // 40
|
||||
}?;
|
||||
@@ -80,7 +82,11 @@ pub async fn download_version_info(
|
||||
.await
|
||||
.and_then(|ref it| Ok(serde_json::from_slice(it)?))
|
||||
} else {
|
||||
tracing::info!("Downloading version info for version {}", &version.id);
|
||||
tracing::info!(
|
||||
"Downloading version info for version {} from {}",
|
||||
&version.id,
|
||||
version.url
|
||||
);
|
||||
let mut info = fetch_json(
|
||||
Method::GET,
|
||||
&version.url,
|
||||
@@ -376,3 +382,45 @@ pub async fn download_libraries(
|
||||
tracing::debug!("Done loading libraries!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn download_log_config(
|
||||
st: &State,
|
||||
version_info: &GameVersionInfo,
|
||||
loading_bar: Option<&LoadingBarId>,
|
||||
force: bool,
|
||||
) -> crate::Result<()> {
|
||||
let log_download = version_info
|
||||
.logging
|
||||
.as_ref()
|
||||
.and_then(|x| x.get(&LoggingSide::Client));
|
||||
let Some(LoggingConfiguration::Log4j2Xml {
|
||||
file: log_download, ..
|
||||
}) = log_download
|
||||
else {
|
||||
if let Some(loading_bar) = loading_bar {
|
||||
emit_loading(loading_bar, 1.0, None)?;
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let path = st.directories.log_configs_dir().join(&log_download.id);
|
||||
|
||||
if !path.exists() || force {
|
||||
let bytes = fetch(
|
||||
&log_download.url,
|
||||
Some(&log_download.sha1),
|
||||
&st.fetch_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
.await?;
|
||||
write(&path, &bytes, &st.io_semaphore).await?;
|
||||
tracing::trace!("Fetched log config {}", log_download.id);
|
||||
}
|
||||
if let Some(loading_bar) = loading_bar {
|
||||
emit_loading(loading_bar, 1.0, None)?;
|
||||
}
|
||||
|
||||
tracing::debug!("Log config {} loaded", log_download.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use crate::data::ModLoader;
|
||||
use crate::event::emit::{emit_loading, init_or_edit_loading};
|
||||
use crate::event::{LoadingBarId, LoadingBarType};
|
||||
use crate::launcher::download::download_log_config;
|
||||
use crate::launcher::io::IOError;
|
||||
use crate::state::{
|
||||
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
||||
@@ -10,7 +11,7 @@ use crate::util::io;
|
||||
use crate::{process, state as st, State};
|
||||
use chrono::Utc;
|
||||
use daedalus as d;
|
||||
use daedalus::minecraft::{RuleAction, VersionInfo};
|
||||
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
|
||||
use daedalus::modded::LoaderVersion;
|
||||
use rand::seq::SliceRandom;
|
||||
use st::Profile;
|
||||
@@ -484,7 +485,7 @@ pub async fn launch_minecraft(
|
||||
format!("{}-{}", version.id.clone(), it.id.clone())
|
||||
});
|
||||
|
||||
let version_info = download::download_version_info(
|
||||
let mut version_info = download::download_version_info(
|
||||
&state,
|
||||
version,
|
||||
loader_version.as_ref(),
|
||||
@@ -492,6 +493,26 @@ pub async fn launch_minecraft(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if version_info.logging.is_none() {
|
||||
let requires_logging_info = version_index
|
||||
<= minecraft
|
||||
.versions
|
||||
.iter()
|
||||
.position(|x| x.id == "13w39a")
|
||||
.unwrap_or(0);
|
||||
if requires_logging_info {
|
||||
version_info = download::download_version_info(
|
||||
&state,
|
||||
version,
|
||||
loader_version.as_ref(),
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
download_log_config(&state, &version_info, None, false).await?;
|
||||
|
||||
let java_version = get_java_version_from_profile(profile, &version_info)
|
||||
.await?
|
||||
@@ -551,6 +572,7 @@ pub async fn launch_minecraft(
|
||||
.map(|x| x.as_slice()),
|
||||
&natives_dir,
|
||||
&state.directories.libraries_dir(),
|
||||
&state.directories.log_configs_dir(),
|
||||
&args::get_class_paths(
|
||||
&state.directories.libraries_dir(),
|
||||
version_info.libraries.as_slice(),
|
||||
@@ -562,6 +584,10 @@ pub async fn launch_minecraft(
|
||||
*memory,
|
||||
Vec::from(java_args),
|
||||
&java_version.architecture,
|
||||
version_info
|
||||
.logging
|
||||
.as_ref()
|
||||
.and_then(|x| x.get(&LoggingSide::Client)),
|
||||
)?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
|
||||
@@ -111,6 +111,12 @@ impl DirectoryInfo {
|
||||
self.objects_dir().join(&hash[..2]).join(hash)
|
||||
}
|
||||
|
||||
/// Get the Minecraft log config's directory
|
||||
#[inline]
|
||||
pub fn log_configs_dir(&self) -> PathBuf {
|
||||
self.metadata_dir().join("log_configs")
|
||||
}
|
||||
|
||||
/// Get the Minecraft legacy assets metadata directory
|
||||
#[inline]
|
||||
pub fn legacy_assets_dir(&self) -> PathBuf {
|
||||
|
||||
@@ -131,7 +131,7 @@ import _PlayIcon from './icons/play.svg?component'
|
||||
import _PlugIcon from './icons/plug.svg?component'
|
||||
import _PlusIcon from './icons/plus.svg?component'
|
||||
import _RadioButtonIcon from './icons/radio-button.svg?component'
|
||||
import _RadioButtonChecked from './icons/radio-button-checked.svg?component'
|
||||
import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
|
||||
import _ReceiptTextIcon from './icons/receipt-text.svg?component'
|
||||
import _ReplyIcon from './icons/reply.svg?component'
|
||||
import _ReportIcon from './icons/report.svg?component'
|
||||
@@ -345,7 +345,7 @@ export const PlayIcon = _PlayIcon
|
||||
export const PlugIcon = _PlugIcon
|
||||
export const PlusIcon = _PlusIcon
|
||||
export const RadioButtonIcon = _RadioButtonIcon
|
||||
export const RadioButtonChecked = _RadioButtonChecked
|
||||
export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
|
||||
export const ReceiptTextIcon = _ReceiptTextIcon
|
||||
export const ReplyIcon = _ReplyIcon
|
||||
export const ReportIcon = _ReportIcon
|
||||
|
||||
@@ -402,6 +402,44 @@ pub enum ArgumentType {
|
||||
Jvm,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
/// The physical side of the logging configuration
|
||||
pub enum LoggingSide {
|
||||
/// Client logging configuration
|
||||
Client,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
/// File download information for a logging configuration
|
||||
pub struct LogConfigDownload {
|
||||
/// The path that the logging configuration should be saved to
|
||||
pub id: String,
|
||||
/// The SHA1 hash of the logging configuration
|
||||
pub sha1: String,
|
||||
/// The size of the logging configuration
|
||||
pub size: u32,
|
||||
/// The URL where the logging configuration can be downloaded
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "kebab-case",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
/// Information about a version's logging configuration
|
||||
pub enum LoggingConfiguration {
|
||||
/// Use a log4j2 XML log config file
|
||||
Log4j2Xml {
|
||||
/// The JVM argument for passing the file to the Java process
|
||||
argument: String,
|
||||
/// The config file to download
|
||||
file: LogConfigDownload,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
/// Information about a version
|
||||
@@ -422,6 +460,9 @@ pub struct VersionInfo {
|
||||
pub java_version: Option<JavaVersion>,
|
||||
/// Libraries that the version depends on
|
||||
pub libraries: Vec<Library>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
/// The logging configuration data for the game
|
||||
pub logging: Option<HashMap<LoggingSide, LoggingConfiguration>>,
|
||||
/// The classpath to the main class to launch the game
|
||||
pub main_class: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -164,6 +164,7 @@ pub fn merge_partial_version(
|
||||
x
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
logging: merge.logging,
|
||||
main_class: if let Some(main_class) = partial.main_class {
|
||||
main_class
|
||||
} else {
|
||||
|
||||
@@ -39,14 +39,14 @@ defineProps({
|
||||
})
|
||||
|
||||
const typeClasses = {
|
||||
info: 'border-blue bg-bg-blue',
|
||||
warning: 'border-orange bg-bg-orange',
|
||||
info: 'border-brand-blue bg-bg-blue',
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
info: 'text-blue',
|
||||
warning: 'text-orange',
|
||||
info: 'text-brand-blue',
|
||||
warning: 'text-brand-orange',
|
||||
critical: 'text-brand-red',
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
pixelated: raised,
|
||||
pixelated: pixelated,
|
||||
}"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
@@ -96,7 +96,7 @@ const LEGACY_PRESETS = {
|
||||
const cssSize = computed(() => LEGACY_PRESETS[props.size] ?? props.size)
|
||||
|
||||
function updatePixelated() {
|
||||
if (img.value && img.value.naturalWidth && img.value.naturalWidth <= 96) {
|
||||
if (img.value && img.value.naturalWidth && img.value.naturalWidth < 32) {
|
||||
pixelated.value = true
|
||||
} else {
|
||||
pixelated.value = false
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
:value="option"
|
||||
:name="name"
|
||||
/>
|
||||
<label :for="`${name}-${index}`">{{ displayName(option) }}</label>
|
||||
<label :for="`${name}-${index}`">{{ getOptionLabel(option) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -101,10 +101,14 @@ const props = defineProps({
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: (option) => option,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
function getOptionLabel(option) {
|
||||
return props.displayName?.(option) ?? option
|
||||
}
|
||||
|
||||
const emit = defineEmits(['input', 'change', 'update:modelValue'])
|
||||
|
||||
const dropdownVisible = ref(false)
|
||||
@@ -114,7 +118,7 @@ const dropdown = ref(null)
|
||||
const optionElements = ref(null)
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.displayName(selectedValue.value) || props.placeholder || 'Select an option'
|
||||
return getOptionLabel(selectedValue.value) ?? props.placeholder ?? 'Select an option'
|
||||
})
|
||||
|
||||
const radioValue = computed({
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
|
||||
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
@@ -59,7 +59,7 @@
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
|
||||
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
@@ -97,7 +97,7 @@ const props = withDefaults(
|
||||
disabled: false,
|
||||
position: 'auto',
|
||||
direction: 'auto',
|
||||
displayName: (option: Option) => option as string,
|
||||
displayName: undefined,
|
||||
search: false,
|
||||
dropdownId: '',
|
||||
dropdownClass: '',
|
||||
@@ -106,6 +106,10 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
function getOptionLabel(option: Option): string {
|
||||
return props.displayName?.(option) ?? (option as string)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
const selectedValues = ref(props.modelValue || [])
|
||||
const searchInput = ref()
|
||||
@@ -127,7 +131,7 @@ const filteredOptions = computed(() => {
|
||||
return props.options.filter(
|
||||
(x) =>
|
||||
!searchQuery.value ||
|
||||
props.displayName(x).toLowerCase().includes(searchQuery.value.toLowerCase()),
|
||||
getOptionLabel(x).toLowerCase().includes(searchQuery.value.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets'
|
||||
import { RadioButtonIcon, RadioButtonCheckedIcon } from '@modrinth/assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -15,7 +15,7 @@ withDefaults(
|
||||
<slot name="preview" />
|
||||
<div>
|
||||
<RadioButtonIcon v-if="!checked" class="w-4 h-4" />
|
||||
<RadioButtonChecked v-else class="w-4 h-4" />
|
||||
<RadioButtonCheckedIcon v-else class="w-4 h-4" />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
}"
|
||||
@click="selected = item"
|
||||
>
|
||||
<RadioButtonChecked v-if="selected === item" class="text-brand h-5 w-5" />
|
||||
<RadioButtonCheckedIcon v-if="selected === item" class="text-brand h-5 w-5" />
|
||||
<RadioButtonIcon v-else class="h-5 w-5" />
|
||||
<slot :item="item" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts" generic="T">
|
||||
import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets'
|
||||
import { RadioButtonIcon, RadioButtonCheckedIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
@click="selectedPlan = interval"
|
||||
>
|
||||
<RadioButtonChecked v-if="selectedPlan === interval" class="h-8 w-8 text-brand" />
|
||||
<RadioButtonCheckedIcon v-if="selectedPlan === interval" class="h-8 w-8 text-brand" />
|
||||
<RadioButtonIcon v-else class="h-8 w-8 text-secondary" />
|
||||
<span
|
||||
class="text-lg capitalize"
|
||||
@@ -483,11 +483,13 @@
|
||||
:disabled="paymentLoading || !eulaAccepted"
|
||||
@click="submitPayment"
|
||||
>
|
||||
<CheckCircleIcon /> Subscribe
|
||||
<CheckCircleIcon />
|
||||
Subscribe
|
||||
</button>
|
||||
<!-- Default Subscribe Button, so M+ still works -->
|
||||
<button v-else class="btn btn-primary" :disabled="paymentLoading" @click="submitPayment">
|
||||
<CheckCircleIcon /> Subscribe
|
||||
<CheckCircleIcon />
|
||||
Subscribe
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@@ -507,7 +509,7 @@ import {
|
||||
IssuesIcon,
|
||||
PayPalIcon,
|
||||
PlusIcon,
|
||||
RadioButtonChecked,
|
||||
RadioButtonCheckedIcon,
|
||||
RadioButtonIcon,
|
||||
RightArrowIcon,
|
||||
XIcon,
|
||||
|
||||
@@ -51,10 +51,10 @@
|
||||
<Avatar :src="option.icon" :circle="circledIcons" />
|
||||
<div class="text">
|
||||
<div class="title">
|
||||
{{ displayName(option.title) }}
|
||||
{{ getOptionLabel(option.title) }}
|
||||
</div>
|
||||
<div class="author">
|
||||
{{ displayName(option.subtitle) }}
|
||||
{{ getOptionLabel(option.subtitle) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ const props = defineProps({
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: (option) => option,
|
||||
default: undefined,
|
||||
},
|
||||
circledIcons: {
|
||||
type: Boolean,
|
||||
@@ -106,6 +106,10 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
function getOptionLabel(option) {
|
||||
return props.displayName?.(option) ?? option
|
||||
}
|
||||
|
||||
const emit = defineEmits(['input', 'onSelected', 'update:modelValue', 'enter'])
|
||||
|
||||
const dropdownVisible = ref(false)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { MoonIcon, RadioButtonChecked, RadioButtonIcon, SunIcon } from '@modrinth/assets'
|
||||
import { MoonIcon, RadioButtonCheckedIcon, RadioButtonIcon, SunIcon } from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -80,7 +80,7 @@ const colorTheme = defineMessages({
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked v-if="currentTheme === option" class="radio" />
|
||||
<RadioButtonCheckedIcon v-if="currentTheme === option" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
{{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
|
||||
<SunIcon
|
||||
|
||||
@@ -10,6 +10,48 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2025-03-25T18:25:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Fixed random 'displayName' error on search pages on some browsers such as Firefox.
|
||||
- Fixed 'Resubmit' icon in publishing checklist showing up when it hasn't been submitted before.`,
|
||||
},
|
||||
{
|
||||
date: `2025-03-25T10:40:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Fixed error with links on error pages.`,
|
||||
},
|
||||
{
|
||||
date: `2025-03-24T22:30:00-07:00`,
|
||||
product: 'servers',
|
||||
body: `### Improvements
|
||||
- Fixed server plugin loaders not being populated when browsing for plugins
|
||||
- Fixed modpack search being filtered by Minecraft version when browsing for modpacks.`,
|
||||
},
|
||||
{
|
||||
date: `2025-03-24T22:30:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Improved error handling, especially when the Modrinth API is down.`,
|
||||
},
|
||||
{
|
||||
date: `2025-03-13T19:30:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Updated Modrinth Servers marketing page, removing Pyro branding.`,
|
||||
},
|
||||
{
|
||||
date: `2025-03-12T10:15:00-07:00`,
|
||||
product: 'web',
|
||||
body: `### Improvements
|
||||
- Fixed low-res icons being pixelated.
|
||||
- Fixed mobile navbar hiding bottom of footer.
|
||||
- Updated CMP info page to correct some incorrect information.
|
||||
- Updated CCPA notice with updated information since Modrinth Servers and Modrinth+.
|
||||
- Fixed review page failing under edge case.`,
|
||||
},
|
||||
{
|
||||
date: `2025-03-05T17:40:00-08:00`,
|
||||
product: 'web',
|
||||
|
||||
Reference in New Issue
Block a user