refactor: migrate to nuxt compatibilityVersion: 4 (#3298)
This commit is contained in:
parent
46e4433e1c
commit
a3fbc056a9
342 changed files with 1200 additions and 2932 deletions
14
app/components/common/AnimateNumber.vue
Normal file
14
app/components/common/AnimateNumber.vue
Normal 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>
|
25
app/components/common/CommonAlert.vue
Normal file
25
app/components/common/CommonAlert.vue
Normal 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>
|
16
app/components/common/CommonBlurhash.vue
Normal file
16
app/components/common/CommonBlurhash.vue
Normal 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>
|
43
app/components/common/CommonCheckbox.vue
Normal file
43
app/components/common/CommonCheckbox.vue
Normal 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>
|
96
app/components/common/CommonCropImage.vue
Normal file
96
app/components/common/CommonCropImage.vue
Normal 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>
|
19
app/components/common/CommonErrorMessage.vue
Normal file
19
app/components/common/CommonErrorMessage.vue
Normal 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>
|
106
app/components/common/CommonInputImage.vue
Normal file
106
app/components/common/CommonInputImage.vue
Normal 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>
|
13
app/components/common/CommonMask.vue
Normal file
13
app/components/common/CommonMask.vue
Normal 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>
|
8
app/components/common/CommonNotFound.vue
Normal file
8
app/components/common/CommonNotFound.vue
Normal 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>
|
121
app/components/common/CommonPaginator.vue
Normal file
121
app/components/common/CommonPaginator.vue
Normal 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>
|
27
app/components/common/CommonPreviewPrompt.vue
Normal file
27
app/components/common/CommonPreviewPrompt.vue
Normal 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>
|
35
app/components/common/CommonRadio.vue
Normal file
35
app/components/common/CommonRadio.vue
Normal 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>
|
86
app/components/common/CommonRouteTabs.vue
Normal file
86
app/components/common/CommonRouteTabs.vue
Normal 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 || ' ' }}</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> </span>
|
||||
<span>{{ option.display }}</span>
|
||||
</span>
|
||||
</CommonDropdownItem>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</commondropdown>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
21
app/components/common/CommonScrollIntoView.vue
Normal file
21
app/components/common/CommonScrollIntoView.vue
Normal 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>
|
62
app/components/common/CommonTabs.vue
Normal file
62
app/components/common/CommonTabs.vue
Normal 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>
|
27
app/components/common/CommonTooltip.vue
Normal file
27
app/components/common/CommonTooltip.vue
Normal 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>
|
23
app/components/common/CommonTrending.vue
Normal file
23
app/components/common/CommonTrending.vue
Normal 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>
|
33
app/components/common/CommonTrendingCharts.vue
Normal file
33
app/components/common/CommonTrendingCharts.vue
Normal 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>
|
26
app/components/common/LocalizedNumber.vue
Normal file
26
app/components/common/LocalizedNumber.vue
Normal 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>
|
14
app/components/common/OfflineChecker.vue
Normal file
14
app/components/common/OfflineChecker.vue
Normal 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>
|
31
app/components/common/dropdown/Dropdown.vue
Normal file
31
app/components/common/dropdown/Dropdown.vue
Normal 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>
|
85
app/components/common/dropdown/DropdownItem.vue
Normal file
85
app/components/common/dropdown/DropdownItem.vue
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue