mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
Merge branch 'main' into cal/drag-and-drop-to-move-skins
This commit is contained in:
@@ -29,4 +29,18 @@ export class LabrinthAuthInternalModule extends AbstractModule {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed Discord community bot handoff URL
|
||||
*/
|
||||
public async createDiscordCommunityLink(): Promise<Labrinth.Auth.Internal.DiscordCommunityLinkResponse> {
|
||||
return this.client.request<Labrinth.Auth.Internal.DiscordCommunityLinkResponse>(
|
||||
'/auth/discord-community-link',
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,6 +510,10 @@ export namespace Labrinth {
|
||||
export type SubscriptionStatus = {
|
||||
subscribed: boolean
|
||||
}
|
||||
|
||||
export type DiscordCommunityLinkResponse = {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
export namespace v2 {
|
||||
|
||||
@@ -271,12 +271,11 @@ pub async fn get_available_capes() -> crate::Result<Vec<Cape>> {
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
|
||||
let profile = selected_credentials
|
||||
.online_profile_fresh()
|
||||
.await
|
||||
.ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
|
||||
user_name: selected_credentials.offline_profile.name.clone(),
|
||||
})?;
|
||||
let Some(profile) = selected_credentials.online_profile_fresh().await
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let pending_skin_change = pending_effective_skin_change(profile.id).await;
|
||||
let pending_cape_id = pending_skin_change
|
||||
.as_ref()
|
||||
@@ -312,16 +311,22 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
|
||||
let profile = selected_credentials
|
||||
.online_profile_fresh()
|
||||
.await
|
||||
.ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
|
||||
user_name: selected_credentials.offline_profile.name.clone(),
|
||||
})?;
|
||||
let online_profile = selected_credentials.online_profile_fresh().await;
|
||||
let profile_id = online_profile
|
||||
.as_ref()
|
||||
.map_or(selected_credentials.offline_profile.id, |profile| {
|
||||
profile.id
|
||||
});
|
||||
|
||||
let current_skin = profile.current_skin()?;
|
||||
let current_cape_id = profile.current_cape().map(|cape| cape.id);
|
||||
let pending_skin_change = pending_effective_skin_change(profile.id).await;
|
||||
let current_skin = online_profile
|
||||
.as_ref()
|
||||
.map(|profile| profile.current_skin())
|
||||
.transpose()?;
|
||||
let current_cape_id = online_profile
|
||||
.as_ref()
|
||||
.and_then(|profile| profile.current_cape())
|
||||
.map(|cape| cape.id);
|
||||
let pending_skin_change = pending_effective_skin_change(profile_id).await;
|
||||
let pending_unequip = pending_skin_change
|
||||
.as_ref()
|
||||
.is_some_and(PendingEffectiveSkinChange::is_unequip);
|
||||
@@ -329,16 +334,15 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
.as_ref()
|
||||
.and_then(PendingEffectiveSkinChange::skin);
|
||||
|
||||
let fallback_default_skin = assets::DEFAULT_SKINS.first();
|
||||
let fallback_default_skin = get_fallback_default_skin()?;
|
||||
let current_skin_texture_key = pending_skin.as_ref().map_or_else(
|
||||
|| {
|
||||
if pending_unequip {
|
||||
fallback_default_skin.map_or_else(
|
||||
|| current_skin.texture_key(),
|
||||
|skin| Arc::clone(&skin.texture_key),
|
||||
)
|
||||
} else {
|
||||
Arc::clone(&fallback_default_skin.texture_key)
|
||||
} else if let Some(current_skin) = current_skin {
|
||||
current_skin.texture_key()
|
||||
} else {
|
||||
Arc::clone(&fallback_default_skin.texture_key)
|
||||
}
|
||||
},
|
||||
|skin| skin.texture_key.clone(),
|
||||
@@ -346,10 +350,11 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
let current_skin_variant = pending_skin.as_ref().map_or_else(
|
||||
|| {
|
||||
if pending_unequip {
|
||||
fallback_default_skin
|
||||
.map_or(current_skin.variant, |skin| skin.variant)
|
||||
} else {
|
||||
fallback_default_skin.variant
|
||||
} else if let Some(current_skin) = current_skin {
|
||||
current_skin.variant
|
||||
} else {
|
||||
fallback_default_skin.variant
|
||||
}
|
||||
},
|
||||
|skin| skin.variant,
|
||||
@@ -357,8 +362,10 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
let current_cape_id = pending_skin.as_ref().map_or(
|
||||
if pending_unequip {
|
||||
None
|
||||
} else {
|
||||
} else if current_skin.is_some() {
|
||||
current_cape_id
|
||||
} else {
|
||||
None
|
||||
},
|
||||
|skin| skin.cape_id,
|
||||
);
|
||||
@@ -367,38 +374,39 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
let mut custom_skins = Vec::new();
|
||||
let mut saved_default_skins = Vec::new();
|
||||
|
||||
for mut custom_skin in CustomMinecraftSkin::get_all(profile.id, &state.pool)
|
||||
for mut custom_skin in CustomMinecraftSkin::get_all(profile_id, &state.pool)
|
||||
.await?
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
{
|
||||
let is_saved_default_skin =
|
||||
is_bundled_skin(&custom_skin.texture_key, custom_skin.variant);
|
||||
let current_skin_sync = if pending_skin.is_some() {
|
||||
SavedSkinSync {
|
||||
is_current_skin: custom_skin.texture_key
|
||||
== current_skin_texture_key.as_ref()
|
||||
&& custom_skin.variant == current_skin_variant
|
||||
&& custom_skin.cape_id == current_cape_id,
|
||||
settings_changed: false,
|
||||
}
|
||||
} else {
|
||||
sync_saved_skin_with_current_profile(
|
||||
&mut custom_skin,
|
||||
¤t_skin_texture_key,
|
||||
current_skin_variant,
|
||||
current_cape_id,
|
||||
)
|
||||
};
|
||||
let current_skin_sync =
|
||||
if pending_skin.is_some() || current_skin.is_none() {
|
||||
SavedSkinSync {
|
||||
is_current_skin: custom_skin.texture_key
|
||||
== current_skin_texture_key.as_ref()
|
||||
&& custom_skin.variant == current_skin_variant
|
||||
&& custom_skin.cape_id == current_cape_id,
|
||||
settings_changed: false,
|
||||
}
|
||||
} else {
|
||||
sync_saved_skin_with_current_profile(
|
||||
&mut custom_skin,
|
||||
¤t_skin_texture_key,
|
||||
current_skin_variant,
|
||||
current_cape_id,
|
||||
)
|
||||
};
|
||||
|
||||
let synced_texture_blob = if current_skin_sync.settings_changed {
|
||||
let texture_blob = custom_skin.texture_blob(&state.pool).await?;
|
||||
|
||||
if is_saved_default_skin && custom_skin.cape_id.is_none() {
|
||||
custom_skin.remove(profile.id, &state.pool).await?;
|
||||
custom_skin.remove(profile_id, &state.pool).await?;
|
||||
} else {
|
||||
CustomMinecraftSkin::add(
|
||||
profile.id,
|
||||
profile_id,
|
||||
&custom_skin.texture_key,
|
||||
&texture_blob,
|
||||
custom_skin.variant,
|
||||
@@ -486,7 +494,7 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
if let Some(mut skin) = pending_skin {
|
||||
skin.is_equipped = true;
|
||||
available_skins.push(skin);
|
||||
} else {
|
||||
} else if let Some(current_skin) = current_skin {
|
||||
available_skins.push(Skin {
|
||||
texture_key: current_skin_texture_key,
|
||||
name: current_skin.name.as_deref().map(Arc::from),
|
||||
@@ -1309,6 +1317,25 @@ fn is_bundled_skin(texture_key: &str, variant: MinecraftSkinVariant) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_fallback_default_skin() -> crate::Result<&'static Skin> {
|
||||
assets::DEFAULT_SKINS
|
||||
.iter()
|
||||
.find(|skin| {
|
||||
skin.name.as_deref() == Some("Steve")
|
||||
&& skin.variant == MinecraftSkinVariant::Classic
|
||||
})
|
||||
.or_else(|| {
|
||||
assets::DEFAULT_SKINS
|
||||
.iter()
|
||||
.find(|skin| skin.name.as_deref() == Some("Steve"))
|
||||
})
|
||||
.or_else(|| assets::DEFAULT_SKINS.first())
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::OtherError("No bundled default skins found".into())
|
||||
.as_error()
|
||||
})
|
||||
}
|
||||
|
||||
fn local_skin_texture_key(texture_blob: &[u8]) -> Arc<str> {
|
||||
Arc::from(format!("local-{:x}", sha2::Sha256::digest(texture_blob)))
|
||||
}
|
||||
|
||||
@@ -315,6 +315,7 @@ pub async fn generate_pack_from_version_id(
|
||||
reason,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
dependent_on: Some(version_id.clone()),
|
||||
};
|
||||
|
||||
let file = fetch_advanced(
|
||||
|
||||
@@ -387,8 +387,8 @@ pub async fn install_zipped_mrpack_files(
|
||||
profile_path: profile_path.clone(),
|
||||
pack_name: pack.name.clone(),
|
||||
icon,
|
||||
pack_id: project_id,
|
||||
pack_version: version_id,
|
||||
pack_id: project_id.clone(),
|
||||
pack_version: version_id.clone(),
|
||||
},
|
||||
100.0,
|
||||
"Downloading modpack",
|
||||
@@ -409,6 +409,7 @@ pub async fn install_zipped_mrpack_files(
|
||||
reason,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
dependent_on: version_id.clone(),
|
||||
};
|
||||
|
||||
let num_files = pack.files.len();
|
||||
|
||||
@@ -462,6 +462,7 @@ pub async fn update_project(
|
||||
profile_path,
|
||||
update_version,
|
||||
fetch::DownloadReason::Update,
|
||||
None,
|
||||
&state.pool,
|
||||
&state.fetch_semaphore,
|
||||
&state.io_semaphore,
|
||||
@@ -503,6 +504,7 @@ pub async fn add_project_from_version(
|
||||
profile_path: &str,
|
||||
version_id: &str,
|
||||
reason: fetch::DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
|
||||
@@ -510,6 +512,7 @@ pub async fn add_project_from_version(
|
||||
profile_path,
|
||||
version_id,
|
||||
reason,
|
||||
dependent_on_version_id,
|
||||
&state.pool,
|
||||
&state.fetch_semaphore,
|
||||
&state.io_semaphore,
|
||||
|
||||
@@ -888,6 +888,7 @@ async fn get_modpack_identifiers(
|
||||
reason: DownloadReason::Modpack,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
dependent_on: Some(version_id.to_string()),
|
||||
};
|
||||
|
||||
let mrpack_bytes = fetch_mirrors(
|
||||
|
||||
@@ -1336,6 +1336,7 @@ impl Profile {
|
||||
profile_path: &str,
|
||||
version_id: &str,
|
||||
reason: util::fetch::DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
io_semaphore: &IoSemaphore,
|
||||
@@ -1352,6 +1353,7 @@ impl Profile {
|
||||
reason,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
dependent_on: dependent_on_version_id,
|
||||
};
|
||||
|
||||
let version =
|
||||
|
||||
@@ -35,6 +35,7 @@ pub struct DownloadMeta {
|
||||
pub reason: DownloadReason,
|
||||
pub game_version: String,
|
||||
pub loader: String,
|
||||
pub dependent_on: Option<String>,
|
||||
}
|
||||
|
||||
impl DownloadMeta {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<button
|
||||
v-if="hasClearButton"
|
||||
type="button"
|
||||
class="absolute right-0.5 z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
|
||||
class="absolute right-0.5 top-px z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
|
||||
aria-label="Clear date"
|
||||
@click.stop="clearValue"
|
||||
>
|
||||
@@ -161,7 +161,7 @@ const props = withDefaults(
|
||||
mode: 'single',
|
||||
showMonths: 1,
|
||||
time24hr: false,
|
||||
clearable: true,
|
||||
clearable: false,
|
||||
placeholder: 'Enter date',
|
||||
showIcon: true,
|
||||
showToday: false,
|
||||
@@ -199,6 +199,7 @@ let originalInputFocus: HTMLInputElement['focus'] | null = null
|
||||
let suppressNextInputFocusScroll = false
|
||||
const calendarBaseClass = 'modrinth-date-picker-calendar'
|
||||
const twoCalendarClass = 'has-two-calendars'
|
||||
const calendarPositionGap = 2
|
||||
const calendarStateClasses = [
|
||||
'calendar-only',
|
||||
'show-today',
|
||||
@@ -1084,6 +1085,83 @@ function syncInputFocusScrollSuppression() {
|
||||
inputFocusScrollSuppressionTarget = target
|
||||
}
|
||||
|
||||
function setCalendarPositionClass(container: HTMLElement, className: string, isEnabled: boolean) {
|
||||
container.classList.toggle(className, isEnabled)
|
||||
}
|
||||
|
||||
function getCalendarPositionParts() {
|
||||
const parts = props.position.split(' ')
|
||||
return {
|
||||
vertical: parts[0] ?? 'auto',
|
||||
horizontal: parts[1] ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function getCalendarHeight(container: HTMLElement) {
|
||||
const height = container.getBoundingClientRect().height
|
||||
if (height > 0) return height
|
||||
|
||||
return Array.from(container.children).reduce(
|
||||
(total, child) => total + (child instanceof HTMLElement ? child.offsetHeight : 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function positionCalendar(instance: Instance, customPositionElement?: HTMLElement) {
|
||||
const container = instance.calendarContainer
|
||||
const positionElement = customPositionElement ?? instance._positionElement
|
||||
if (!container || !positionElement) return
|
||||
|
||||
const calendarHeight = getCalendarHeight(container)
|
||||
const calendarWidth = container.offsetWidth
|
||||
const { vertical, horizontal } = getCalendarPositionParts()
|
||||
const inputBounds = positionElement.getBoundingClientRect()
|
||||
const distanceFromBottom = window.innerHeight - inputBounds.bottom
|
||||
const showOnTop =
|
||||
vertical === 'above' ||
|
||||
(vertical !== 'below' &&
|
||||
distanceFromBottom < calendarHeight &&
|
||||
inputBounds.top > calendarHeight)
|
||||
|
||||
const top =
|
||||
window.pageYOffset +
|
||||
inputBounds.top +
|
||||
(showOnTop
|
||||
? -calendarHeight - calendarPositionGap
|
||||
: positionElement.offsetHeight + calendarPositionGap)
|
||||
let left = window.pageXOffset + inputBounds.left
|
||||
let isCenter = false
|
||||
let isRight = false
|
||||
|
||||
if (horizontal === 'center') {
|
||||
left -= (calendarWidth - inputBounds.width) / 2
|
||||
isCenter = true
|
||||
} else if (horizontal === 'right') {
|
||||
left -= calendarWidth - inputBounds.width
|
||||
isRight = true
|
||||
}
|
||||
|
||||
const viewportLeft = window.pageXOffset
|
||||
const viewportRight = viewportLeft + document.documentElement.clientWidth
|
||||
const isOverflowingRight = left + calendarWidth > viewportRight
|
||||
const clampedLeft = Math.min(
|
||||
Math.max(viewportLeft, left),
|
||||
Math.max(viewportLeft, viewportRight - calendarWidth),
|
||||
)
|
||||
|
||||
setCalendarPositionClass(container, 'arrowTop', !showOnTop)
|
||||
setCalendarPositionClass(container, 'arrowBottom', showOnTop)
|
||||
setCalendarPositionClass(container, 'arrowLeft', !isCenter && !isRight)
|
||||
setCalendarPositionClass(container, 'arrowCenter', isCenter)
|
||||
setCalendarPositionClass(container, 'arrowRight', isRight)
|
||||
setCalendarPositionClass(container, 'rightMost', isOverflowingRight)
|
||||
setCalendarPositionClass(container, 'centerMost', false)
|
||||
|
||||
container.style.top = `${top}px`
|
||||
container.style.left = `${clampedLeft}px`
|
||||
container.style.right = 'auto'
|
||||
}
|
||||
|
||||
const resolvedDateFormat = computed(
|
||||
() => props.dateFormat ?? (props.enableTime ? 'Y-m-d H:i' : 'Y-m-d'),
|
||||
)
|
||||
@@ -1104,12 +1182,7 @@ const selectedDates = computed(() => {
|
||||
})
|
||||
|
||||
const hasClearButton = computed(
|
||||
() =>
|
||||
!props.calendarOnly &&
|
||||
props.clearable &&
|
||||
!props.disabled &&
|
||||
!props.readonly &&
|
||||
selectedDates.value.length > 0,
|
||||
() => !props.calendarOnly && props.clearable && !props.disabled && selectedDates.value.length > 0,
|
||||
)
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
@@ -1143,6 +1216,7 @@ watch(
|
||||
props.calendarOnly,
|
||||
props.closeOnSelect,
|
||||
props.position,
|
||||
props.clearable,
|
||||
],
|
||||
() => {
|
||||
if (!picker.value) return
|
||||
@@ -1399,7 +1473,7 @@ function flatpickrOptions(): Options {
|
||||
mode: props.mode,
|
||||
noCalendar: false,
|
||||
nextArrow: chevronRightIcon,
|
||||
position: props.position,
|
||||
position: positionCalendar,
|
||||
prevArrow: chevronLeftIcon,
|
||||
showMonths: resolvedShowMonths.value,
|
||||
static: false,
|
||||
@@ -1480,7 +1554,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.arrowBottom) {
|
||||
margin-top: -2.5rem;
|
||||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.modrinth-date-picker.calendar-only {
|
||||
|
||||
@@ -136,7 +136,6 @@
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useDebugLogger } from '../../composables/debug-logger'
|
||||
import { useVIntl } from '../../composables/i18n'
|
||||
import { useModalStack } from '../../composables/modal-stack'
|
||||
import { useScrollIndicator } from '../../composables/scroll-indicator'
|
||||
@@ -145,7 +144,6 @@ import { commonMessages } from '../../utils/common-messages'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('NewModal')
|
||||
|
||||
const modalBehavior = injectModalBehavior(null)
|
||||
const {
|
||||
@@ -235,138 +233,56 @@ function getFocusableElements(): HTMLElement[] {
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
debug('show: start', {
|
||||
header: props.header,
|
||||
open: open.value,
|
||||
visible: visible.value,
|
||||
stackSize: modalStackSize(),
|
||||
hasEvent: !!event,
|
||||
})
|
||||
props.onShow?.()
|
||||
debug('show: after onShow', { header: props.header })
|
||||
const wasEmpty = modalStackSize() === 0
|
||||
stackDepth.value = modalStackSize()
|
||||
debug('show: before open=true', {
|
||||
header: props.header,
|
||||
wasEmpty,
|
||||
stackDepth: stackDepth.value,
|
||||
})
|
||||
open.value = true
|
||||
debug('show: after open=true', {
|
||||
header: props.header,
|
||||
open: open.value,
|
||||
modalBodyExists: !!modalBodyRef.value,
|
||||
})
|
||||
previousFocusEl = document.activeElement
|
||||
debug('show: previous focus captured', {
|
||||
header: props.header,
|
||||
previousFocusTag: previousFocusEl instanceof HTMLElement ? previousFocusEl.tagName : null,
|
||||
previousFocusClass: previousFocusEl instanceof HTMLElement ? previousFocusEl.className : null,
|
||||
})
|
||||
pushModal()
|
||||
debug('show: after pushModal', { header: props.header, stackSize: modalStackSize() })
|
||||
if (wasEmpty) modalBehavior?.onShow?.()
|
||||
debug('show: after modalBehavior onShow', { header: props.header })
|
||||
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
window.addEventListener('mousedown', updateMousePosition)
|
||||
debug('show: listeners attached', { header: props.header })
|
||||
if (event) {
|
||||
updateMousePosition(event)
|
||||
} else {
|
||||
mouseX.value = Math.round(window.innerWidth / 2)
|
||||
mouseY.value = Math.round(window.innerHeight / 2)
|
||||
}
|
||||
debug('show: mouse position set', {
|
||||
header: props.header,
|
||||
mouseX: mouseX.value,
|
||||
mouseY: mouseY.value,
|
||||
})
|
||||
setTimeout(() => {
|
||||
debug('show: timeout before visible=true', {
|
||||
header: props.header,
|
||||
open: open.value,
|
||||
visible: visible.value,
|
||||
modalBodyExists: !!modalBodyRef.value,
|
||||
})
|
||||
visible.value = true
|
||||
debug('show: timeout after visible=true', {
|
||||
header: props.header,
|
||||
open: open.value,
|
||||
visible: visible.value,
|
||||
modalBodyExists: !!modalBodyRef.value,
|
||||
})
|
||||
nextTick(() => {
|
||||
debug('show: nextTick focus start', {
|
||||
header: props.header,
|
||||
modalBodyExists: !!modalBodyRef.value,
|
||||
})
|
||||
const focusable = getFocusableElements()
|
||||
debug('show: focusable elements', {
|
||||
header: props.header,
|
||||
count: focusable.length,
|
||||
firstTag: focusable[0]?.tagName,
|
||||
})
|
||||
if (focusable.length > 0) {
|
||||
focusable[0].focus()
|
||||
} else {
|
||||
modalBodyRef.value?.focus()
|
||||
}
|
||||
debug('show: nextTick focus done', { header: props.header })
|
||||
})
|
||||
}, 50)
|
||||
debug('show: end', { header: props.header })
|
||||
}
|
||||
|
||||
function hide() {
|
||||
debug('hide: start', {
|
||||
header: props.header,
|
||||
open: open.value,
|
||||
visible: visible.value,
|
||||
disableClose: props.disableClose,
|
||||
stackSize: modalStackSize(),
|
||||
})
|
||||
if (props.disableClose) {
|
||||
debug('hide: ignored disableClose', { header: props.header })
|
||||
return
|
||||
}
|
||||
props.onHide?.()
|
||||
debug('hide: after onHide', { header: props.header })
|
||||
visible.value = false
|
||||
debug('hide: after visible=false', { header: props.header, visible: visible.value })
|
||||
popModal()
|
||||
debug('hide: after popModal', { header: props.header, stackSize: modalStackSize() })
|
||||
if (modalStackSize() === 0) {
|
||||
modalBehavior?.onHide?.()
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
window.removeEventListener('mousedown', updateMousePosition)
|
||||
debug('hide: listeners removed', { header: props.header })
|
||||
if (previousFocusEl instanceof HTMLElement) {
|
||||
debug('hide: restoring focus', {
|
||||
header: props.header,
|
||||
previousFocusTag: previousFocusEl.tagName,
|
||||
previousFocusClass: previousFocusEl.className,
|
||||
})
|
||||
previousFocusEl.focus()
|
||||
}
|
||||
previousFocusEl = null
|
||||
setTimeout(() => {
|
||||
debug('hide: timeout before open=false', {
|
||||
header: props.header,
|
||||
open: open.value,
|
||||
visible: visible.value,
|
||||
})
|
||||
open.value = false
|
||||
debug('hide: timeout after open=false', {
|
||||
header: props.header,
|
||||
open: open.value,
|
||||
visible: visible.value,
|
||||
})
|
||||
}, 300)
|
||||
debug('hide: end', { header: props.header })
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -394,12 +310,6 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
debug('unmounted', {
|
||||
header: props.header,
|
||||
open: open.value,
|
||||
visible: visible.value,
|
||||
stackSize: modalStackSize(),
|
||||
})
|
||||
if (open.value) {
|
||||
popModal()
|
||||
window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
@@ -528,11 +438,12 @@ defineOptions({
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(v-bind(mouseXOffset), v-bind(mouseYOffset));
|
||||
transition: all 0.2s ease-out;
|
||||
transition: none;
|
||||
|
||||
&.shown {
|
||||
visibility: visible;
|
||||
transform: translate(0, 0);
|
||||
transition: all 0.2s ease-out;
|
||||
|
||||
> .modal-body {
|
||||
opacity: 1;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
class="popup-notification-group"
|
||||
:class="{
|
||||
'has-sidebar': hasSidebar,
|
||||
'has-modal': hasModalActive,
|
||||
}"
|
||||
:style="notificationGroupStyle"
|
||||
>
|
||||
<transition-group name="popup-notifs">
|
||||
<div
|
||||
@@ -155,6 +157,7 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useModalStack } from '../../composables/modal-stack'
|
||||
import {
|
||||
injectPopupNotificationManager,
|
||||
type PopupNotification,
|
||||
@@ -169,6 +172,11 @@ const popupNotificationManager = injectPopupNotificationManager()
|
||||
const notifications = computed<PopupNotification[]>(() =>
|
||||
popupNotificationManager.getNotifications(),
|
||||
)
|
||||
const { stackCount } = useModalStack()
|
||||
const hasModalActive = computed(() => stackCount.value > 0)
|
||||
const notificationGroupStyle = computed(() => ({
|
||||
zIndex: hasModalActive.value ? 100 + stackCount.value * 10 + 8 : 200,
|
||||
}))
|
||||
|
||||
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
|
||||
const setNotificationTimer = (n: PopupNotification) =>
|
||||
@@ -252,12 +260,21 @@ withDefaults(
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
transition:
|
||||
opacity 0.2s ease-in-out,
|
||||
transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.popup-notification-group.has-sidebar {
|
||||
right: calc(var(--right-bar-width, 0px) + 1.5rem);
|
||||
}
|
||||
|
||||
.popup-notification-group.has-modal {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.popup-notification-group {
|
||||
right: 0.75rem;
|
||||
|
||||
@@ -13,14 +13,14 @@ const props = withDefaults(
|
||||
selected: boolean
|
||||
active?: boolean
|
||||
tooltip?: string
|
||||
selectable?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
forwardImageSrc: undefined,
|
||||
backwardImageSrc: undefined,
|
||||
active: false,
|
||||
tooltip: undefined,
|
||||
selectable: true,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -54,7 +54,11 @@ watch(
|
||||
class="skin-button group relative flex items-end justify-center overflow-hidden border border-solid transition-[border-color,box-shadow] duration-200"
|
||||
:class="[
|
||||
selected ? 'skin-button--selected' : '',
|
||||
{ 'skin-button--with-actions': $slots['overlay-buttons'] },
|
||||
active ? 'skin-button--active' : '',
|
||||
{
|
||||
'skin-button--with-actions': $slots['overlay-buttons'] && !disabled,
|
||||
'skin-button--disabled': disabled,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<span
|
||||
@@ -69,6 +73,7 @@ watch(
|
||||
class="absolute inset-0 z-10 cursor-pointer border-none bg-transparent p-0 focus-visible:outline-none"
|
||||
:aria-label="tooltip ? `Select ${tooltip}` : 'Select skin'"
|
||||
:aria-pressed="selected"
|
||||
:disabled="disabled"
|
||||
@click="emit('select')"
|
||||
></button>
|
||||
|
||||
@@ -108,7 +113,7 @@ watch(
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="$slots['overlay-buttons']"
|
||||
v-if="$slots['overlay-buttons'] && !disabled"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-3 z-30 flex translate-y-2 items-center justify-start gap-1.5 px-3 opacity-0 transition-all duration-200 group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:translate-y-0 group-hover:opacity-100"
|
||||
>
|
||||
<slot name="overlay-buttons" />
|
||||
@@ -166,8 +171,8 @@ watch(
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skin-button:hover,
|
||||
.skin-button:focus-within,
|
||||
.skin-button:not(.skin-button--disabled):hover,
|
||||
.skin-button:not(.skin-button--disabled):focus-within,
|
||||
.skin-button--with-actions:hover,
|
||||
.skin-button--with-actions:focus-within {
|
||||
border-color: var(--surface-5);
|
||||
@@ -177,13 +182,27 @@ watch(
|
||||
0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.skin-button--selected,
|
||||
.skin-button--selected:hover,
|
||||
.skin-button--selected:focus-within {
|
||||
.skin-button.skin-button--selected,
|
||||
.skin-button.skin-button--selected:hover,
|
||||
.skin-button.skin-button--selected:focus-within,
|
||||
.skin-button.skin-button--selected.skin-button--with-actions:hover,
|
||||
.skin-button.skin-button--selected.skin-button--with-actions:focus-within,
|
||||
.skin-button.skin-button--active:hover,
|
||||
.skin-button.skin-button--active:focus-within,
|
||||
.skin-button.skin-button--active.skin-button--with-actions:hover,
|
||||
.skin-button.skin-button--active.skin-button--with-actions:focus-within {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-highlight);
|
||||
}
|
||||
|
||||
.skin-button--disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.skin-button--disabled button {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.skin-button__image-parent {
|
||||
width: 100%;
|
||||
height: 95%;
|
||||
@@ -195,7 +214,7 @@ watch(
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.skin-button:hover .skin-button__image-parent {
|
||||
.skin-button:not(.skin-button--disabled):hover .skin-button__image-parent {
|
||||
transform: rotateY(180deg) translateZ(0);
|
||||
}
|
||||
|
||||
@@ -218,7 +237,7 @@ watch(
|
||||
transition: filter 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.group:hover .skin-button__image-parent img {
|
||||
.group:not(.skin-button--disabled):hover .skin-button__image-parent img {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,12 +7,14 @@ const props = withDefaults(
|
||||
tooltip?: string
|
||||
dragActive?: boolean
|
||||
dropzone?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
selected: false,
|
||||
tooltip: undefined,
|
||||
dragActive: false,
|
||||
dropzone: false,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -28,6 +30,10 @@ function handleDragEvent(
|
||||
eventName: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
|
||||
event: DragEvent,
|
||||
) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.dropzone) {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
@@ -53,7 +59,10 @@ defineExpose({ getRootElement })
|
||||
:class="[
|
||||
isHighlighted
|
||||
? 'border-brand bg-brand-highlight'
|
||||
: 'border-surface-5 bg-surface-2 hover:bg-surface-3',
|
||||
: disabled
|
||||
? 'border-surface-5 bg-surface-2'
|
||||
: 'border-surface-5 bg-surface-2 hover:bg-surface-3',
|
||||
disabled ? 'opacity-[0.65]' : '',
|
||||
]"
|
||||
@dragenter="handleDragEvent('dragenter', $event)"
|
||||
@dragover="handleDragEvent('dragover', $event)"
|
||||
@@ -64,6 +73,8 @@ defineExpose({ getRootElement })
|
||||
type="button"
|
||||
:aria-label="tooltip ?? undefined"
|
||||
class="absolute inset-0 z-0 cursor-pointer border-none bg-transparent p-0"
|
||||
:class="{ 'cursor-not-allowed': disabled }"
|
||||
:disabled="disabled"
|
||||
@click="(e) => emit('click', e)"
|
||||
></button>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ function configureSkinPreviewMesh(mesh: THREE.Mesh) {
|
||||
materials.forEach((material) => {
|
||||
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
|
||||
|
||||
material.transparent = false
|
||||
material.transparent = isSkinLayer
|
||||
material.alphaTest = 0.1
|
||||
material.depthTest = true
|
||||
material.depthWrite = true
|
||||
|
||||
@@ -52,17 +52,30 @@ export const Clearable: Story = {
|
||||
render: () => ({
|
||||
components: { DatePicker },
|
||||
setup() {
|
||||
const value = ref('2026-04-27')
|
||||
return { value }
|
||||
const emptyValue = ref(null)
|
||||
const selectedValue = ref('2026-04-27')
|
||||
return { emptyValue, selectedValue }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex max-w-sm flex-col gap-2">
|
||||
<DatePicker
|
||||
v-model="value"
|
||||
wrapperClass="w-[300px]"
|
||||
placeholder="Select a date..."
|
||||
/>
|
||||
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
|
||||
<div class="flex max-w-sm flex-col gap-5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<DatePicker
|
||||
v-model="emptyValue"
|
||||
wrapperClass="w-[300px]"
|
||||
clearable
|
||||
placeholder="Button hidden while empty..."
|
||||
/>
|
||||
<p class="text-sm text-secondary">Empty value: {{ emptyValue || 'None' }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DatePicker
|
||||
v-model="selectedValue"
|
||||
wrapperClass="w-[300px]"
|
||||
clearable
|
||||
placeholder="Select a date..."
|
||||
/>
|
||||
<p class="text-sm text-secondary">Selected value: {{ selectedValue || 'None' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -157,6 +157,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
const isSkinLayer = mesh.name.endsWith('_Layer')
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
@@ -168,7 +169,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
|
||||
flatShading: true,
|
||||
side: THREE.FrontSide,
|
||||
toneMapped: false,
|
||||
transparent: false,
|
||||
transparent: isSkinLayer,
|
||||
})
|
||||
|
||||
setCommonMaterialProperties(mat)
|
||||
|
||||
Reference in New Issue
Block a user