mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
Merge branch 'main' into prospector/app-layout-cleanup
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -61,6 +62,11 @@ function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
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 })
|
||||
</script>
|
||||
<template>
|
||||
@@ -81,26 +87,32 @@ defineExpose({ show, hide, selectedTab, setTab })
|
||||
<div
|
||||
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
|
||||
>
|
||||
<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"
|
||||
<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.badge) }}
|
||||
</span>
|
||||
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
|
||||
</component>
|
||||
{{ 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>
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
+16
-2
@@ -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
|
||||
@@ -266,6 +268,12 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
: `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 } : {}),
|
||||
installing: item.installing === true,
|
||||
toggleDisabled: props.actionDisabled,
|
||||
@@ -293,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> = {}
|
||||
@@ -335,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
|
||||
|
||||
@@ -402,7 +402,11 @@ import {
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { UserBadge } from '@modrinth/utils'
|
||||
import {
|
||||
isModrinthUser as checkIsModrinthUser,
|
||||
isOfficialAccount as checkIsOfficialAccount,
|
||||
UserBadge,
|
||||
} from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -716,8 +720,8 @@ const earliestProjectByType = computed(() => {
|
||||
return earliest
|
||||
})
|
||||
|
||||
const isModrinthUser = computed(() => user.value?.id === '2REoufqX')
|
||||
const isOfficialAccount = computed(() => isModrinthUser.value || user.value?.id === 'GVFjtWTf')
|
||||
const isModrinthUser = computed(() => checkIsModrinthUser(user.value?.id))
|
||||
const isOfficialAccount = computed(() => checkIsOfficialAccount(user.value?.id))
|
||||
const isSelf = computed(() => auth.user.value?.id === user.value?.id)
|
||||
const isAdminViewing = computed(() => auth.user.value?.role === 'admin')
|
||||
const isStaffViewing = computed(
|
||||
|
||||
@@ -2321,6 +2321,9 @@
|
||||
"label.password": {
|
||||
"defaultMessage": "Password"
|
||||
},
|
||||
"label.permissions": {
|
||||
"defaultMessage": "Permissions"
|
||||
},
|
||||
"label.plan-custom": {
|
||||
"defaultMessage": "Custom"
|
||||
},
|
||||
@@ -5240,6 +5243,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"
|
||||
},
|
||||
@@ -5249,12 +5255,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"
|
||||
},
|
||||
|
||||
@@ -232,46 +232,55 @@ export const ManyTabs: StoryObj = {
|
||||
const tabs = [
|
||||
{
|
||||
name: { id: 'general', defaultMessage: 'General' },
|
||||
category: { id: 'display-category', defaultMessage: 'Display' },
|
||||
icon: InfoIcon,
|
||||
content: makeTabContent('General'),
|
||||
},
|
||||
{
|
||||
name: { id: 'appearance', defaultMessage: 'Appearance' },
|
||||
category: { id: 'display-category', defaultMessage: 'Display' },
|
||||
icon: PaintbrushIcon,
|
||||
content: makeTabContent('Appearance'),
|
||||
},
|
||||
{
|
||||
name: { id: 'language', defaultMessage: 'Language' },
|
||||
category: { id: 'display-category', defaultMessage: 'Display' },
|
||||
icon: LanguagesIcon,
|
||||
content: makeTabContent('Language'),
|
||||
},
|
||||
{
|
||||
name: { id: 'privacy', defaultMessage: 'Privacy' },
|
||||
category: { id: 'account-category', defaultMessage: 'Account' },
|
||||
icon: ShieldIcon,
|
||||
content: makeTabContent('Privacy'),
|
||||
},
|
||||
{
|
||||
name: { id: 'java', defaultMessage: 'Java and memory' },
|
||||
category: { id: 'instances-category', defaultMessage: 'Instances' },
|
||||
icon: CoffeeIcon,
|
||||
content: makeTabContent('Java and memory'),
|
||||
},
|
||||
{
|
||||
name: { id: 'instances', defaultMessage: 'Default instance options' },
|
||||
category: { id: 'instances-category', defaultMessage: 'Instances' },
|
||||
icon: GameIcon,
|
||||
content: makeTabContent('Default instance options'),
|
||||
},
|
||||
{
|
||||
name: { id: 'resources', defaultMessage: 'Resource management' },
|
||||
category: { id: 'instances-category', defaultMessage: 'Instances' },
|
||||
icon: GaugeIcon,
|
||||
content: makeTabContent('Resource management'),
|
||||
},
|
||||
{
|
||||
name: { id: 'window', defaultMessage: 'Window' },
|
||||
category: { id: 'advanced-category', defaultMessage: 'Advanced' },
|
||||
icon: MonitorIcon,
|
||||
content: makeTabContent('Window'),
|
||||
},
|
||||
{
|
||||
name: { id: 'hooks', defaultMessage: 'Launch hooks' },
|
||||
category: { id: 'advanced-category', defaultMessage: 'Advanced' },
|
||||
icon: WrenchIcon,
|
||||
content: makeTabContent('Launch hooks'),
|
||||
},
|
||||
|
||||
@@ -291,6 +291,10 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.scopes',
|
||||
defaultMessage: 'Scopes',
|
||||
},
|
||||
permissionsLabel: {
|
||||
id: 'label.permissions',
|
||||
defaultMessage: 'Permissions',
|
||||
},
|
||||
searchLabel: {
|
||||
id: 'label.search',
|
||||
defaultMessage: 'Search',
|
||||
|
||||
Reference in New Issue
Block a user