mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
qa: invite modal dropdown + other + clamp limits
This commit is contained in:
@@ -142,6 +142,7 @@ const props = withDefaults(
|
|||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
step?: number
|
step?: number
|
||||||
|
clamp?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
error?: boolean
|
error?: boolean
|
||||||
@@ -159,6 +160,7 @@ const props = withDefaults(
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
size: 'standard',
|
size: 'standard',
|
||||||
variant: 'filled',
|
variant: 'filled',
|
||||||
|
clamp: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
error: false,
|
error: false,
|
||||||
@@ -189,12 +191,22 @@ defineExpose({
|
|||||||
|
|
||||||
function onInput(event: Event) {
|
function onInput(event: Event) {
|
||||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement
|
||||||
model.value =
|
if (props.type !== 'number' || props.multiline) {
|
||||||
props.type === 'number' && !props.multiline
|
model.value = target.value
|
||||||
? target.value === ''
|
return
|
||||||
? undefined
|
}
|
||||||
: Number(target.value)
|
if (target.value === '') {
|
||||||
: 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() {
|
function clear() {
|
||||||
|
|||||||
+229
-17
@@ -1,19 +1,62 @@
|
|||||||
<template>
|
<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-6">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span>
|
<span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span>
|
||||||
<DatePicker
|
<Combobox
|
||||||
v-model="expiry"
|
:model-value="selectedExpiryPreset"
|
||||||
|
:options="expiryDropdownOptions"
|
||||||
|
:display-value="expiryPickerLabel"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
:min-date="minimumExpiry"
|
:dropdown-min-width="customExpiryOpen ? '20rem' : undefined"
|
||||||
:max-date="maximumExpiry"
|
:dropdown-class="customExpiryOpen ? 'bg-transparent border-0 -mt-1 pb-2 shadow-none' : ''"
|
||||||
date-format="Y-m-d H:i"
|
@open="handleExpiryPickerOpen"
|
||||||
alt-format="F j, Y at h:i K"
|
@close="handleExpiryPickerClose"
|
||||||
enable-time
|
@select="selectExpiryPreset"
|
||||||
wrapper-class="w-full"
|
>
|
||||||
input-class="w-full"
|
<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>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span>
|
<span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span>
|
||||||
@@ -24,6 +67,7 @@
|
|||||||
:max="maximumUses"
|
:max="maximumUses"
|
||||||
:step="1"
|
:step="1"
|
||||||
:disabled="saving || maximumUses === 0"
|
:disabled="saving || maximumUses === 0"
|
||||||
|
clamp
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,14 +95,30 @@
|
|||||||
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
import { SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useFormatDateTime } from '../../../composables'
|
||||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||||
import { injectNotificationManager } from '../../../providers'
|
import { injectNotificationManager } from '../../../providers'
|
||||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||||
|
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
|
||||||
import DatePicker from '../../base/DatePicker.vue'
|
import DatePicker from '../../base/DatePicker.vue'
|
||||||
import StyledInput from '../../base/StyledInput.vue'
|
import StyledInput from '../../base/StyledInput.vue'
|
||||||
import NewModal from '../../modal/NewModal.vue'
|
import NewModal from '../../modal/NewModal.vue'
|
||||||
import type { InviteLinkSettings } from './types'
|
import type { InviteLinkSettings } from './types'
|
||||||
|
|
||||||
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
linkExpiresAt?: string | Date | null
|
linkExpiresAt?: string | Date | null
|
||||||
@@ -74,11 +134,17 @@ const { formatMessage } = useVIntl()
|
|||||||
const notificationManager = injectNotificationManager(null)
|
const notificationManager = injectNotificationManager(null)
|
||||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||||
const expiry = ref('')
|
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 maxUses = ref<number>()
|
||||||
const minimumExpiry = ref(new Date())
|
const minimumExpiry = ref(new Date())
|
||||||
const maximumExpiry = ref(new Date())
|
const maximumExpiry = ref(new Date())
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const maximumUses = computed(() => Math.max(0, Math.floor(props.linkMaxUsesLimit)))
|
const maximumUses = computed(() => Math.max(0, Math.floor(props.linkMaxUsesLimit)))
|
||||||
|
const formatExpiryDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
@@ -93,10 +159,46 @@ const messages = defineMessages({
|
|||||||
id: 'sharing.invite-players-modal.max-uses-label',
|
id: 'sharing.invite-players-modal.max-uses-label',
|
||||||
defaultMessage: 'Maximum uses',
|
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: {
|
cancel: {
|
||||||
id: 'sharing.invite-players-modal.cancel-button',
|
id: 'sharing.invite-players-modal.cancel-button',
|
||||||
defaultMessage: 'Cancel',
|
defaultMessage: 'Cancel',
|
||||||
},
|
},
|
||||||
|
apply: {
|
||||||
|
id: 'sharing.invite-players-modal.apply-button',
|
||||||
|
defaultMessage: 'Apply',
|
||||||
|
},
|
||||||
save: {
|
save: {
|
||||||
id: 'sharing.invite-players-modal.save-button',
|
id: 'sharing.invite-players-modal.save-button',
|
||||||
defaultMessage: 'Save',
|
defaultMessage: 'Save',
|
||||||
@@ -107,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 canSave = computed(() => {
|
||||||
const date = parseLocalDate(expiry.value)
|
const date = parseLocalDate(expiry.value)
|
||||||
return (
|
return (
|
||||||
@@ -133,13 +264,48 @@ function parseLocalDate(value: string) {
|
|||||||
return Number.isNaN(date.getTime()) ? null : date
|
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() {
|
function show() {
|
||||||
const now = new Date()
|
expiryReferenceTime.value = Date.now()
|
||||||
minimumExpiry.value = new Date(now.getTime() + 3_600_000)
|
minimumExpiry.value = roundUpToMinute(expiryReferenceTime.value + MINIMUM_EXPIRY_DURATION)
|
||||||
minimumExpiry.value.setSeconds(0, 0)
|
maximumExpiry.value = roundDownToMinute(expiryReferenceTime.value + MAXIMUM_EXPIRY_DURATION)
|
||||||
minimumExpiry.value.setMinutes(minimumExpiry.value.getMinutes() + 1)
|
|
||||||
maximumExpiry.value = new Date(now.getTime() + 7 * 86_400_000)
|
|
||||||
maximumExpiry.value.setSeconds(0, 0)
|
|
||||||
const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value
|
const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value
|
||||||
const date =
|
const date =
|
||||||
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
|
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
|
||||||
@@ -148,10 +314,56 @@ function show() {
|
|||||||
? maximumExpiry.value
|
? maximumExpiry.value
|
||||||
: currentExpiry
|
: currentExpiry
|
||||||
expiry.value = formatLocalDate(date)
|
expiry.value = formatLocalDate(date)
|
||||||
|
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)
|
maxUses.value = Math.min(props.linkMaxUses, maximumUses.value)
|
||||||
modal.value?.show()
|
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() {
|
async function save() {
|
||||||
const date = parseLocalDate(expiry.value)
|
const date = parseLocalDate(expiry.value)
|
||||||
if (!canSave.value || !date || !props.updateInviteLink) return
|
if (!canSave.value || !date || !props.updateInviteLink) return
|
||||||
|
|||||||
Reference in New Issue
Block a user