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,14 @@
<script setup lang="ts">
defineProps<{
increased?: boolean
}>()
</script>
<template>
<div of-hidden h="1.25rem">
<div flex="~ col" transition-transform duration-300 :class="increased ? 'translate-y--1/2' : 'translate-y-0'">
<slot />
<slot name="next" />
</div>
</div>
</template>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
const emit = defineEmits<{
(event: 'close'): void
}>()
const visible = defineModel<boolean>()
function close() {
emit('close')
visible.value = false
}
</script>
<template>
<div
flex="~ gap-2" justify-between items-center
border="b base" text-sm text-secondary px4 py2 sm:py4
>
<div>
<slot />
</div>
<button text-xl hover:text-primary bg-hover-overflow w="1.2em" h="1.2em" @click="close()">
<div i-ri:close-line />
</button>
</div>
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
})
const { blurhash = '', shouldLoadImage = true } = defineProps<{
blurhash?: string
src: string
srcset?: string
shouldLoadImage?: boolean
}>()
</script>
<template>
<UnLazyImage v-bind="$attrs" :blurhash="blurhash" :src="src" :src-set="srcset" :lazy-load="shouldLoadImage" auto-sizes />
</template>

View file

@ -0,0 +1,43 @@
<script setup lang="ts">
defineProps<{
label?: string
hover?: boolean
iconChecked?: string
iconUnchecked?: string
checkedIconColor?: string
prependCheckbox?: boolean
}>()
const modelValue = defineModel<boolean | null>()
</script>
<template>
<label
class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
:class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
v-bind="$attrs"
@click.prevent="modelValue = !modelValue"
>
<span v-if="label && !prependCheckbox" flex-1 ms-2 pointer-events-none>{{ label }}</span>
<span
:class="[
modelValue ? (iconChecked ?? 'i-ri:checkbox-line') : (iconUnchecked ?? 'i-ri:checkbox-blank-line'),
modelValue && checkedIconColor,
]"
text-lg
aria-hidden="true"
/>
<input
v-model="modelValue"
type="checkbox"
sr-only
>
<span v-if="label && prependCheckbox" flex-1 ms-2 pointer-events-none>{{ label }}</span>
</label>
</template>
<style>
.common-checkbox:focus-within {
outline: none;
border-bottom: 1px solid var(--c-text-base);
}
</style>

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
import type { Boundaries } from 'vue-advanced-cropper'
import { Cropper } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
const { stencilAspectRatio = 1 / 1, stencilSizePercentage = 0.9 } = defineProps<{
/** Crop frame aspect ratio (width/height), default 1/1 */
stencilAspectRatio?: number
/** The ratio of the longest edge of the cut box to the length of the cut screen, default 0.9, not more than 1 */
stencilSizePercentage?: number
}>()
const file = defineModel<File | null>()
const cropperDialog = ref(false)
const cropper = ref<InstanceType<typeof Cropper>>()
const cropperFlag = ref(false)
const cropperImage = reactive({
src: '',
type: 'image/jpg',
})
function stencilSize({ boundaries }: { boundaries: Boundaries }) {
return {
width: boundaries.width * stencilSizePercentage,
height: boundaries.height * stencilSizePercentage,
}
}
watch(file, (file, _, onCleanup) => {
let expired = false
onCleanup(() => expired = true)
if (file && !cropperFlag.value) {
cropperDialog.value = true
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = (e) => {
if (expired)
return
cropperImage.src = e.target?.result as string
cropperImage.type = file.type
}
}
cropperFlag.value = false
})
function cropImage() {
if (cropper.value && file.value) {
cropperFlag.value = true
cropperDialog.value = false
const { canvas } = cropper.value.getResult()
canvas?.toBlob((blob) => {
file.value = new File([blob as any], `cropped${file.value?.name}` as string, { type: blob?.type })
}, cropperImage.type)
}
}
</script>
<template>
<ModalDialog v-model="cropperDialog" :use-v-if="false" max-w-500px flex>
<div flex-1 w-0>
<div text-lg text-center my2 px3>
<h1>
{{ $t('action.edit') }}
</h1>
</div>
<div aspect-ratio-1>
<Cropper
ref="cropper"
class="overflow-hidden w-full h-full"
:src="cropperImage.src"
:resize-image="{
adjustStencil: false,
}"
:stencil-size="stencilSize"
:stencil-props="{
aspectRatio: stencilAspectRatio,
movable: false,
resizable: false,
handlers: {},
}"
image-restriction="stencil"
/>
</div>
<div m-4>
<button
btn-solid w-full rounded text-sm
@click="cropImage()"
>
{{ $t('action.confirm') }}
</button>
</div>
</div>
</ModalDialog>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{ describedBy: string }>()
</script>
<template>
<div
role="alert"
aria-live="polite"
:aria-describedby="describedBy"
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"
v-bind="$attrs"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,106 @@
<script setup lang="ts">
import type { FileWithHandle } from 'browser-fs-access'
import { fileOpen } from 'browser-fs-access'
const {
original,
allowedFileTypes = ['image/jpeg', 'image/png'],
allowedFileSize = 1024 * 1024 * 5, // 5 MB
} = defineProps<{
/** The image src before change */
original?: string
/** Allowed file types */
allowedFileTypes?: string[]
/** Allowed file size */
allowedFileSize?: number
imgClass?: string
loading?: boolean
}>()
const emit = defineEmits<{
(event: 'pick', value: FileWithHandle): void
(event: 'error', code: number, message: string): void
}>()
const file = defineModel<FileWithHandle | null>()
const { t } = useI18n()
const defaultImage = computed(() => original || '')
/** Preview of selected images */
const previewImage = ref('')
/** The current images on display */
const imageSrc = computed<string>(() => previewImage.value || defaultImage.value)
async function pickImage() {
if (import.meta.server)
return
const image = await fileOpen({
description: 'Image',
mimeTypes: allowedFileTypes,
})
if (!allowedFileTypes.includes(image.type)) {
emit('error', 1, t('error.unsupported_file_format'))
return
}
else if (image.size > allowedFileSize) {
emit('error', 2, t('error.file_size_cannot_exceed_n_mb', [5]))
return
}
file.value = image
emit('pick', file.value)
}
watch(file, (image, _, onCleanup) => {
let expired = false
onCleanup(() => expired = true)
if (!image) {
previewImage.value = ''
return
}
const reader = new FileReader()
reader.readAsDataURL(image)
reader.onload = (evt) => {
if (expired)
return
previewImage.value = evt.target?.result as string
}
})
</script>
<template>
<label
class="bg-slate-500/10 focus-within:(outline outline-primary)"
relative
flex justify-center items-center
cursor-pointer
of-hidden
@click="pickImage"
>
<img
v-if="imageSrc"
:src="imageSrc"
:class="imgClass || ''"
object-cover
w-full
h-full
>
<span absolute bg="black/50" text-white rounded-full text-xl w12 h12 flex justify-center items-center hover="bg-black/40 text-primary">
<span block i-ri:upload-line />
</span>
<span
v-if="loading"
absolute inset-0
bg="black/30" text-white
flex justify-center items-center
>
<span class="animate-spin animate-duration-[2.5s] preserve-3d">
<span block i-ri:loader-4-line text-4xl />
</span>
</span>
</label>
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
const {
zIndex = 100,
background = 'transparent',
} = defineProps<{
zIndex?: number
background?: string
}>()
</script>
<template>
<div fixed top-0 bottom-0 left-0 right-0 :style="{ background, zIndex }" />
</template>

View file

@ -0,0 +1,8 @@
<template>
<div flex="~ col" items-center>
<div i-ri:forbid-line text-10 mt10 mb2 />
<div text-lg>
<slot>{{ $t('common.not_found') }}</slot>
</div>
</div>
</template>

View file

@ -0,0 +1,121 @@
<script setup lang="ts" generic="T, O, U = T">
import type { mastodon } from 'masto'
// @ts-expect-error missing types
import { DynamicScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const {
paginator,
keyProp = 'id',
virtualScroller = false,
stream,
eventType,
preprocess,
endMessage = true,
} = defineProps<{
paginator: mastodon.Paginator<T[], O>
keyProp?: keyof T
virtualScroller?: boolean
stream?: mastodon.streaming.Subscription
eventType?: 'update' | 'notification'
preprocess?: (items: (U | T)[]) => U[]
endMessage?: boolean | string
}>()
defineSlots<{
default: (props: {
items: U[]
item: U
index: number
active?: boolean
older: U
newer: U // newer is undefined when index === 0
}) => void
items: (props: {
items: U[]
}) => void
updater: (props: {
number: number
update: () => void
}) => void
loading: (props: object) => void
done: (props: { items: U[] }) => void
}>()
const { t } = useI18n()
const nuxtApp = useNuxtApp()
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, toRef(() => stream), eventType, preprocess)
nuxtApp.hook('elk-logo:click', () => {
update()
nuxtApp.$scrollToTop()
})
function createEntry(item: any) {
items.value = [...items.value, preprocess?.([item]) ?? item]
}
function updateEntry(item: any) {
const id = item[keyProp]
const index = items.value.findIndex(i => (i as any)[keyProp] === id)
if (index > -1)
items.value = [...items.value.slice(0, index), preprocess?.([item]) ?? item, ...items.value.slice(index + 1)]
}
function removeEntry(entryId: any) {
items.value = items.value.filter(i => (i as any)[keyProp] !== entryId)
}
defineExpose({ createEntry, removeEntry, updateEntry })
</script>
<template>
<div>
<slot v-if="prevItems.length" name="updater" v-bind="{ number: prevItems.length, update }" />
<slot name="items" :items="items as U[]">
<template v-if="virtualScroller">
<DynamicScroller
v-slot="{ item, active, index }"
:items="items"
:min-item-size="200"
:key-field="keyProp"
page-mode
>
<slot
v-bind="{ key: item[keyProp] }"
:item="item"
:active="active"
:older="items[index + 1] as U"
:newer="items[index - 1] as U"
:index="index"
:items="items as U[]"
/>
</DynamicScroller>
</template>
<template v-else>
<slot
v-for="(item, index) of items"
v-bind="{ key: (item as U)[keyProp as keyof U] }"
:item="item as U"
:older="items[index + 1] as U"
:newer="items[index - 1] as U"
:index="index"
:items="items as U[]"
/>
</template>
</slot>
<div ref="endAnchor" />
<slot v-if="state === 'loading'" name="loading">
<TimelineSkeleton />
</slot>
<slot v-else-if="state === 'done' && endMessage !== false" name="done" :items="items as U[]">
<div p5 text-secondary italic text-center>
{{ t(typeof endMessage === 'string' && items.length <= 0 ? endMessage : 'common.end_of_list') }}
</div>
</slot>
<div v-else-if="state === 'error'" p5 text-secondary>
{{ t('common.error') }}: {{ error }}
</div>
</div>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
const build = useBuildInfo()
</script>
<template>
<div
m-2 p5 bg-rose:10 relative
rounded-lg of-hidden
flex="~ col gap-3"
>
<h2 font-bold text-rose>
{{ $t('help.build_preview.title') }}
</h2>
<p>
<i18n-t keypath="help.build_preview.desc1">
<NuxtLink :href="`https://github.com/elk-zone/elk/commit/${build.commit}`" target="_blank" text-rose hover:underline>
<code>{{ build.shortCommit }}</code>
</NuxtLink>
</i18n-t>
</p>
<p>{{ $t('help.build_preview.desc2') }}</p>
<p font-bold>
{{ $t('help.build_preview.desc3') }}
</p>
<div i-ri-git-pull-request-line absolute text-10em bottom--10 inset-ie--10 text-rose op10 class="-z-1" />
</div>
</template>

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{
label: string
value: any
hover?: boolean
}>()
const modelValue = defineModel()
</script>
<template>
<label
class="common-radio flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
:class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
@click.prevent="modelValue = value"
>
<span flex-1 ms-2 pointer-events-none>{{ label }}</span>
<span
:class="modelValue === value ? 'i-ri:radio-button-line' : 'i-ri:checkbox-blank-circle-line'"
aria-hidden="true"
/>
<input
v-model="modelValue"
type="radio"
:value="value"
sr-only
>
</label>
</template>
<style>
.common-radio:focus-within {
outline: none;
border-bottom: 1px solid var(--c-text-base);
}
</style>

View file

@ -0,0 +1,86 @@
<script setup lang="ts">
import type { CommonRouteTabMoreOption, CommonRouteTabOption } from '#shared/types'
const { options, command, preventScrollTop = false } = defineProps<{
options: CommonRouteTabOption[]
moreOptions?: CommonRouteTabMoreOption
command?: boolean
replace?: boolean
preventScrollTop?: boolean
}>()
const { t } = useI18n()
const router = useRouter()
useCommands(() => command
? options.map(tab => ({
scope: 'Tabs',
name: tab.display,
icon: tab.icon ?? 'i-ri:file-list-2-line',
onActivate: () => router.replace(tab.to),
}))
: [])
</script>
<template>
<div flex w-full items-center lg:text-lg of-x-auto scrollbar-hide border="b base">
<template
v-for="(option, index) in options.filter(item => !item.hide)"
:key="option?.name || index"
>
<NuxtLink
v-if="!option.disabled"
:to="option.to"
:replace="replace"
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
tabindex="0"
hover:bg-active transition-100
exact-active-class="children:(text-secondary !border-primary !op100 !text-base)"
@click="!preventScrollTop && $scrollToTop()"
>
<span ws-nowrap mxa sm:px2 sm:py3 xl:pb4 xl:pt5 py2 text-center border-b-3 text-secondary-light hover:text-secondary border-transparent>{{ option.display || '&nbsp;' }}</span>
</NuxtLink>
<div v-else flex flex-auto sm:px6 px2 xl:pb4 xl:pt5>
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center text-secondary-light op50>{{ option.display }}</span>
</div>
</template>
<template v-if="isHydrated && moreOptions?.options?.length">
<CommonDropdown placement="bottom" flex cursor-pointer mx-1.25rem>
<CommonTooltip placement="top" :content="moreOptions.tooltip || t('action.more')">
<button
cursor-pointer
flex
gap-1
w-12
rounded
hover:bg-active
btn-action-icon
op75
px4
group
:aria-label="t('action.more')"
:class="moreOptions.match ? 'text-primary' : 'text-secondary'"
>
<span v-if="moreOptions.icon" :class="moreOptions.icon" text-sm me--1 block />
<span i-ri:arrow-down-s-line text-sm me--1 block />
</button>
</CommonTooltip>
<template #popper>
<NuxtLink
v-for="(option, index) in moreOptions.options.filter(item => !item.hide)"
:key="option?.name || index"
:to="option.to"
>
<CommonDropdownItem>
<span flex="~ row" gap-x-4 items-center :class="option.match ? 'text-primary' : ''">
<span v-if="option.icon" :class="[option.icon, option.match ? 'text-primary' : 'text.secondary']" text-md me--1 block />
<span v-else block>&#160;</span>
<span>{{ option.display }}</span>
</span>
</CommonDropdownItem>
</NuxtLink>
</template>
</commondropdown>
</template>
</div>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
const { as = 'div', active } = defineProps<{
as: any
active: boolean
}>()
const el = ref()
watch(() => active, (active) => {
const _el = unrefElement(el)
if (active && _el)
_el.scrollIntoView({ block: 'nearest', inline: 'start' })
})
</script>
<template>
<component :is="as" ref="el">
<slot />
</component>
</template>

