refactor: migrate to nuxt compatibilityVersion: 4 (#3298)

This commit is contained in:
Daniel Roe 2025-05-20 15:05:01 +01:00 committed by GitHub
parent 46e4433e1c
commit a3fbc056a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
342 changed files with 1200 additions and 2932 deletions

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
const model = defineModel<number>()
const isValid = defineModel<boolean>('isValid')
const days = ref<number | ''>(0)
const hours = ref<number | ''>(1)
const minutes = ref<number | ''>(0)
watchEffect(() => {
if (days.value === '' || hours.value === '' || minutes.value === '') {
isValid.value = false
return
}
const duration
= days.value * 24 * 60 * 60
+ hours.value * 60 * 60
+ minutes.value * 60
if (duration <= 0) {
isValid.value = false
return
}
isValid.value = true
model.value = duration
})
</script>
<template>
<div flex flex-grow-0 gap-2>
<label flex items-center gap-2>
<input v-model="days" type="number" min="0" max="1999" input-base :class="!isValid ? 'input-error' : null">
{{ $t('confirm.mute_account.days', days === '' ? 0 : days) }}
</label>
<label flex items-center gap-2>
<input v-model="hours" type="number" min="0" max="24" input-base :class="!isValid ? 'input-error' : null">
{{ $t('confirm.mute_account.hours', hours === '' ? 0 : hours) }}
</label>
<label flex items-center gap-2>
<input v-model="minutes" type="number" min="0" max="59" step="5" input-base :class="!isValid ? 'input-error' : null">
{{ $t('confirm.mute_account.minute', minutes === '' ? 0 : minutes) }}
</label>
</div>
</template>

View file

@ -0,0 +1,56 @@
<script setup lang="ts">
import type { ConfirmDialogChoice, ConfirmDialogOptions } from '#shared/types'
const { extraOptionType } = defineProps<ConfirmDialogOptions>()
const emit = defineEmits<{
(evt: 'choice', choice: ConfirmDialogChoice): void
}>()
const hasDuration = ref(false)
const isValidDuration = ref(true)
const duration = ref(60 * 60) // default to 1 hour
const shouldMuteNotifications = ref(true)
const isMute = computed(() => extraOptionType === 'mute')
function handleChoice(choice: ConfirmDialogChoice['choice']) {
const dialogChoice = {
choice,
...isMute.value && {
extraOptions: {
mute: {
duration: hasDuration.value ? duration.value : 0,
notifications: shouldMuteNotifications.value,
},
},
},
}
emit('choice', dialogChoice)
}
</script>
<template>
<div flex="~ col" gap-6>
<div font-bold text-lg>
{{ title }}
</div>
<div v-if="description">
{{ description }}
</div>
<div v-if="isMute" flex-col flex gap-4>
<CommonCheckbox v-model="hasDuration" :label="$t('confirm.mute_account.specify_duration')" prepend-checkbox checked-icon-color="text-primary" />
<ModalDurationPicker v-if="hasDuration" v-model="duration" v-model:is-valid="isValidDuration" />
<CommonCheckbox v-model="shouldMuteNotifications" :label="$t('confirm.mute_account.notifications')" prepend-checkbox checked-icon-color="text-primary" />
</div>
<div flex justify-end gap-2>
<button btn-text @click="handleChoice('cancel')">
{{ cancel || $t('confirm.common.cancel') }}
</button>
<button btn-solid :disabled="!isValidDuration" @click="handleChoice('confirm')">
{{ confirm || $t('confirm.common.confirm') }}
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,111 @@
<script setup lang="ts">
import type { ConfirmDialogChoice } from '#shared/types'
import type { mastodon } from 'masto'
import {
isCommandPanelOpen,
isConfirmDialogOpen,
isEditHistoryDialogOpen,
isErrorDialogOpen,
isFavouritedBoostedByDialogOpen,
isKeyboardShortcutsDialogOpen,
isMediaPreviewOpen,
isPreviewHelpOpen,
isPublishDialogOpen,
isReportDialogOpen,
isSigninDialogOpen,
} from '~/composables/dialog'
const isMac = useIsMac()
// TODO: temporary, await for keybind system
// open search panel
// listen to ctrl+k on windows/linux or cmd+k on mac
// open command panel
// listen to ctrl+/ on windows/linux or cmd+/ on mac
// or shift+ctrl+k on windows/linux or shift+cmd+k on mac
useEventListener('keydown', (e: KeyboardEvent) => {
if ((e.key === 'k' || e.key === 'л') && (isMac.value ? e.metaKey : e.ctrlKey)) {
e.preventDefault()
openCommandPanel(e.shiftKey)
}
if ((e.key === '/' || e.key === ',') && (isMac.value ? e.metaKey : e.ctrlKey)) {
e.preventDefault()
openCommandPanel(true)
}
})
function handlePublished(status: mastodon.v1.Status) {
lastPublishDialogStatus.value = status
isPublishDialogOpen.value = false
}
function handlePublishClose() {
lastPublishDialogStatus.value = null
}
function handleConfirmChoice(choice: ConfirmDialogChoice) {
confirmDialogChoice.value = choice
isConfirmDialogOpen.value = false
}
function handleFavouritedBoostedByClose() {
isFavouritedBoostedByDialogOpen.value = false
}
</script>
<template>
<template v-if="isHydrated">
<ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125>
<UserSignIn />
</ModalDialog>
<ModalDialog v-model="isPreviewHelpOpen" keep-alive max-w-125>
<HelpPreview @close="closePreviewHelp()" />
</ModalDialog>
<ModalDialog
v-model="isPublishDialogOpen"
max-w-180 flex w-full
@close="handlePublishClose"
>
<PublishWidgetList
v-if="dialogDraftKey"
:draft-key="dialogDraftKey"
expanded
class="flex-1"
@published="handlePublished"
/>
</ModalDialog>
<ModalDialog
:model-value="isMediaPreviewOpen"
w-full max-w-full h-full max-h-full
bg-transparent border-0 shadow-none
@update:model-value="closeMediaPreview"
>
<ModalMediaPreview v-if="isMediaPreviewOpen" @close="closeMediaPreview()" />
</ModalDialog>
<ModalDialog v-model="isEditHistoryDialogOpen" max-w-125>
<StatusEditPreview v-if="statusEdit" :edit="statusEdit" />
</ModalDialog>
<ModalDialog v-model="isCommandPanelOpen" max-w-fit flex>
<CommandPanel @close="closeCommandPanel()" />
</ModalDialog>
<ModalDialog v-model="isConfirmDialogOpen" py-4 px-8 max-w-125>
<ModalConfirm v-if="confirmDialogLabel" v-bind="confirmDialogLabel" @choice="handleConfirmChoice" />
</ModalDialog>
<ModalDialog v-model="isErrorDialogOpen" py-4 px-8 max-w-125>
<ModalError v-if="errorDialogData" v-bind="errorDialogData" />
</ModalDialog>
<ModalDialog
v-model="isFavouritedBoostedByDialogOpen"
max-w-180
@close="handleFavouritedBoostedByClose"
>
<StatusFavouritedBoostedBy />
</ModalDialog>
<ModalDialog v-model="isKeyboardShortcutsDialogOpen" max-w-full sm:max-w-140 md:max-w-170 lg:max-w-220 md:min-w-160>
<MagickeysKeyboardShortcuts @close="closeKeyboardShortcuts()" />
</ModalDialog>
<ModalDialog v-model="isReportDialogOpen" keep-alive max-w-175>
<ReportModal v-if="reportAccount" :account="reportAccount" :status="reportStatus" @close="closeReportDialog()" />
</ModalDialog>
</template>
</template>

View file

@ -0,0 +1,195 @@
<script setup lang="ts">
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
defineOptions({
inheritAttrs: false,
})
const {
zIndex = 100,
closeByMask = true,
useVIf = true,
keepAlive = false,
} = defineProps<{
// level of depth
zIndex?: number
// whether to allow close dialog by clicking mask layer
closeByMask?: boolean
// use v-if, destroy all the internal elements after closed
useVIf?: boolean
// keep the dialog opened even when in other views
keepAlive?: boolean
// The aria-labelledby id for the dialog.
dialogLabelledBy?: string
}>()
const emit = defineEmits<{
/** v-model dialog visibility */
(event: 'close'): void
}>()
const visible = defineModel<boolean>({ required: true })
const deactivated = useDeactivated()
const route = useRoute()
const userSettings = useUserSettings()
/** scrollable HTML element */
const elDialogMain = ref<HTMLDivElement>()
const elDialogRoot = ref<HTMLDivElement>()
const { activate } = useFocusTrap(elDialogRoot, {
immediate: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
escapeDeactivates: true,
preventScroll: true,
returnFocusOnDeactivate: true,
})
defineExpose({
elDialogRoot,
elDialogMain,
})
/** close the dialog */
function close() {
if (!visible.value)
return
visible.value = false
emit('close')
}
function clickMask() {
if (closeByMask)
close()
}
const routePath = ref(route.path)
watch(visible, (value) => {
if (value)
routePath.value = route.path
})
const notInCurrentPage = computed(() => deactivated.value || routePath.value !== route.path)
watch(notInCurrentPage, (value) => {
if (keepAlive)
return
if (value)
close()
})
// controls the state of v-if.
// when useVIf is toggled, v-if has the same state as modelValue, otherwise v-if is true
const isVIf = computed(() => {
return useVIf
? visible.value
: true
})
// controls the state of v-show.
// when useVIf is toggled, v-show is true, otherwise it has the same state as modelValue
const isVShow = computed(() => {
return !useVIf
? visible.value
: true
})
function bindTypeToAny($attrs: any) {
return $attrs as any
}
function trapFocusDialog() {
if (isVShow.value)
nextTick().then(() => activate())
}
useEventListener('keydown', (e: KeyboardEvent) => {
if (!visible.value)
return
if (e.key === 'Escape') {
close()
e.preventDefault()
}
})
</script>
<template>
<Teleport to="body">
<!-- Dialog component -->
<Transition name="dialog-visible" @transitionend="trapFocusDialog">
<div
v-if="isVIf"
v-show="isVShow"
ref="elDialogRoot"
aria-modal="true"
:aria-labelledby="dialogLabelledBy"
:style="{
'z-index': zIndex,
}"
fixed inset-0 of-y-auto scrollbar-hide overscroll-none
>
<!-- The style `scrollbar-hide overscroll-none overflow-y-scroll` and `h="[calc(100%+0.5px)]"` is used to implement scroll locking, -->
<!-- corresponding to issue: #106, so please don't remove it. -->
<!-- Mask layer: blur -->
<div
class="dialog-mask"
:class="{
'backdrop-blur-sm': !getPreferences(userSettings, 'optimizeForLowPerformanceDevice'),
}"
absolute inset-0 z-0 bg-transparent opacity-100 backdrop-filter touch-none
/>
<!-- Mask layer: dimming -->
<div class="dialog-mask" absolute inset-0 z-0 bg-black opacity-48 touch-none h="[calc(100%+0.5px)]" @click="clickMask" />
<!-- Dialog container -->
<div class="p-safe-area" absolute inset-0 z-1 pointer-events-none opacity-100 flex>
<div flex-1 flex items-center justify-center p-4>
<!-- We use `class` here to make v-bind being able to be override them -->
<div
ref="elDialogMain"
class="dialog-main rounded shadow-lg pointer-events-auto isolate bg-base border-base border-1px border-solid w-full max-h-full of-y-auto overscroll-contain touch-pan-y touch-pan-x"
v-bind="bindTypeToAny($attrs)"
>
<slot />
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style lang="postcss" scoped>
.dialog-visible-enter-active,
.dialog-visible-leave-active {
transition-duration: 0.25s;
.dialog-mask {
transition: opacity 0.25s ease;
}
.dialog-main {
transition: opacity 0.25s ease, transform 0.25s ease;
}
}
.dialog-visible-enter-from,
.dialog-visible-leave-to {
.dialog-mask {
opacity: 0;
}
.dialog-main {
transform: translateY(50px);
opacity: 0;
}
}
.p-safe-area {
padding-top: env(safe-area-inset-top);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
}
</style>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { ErrorDialogData } from '#shared/types'
defineProps<ErrorDialogData>()
</script>
<template>
<div flex="~ col" gap-6>
<div font-bold text-lg text-center>
{{ title }}
</div>
<div
flex="~ col"
gap-1 text-sm
pt-1 ps-2 pe-1 pb-2
text-red-600 dark:text-red-400
border="~ base rounded red-600 dark:red-400"
>
<ol ps-2 sm:ps-1>
<li v-for="(message, i) in messages" :key="i" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
{{ message }}
</li>
</ol>
</div>
<div flex justify-end gap-2>
<button btn-text @click="closeErrorDialog()">
{{ close }}
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
const emit = defineEmits(['close'])
const locked = useScrollLock(document.body)
// Use to avoid strange error when directlying assigning to v-model on ModelMediaPreviewCarousel
const index = mediaPreviewIndex
const current = computed(() => mediaPreviewList.value[mediaPreviewIndex.value])
const hasNext = computed(() => index.value < mediaPreviewList.value.length - 1)
const hasPrev = computed(() => index.value > 0)
const keys = useMagicKeys()
whenever(keys.arrowLeft, prev)
whenever(keys.arrowRight, next)
function next() {
if (hasNext.value)
index.value++
}
function prev() {
if (hasPrev.value)
index.value--
}
function onClick(e: MouseEvent) {
const path = e.composedPath() as HTMLElement[]
const el = path.find(el => ['A', 'BUTTON', 'IMG', 'VIDEO', 'P'].includes(el.tagName?.toUpperCase()))
if (!el)
emit('close')
}
onMounted(() => locked.value = true)
onUnmounted(() => locked.value = false)
</script>
<template>
<div relative h-full w-full flex pt-12 @click="onClick">
<button
v-if="hasNext" pointer-events-auto btn-action-icon bg="black/20" :aria-label="$t('action.next')"
hover:bg="black/40" dark:bg="white/30" dark-hover:bg="white/20" absolute top="1/2" right-1 z5
:title="$t('action.next')" @click="next"
>
<div i-ri:arrow-right-s-line text-white />
</button>
<button
v-if="hasPrev" pointer-events-auto btn-action-icon bg="black/20" :aria-label="$t('action.prev')"
hover:bg="black/40" dark:bg="white/30" dark:hover-bg="white/20" absolute top="1/2" left-1 z5
:title="$t('action.prev')" @click="prev"
>
<div i-ri:arrow-left-s-line text-white />
</button>
<div flex="~ col center" h-full w-full>
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
<div bg="black/30" dark:bg="white/10" mb-6 mt-4 text-white rounded-full flex="~ center shrink-0" overflow-hidden>
<div v-if="mediaPreviewList.length > 1" p="y-1 x-3" rounded-r-0 shrink-0>
{{ index + 1 }} / {{ mediaPreviewList.length }}
</div>
<p
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-3" rounded-ie-full line-clamp-1
ws-pre-wrap break-all :title="current.description" w-full
>
{{ current.description }}
</p>
</div>
</div>
<div absolute top-0 w-full flex justify-end>
<button
btn-action-icon bg="black/30" :aria-label="$t('action.close')" hover:bg="black/40" dark:bg="white/30"
dark:hover-bg="white/20" pointer-events-auto shrink-0 @click="emit('close')"
>
<div i-ri:close-line text-white />
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,286 @@
<script setup lang="ts">
import type { Vector2 } from '@vueuse/gesture'
import type { mastodon } from 'masto'
import { useGesture } from '@vueuse/gesture'
import { useReducedMotion } from '@vueuse/motion'
const { media = [] } = defineProps<{
media?: mastodon.v1.MediaAttachment[]
}>()
const emit = defineEmits<{
(event: 'close'): void
}>()
const modelValue = defineModel<number>({ required: true })
const slideGap = 20
const doubleTapThreshold = 250
const view = ref()
const slider = ref()
const slide = ref()
const image = ref()
const reduceMotion = import.meta.server ? ref(false) : useReducedMotion()
const isInitialScrollDone = useTimeout(350)
const canAnimate = computed(() => isInitialScrollDone.value && !reduceMotion.value)
const scale = ref(1)
const x = ref(0)
const y = ref(0)
const isDragging = ref(false)
const isPinching = ref(false)
const maxZoomOut = ref(1)
const isZoomedIn = computed(() => scale.value > 1)
const enableAutoplay = usePreferences('enableAutoplay')
function goToFocusedSlide() {
scale.value = 1
x.value = slide.value[modelValue.value].offsetLeft * scale.value
y.value = 0
}
onMounted(() => {
const slideGapAsScale = slideGap / view.value.clientWidth
maxZoomOut.value = 1 - slideGapAsScale
goToFocusedSlide()
})
watch(modelValue, goToFocusedSlide)
let lastOrigin = [0, 0]
let initialScale = 0
useGesture({
onPinch({ first, initial: [initialDistance], movement: [deltaDistance], da: [distance], origin, touches }) {
isPinching.value = true
if (first) {
initialScale = scale.value
}
else {
if (touches === 0)
handleMouseWheelZoom(initialScale, deltaDistance, origin)
else
handlePinchZoom(initialScale, initialDistance, distance, origin)
}
lastOrigin = origin
},
onPinchEnd() {
isPinching.value = false
isDragging.value = false
if (!isZoomedIn.value)
goToFocusedSlide()
},
onDrag({ movement, delta, pinching, tap, last, swipe, event, xy }) {
event.preventDefault()
if (pinching)
return
if (last)
handleLastDrag(tap, swipe, movement, xy)
else
handleDrag(delta, movement)
},
}, {
domTarget: view,
eventOptions: {
passive: false,
},
})
const shiftRestrictions = computed(() => {
const focusedImage = image.value[modelValue.value]
const focusedSlide = slide.value[modelValue.value]
const scaledImageWidth = focusedImage.offsetWidth * scale.value
const scaledHorizontalOverflow = scaledImageWidth / 2 - view.value.clientWidth / 2 + slideGap
const horizontalOverflow = Math.max(0, scaledHorizontalOverflow / scale.value)
const scaledImageHeight = focusedImage.offsetHeight * scale.value
const scaledVerticalOverflow = scaledImageHeight / 2 - view.value.clientHeight / 2 + slideGap
const verticalOverflow = Math.max(0, scaledVerticalOverflow / scale.value)
return {
left: focusedSlide.offsetLeft - horizontalOverflow,
right: focusedSlide.offsetLeft + horizontalOverflow,
top: focusedSlide.offsetTop - verticalOverflow,
bottom: focusedSlide.offsetTop + verticalOverflow,
}
})
function handlePinchZoom(initialScale: number, initialDistance: number, distance: number, [originX, originY]: Vector2) {
scale.value = initialScale * (distance / initialDistance)
scale.value = Math.max(maxZoomOut.value, scale.value)
const deltaCenterX = originX - lastOrigin[0]
const deltaCenterY = originY - lastOrigin[1]
handleZoomDrag([deltaCenterX, deltaCenterY])
}
function handleMouseWheelZoom(initialScale: number, deltaDistance: number, [originX, originY]: Vector2) {
scale.value = initialScale + (deltaDistance / 1000)
scale.value = Math.max(maxZoomOut.value, scale.value)
const deltaCenterX = lastOrigin[0] - originX
const deltaCenterY = lastOrigin[1] - originY
handleZoomDrag([deltaCenterX, deltaCenterY])
}
function handleLastDrag(tap: boolean, swipe: Vector2, movement: Vector2, position: Vector2) {
isDragging.value = false
if (tap)
handleTap(position)
else if (swipe[0] || swipe[1])
handleSwipe(swipe, movement)
else if (!isZoomedIn.value)
slideToClosestSlide()
}
let lastTapAt = 0
function handleTap([positionX, positionY]: Vector2) {
const now = Date.now()
const isDoubleTap = now - lastTapAt < doubleTapThreshold
lastTapAt = now
if (!isDoubleTap)
return
if (isZoomedIn.value) {
goToFocusedSlide()
}
else {
const focusedSlideBounding = slide.value[modelValue.value].getBoundingClientRect()
const slideCenterX = focusedSlideBounding.left + focusedSlideBounding.width / 2
const slideCenterY = focusedSlideBounding.top + focusedSlideBounding.height / 2
scale.value = 3
x.value += positionX - slideCenterX
y.value += positionY - slideCenterY
restrictShiftToInsideSlide()
}
}
function handleSwipe([horiz, vert]: Vector2, [movementX, movementY]: Vector2) {
if (isZoomedIn.value || isPinching.value)
return
const isHorizontalDrag = Math.abs(movementX) >= Math.abs(movementY)
if (isHorizontalDrag) {
if (horiz === 1) // left
modelValue.value = Math.max(0, modelValue.value - 1)
if (horiz === -1) // right
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
}
else if (vert === 1 || vert === -1) {
emit('close')
}
goToFocusedSlide()
}
function slideToClosestSlide() {
const startOfFocusedSlide = slide.value[modelValue.value].offsetLeft * scale.value
const slideWidth = slide.value[modelValue.value].offsetWidth * scale.value
if (x.value > startOfFocusedSlide + slideWidth / 2)
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
else if (x.value < startOfFocusedSlide - slideWidth / 2)
modelValue.value = Math.max(0, modelValue.value - 1)
goToFocusedSlide()
}
function handleDrag(delta: Vector2, movement: Vector2) {
isDragging.value = true
if (isZoomedIn.value)
handleZoomDrag(delta)
else
handleSlideDrag(movement)
}
function handleZoomDrag([deltaX, deltaY]: Vector2) {
x.value -= deltaX / scale.value
y.value -= deltaY / scale.value
restrictShiftToInsideSlide()
}
function handleSlideDrag([movementX, movementY]: Vector2) {
goToFocusedSlide()
if (Math.abs(movementY) > Math.abs(movementX)) // vertical movement is more than horizontal
y.value -= movementY / scale.value
else
x.value -= movementX / scale.value
if (media.length === 1)
x.value = 0
}
function restrictShiftToInsideSlide() {
x.value = Math.min(shiftRestrictions.value.right, Math.max(shiftRestrictions.value.left, x.value))
y.value = Math.min(shiftRestrictions.value.bottom, Math.max(shiftRestrictions.value.top, y.value))
}
const sliderStyle = computed(() => {
const style = {
transform: `scale(${scale.value}) translate(${-x.value}px, ${-y.value}px)`,
transition: 'none',
gap: `${slideGap}px`,
}
if (canAnimate.value && !isDragging.value && !isPinching.value)
style.transition = 'all 0.3s ease'
return style
})
const imageStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab',
}))
</script>
<template>
<div ref="view" flex flex-row h-full w-full overflow-hidden>
<div ref="slider" :style="sliderStyle" w-full h-full flex items-center>
<div
v-for="item in media"
:key="item.id"
ref="slide"
flex-shrink-0
w-full
h-full
flex
items-center
justify-center
>
<component
:is="item.type === 'gifv' ? 'video' : 'img'"
ref="image"
:autoplay="enableAutoplay"
controls
loop
select-none
max-w-full
max-h-full
:style="imageStyle"
:draggable="false"
:src="item.url || item.previewUrl"
:alt="item.description || ''"
/>
</div>
</div>
</div>
</template>