View file

@ -0,0 +1,62 @@
<script setup lang="ts">
const { options, command } = defineProps<{
options: string[] | {
name: string
icon?: string
display: string
}[]
command?: boolean
}>()
const modelValue = defineModel<string>({ required: true })
const tabs = computed(() => {
return options.map((option) => {
if (typeof option === 'string')
return { name: option, display: option }
else
return option
})
})
function toValidName(option: string) {
return option.toLowerCase().replace(/[^a-z0-9]/gi, '-')
}
useCommands(() => command
? tabs.value.map(tab => ({
scope: 'Tabs',
name: tab.display,
icon: tab.icon ?? 'i-ri:file-list-2-line',
onActivate: () => modelValue.value = tab.name,
}))
: [])
</script>
<template>
<div flex w-full items-center lg:text-lg>
<template v-for="option in tabs" :key="option">
<input
:id="`tab-${toValidName(option.name)}`"
:checked="modelValue === option.name"
:value="option"
type="radio"
name="tabs"
display="none"
@change="modelValue = option.name"
><label
flex flex-auto cursor-pointer px3 m1 rounded transition-all
:for="`tab-${toValidName(option.name)}`"
tabindex="0"
hover:bg-active transition-100
@keypress.enter="modelValue = option.name"
><span
mxa px4 py3 text-center border-b-3
:class="modelValue === option.name ? 'font-bold border-primary' : 'op50 hover:op50 border-transparent'"
>{{ option.display }}</span>
</label>
</template>
</div>
</template>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import type { Popper as VTooltipType } from 'floating-vue'
export interface Props extends Partial<typeof VTooltipType> {
content?: string
}
defineProps<Props>()
</script>
<template>
<VTooltip
v-if="isHydrated"
v-bind="$attrs"
auto-hide
no-auto-focus
>
<slot />
<template #popper>
<div text-3>
<slot name="popper">
{{ content }}
</slot>
</div>
</template>
</VTooltip>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const {
history,
maxDay = 2,
} = defineProps<{
history: mastodon.v1.TagHistory[]
maxDay?: number
}>()
const ongoingHot = computed(() => history.slice(0, maxDay))
const people = computed(() =>
ongoingHot.value.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
)
</script>
<template>
<p>
{{ $t('command.n_people_in_the_past_n_days', [people, maxDay]) }}
</p>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import sparkline from '@fnando/sparkline'
const {
history,
width = 60,
height = 40,
} = defineProps<{
history?: mastodon.v1.TagHistory[]
width?: number
height?: number
}>()
const historyNum = computed(() => {
if (!history)
return [1, 1, 1, 1, 1, 1, 1]
return [...history].reverse().map(item => Number(item.accounts) || 0)
})
const sparklineEl = ref<SVGSVGElement>()
const sparklineFn = typeof sparkline !== 'function' ? (sparkline as any).default : sparkline
watch([historyNum, sparklineEl], ([historyNum, sparklineEl]) => {
if (!sparklineEl)
return
sparklineFn(sparklineEl, historyNum)
})
</script>
<template>
<svg ref="sparklineEl" class="sparkline" :width="width" :height="height" stroke-width="3" />
</template>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
})
const { count } = defineProps<{
count: number
keypath: string
}>()
const { formatHumanReadableNumber, formatNumber, forSR } = useHumanReadableNumber()
const useSR = computed(() => forSR(count))
const rawNumber = computed(() => formatNumber(count))
const humanReadableNumber = computed(() => formatHumanReadableNumber(count))
</script>
<template>
<i18n-t :keypath="keypath" :plural="count" tag="span" class="flex gap-x-1">
<CommonTooltip v-if="useSR" :content="rawNumber" placement="bottom">
<span aria-hidden="true" v-bind="$attrs">{{ humanReadableNumber }}</span>
<span sr-only>{{ rawNumber }}</span>
</CommonTooltip>
<span v-else v-bind="$attrs">{{ humanReadableNumber }}</span>
</i18n-t>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
const online = useOnline()
</script>
<template>
<div
v-if="!online"
w-full min-h-30px px4 py3 text-primary bg-base
border="t base" flex="~ gap-2 center"
>
<div i-ri:wifi-off-line />
{{ $t('common.offline_desc') }}
</div>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { InjectionKeyDropdownContext } from '~/constants/symbols'
defineProps<{
placement?: string
autoBoundaryMaxSize?: boolean
}>()
const dropdown = ref<any>()
const colorMode = useColorMode()
function hide() {
return dropdown.value.hide()
}
provide(InjectionKeyDropdownContext, {
hide,
})
defineExpose({
hide,
})
</script>
<template>
<VDropdown v-bind="$attrs" ref="dropdown" :class="colorMode.value" :placement="placement || 'auto'" :auto-boundary-max-size="autoBoundaryMaxSize">
<slot />
<template #popper="scope">
<slot name="popper" v-bind="scope" />
</template>
</VDropdown>
</template>

View file

@ -0,0 +1,85 @@
<script setup lang="ts">
const {
is = 'div',
text,
description,
icon,
command,
} = defineProps<{
is?: string
text?: string
description?: string
icon?: string
checked?: boolean
command?: boolean
}>()
const emit = defineEmits(['click'])
const type = computed(() => is === 'button' ? 'button' : null)
const { hide } = useDropdownContext() || {}
const el = ref<HTMLDivElement>()
function handleClick(evt: MouseEvent) {
hide?.()
emit('click', evt)
}
useCommand({
scope: 'Actions',
order: -1,
visible: () => command && text,
name: () => text!,
icon: () => icon ?? 'i-ri:question-line',
description: () => description,
onActivate() {
const clickEvent = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
el.value?.dispatchEvent(clickEvent)
},
})
</script>
<template>
<component
v-bind="$attrs"
:is="is"
ref="el"
:type="type"
w-full
flex gap-3 items-center cursor-pointer px4 py3
select-none
hover-bg-active
:aria-label="text"
@click="handleClick"
>
<div v-if="icon" :class="icon" />
<div flex="~ col">
<div text-15px>
<slot>
{{ text }}
</slot>
</div>
<div text-3 text-secondary>
<slot name="description">
<p v-if="description">
{{ description }}
</p>
</slot>
</div>
</div>
<div flex-auto />
<div v-if="checked" i-ri:check-line />
<slot name="actions" />
</component>
</template>