forked from Mirrors/elk
Merge branch 'main' into feat/387-expand-mastodon-links
This commit is contained in:
commit
05bbd6ba4d
180 changed files with 3257 additions and 1451 deletions
1
.github/_workflows/README.md
vendored
1
.github/_workflows/README.md
vendored
|
@ -1 +0,0 @@
|
|||
GitHub Actions is temporary disabled as we are reaching the usage limit as a private repo. Tests have been moved to Netlify pipeline as an workaround. We shall recover this once we open up.
|
|
@ -7,6 +7,7 @@ on:
|
|||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
|
@ -17,7 +18,7 @@ jobs:
|
|||
- run: corepack enable
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 18
|
||||
cache: pnpm
|
||||
|
||||
- name: 📦 Install dependencies
|
34
.vscode/settings.json
vendored
34
.vscode/settings.json
vendored
|
@ -1,27 +1,29 @@
|
|||
{
|
||||
"prettier.enable": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"files.associations": {
|
||||
"*.css": "postcss"
|
||||
},
|
||||
"editor.formatOnSave": false,
|
||||
"cSpell.words": [
|
||||
"masto",
|
||||
"Nuxtodon",
|
||||
"unmute",
|
||||
"unstorage"
|
||||
],
|
||||
"i18n-ally.localesPaths": [
|
||||
"locales"
|
||||
],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"i18n-ally.preferredDelimiter": "_",
|
||||
"i18n-ally.sortKeys": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"editor.formatOnSave": false,
|
||||
"files.associations": {
|
||||
"*.css": "postcss"
|
||||
},
|
||||
"i18n-ally.keysInUse": [
|
||||
"time_ago_options.*",
|
||||
"visibility.*"
|
||||
]
|
||||
],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.localesPaths": [
|
||||
"locales"
|
||||
],
|
||||
"i18n-ally.preferredDelimiter": "_",
|
||||
"i18n-ally.sortKeys": true,
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"prettier.enable": false,
|
||||
"volar.completion.preferredTagNameCase": "pascal",
|
||||
"volar.completion.preferredAttrNameCase": "kebab"
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ git checkout -b my-new-branch
|
|||
|
||||
### Running PWA on dev server
|
||||
|
||||
In order to run Elk with PWA enabled, run `pnpm run dev:pwa` in Elk's root folder to start dev server or `pnpm dev:pwa:mocked` to start dev server with `@elkdev@universeodon.com` user.
|
||||
In order to run Elk with PWA enabled, run `pnpm run dev:pwa` in Elk's root folder to start dev server or `pnpm dev:mocked:pwa` to start dev server with `@elkdev@universeodon.com` user.
|
||||
|
||||
You should test the Elk PWA application on private browsing mode on any Chromium based browser: will not work on Firefox and Safari.
|
||||
|
||||
|
|
9
app.vue
9
app.vue
|
@ -12,4 +12,13 @@ const key = computed(() => `${currentUser.value?.server ?? currentServer.value}:
|
|||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<AriaAnnouncer />
|
||||
|
||||
<!-- Avatar Mask -->
|
||||
<svg absolute op0 width="0" height="0">
|
||||
<defs>
|
||||
<clipPath id="avatar-mask" clipPathUnits="objectBoundingBox">
|
||||
<path d="M 0,0.5 C 0,0 0,0 0.5,0 S 1,0 1,0.5 1,1 0.5,1 0,1 0,0.5" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { Account } from 'masto'
|
|||
|
||||
defineProps<{
|
||||
account: Account
|
||||
square?: boolean
|
||||
}>()
|
||||
|
||||
const loaded = $ref(false)
|
||||
|
@ -17,8 +18,8 @@ const error = $ref(false)
|
|||
:src="error ? 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' : account.avatar"
|
||||
:alt="$t('account.avatar_description', [account.username])"
|
||||
loading="lazy"
|
||||
rounded-full
|
||||
:class="loaded ? 'bg-base' : 'bg-gray:10'"
|
||||
:class="(loaded ? 'bg-base' : 'bg-gray:10') + (square ? ' ' : ' rounded-full')"
|
||||
:style="{ 'clip-path': square ? `url(#avatar-mask)` : 'none' }"
|
||||
v-bind="$attrs"
|
||||
@load="loaded = true"
|
||||
@error="error = true"
|
||||
|
|
|
@ -6,11 +6,12 @@ import type { Account } from 'masto'
|
|||
|
||||
defineProps<{
|
||||
account: Account
|
||||
square?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :key="account.avatar" v-bind="$attrs" rounded-full bg-base w-54px h-54px flex items-center justify-center>
|
||||
<AccountAvatar :account="account" w-48px h-48px />
|
||||
<AccountAvatar :account="account" w-48px h-48px :square="square" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { Account, Field } from 'masto'
|
||||
import { getAccountFieldIcon } from '~/composables/masto/icons'
|
||||
|
||||
const { account } = defineProps<{
|
||||
account: Account
|
||||
|
@ -118,7 +119,9 @@ const isSelf = $computed(() => currentUser.value?.account.id === account.id)
|
|||
</div>
|
||||
<div v-if="iconFields.length" flex="~ wrap gap-4">
|
||||
<div v-for="field in iconFields" :key="field.name" flex="~ gap-1" items-center>
|
||||
<div text-secondary :class="getAccountFieldIcon(field.name)" :title="getFieldIconTitle(field.name)" />
|
||||
<CommonTooltip :content="getFieldIconTitle(field.name)">
|
||||
<div text-secondary :class="getAccountFieldIcon(field.name)" :title="getFieldIconTitle(field.name)" />
|
||||
</CommonTooltip>
|
||||
<ContentRich text-sm filter-saturate-0 :content="field.value" :emojis="account.emojis" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -5,6 +5,7 @@ const { account, as = 'div' } = defineProps<{
|
|||
account: Account
|
||||
as?: string
|
||||
hoverCard?: boolean
|
||||
square?: boolean
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
|
@ -17,9 +18,9 @@ defineOptions({
|
|||
<template>
|
||||
<component :is="as" flex gap-3 v-bind="$attrs">
|
||||
<AccountHoverWrapper :disabled="!hoverCard" :account="account">
|
||||
<AccountBigAvatar :account="account" shrink-0 />
|
||||
<AccountBigAvatar :account="account" shrink-0 :square="square" />
|
||||
</AccountHoverWrapper>
|
||||
<div flex="~ col" shrink overflow-hidden justify-center leading-none>
|
||||
<div flex="~ col" shrink pt-1 h-full overflow-hidden justify-center leading-none>
|
||||
<div flex="~" gap-2>
|
||||
<ContentRich
|
||||
font-bold line-clamp-1 ws-pre-wrap break-all text-lg
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
// type used in <template>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type { Account } from 'masto'
|
||||
|
||||
defineProps<{
|
||||
|
@ -14,8 +16,9 @@ defineProps<{
|
|||
</div>
|
||||
|
||||
<div flex>
|
||||
<NuxtLink :to="getAccountRoute(account.moved as any)">
|
||||
<AccountInfo :account="account.moved" />
|
||||
<!-- type error of masto.js -->
|
||||
<NuxtLink :to="getAccountRoute(account.moved as unknown as Account)">
|
||||
<AccountInfo :account="account.moved as unknown as Account" />
|
||||
</NuxtLink>
|
||||
<div flex-auto />
|
||||
<div flex items-center>
|
||||
|
|
|
@ -37,5 +37,5 @@ const tabs = $computed(() => [
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<CommonRouteTabs force :options="tabs" prevent-scroll-top command />
|
||||
<CommonRouteTabs force replace :options="tabs" prevent-scroll-top command border="base b" />
|
||||
</template>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type { SearchResult as SearchResultType } from '@/components/search/types'
|
||||
import type { AccountResult, HashTagResult, SearchResult as SearchResultType } from '@/components/search/types'
|
||||
import type { CommandScope, QueryResult, QueryResultItem } from '@/composables/command'
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -37,11 +37,23 @@ const searchResult = $computed<QueryResult>(() => {
|
|||
if (query.length === 0 || loading.value)
|
||||
return { length: 0, items: [], grouped: {} as any }
|
||||
|
||||
// TODO extract this scope
|
||||
// duplicate in SearchWidget.vue
|
||||
const hashtagList = hashtags.value.slice(0, 3)
|
||||
.map<SearchResultType>(hashtag => ({ type: 'hashtag', hashtag, to: `/tags/${hashtag.name}` }))
|
||||
.map<HashTagResult>(hashtag => ({
|
||||
type: 'hashtag',
|
||||
id: hashtag.id,
|
||||
hashtag,
|
||||
to: getTagRoute(hashtag.name),
|
||||
}))
|
||||
.map(toSearchQueryResultItem)
|
||||
const accountList = accounts.value
|
||||
.map<SearchResultType>(account => ({ type: 'account', account, to: `/@${account.acct}` }))
|
||||
.map<AccountResult>(account => ({
|
||||
type: 'account',
|
||||
id: account.id,
|
||||
account,
|
||||
to: getAccountRoute(account),
|
||||
}))
|
||||
.map(toSearchQueryResultItem)
|
||||
|
||||
const grouped: QueryResult['grouped'] = new Map()
|
||||
|
@ -235,7 +247,7 @@ const onKeyDown = (e: KeyboardEvent) => {
|
|||
<!-- Footer -->
|
||||
<div class="flex items-center px-3 py-1 text-xs">
|
||||
<div i-ri:lightbulb-flash-line /> Tip: Use
|
||||
<!-- <CommandKey name="Ctrl+K" /> to search, -->
|
||||
<CommandKey name="Ctrl+K" /> to search,
|
||||
<CommandKey name="Ctrl+/" /> to activate command mode.
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
<script lang="ts" setup>
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: boolean
|
||||
}>(), {
|
||||
modelValue: true,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
const visible = useVModel(props, 'modelValue', emit, { passive: true })
|
||||
const { modelValue: visible } = defineModel<{
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
import { decode } from 'blurhash'
|
||||
|
||||
export default defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
blurhash: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
srcset: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
const placeholderSrc = ref<string>()
|
||||
const isLoaded = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const img = document.createElement('img')
|
||||
img.onload = () => {
|
||||
isLoaded.value = true
|
||||
}
|
||||
img.src = props.src
|
||||
if (props.srcset)
|
||||
img.srcset = props.srcset
|
||||
setTimeout(() => {
|
||||
isLoaded.value = true
|
||||
}, 3_000)
|
||||
|
||||
if (props.blurhash) {
|
||||
const pixels = decode(props.blurhash, 32, 32)
|
||||
placeholderSrc.value = getDataUrlFromArr(pixels, 32, 32)
|
||||
}
|
||||
})
|
||||
|
||||
return () => isLoaded.value || !placeholderSrc.value
|
||||
? h('img', { ...attrs, src: props.src, srcset: props.srcset })
|
||||
: h('img', { ...attrs, src: placeholderSrc.value })
|
||||
},
|
||||
})
|
43
components/common/CommonBlurhash.vue
Normal file
43
components/common/CommonBlurhash.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<script setup lang="ts">
|
||||
import { decode } from 'blurhash'
|
||||
|
||||
const { blurhash, src, srcset } = defineProps<{
|
||||
blurhash?: string | null | undefined
|
||||
src: string
|
||||
srcset?: string
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const isLoaded = ref(false)
|
||||
const placeholderSrc = $computed(() => {
|
||||
if (!blurhash)
|
||||
return ''
|
||||
const pixels = decode(blurhash, 32, 32)
|
||||
return getDataUrlFromArr(pixels, 32, 32)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const img = document.createElement('img')
|
||||
|
||||
img.onload = () => {
|
||||
isLoaded.value = true
|
||||
}
|
||||
|
||||
img.src = src
|
||||
|
||||
if (srcset)
|
||||
img.srcset = srcset
|
||||
|
||||
setTimeout(() => {
|
||||
isLoaded.value = true
|
||||
}, 3_000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="isLoaded || !placeholderSrc" v-bind="$attrs" :src="src" :srcset="srcset">
|
||||
<img v-else v-bind="$attrs" :src="placeholderSrc">
|
||||
</template>
|
|
@ -11,11 +11,13 @@ const { modelValue } = defineModel<{
|
|||
<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 ps-4' : null"
|
||||
:class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
|
||||
@click.prevent="modelValue = !modelValue"
|
||||
>
|
||||
<span flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
||||
<span
|
||||
:class="modelValue ? 'i-ri:checkbox-line' : 'i-ri:checkbox-blank-line'"
|
||||
text-lg
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
|
@ -23,7 +25,6 @@ const { modelValue } = defineModel<{
|
|||
type="checkbox"
|
||||
sr-only
|
||||
>
|
||||
<span ms-2 pointer-events-none>{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -4,8 +4,6 @@ import { Cropper } from 'vue-advanced-cropper'
|
|||
import 'vue-advanced-cropper/dist/style.css'
|
||||
|
||||
export interface Props {
|
||||
/** Images to be cropped */
|
||||
modelValue?: File
|
||||
/** 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 */
|
||||
|
@ -16,12 +14,11 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
stencilSizePercentage: 0.9,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: File): void
|
||||
const { modelValue: file } = defineModel<{
|
||||
/** Images to be cropped */
|
||||
modelValue: File | null
|
||||
}>()
|
||||
|
||||
const vmFile = useVModel(props, 'modelValue', emit, { passive: true })
|
||||
|
||||
const cropperDialog = ref(false)
|
||||
|
||||
const cropper = ref<InstanceType<typeof Cropper>>()
|
||||
|
@ -40,7 +37,7 @@ const stencilSize = ({ boundaries }: { boundaries: Boundaries }) => {
|
|||
}
|
||||
}
|
||||
|
||||
watch(vmFile, (file, _, onCleanup) => {
|
||||
watch(file, (file, _, onCleanup) => {
|
||||
let expired = false
|
||||
onCleanup(() => expired = true)
|
||||
|
||||
|
@ -59,12 +56,12 @@ watch(vmFile, (file, _, onCleanup) => {
|
|||
})
|
||||
|
||||
const cropImage = () => {
|
||||
if (cropper.value && vmFile.value) {
|
||||
if (cropper.value && file.value) {
|
||||
cropperFlag.value = true
|
||||
cropperDialog.value = false
|
||||
const { canvas } = cropper.value.getResult()
|
||||
canvas?.toBlob((blob) => {
|
||||
vmFile.value = new File([blob as any], `cropped${vmFile.value?.name}` as string, { type: blob?.type })
|
||||
file.value = new File([blob as any], `cropped${file.value?.name}` as string, { type: blob?.type })
|
||||
}, cropperImage.type)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ import { fileOpen } from 'browser-fs-access'
|
|||
import type { FileWithHandle } from 'browser-fs-access'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: FileWithHandle
|
||||
/** The image src before change */
|
||||
original?: string
|
||||
/** Allowed file types */
|
||||
|
@ -19,12 +18,13 @@ const props = withDefaults(defineProps<{
|
|||
allowedFileSize: 1024 * 1024 * 5, // 5 MB
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: FileWithHandle): void
|
||||
(event: 'pick', value: FileWithHandle): void
|
||||
(event: 'error', code: number, message: string): void
|
||||
}>()
|
||||
|
||||
const file = useVModel(props, 'modelValue', emit, { passive: true })
|
||||
const { modelValue: file } = defineModel<{
|
||||
modelValue: FileWithHandle | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// @ts-expect-error missing types
|
||||
import { DynamicScroller } from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
import type { Paginator, WsEvents } from 'masto'
|
||||
import type { Account, Paginator, WsEvents } from 'masto'
|
||||
|
||||
const {
|
||||
paginator,
|
||||
|
@ -11,6 +11,7 @@ const {
|
|||
virtualScroller = false,
|
||||
eventType = 'update',
|
||||
preprocess,
|
||||
isAccountTimeline,
|
||||
} = defineProps<{
|
||||
paginator: Paginator<any, any[]>
|
||||
keyProp?: string
|
||||
|
@ -18,6 +19,7 @@ const {
|
|||
stream?: Promise<WsEvents>
|
||||
eventType?: 'notification' | 'update'
|
||||
preprocess?: (items: any[]) => any[]
|
||||
isAccountTimeline?: boolean
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
|
@ -34,6 +36,16 @@ defineSlots<{
|
|||
loading: {}
|
||||
}>()
|
||||
|
||||
let account: Account | null = null
|
||||
|
||||
const { params } = useRoute()
|
||||
|
||||
if (isAccountTimeline) {
|
||||
const handle = $(computedEager(() => params.account as string))
|
||||
|
||||
account = await fetchAccountByHandle(handle)
|
||||
}
|
||||
|
||||
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, stream, eventType, preprocess)
|
||||
</script>
|
||||
|
||||
|
@ -72,8 +84,14 @@ const { items, prevItems, update, state, endAnchor, error } = usePaginator(pagin
|
|||
<slot v-if="state === 'loading'" name="loading">
|
||||
<TimelineSkeleton />
|
||||
</slot>
|
||||
<div v-else-if="state === 'done'" p5 text-secondary italic text-center>
|
||||
{{ $t('common.end_of_list') }}
|
||||
<div v-else-if="state === 'done'" p5 text-secondary italic text-center flex flex-col items-center gap1>
|
||||
<template v-if="isAccountTimeline">
|
||||
<span>{{ $t('timeline.view_older_posts') }}</span>
|
||||
<a :href="account!.url" not-italic text-primary hover="underline text-primary-active">{{ $t('menu.open_in_original_site') }}</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('common.end_of_list') }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="state === 'error'" p5 text-secondary>
|
||||
{{ $t('common.error') }}: {{ error }}
|
||||
|
|
|
@ -12,9 +12,10 @@ const { modelValue } = defineModel<{
|
|||
<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 ps-4' : null"
|
||||
: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"
|
||||
|
@ -25,7 +26,6 @@ const { modelValue } = defineModel<{
|
|||
:value="value"
|
||||
sr-only
|
||||
>
|
||||
<span ms-2 pointer-events-none>{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -40,12 +40,12 @@ useCommands(() => command
|
|||
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
|
||||
tabindex="1"
|
||||
hover:bg-active transition-100
|
||||
exact-active-class="children:(text-secondary !border-primary !op100)"
|
||||
exact-active-class="children:(text-secondary !border-primary !op100 !text-base)"
|
||||
@click="!preventScrollTop && $scrollToTop()"
|
||||
>
|
||||
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center border-b-3 text-secondary-light hover:text-secondary border-transparent>{{ option.display }}</span>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
@ -4,8 +4,12 @@ import sparkline from '@fnando/sparkline'
|
|||
|
||||
const {
|
||||
history,
|
||||
width = 60,
|
||||
height = 40,
|
||||
} = $defineProps<{
|
||||
history?: History[]
|
||||
width?: number
|
||||
height?: number
|
||||
}>()
|
||||
|
||||
const historyNum = $computed(() => {
|
||||
|
@ -24,5 +28,5 @@ watch([$$(historyNum), $$(sparklineEl)], ([historyNum, sparklineEl]) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<svg ref="sparklineEl" class="sparkline" width="60" height="40" stroke-width="3" />
|
||||
<svg ref="sparklineEl" class="sparkline" :width="width" :height="height" stroke-width="3" />
|
||||
</template>
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import { dropdownContextKey } from './ctx'
|
||||
import { InjectionKeyDropdownContext } from '~/constants/symbols'
|
||||
|
||||
defineProps<{
|
||||
placement?: string
|
||||
autoBoundaryMaxSize?: boolean
|
||||
}>()
|
||||
|
||||
const dropdown = $ref<any>()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const hide = () => dropdown.hide()
|
||||
provide(dropdownContextKey, {
|
||||
provide(InjectionKeyDropdownContext, {
|
||||
hide,
|
||||
})
|
||||
|
||||
|
@ -18,7 +20,7 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<VDropdown v-bind="$attrs" ref="dropdown" :class="colorMode.value" :placement="placement || 'auto'">
|
||||
<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" />
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { dropdownContextKey } from './ctx'
|
||||
|
||||
const props = defineProps<{
|
||||
text?: string
|
||||
description?: string
|
||||
|
@ -10,7 +8,7 @@ const props = defineProps<{
|
|||
}>()
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const { hide } = inject(dropdownContextKey, undefined) || {}
|
||||
const { hide } = useDropdownContext() || {}
|
||||
|
||||
const el = ref<HTMLDivElement>()
|
||||
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export const dropdownContextKey: InjectionKey<{
|
||||
hide: () => void
|
||||
}> = Symbol('dropdownContextKey')
|
|
@ -22,15 +22,17 @@ const emit = defineEmits<{
|
|||
{{ $t('help.desc_para2') }}
|
||||
</p>
|
||||
<p>
|
||||
Before that, if you'd like to help with testing, giving feedback, or contributing, <a font-bold text-primary href="/m.webtoo.ls/@elk" target="_blank">
|
||||
reach out to us on Mastodon
|
||||
</a> and get involved.
|
||||
{{ $t('help.desc_para4') }}
|
||||
<a font-bold text-primary href="/m.webtoo.ls/@elk" target="_blank">
|
||||
{{ $t('help.desc_para5') }}
|
||||
</a>
|
||||
{{ $t('help.desc_para6') }}
|
||||
</p>
|
||||
{{ $t('help.desc_para3') }}
|
||||
<p flex="~ gap-2 wrap" mxa>
|
||||
<template v-for="team of teams" :key="team.github">
|
||||
<a :href="`https://github.com/sponsors/${team.github}`" target="_blank" rounded-full transition duration-300 border="~ transparent" hover="scale-105 border-primary">
|
||||
<img :src="`https://res.cloudinary.com/dchoja2nb/image/twitter_name/h_120,w_120/f_auto/${team.twitter}.jpg`" :alt="team.display" rounded-full w-15 h-15 height="60" width="60">
|
||||
<img :src="`/avatars/${team.github}-100x100.png`" :alt="team.display" rounded-full w-15 h-15 height="60" width="60">
|
||||
</a>
|
||||
</template>
|
||||
</p>
|
||||
|
|
|
@ -14,7 +14,7 @@ defineProps<{
|
|||
pt="[env(safe-area-inset-top,0)]"
|
||||
border="b base" bg="[rgba(var(--c-bg-base-rgb),0.7)]"
|
||||
>
|
||||
<div flex justify-between px5 py2>
|
||||
<div xl:hidden flex justify-between px5 py2>
|
||||
<div flex gap-3 items-center overflow-hidden py2>
|
||||
<NuxtLink
|
||||
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0
|
||||
|
@ -37,6 +37,7 @@ defineProps<{
|
|||
</div>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div hidden xl:block h-6 />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -43,7 +43,7 @@ const handlePublishClose = () => {
|
|||
<ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125>
|
||||
<UserSignIn />
|
||||
</ModalDialog>
|
||||
<ModalDialog v-model="isPreviewHelpOpen" max-w-125>
|
||||
<ModalDialog v-model="isPreviewHelpOpen" keep-alive max-w-125>
|
||||
<HelpPreview @close="closePreviewHelp()" />
|
||||
</ModalDialog>
|
||||
<ModalDialog
|
||||
|
@ -53,6 +53,7 @@ const handlePublishClose = () => {
|
|||
>
|
||||
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
||||
<PublishWidget
|
||||
v-if="dialogDraftKey"
|
||||
:draft-key="dialogDraftKey" expanded flex-1 w-0
|
||||
@published="handlePublished"
|
||||
/>
|
||||
|
@ -65,7 +66,7 @@ const handlePublishClose = () => {
|
|||
<ModalMediaPreview v-if="isMediaPreviewOpen" @close="closeMediaPreview()" />
|
||||
</ModalDialog>
|
||||
<ModalDialog v-model="isEditHistoryDialogOpen" max-w-125>
|
||||
<StatusEditPreview :edit="statusEdit" />
|
||||
<StatusEditPreview v-if="statusEdit" :edit="statusEdit" />
|
||||
</ModalDialog>
|
||||
<ModalDialog v-model="isCommandPanelOpen" max-w-fit flex>
|
||||
<CommandPanel @close="closeCommandPanel()" />
|
||||
|
|
|
@ -2,9 +2,6 @@
|
|||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||
|
||||
export interface Props {
|
||||
/** v-model dislog visibility */
|
||||
modelValue: boolean
|
||||
|
||||
/**
|
||||
* level of depth
|
||||
*
|
||||
|
@ -48,11 +45,13 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
|
||||
const emit = defineEmits<{
|
||||
/** v-model dialog visibility */
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'close',): void
|
||||
}>()
|
||||
|
||||
const visible = useVModel(props, 'modelValue', emit, { passive: true })
|
||||
const { modelValue: visible } = defineModel<{
|
||||
/** v-model dislog visibility */
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const deactivated = useDeactivated()
|
||||
const route = useRoute()
|
||||
|
@ -66,6 +65,8 @@ const { activate } = useFocusTrap(elDialogRoot, {
|
|||
allowOutsideClick: true,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: true,
|
||||
preventScroll: true,
|
||||
returnFocusOnDeactivate: true,
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
|
|
|
@ -1,32 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { useImageGesture } from '~/composables/gestures'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const img = ref()
|
||||
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(() => mediaPreviewIndex.value < mediaPreviewList.value.length - 1)
|
||||
const hasPrev = computed(() => mediaPreviewIndex.value > 0)
|
||||
|
||||
useImageGesture(img, {
|
||||
hasNext,
|
||||
hasPrev,
|
||||
onNext() {
|
||||
if (hasNext.value)
|
||||
mediaPreviewIndex.value++
|
||||
},
|
||||
onPrev() {
|
||||
if (hasPrev.value)
|
||||
mediaPreviewIndex.value--
|
||||
},
|
||||
})
|
||||
|
||||
// stop global zooming
|
||||
useEventListener('wheel', (evt) => {
|
||||
if (evt.ctrlKey && (evt.deltaY < 0 || evt.deltaY > 0))
|
||||
evt.preventDefault()
|
||||
}, { passive: false })
|
||||
const hasNext = computed(() => index.value < mediaPreviewList.value.length - 1)
|
||||
const hasPrev = computed(() => index.value > 0)
|
||||
|
||||
const keys = useMagicKeys()
|
||||
|
||||
|
@ -35,12 +17,12 @@ whenever(keys.arrowRight, next)
|
|||
|
||||
function next() {
|
||||
if (hasNext.value)
|
||||
mediaPreviewIndex.value++
|
||||
index.value++
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (hasPrev.value)
|
||||
mediaPreviewIndex.value--
|
||||
index.value--
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
|
@ -49,30 +31,31 @@ function onClick(e: MouseEvent) {
|
|||
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">
|
||||
<div relative h-full w-full flex pt-12 w-100vh @click="onClick">
|
||||
<button
|
||||
v-if="hasNext" pointer-events-auto btn-action-icon bg="black/20" :aria-label="$t('action.previous')"
|
||||
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" right-1
|
||||
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="action.next"
|
||||
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" left-1
|
||||
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>
|
||||
<img
|
||||
ref="img"
|
||||
:src="current.url || current.previewUrl"
|
||||
:alt="current.description || ''"
|
||||
max-h-full max-w-full ma
|
||||
>
|
||||
|
||||
<div flex flex-row items-center mxa>
|
||||
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
|
||||
</div>
|
||||
|
||||
<div absolute top-0 w-full flex justify-between>
|
||||
<button
|
||||
|
@ -83,7 +66,7 @@ function onClick(e: MouseEvent) {
|
|||
</button>
|
||||
<div bg="black/30" dark:bg="white/10" ms-4 my-auto text-white rounded-full flex="~ center" overflow-hidden>
|
||||
<div v-if="mediaPreviewList.length > 1" p="y-1 x-2" rounded-r-0 shrink-0>
|
||||
{{ mediaPreviewIndex + 1 }} / {{ mediaPreviewList.length }}
|
||||
{{ index + 1 }} / {{ mediaPreviewList.length }}
|
||||
</div>
|
||||
<p
|
||||
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-2" rounded-ie-full line-clamp-1
|
||||
|
|
71
components/modal/ModalMediaPreviewCarousel.vue
Normal file
71
components/modal/ModalMediaPreviewCarousel.vue
Normal file
|
@ -0,0 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
import { SwipeDirection } from '@vueuse/core'
|
||||
import { useReducedMotion } from '@vueuse/motion'
|
||||
import type { Attachment } from 'masto'
|
||||
|
||||
const { media = [], threshold = 20 } = defineProps<{
|
||||
media?: Attachment[]
|
||||
threshold?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
|
||||
const { modelValue } = defineModel<{
|
||||
modelValue: number
|
||||
}>()
|
||||
|
||||
const target = ref()
|
||||
|
||||
const animateTimeout = useTimeout(10)
|
||||
const reduceMotion = useReducedMotion()
|
||||
|
||||
const canAnimate = computed(() => !reduceMotion.value && animateTimeout.value)
|
||||
|
||||
const { width, height } = useElementSize(target)
|
||||
const { isSwiping, lengthX, lengthY, direction } = useSwipe(target, {
|
||||
threshold: 5,
|
||||
passive: false,
|
||||
onSwipeEnd(e, direction) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
if (direction === SwipeDirection.RIGHT && Math.abs(distanceX.value) > threshold)
|
||||
modelValue.value = Math.max(0, modelValue.value - 1)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
if (direction === SwipeDirection.LEFT && Math.abs(distanceX.value) > threshold)
|
||||
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
if (direction === SwipeDirection.UP && Math.abs(distanceY.value) > threshold)
|
||||
emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const distanceX = computed(() => {
|
||||
if (width.value === 0)
|
||||
return 0
|
||||
|
||||
if (!isSwiping.value || (direction.value !== SwipeDirection.LEFT && direction.value !== SwipeDirection.RIGHT))
|
||||
return modelValue.value * 100 * -1
|
||||
|
||||
return (lengthX.value / width.value) * 100 * -1 + (modelValue.value * 100) * -1
|
||||
})
|
||||
|
||||
const distanceY = computed(() => {
|
||||
if (height.value === 0 || !isSwiping.value || direction.value !== SwipeDirection.UP)
|
||||
return 0
|
||||
|
||||
return (lengthY.value / height.value) * 100 * -1
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="target" flex flex-row max-h-full max-w-full overflow-hidden>
|
||||
<div flex :style="{ transform: `translateX(${distanceX}%) translateY(${distanceY}%)`, transition: isSwiping ? 'none' : canAnimate ? 'all 0.5s ease' : 'none' }">
|
||||
<div v-for="item in media" :key="item.id" p4 select-none w-full flex-shrink-0 flex flex-col place-items-center>
|
||||
<img max-h-full max-w-full :draggable="false" select-none :src="item.url || item.previewUrl" :alt="item.description || ''">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -38,12 +38,12 @@ const moreMenuVisible = ref(false)
|
|||
<div i-ri:earth-line />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<NavBottomMoreMenu v-slot="{ changeShow, show }" v-model="moreMenuVisible" flex flex-row items-center place-content-center h-full flex-1 cursor-pointer>
|
||||
<NavBottomMoreMenu v-slot="{ toggleVisible, show }" v-model="moreMenuVisible" flex flex-row items-center place-content-center h-full flex-1 cursor-pointer>
|
||||
<label
|
||||
flex items-center place-content-center h-full flex-1 class="select-none"
|
||||
:class="show ? '!text-primary' : ''"
|
||||
>
|
||||
<input type="checkbox" z="-1" absolute inset-0 opacity-0 @click="changeShow">
|
||||
<input type="checkbox" z="-1" absolute inset-0 opacity-0 @click="toggleVisible">
|
||||
<span v-show="show" i-ri:close-fill />
|
||||
<span v-show="!show" i-ri:more-fill />
|
||||
</label>
|
||||
|
|
|
@ -1,24 +1,20 @@
|
|||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
modelValue?: boolean
|
||||
let { modelValue } = $defineModel<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
const visible = useVModel(props, 'modelValue', emit, { passive: true })
|
||||
const colorMode = useColorMode()
|
||||
|
||||
function changeShow() {
|
||||
visible.value = !visible.value
|
||||
function toggleVisible() {
|
||||
modelValue = !modelValue
|
||||
}
|
||||
|
||||
const buttonEl = ref<HTMLDivElement>()
|
||||
/** Close the drop-down menu if the mouse click is not on the drop-down menu button when the drop-down menu is opened */
|
||||
function clickEvent(mouse: MouseEvent) {
|
||||
if (mouse.target && !buttonEl.value?.children[0].contains(mouse.target as any)) {
|
||||
if (visible.value) {
|
||||
if (modelValue) {
|
||||
document.removeEventListener('click', clickEvent)
|
||||
visible.value = false
|
||||
modelValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -27,7 +23,7 @@ function toggleDark() {
|
|||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
watch(visible, (val) => {
|
||||
watch($$(modelValue), (val) => {
|
||||
if (val && typeof document !== 'undefined')
|
||||
document.addEventListener('click', clickEvent)
|
||||
})
|
||||
|
@ -39,7 +35,7 @@ onBeforeUnmount(() => {
|
|||
|
||||
<template>
|
||||
<div ref="buttonEl" flex items-center static>
|
||||
<slot :change-show="changeShow" :show="visible" />
|
||||
<slot :toggle-visible="toggleVisible" :show="modelValue" />
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition
|
||||
|
@ -51,7 +47,7 @@ onBeforeUnmount(() => {
|
|||
leave-to-class="opacity-0 children:(transform translate-y-full)"
|
||||
>
|
||||
<div
|
||||
v-show="visible"
|
||||
v-show="modelValue"
|
||||
absolute inset-x-0 top-auto bottom-full z-20 h-100vh
|
||||
flex items-end of-y-scroll of-x-hidden scrollbar-hide overscroll-none
|
||||
bg="black/50"
|
||||
|
@ -86,17 +82,6 @@ onBeforeUnmount(() => {
|
|||
<span class="i-ri:sun-line dark:i-ri:moon-line flex-shrink-0 text-xl me-4 !align-middle" />
|
||||
{{ colorMode.value === 'light' ? $t('menu.toggle_theme.dark') : $t('menu.toggle_theme.light') }}
|
||||
</button>
|
||||
<NuxtLink
|
||||
flex flex-row items-center
|
||||
block px-5 py-2 focus-blue w-full
|
||||
text-sm text-base capitalize text-left whitespace-nowrap
|
||||
transition-colors duration-200 transform
|
||||
hover="bg-gray-100 dark:(bg-gray-700 text-white)"
|
||||
to="/settings"
|
||||
>
|
||||
<span class="i-ri:settings-2-line flex-shrink-0 text-xl me-4 !align-middle" />
|
||||
{{ $t('nav.settings') }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import buildInfo from 'virtual:build-info'
|
||||
import { buildInfo } from 'virtual:build-info'
|
||||
|
||||
const timeAgoOptions = useTimeAgoOptions()
|
||||
|
||||
|
@ -22,35 +22,23 @@ function toggleDark() {
|
|||
<button
|
||||
flex
|
||||
text-lg
|
||||
:class="isZenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'"
|
||||
:class="userSettings.zenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'"
|
||||
:aria-label="$t('nav.zen_mode')"
|
||||
@click="toggleZenMode()"
|
||||
/>
|
||||
</CommonTooltip>
|
||||
<CommonTooltip :content="$t('nav.settings')">
|
||||
<NuxtLink
|
||||
flex
|
||||
text-lg
|
||||
to="/settings"
|
||||
i-ri:settings-4-line
|
||||
:aria-label="$t('nav.settings')"
|
||||
@click="userSettings.zenMode = !userSettings.zenMode"
|
||||
/>
|
||||
</CommonTooltip>
|
||||
</div>
|
||||
<div>
|
||||
<button cursor-pointer hover:underline @click="openPreviewHelp">
|
||||
{{ $t('nav.show_intro') }}
|
||||
</button>
|
||||
</div>
|
||||
<div>{{ $t('app_desc_short') }}</div>
|
||||
<div>
|
||||
<i18n-t keypath="nav.built_at">
|
||||
<i18n-t v-if="isHydrated" keypath="nav.built_at">
|
||||
<time :datetime="String(buildTimeDate)" :title="$d(buildTimeDate, 'long')">{{ buildTimeAgo }}</time>
|
||||
</i18n-t>
|
||||
<template v-if="buildInfo.version">
|
||||
·
|
||||
v{{ buildInfo.version }}
|
||||
</template>
|
||||
<span v-else>
|
||||
{{ $t('nav.built_at', [$d(buildTimeDate, 'shortDate')]) }}
|
||||
</span>
|
||||
·
|
||||
<!-- TODO click version to show changelog -->
|
||||
<span v-if="buildInfo.env === 'release'">v{{ buildInfo.version }}</span>
|
||||
<span v-else>{{ buildInfo.env }}</span>
|
||||
<template v-if="buildInfo.commit && buildInfo.branch !== 'release'">
|
||||
·
|
||||
<NuxtLink
|
||||
|
@ -64,7 +52,15 @@ function toggleDark() {
|
|||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/m.webtoo.ls/@elk" target="_blank">Mastodon</a> · <a href="https://chat.elk.zone" target="_blank">Discord</a> · <a href="https://github.com/elk-zone" target="_blank">GitHub</a>
|
||||
<NuxtLink cursor-pointer hover:underline to="/settings/about">
|
||||
{{ $t('settings.about.label') }}
|
||||
</NuxtLink>
|
||||
·
|
||||
<a href="/m.webtoo.ls/@elk" target="_blank">Mastodon</a>
|
||||
·
|
||||
<a href="https://chat.elk.zone" target="_blank">Discord</a>
|
||||
·
|
||||
<a href="https://github.com/elk-zone" target="_blank">GitHub</a>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
|
|
@ -6,7 +6,12 @@ const { notifications } = useNotifications()
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<nav sm:px3 sm:py4 flex="~ col gap2" text-size-base leading-normal md:text-lg>
|
||||
<nav sm:px3 flex="~ col gap2" shrink text-size-base leading-normal md:text-lg>
|
||||
<div shrink hidden sm:block mt-4 />
|
||||
<SearchWidget lg:ms-1 lg:me-5 hidden xl:block />
|
||||
<NavSideItem :text="$t('nav.search')" to="/search" icon="i-ri:search-line" xl:hidden :command="command" />
|
||||
|
||||
<div shrink hidden sm:block mt-4 />
|
||||
<NavSideItem :text="$t('nav.home')" to="/home" icon="i-ri:home-5-line" user-only :command="command" />
|
||||
<NavSideItem :text="$t('nav.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command">
|
||||
<template #icon>
|
||||
|
@ -18,15 +23,17 @@ const { notifications } = useNotifications()
|
|||
</div>
|
||||
</template>
|
||||
</NavSideItem>
|
||||
|
||||
<!-- Use Search for small screens once the right sidebar is collapsed -->
|
||||
<NavSideItem :text="$t('nav.search')" to="/search" icon="i-ri:search-line" lg:hidden :command="command" />
|
||||
<NavSideItem :text="$t('nav.explore')" :to="`/${currentServer}/explore`" icon="i-ri:hashtag" :command="command" />
|
||||
|
||||
<NavSideItem :text="$t('nav.local')" :to="`/${currentServer}/public/local`" icon="i-ri:group-2-line " :command="command" />
|
||||
<NavSideItem :text="$t('nav.federated')" :to="`/${currentServer}/public`" icon="i-ri:earth-line" :command="command" />
|
||||
<NavSideItem :text="$t('nav.conversations')" to="/conversations" icon="i-ri:at-line" user-only :command="command" />
|
||||
<NavSideItem :text="$t('nav.favourites')" to="/favourites" icon="i-ri:heart-3-line" user-only :command="command" />
|
||||
<NavSideItem :text="$t('nav.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " user-only :command="command" />
|
||||
<NavSideItem :text="$t('nav.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line" user-only :command="command" />
|
||||
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
|
||||
|
||||
<div shrink hidden sm:block mt-4 />
|
||||
<NavSideItem :text="$t('nav.explore')" :to="`/${currentServer}/explore`" icon="i-ri:hashtag" :command="command" />
|
||||
<NavSideItem :text="$t('nav.local')" :to="`/${currentServer}/public/local`" icon="i-ri:group-2-line " :command="command" />
|
||||
<NavSideItem :text="$t('nav.federated')" :to="`/${currentServer}/public`" icon="i-ri:earth-line" :command="command" />
|
||||
|
||||
<div shrink hidden sm:block mt-4 />
|
||||
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />
|
||||
</nav>
|
||||
</template>
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { warn } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
text?: string
|
||||
icon: string
|
||||
|
@ -31,14 +29,12 @@ useCommand({
|
|||
})
|
||||
|
||||
let activeClass = $ref('text-primary')
|
||||
watch(isMastoInitialised, async () => {
|
||||
if (!props.userOnly) {
|
||||
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
|
||||
// we don't have currentServer defined until later
|
||||
activeClass = ''
|
||||
await nextTick()
|
||||
activeClass = 'text-primary'
|
||||
}
|
||||
onMastoInit(async () => {
|
||||
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
|
||||
// we don't have currentServer defined until later
|
||||
activeClass = ''
|
||||
await nextTick()
|
||||
activeClass = 'text-primary'
|
||||
})
|
||||
|
||||
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items
|
||||
|
@ -60,17 +56,17 @@ const noUserVisual = computed(() => isMastoInitialised.value && props.userOnly &
|
|||
<CommonTooltip :disabled="!isMediumScreen" :content="text" placement="right">
|
||||
<div
|
||||
flex items-center gap4
|
||||
w-fit rounded-full
|
||||
w-fit rounded-3
|
||||
px2 py2 mx3 sm:mxa
|
||||
lg="mx0 px5"
|
||||
xl="ml0 mr5 px5 w-auto"
|
||||
transition-100
|
||||
group-hover:bg-active group-focus-visible:ring="2 current"
|
||||
group-hover="bg-active" group-focus-visible:ring="2 current"
|
||||
>
|
||||
<slot name="icon">
|
||||
<div :class="icon" text-xl />
|
||||
</slot>
|
||||
<slot>
|
||||
<span block sm:hidden lg:block>{{ text }}</span>
|
||||
<span block sm:hidden xl:block>{{ text }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</CommonTooltip>
|
||||
|
|
|
@ -1,23 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
const env = useRuntimeConfig().public.env
|
||||
const sub = env === 'local' ? 'dev' : env === 'staging' ? 'preview' : 'alpha'
|
||||
import { buildInfo } from 'virtual:build-info'
|
||||
|
||||
const { env } = buildInfo
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Use external to force refresh page and jump to top of timeline -->
|
||||
<NuxtLink
|
||||
flex items-end gap-2
|
||||
w-fit
|
||||
py2 px-2 lg:px-3
|
||||
text-2xl hover:bg-active
|
||||
focus-visible:ring="2 current"
|
||||
rounded-full
|
||||
to="/"
|
||||
external
|
||||
>
|
||||
<img :alt="$t('app_logo')" src="/logo.svg" shrink-0 aspect="1/1" sm:h-8 lg:h-10 class="rtl-flip">
|
||||
<div hidden lg:block>
|
||||
{{ $t('app_name') }} <sup text-sm italic text-secondary mt-1>{{ sub }}</sup>
|
||||
<div flex justify-between>
|
||||
<NuxtLink
|
||||
flex items-end gap-4
|
||||
py2 px-5
|
||||
text-2xl
|
||||
focus-visible:ring="2 current"
|
||||
to="/"
|
||||
external
|
||||
>
|
||||
<img :alt="$t('app_logo')" src="/logo.svg" shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip">
|
||||
<div hidden xl:block>
|
||||
{{ $t('app_name') }} <sup text-sm italic text-secondary mt-1>{{ env === 'release' ? 'alpha' : env }}</sup>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div hidden xl:flex items-center me-8 mt-2>
|
||||
<NuxtLink
|
||||
@click="$router.go(-1)"
|
||||
>
|
||||
<div i-ri:arrow-left-line class="rtl-flip" btn-text />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
h-8
|
||||
w-8
|
||||
:draggable="false"
|
||||
square
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -14,7 +15,7 @@
|
|||
<UserSwitcher ref="switcher" @click="hide()" />
|
||||
</template>
|
||||
</VDropdown>
|
||||
<button v-else btn-solid text-sm px-2 py-1 text-center lg:hidden @click="openSigninDialog()">
|
||||
<button v-else btn-solid text-sm px-2 py-1 text-center xl:hidden @click="openSigninDialog()">
|
||||
{{ $t('action.sign_in') }}
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
showReAuthMessage: boolean
|
||||
withHeader?: boolean
|
||||
closeableHeader?: boolean
|
||||
busy?: boolean
|
||||
animate?: boolean
|
||||
}>()
|
||||
|
@ -16,15 +15,22 @@ const isLegacyAccount = computed(() => !currentUser.value?.vapidKey)
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col" gap-y-2 role="alert" aria-labelledby="notifications-warning" :class="withHeader ? 'border-b border-base' : null">
|
||||
<header v-if="withHeader" flex items-center pb-2>
|
||||
<div
|
||||
flex="~ col"
|
||||
gap-y-2
|
||||
role="alert"
|
||||
aria-labelledby="notifications-warning"
|
||||
:class="closeableHeader ? 'border-b border-base' : 'px6 px4'"
|
||||
>
|
||||
<header flex items-center pb-2>
|
||||
<h2 id="notifications-warning" text-md font-bold w-full>
|
||||
{{ $t('notification.settings.warning.enable_title') }}
|
||||
{{ $t('settings.notifications.push_notifications.warning.enable_title') }}
|
||||
</h2>
|
||||
<button
|
||||
v-if="closeableHeader"
|
||||
flex rounded-4
|
||||
type="button"
|
||||
:title="$t('notification.settings.warning.enable_close')"
|
||||
:title="$t('settings.notifications.push_notifications.warning.enable_close')"
|
||||
hover:bg-active cursor-pointer transition-100
|
||||
:disabled="busy"
|
||||
@click="$emit('hide')"
|
||||
|
@ -33,10 +39,10 @@ const isLegacyAccount = computed(() => !currentUser.value?.vapidKey)
|
|||
</button>
|
||||
</header>
|
||||
<p>
|
||||
{{ $t(withHeader ? 'notification.settings.warning.enable_description' : 'notification.settings.warning.enable_description_short') }}
|
||||
{{ $t(`settings.notifications.push_notifications.warning.enable_description${closeableHeader ? '' : '_settings'}`) }}
|
||||
</p>
|
||||
<p v-if="isLegacyAccount && showReAuthMessage">
|
||||
{{ $t('notification.settings.warning.re_auth') }}
|
||||
<p v-if="isLegacyAccount">
|
||||
{{ $t('settings.notifications.push_notifications.warning.re_auth') }}
|
||||
</p>
|
||||
<button
|
||||
btn-outline rounded-full font-bold py4 flex="~ gap2 center" m5
|
||||
|
@ -46,8 +52,8 @@ const isLegacyAccount = computed(() => !currentUser.value?.vapidKey)
|
|||
@click="$emit('subscribe')"
|
||||
>
|
||||
<span aria-hidden="true" :class="busy && animate ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:check-line'" />
|
||||
{{ $t('notification.settings.warning.enable_desktop') }}
|
||||
{{ $t('settings.notifications.push_notifications.warning.enable_desktop') }}
|
||||
</button>
|
||||
<slot v-if="showReAuthMessage" name="error" />
|
||||
<slot name="error" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
// type used in <template>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type { Notification, Paginator, WsEvents } from 'masto'
|
||||
// type used in <template>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type { GroupedLikeNotifications } from '~/types'
|
||||
|
||||
import type { GroupedAccountLike, NotificationSlot } from '~/types'
|
||||
|
||||
const { paginator, stream } = defineProps<{
|
||||
|
@ -118,12 +124,12 @@ const { formatNumber } = useHumanReadableNumber()
|
|||
/>
|
||||
<NotificationGroupedLikes
|
||||
v-else-if="item.type === 'grouped-reblogs-and-favourites'"
|
||||
:group="item"
|
||||
:group="item as GroupedLikeNotifications"
|
||||
border="b base"
|
||||
/>
|
||||
<NotificationCard
|
||||
v-else
|
||||
:notification="item"
|
||||
:notification="item as Notification"
|
||||
hover:bg-active
|
||||
border="b base"
|
||||
/>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import NotificationSubscribePushNotificationError
|
||||
from '~/components/notification/NotificationSubscribePushNotificationError.vue'
|
||||
|
||||
defineProps<{ show: boolean }>()
|
||||
defineProps<{ show?: boolean }>()
|
||||
|
||||
const {
|
||||
pushNotificationData,
|
||||
|
@ -71,12 +71,12 @@ const doSubscribe = async () => {
|
|||
try {
|
||||
const result = await subscribe()
|
||||
if (result !== 'subscribed') {
|
||||
subscribeError = t(`notification.settings.subscription_error.${result === 'notification-denied' ? 'permission_denied' : 'request_error'}`)
|
||||
subscribeError = t(`settings.notifications.push_notifications.subscription_error.${result === 'notification-denied' ? 'permission_denied' : 'request_error'}`)
|
||||
showSubscribeError = true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
subscribeError = t('notification.settings.subscription_error.request_error')
|
||||
subscribeError = t('settings.notifications.push_notifications.subscription_error.request_error')
|
||||
showSubscribeError = true
|
||||
}
|
||||
finally {
|
||||
|
@ -103,40 +103,41 @@ onActivated(() => (busy = false))
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="pwaEnabled && (showWarning || show)">
|
||||
<section v-if="pwaEnabled && (showWarning || show)" aria-labelledby="pn-s">
|
||||
<Transition name="slide-down">
|
||||
<div v-if="show" flex="~ col" border="b base" px5 py4>
|
||||
<header flex items-center pb-2>
|
||||
<h2 id="notifications-title" text-md font-bold w-full>
|
||||
{{ $t('notification.settings.title') }}
|
||||
</h2>
|
||||
</header>
|
||||
<div v-if="show" flex="~ col" border="b base">
|
||||
<h3 id="pn-settings" px6 py4 mt2 font-bold text-xl flex="~ gap-1" items-center>
|
||||
{{ $t('settings.notifications.push_notifications.label') }}
|
||||
</h3>
|
||||
<template v-if="isSupported">
|
||||
<div v-if="isSubscribed" flex="~ col">
|
||||
<form flex="~ col" gap-y-2 @submit.prevent="saveSettings">
|
||||
<form flex="~ col" gap-y-2 px6 pb4 @submit.prevent="saveSettings">
|
||||
<p id="pn-instructions" text-sm pb2 aria-hidden="true">
|
||||
{{ $t('settings.notifications.push_notifications.instructions') }}
|
||||
</p>
|
||||
<fieldset flex="~ col" gap-y-1 py-1>
|
||||
<legend>{{ $t('notification.settings.alerts.title') }}</legend>
|
||||
<CommonCheckbox v-model="pushNotificationData.follow" hover :label="$t('notification.settings.alerts.follow')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.favourite" hover :label="$t('notification.settings.alerts.favourite')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.reblog" hover :label="$t('notification.settings.alerts.reblog')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.mention" hover :label="$t('notification.settings.alerts.mention')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.poll" hover :label="$t('notification.settings.alerts.poll')" />
|
||||
<legend>{{ $t('settings.notifications.push_notifications.alerts.title') }}</legend>
|
||||
<CommonCheckbox v-model="pushNotificationData.follow" hover :label="$t('settings.notifications.push_notifications.alerts.follow')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.favourite" hover :label="$t('settings.notifications.push_notifications.alerts.favourite')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.reblog" hover :label="$t('settings.notifications.push_notifications.alerts.reblog')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.mention" hover :label="$t('settings.notifications.push_notifications.alerts.mention')" />
|
||||
<CommonCheckbox v-model="pushNotificationData.poll" hover :label="$t('settings.notifications.push_notifications.alerts.poll')" />
|
||||
</fieldset>
|
||||
<fieldset flex="~ col" gap-y-1 py-1>
|
||||
<legend>{{ $t('notification.settings.policy.title') }}</legend>
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="all" :label="$t('notification.settings.policy.all')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="followed" :label="$t('notification.settings.policy.followed')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="follower" :label="$t('notification.settings.policy.follower')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="none" :label="$t('notification.settings.policy.none')" />
|
||||
<legend>{{ $t('settings.notifications.push_notifications.policy.title') }}</legend>
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="all" :label="$t('settings.notifications.push_notifications.policy.all')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="followed" :label="$t('settings.notifications.push_notifications.policy.followed')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="follower" :label="$t('settings.notifications.push_notifications.policy.follower')" />
|
||||
<CommonRadio v-model="pushNotificationData.policy" hover value="none" :label="$t('settings.notifications.push_notifications.policy.none')" />
|
||||
</fieldset>
|
||||
<div flex="~ col" gap-y-4 py-1 sm="~ justify-between flex-row">
|
||||
<div flex="~ col" gap-y-4 gap-x-2 py-1 sm="~ justify-between flex-row">
|
||||
<button
|
||||
btn-solid font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||
:class="busy || !saveEnabled ? 'border-transparent' : null"
|
||||
:disabled="busy || !saveEnabled"
|
||||
>
|
||||
<span :class="busy && animateSave ? 'i-ri:loader-2-fill animate-spin' : 'i-ri:save-2-fill'" />
|
||||
{{ $t('notification.settings.save_settings') }}
|
||||
{{ $t('settings.notifications.push_notifications.save_settings') }}
|
||||
</button>
|
||||
<button
|
||||
btn-outline font-bold py2 full-w sm-wa flex="~ gap2 center"
|
||||
|
@ -146,7 +147,7 @@ onActivated(() => (busy = false))
|
|||
@click="undoChanges"
|
||||
>
|
||||
<span aria-hidden="true" class="i-material-symbols:undo-rounded" />
|
||||
{{ $t('notification.settings.undo_settings') }}
|
||||
{{ $t('settings.notifications.push_notifications.undo_settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -158,19 +159,14 @@ onActivated(() => (busy = false))
|
|||
:disabled="busy"
|
||||
>
|
||||
<span aria-hidden="true" :class="busy && animateRemoveSubscription ? 'i-ri:loader-2-fill animate-spin' : 'i-material-symbols:cancel-rounded'" />
|
||||
{{ $t('notification.settings.unsubscribe') }}
|
||||
{{ $t('settings.notifications.push_notifications.unsubscribe') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p v-if="showWarning" role="alert" aria-labelledby="notifications-title">
|
||||
{{ $t('notification.settings.unsubscribed_with_warning') }}
|
||||
</p>
|
||||
<NotificationEnablePushNotification
|
||||
v-else
|
||||
:animate="animateSubscription"
|
||||
:busy="busy"
|
||||
:show-re-auth-message="!showWarning"
|
||||
@hide="hideNotification"
|
||||
@subscribe="doSubscribe"
|
||||
>
|
||||
|
@ -185,15 +181,16 @@ onActivated(() => (busy = false))
|
|||
</NotificationEnablePushNotification>
|
||||
</template>
|
||||
</template>
|
||||
<p v-else role="alert" aria-labelledby="notifications-unsupported">
|
||||
{{ $t('notification.settings.unsupported') }}
|
||||
</p>
|
||||
<div v-else px6 pb4 role="alert" aria-labelledby="n-unsupported">
|
||||
<p id="n-unsupported">
|
||||
{{ $t('settings.notifications.push_notifications.unsupported') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<NotificationEnablePushNotification
|
||||
v-if="showWarning"
|
||||
show-re-auth-message
|
||||
with-header
|
||||
v-if="showWarning && !show"
|
||||
closeable-header
|
||||
px5
|
||||
py4
|
||||
:animate="animateSubscription"
|
||||
|
@ -210,5 +207,5 @@ onActivated(() => (busy = false))
|
|||
</Transition>
|
||||
</template>
|
||||
</NotificationEnablePushNotification>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
@ -22,13 +22,13 @@ const { modelValue } = defineModel<{
|
|||
<head id="notification-failed" flex justify-between>
|
||||
<div flex items-center gap-x-2 font-bold>
|
||||
<div aria-hidden="true" i-ri:error-warning-fill />
|
||||
<p>{{ title ?? $t('notification.settings.subscription_error.title') }}</p>
|
||||
<p>{{ title ?? $t('settings.notifications.push_notifications.subscription_error.title') }}</p>
|
||||
</div>
|
||||
<CommonTooltip placement="bottom" :content="$t('notification.settings.subscription_error.clear_error')">
|
||||
<CommonTooltip placement="bottom" :content="$t('settings.notifications.push_notifications.subscription_error.clear_error')">
|
||||
<button
|
||||
flex rounded-4 p1
|
||||
hover:bg-active cursor-pointer transition-100
|
||||
:aria-label="$t('notification.settings.subscription_error.clear_error')"
|
||||
:aria-label="$t('settings.notifications.push_notifications.subscription_error.clear_error')"
|
||||
@click="modelValue = false"
|
||||
>
|
||||
<span aria-hidden="true" w-1.75em h-1.75em i-ri:close-line />
|
||||
|
|
|
@ -7,16 +7,16 @@ const disabledVisual = computed(() => isMastoInitialised.value && !currentUser.v
|
|||
<button
|
||||
flex="~ gap2 center"
|
||||
w-9 h-9 py2
|
||||
lg="w-auto h-auto py-4"
|
||||
rounded-full
|
||||
xl="w-auto h-auto"
|
||||
rounded-3
|
||||
cursor-pointer disabled:pointer-events-none
|
||||
text-primary font-bold
|
||||
text-primary
|
||||
border-1 border-primary
|
||||
:class="disabledVisual ? 'op25' : 'hover:bg-primary hover:text-inverted'"
|
||||
:disabled="disabled"
|
||||
@click="openPublishDialog()"
|
||||
>
|
||||
<div i-ri:quill-pen-line />
|
||||
<span hidden lg:block>{{ $t('action.compose') }}</span>
|
||||
<span hidden xl:block>{{ $t('action.compose') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
|
|
@ -44,15 +44,17 @@ const hideEmojiPicker = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<VDropdown
|
||||
@apply-show="openEmojiPicker()"
|
||||
@apply-hide="hideEmojiPicker()"
|
||||
>
|
||||
<button btn-action-icon :title="$t('tooltip.emoji')">
|
||||
<div i-ri:emotion-line />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div ref="el" min-w-10 min-h-10 />
|
||||
</template>
|
||||
</VDropdown>
|
||||
<CommonTooltip content="Add emojis">
|
||||
<VDropdown
|
||||
auto-boundary-max-size
|
||||
@apply-show="openEmojiPicker()"
|
||||
@apply-hide="hideEmojiPicker()"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<template #popper>
|
||||
<div ref="el" min-w-10 min-h-10 />
|
||||
</template>
|
||||
</VDropdown>
|
||||
</CommonTooltip>
|
||||
</template>
|
||||
|
|
60
components/publish/PublishLanguagePicker.vue
Normal file
60
components/publish/PublishLanguagePicker.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import ISO6391 from 'iso-639-1'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
let { modelValue } = $defineModel<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const languageKeyword = $ref('')
|
||||
|
||||
const languageList: {
|
||||
code: string
|
||||
nativeName: string
|
||||
name: string
|
||||
}[] = ISO6391.getAllCodes().map(code => ({
|
||||
code,
|
||||
nativeName: ISO6391.getNativeName(code),
|
||||
name: ISO6391.getName(code),
|
||||
}))
|
||||
|
||||
const fuse = new Fuse(languageList, {
|
||||
keys: ['code', 'nativeName', 'name'],
|
||||
shouldSort: true,
|
||||
})
|
||||
|
||||
const languages = $computed(() =>
|
||||
languageKeyword.trim()
|
||||
? fuse.search(languageKeyword).map(r => r.item)
|
||||
: [...languageList].sort(({ code: a }, { code: b }) => {
|
||||
return a === modelValue ? -1 : b === modelValue ? 1 : a.localeCompare(b)
|
||||
}),
|
||||
)
|
||||
|
||||
function chooseLanguage(language: string) {
|
||||
modelValue = language
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
v-model="languageKeyword"
|
||||
:placeholder="t('language.search')"
|
||||
p2 mb2 border-rounded w-full bg-transparent
|
||||
outline-none border="~ base"
|
||||
>
|
||||
<div max-h-40vh overflow-auto>
|
||||
<CommonDropdownItem
|
||||
v-for="{ code, nativeName, name } in languages"
|
||||
:key="code"
|
||||
:text="nativeName"
|
||||
:description="name"
|
||||
:checked="code === modelValue"
|
||||
@click="chooseLanguage(code)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
38
components/publish/PublishVisibilityPicker.vue
Normal file
38
components/publish/PublishVisibilityPicker.vue
Normal file
|
@ -0,0 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
import { statusVisibilities } from '~/composables/masto/icons'
|
||||
|
||||
const { editing } = defineProps<{
|
||||
editing?: boolean
|
||||
}>()
|
||||
|
||||
let { modelValue } = $defineModel<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const currentVisibility = $computed(() =>
|
||||
statusVisibilities.find(v => v.value === modelValue) || statusVisibilities[0],
|
||||
)
|
||||
|
||||
const chooseVisibility = (visibility: string) => {
|
||||
modelValue = visibility
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonTooltip placement="top" :content="editing ? $t(`visibility.${currentVisibility.value}`) : $t('tooltip.change_content_visibility')">
|
||||
<CommonDropdown placement="bottom">
|
||||
<slot :visibility="currentVisibility" />
|
||||
<template #popper>
|
||||
<CommonDropdownItem
|
||||
v-for="visibility in statusVisibilities"
|
||||
:key="visibility.value"
|
||||
:icon="visibility.icon"
|
||||
:text="$t(`visibility.${visibility.value}`)"
|
||||
:description="$t(`visibility.${visibility.value}_desc`)"
|
||||
:checked="visibility.value === modelValue"
|
||||
@click="chooseVisibility(visibility.value)"
|
||||
/>
|
||||
</template>
|
||||
</CommonDropdown>
|
||||
</CommonTooltip>
|
||||
</template>
|
|
@ -3,8 +3,6 @@ import type { Attachment, CreateStatusParams, Status, StatusVisibility } from 'm
|
|||
import { fileOpen } from 'browser-fs-access'
|
||||
import { useDropZone } from '@vueuse/core'
|
||||
import { EditorContent } from '@tiptap/vue-3'
|
||||
import ISO6391 from 'iso-639-1'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { Draft } from '~/types'
|
||||
|
||||
type FileUploadError = [filename: string, message: string]
|
||||
|
@ -16,7 +14,7 @@ const {
|
|||
placeholder,
|
||||
dialogLabelledBy,
|
||||
} = defineProps<{
|
||||
draftKey: string
|
||||
draftKey?: string
|
||||
initial?: () => Draft
|
||||
placeholder?: string
|
||||
inReplyToId?: string
|
||||
|
@ -40,7 +38,10 @@ const shouldExpanded = $computed(() => _expanded || isExpanded || !isEmpty)
|
|||
const { editor } = useTiptap({
|
||||
content: computed({
|
||||
get: () => draft.params.status,
|
||||
set: newVal => draft.params.status = newVal,
|
||||
set: (newVal) => {
|
||||
draft.params.status = newVal
|
||||
draft.lastUpdated = Date.now()
|
||||
},
|
||||
}),
|
||||
placeholder: computed(() => placeholder ?? draft.params.inReplyToId ? t('placeholder.replying') : t('placeholder.default_1')),
|
||||
autofocus: shouldExpanded,
|
||||
|
@ -55,10 +56,6 @@ const { editor } = useTiptap({
|
|||
onPaste: handlePaste,
|
||||
})
|
||||
|
||||
const currentVisibility = $computed(() => {
|
||||
return STATUS_VISIBILITIES.find(v => v.value === draft.params.visibility) || STATUS_VISIBILITIES[0]
|
||||
})
|
||||
|
||||
let isUploading = $ref<boolean>(false)
|
||||
let isExceedingAttachmentLimit = $ref<boolean>(false)
|
||||
let failed = $ref<FileUploadError[]>([])
|
||||
|
@ -133,19 +130,12 @@ function removeAttachment(index: number) {
|
|||
draft.attachments.splice(index, 1)
|
||||
}
|
||||
|
||||
function chooseVisibility(visibility: StatusVisibility) {
|
||||
draft.params.visibility = visibility
|
||||
}
|
||||
|
||||
function chooseLanguage(language: string | null) {
|
||||
draft.params.language = language
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const payload = {
|
||||
...draft.params,
|
||||
status: htmlToText(draft.params.status || ''),
|
||||
mediaIds: draft.attachments.map(a => a.id),
|
||||
...(masto.version.includes('+glitch') ? { 'content-type': 'text/markdown' } : {}),
|
||||
} as CreateStatusParams
|
||||
|
||||
if (process.dev) {
|
||||
|
@ -186,29 +176,6 @@ async function onDrop(files: File[] | null) {
|
|||
|
||||
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
|
||||
|
||||
const languageKeyword = $ref('')
|
||||
const languageList: {
|
||||
code: string | null
|
||||
nativeName: string
|
||||
name?: string
|
||||
}[] = [{
|
||||
code: null,
|
||||
nativeName: t('language.none'),
|
||||
}, ...ISO6391.getAllCodes().map(code => ({
|
||||
code,
|
||||
nativeName: ISO6391.getNativeName(code),
|
||||
name: ISO6391.getName(code),
|
||||
}))]
|
||||
const fuse = new Fuse(languageList, {
|
||||
keys: ['code', 'nativeName', 'name'],
|
||||
shouldSort: true,
|
||||
})
|
||||
const languages = $computed(() =>
|
||||
languageKeyword.trim()
|
||||
? fuse.search(languageKeyword).map(r => r.item)
|
||||
: languageList,
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
focusEditor: () => {
|
||||
editor.value?.commands?.focus?.()
|
||||
|
@ -230,7 +197,7 @@ defineExpose({
|
|||
|
||||
<div flex gap-3 flex-1>
|
||||
<NuxtLink :to="getAccountRoute(currentUser.account)">
|
||||
<AccountBigAvatar :account="currentUser.account" />
|
||||
<AccountBigAvatar :account="currentUser.account" square />
|
||||
</NuxtLink>
|
||||
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
||||
<div
|
||||
|
@ -302,7 +269,7 @@ defineExpose({
|
|||
<PublishAttachment
|
||||
v-for="(att, idx) in draft.attachments" :key="att.id"
|
||||
:attachment="att"
|
||||
:dialog-labelled-by="dialogLabelledBy ?? (draft.editingStatus ? 'state-editing' : null)"
|
||||
:dialog-labelled-by="dialogLabelledBy ?? (draft.editingStatus ? 'state-editing' : undefined)"
|
||||
@remove="removeAttachment(idx)"
|
||||
@set-description="setDescription(att, $event)"
|
||||
/>
|
||||
|
@ -318,16 +285,20 @@ defineExpose({
|
|||
<PublishEmojiPicker
|
||||
@select="insertEmoji"
|
||||
@select-custom="insertCustomEmoji"
|
||||
/>
|
||||
>
|
||||
<button btn-action-icon :title="$t('tooltip.emoji')">
|
||||
<div i-ri:emotion-line />
|
||||
</button>
|
||||
</PublishEmojiPicker>
|
||||
|
||||
<CommonTooltip placement="bottom" :content="$t('tooltip.add_media')">
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.add_media')">
|
||||
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
||||
<div i-ri:image-add-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
|
||||
<template v-if="editor">
|
||||
<CommonTooltip placement="bottom" :content="$t('tooltip.toggle_code_block')">
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.toggle_code_block')">
|
||||
<button
|
||||
btn-action-icon
|
||||
:aria-label="$t('tooltip.toggle_code_block')"
|
||||
|
@ -345,7 +316,7 @@ defineExpose({
|
|||
{{ editor?.storage.characterCount.characters() }}<span text-secondary-light>/</span><span text-secondary-light>{{ characterLimit }}</span>
|
||||
</div>
|
||||
|
||||
<CommonTooltip placement="bottom" :content="$t('tooltip.add_content_warning')">
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.add_content_warning')">
|
||||
<button btn-action-icon :aria-label="$t('tooltip.add_content_warning')" @click="toggleSensitive">
|
||||
<div v-if="draft.params.sensitive" i-ri:alarm-warning-fill text-orange />
|
||||
<div v-else i-ri:alarm-warning-line />
|
||||
|
@ -353,66 +324,29 @@ defineExpose({
|
|||
</CommonTooltip>
|
||||
|
||||
<CommonTooltip placement="top" :content="$t('tooltip.change_language')">
|
||||
<CommonDropdown placement="bottom">
|
||||
<CommonDropdown placement="bottom" auto-boundary-max-size>
|
||||
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-12 mr--1>
|
||||
<div i-ri:translate-2 />
|
||||
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||
</button>
|
||||
|
||||
<template #popper>
|
||||
<div min-w-80 p3>
|
||||
<input
|
||||
v-model="languageKeyword"
|
||||
:placeholder="t('language.search')"
|
||||
p2 mb2 border-rounded w-full bg-transparent
|
||||
outline-none border="~ base"
|
||||
>
|
||||
<div max-h-40vh overflow-auto>
|
||||
<CommonDropdownItem
|
||||
v-for="{ code, nativeName, name } in languages"
|
||||
:key="code"
|
||||
:checked="code === (draft.params.language || null)"
|
||||
@click="chooseLanguage(code)"
|
||||
>
|
||||
{{ nativeName }}
|
||||
<template #description>
|
||||
<template v-if="name">
|
||||
{{ name }}
|
||||
</template>
|
||||
</template>
|
||||
</CommonDropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
<PublishLanguagePicker v-model="draft.params.language" min-w-80 p3 />
|
||||
</template>
|
||||
</CommonDropdown>
|
||||
</CommonTooltip>
|
||||
|
||||
<CommonTooltip placement="bottom" :content="draft.editingStatus ? $t(`visibility.${currentVisibility.value}`) : $t('tooltip.change_content_visibility')">
|
||||
<CommonDropdown>
|
||||
<PublishVisibilityPicker v-model="draft.params.visibility" :editing="!!draft.editingStatus">
|
||||
<template #default="{ visibility }">
|
||||
<button :disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')" btn-action-icon :class="{ 'w-12': !draft.editingStatus }">
|
||||
<div :class="currentVisibility.icon" />
|
||||
<div :class="visibility.icon" />
|
||||
<div v-if="!draft.editingStatus" i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||
</button>
|
||||
|
||||
<template #popper>
|
||||
<CommonDropdownItem
|
||||
v-for="visibility in STATUS_VISIBILITIES"
|
||||
:key="visibility.value"
|
||||
:icon="visibility.icon"
|
||||
:checked="visibility.value === draft.params.visibility"
|
||||
@click="chooseVisibility(visibility.value)"
|
||||
>
|
||||
{{ $t(`visibility.${visibility.value}`) }}
|
||||
<template #description>
|
||||
{{ $t(`visibility.${visibility.value}_desc`) }}
|
||||
</template>
|
||||
</CommonDropdownItem>
|
||||
</template>
|
||||
</CommonDropdown>
|
||||
</CommonTooltip>
|
||||
</template>
|
||||
</PublishVisibilityPicker>
|
||||
|
||||
<button
|
||||
btn-solid rounded-full text-sm w-full md:w-fit
|
||||
btn-solid rounded-3 text-sm w-full md:w-fit
|
||||
:disabled="isEmpty || isUploading || (draft.attachments.length === 0 && !draft.params.status)"
|
||||
@click="publish"
|
||||
>
|
||||
|
|
60
components/publish/PublishWidgetFull.client.vue
Normal file
60
components/publish/PublishWidgetFull.client.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import { formatTimeAgo } from '@vueuse/core'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
let draftKey = $ref('home')
|
||||
|
||||
const draftKeys = $computed(() => Object.keys(currentUserDrafts.value))
|
||||
const nonEmptyDrafts = $computed(() => draftKeys
|
||||
.filter(i => i !== draftKey && !isEmptyDraft(currentUserDrafts.value[i]))
|
||||
.map(i => [i, currentUserDrafts.value[i]] as const),
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
draftKey = route.query.draft?.toString() || 'home'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
clearEmptyDrafts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col" pt-6 h-screen>
|
||||
<div text-right h-8>
|
||||
<VDropdown v-if="nonEmptyDrafts.length" placement="bottom-end">
|
||||
<button btn-text flex="inline center">
|
||||
Drafts ({{ nonEmptyDrafts.length }}) <div i-ri:arrow-down-s-line />
|
||||
</button>
|
||||
<template #popper="{ hide }">
|
||||
<div flex="~ col">
|
||||
<NuxtLink
|
||||
v-for="[key, draft] of nonEmptyDrafts" :key="key"
|
||||
border="b base" text-left py2 px4 hover:bg-active
|
||||
:replace="true"
|
||||
:to="`/compose?draft=${encodeURIComponent(key)}`"
|
||||
@click="hide()"
|
||||
>
|
||||
<div>
|
||||
<div flex="~ gap-1" items-center>
|
||||
Draft <code>{{ key }}</code>
|
||||
<span v-if="draft.lastUpdated" text-secondary text-sm>
|
||||
· {{ formatTimeAgo(new Date(draft.lastUpdated)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div text-secondary>
|
||||
{{ htmlToText(draft.params.status).slice(0, 50) }}
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</div>
|
||||
<div>
|
||||
<PublishWidget :key="draftKey" expanded class="min-h-100!" :draft-key="draftKey" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,19 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ hashtag: any }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex flex-row items-center gap2>
|
||||
<div w-12 h-12 rounded-full bg-active flex place-items-center place-content-center>
|
||||
<div i-ri:hashtag text-secondary text-lg />
|
||||
</div>
|
||||
<div flex flex-col>
|
||||
<span>
|
||||
{{ hashtag.name }}
|
||||
</span>
|
||||
<span text-xs text-secondary>
|
||||
{{ hashtag.following ? 'Following' : 'Not Following' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
24
components/search/SearchAccountInfo.vue
Normal file
24
components/search/SearchAccountInfo.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
import type { Account } from 'masto'
|
||||
|
||||
defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button flex gap-2 items-center>
|
||||
<AccountAvatar w-10 h-10 :account="account" shrink-0 />
|
||||
<div flex="~ col gap1" shrink h-full overflow-hidden leading-none>
|
||||
<div flex="~" gap-2>
|
||||
<ContentRich
|
||||
line-clamp-1 ws-pre-wrap break-all text-base
|
||||
:content="getDisplayName(account, { rich: true })"
|
||||
:emojis="account.emojis"
|
||||
/>
|
||||
<AccountBotIndicator v-if="account.bot" />
|
||||
</div>
|
||||
<AccountHandle text-sm :account="account" text-secondary-light />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
26
components/search/SearchHashtagInfo.vue
Normal file
26
components/search/SearchHashtagInfo.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import type { History, Tag } from 'masto'
|
||||
|
||||
const { hashtag } = defineProps<{ hashtag: Tag }>()
|
||||
|
||||
const totalTrend = $computed(() =>
|
||||
hashtag.history?.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex flex-row items-center gap2 relative>
|
||||
<div w-10 h-10 flex-none rounded-full bg-active flex place-items-center place-content-center>
|
||||
<div i-ri:hashtag text-secondary text-lg />
|
||||
</div>
|
||||
<div flex flex-col>
|
||||
<span>
|
||||
{{ hashtag.name }}
|
||||
</span>
|
||||
<CommonTrending :history="hashtag.history" text-xs text-secondary truncate />
|
||||
</div>
|
||||
<div v-if="totalTrend" absolute left-15 right-0 top-0 bottom-4 op35 flex place-items-center place-content-center ml-auto>
|
||||
<CommonTrendingCharts :history="hashtag.history" text-xs text-secondary width="150" height="20" h-full w-full />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,6 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import type { SearchResult } from './types'
|
||||
defineProps<{ result: SearchResult; active: boolean }>()
|
||||
|
||||
defineProps<{
|
||||
result: SearchResult
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const onActivate = () => {
|
||||
(document.activeElement as HTMLElement).blur()
|
||||
|
@ -8,12 +12,20 @@ const onActivate = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<CommonScrollIntoView as="RouterLink" :active="active" :to="result.to" py2 block px2 :aria-selected="active" :class="{ 'bg-active': active }" hover:bg-active @click="() => onActivate()">
|
||||
<CommonScrollIntoView
|
||||
as="RouterLink"
|
||||
hover:bg-active
|
||||
:active="active"
|
||||
:to="result.to" py2 block px2
|
||||
:aria-selected="active"
|
||||
:class="{ 'bg-active': active }"
|
||||
@click="() => onActivate()"
|
||||
>
|
||||
<SearchHashtagInfo v-if="result.type === 'hashtag'" :hashtag="result.hashtag" />
|
||||
<AccountInfo v-else-if="result.type === 'account'" :account="result.account" />
|
||||
<StatusCard v-else-if="result.type === 'status'" :status="result.status" :actions="false" :show-reply-to="false" />
|
||||
<div v-else-if="result.type === 'action'" text-center>
|
||||
<SearchAccountInfo v-else-if="result.type === 'account' && result.account" :account="result.account" />
|
||||
<StatusCard v-else-if="result.type === 'status' && result.status" :status="result.status" :actions="false" :show-reply-to="false" />
|
||||
<!-- <div v-else-if="result.type === 'action'" text-center>
|
||||
{{ result.action!.label }}
|
||||
</div>
|
||||
</div> -->
|
||||
</CommonScrollIntoView>
|
||||
</template>
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { AccountResult, HashTagResult, StatusResult } from './types'
|
||||
|
||||
const query = ref('')
|
||||
const { accounts, hashtags, loading, statuses } = useSearch(query)
|
||||
const index = ref(0)
|
||||
|
@ -13,9 +15,24 @@ const results = computed(() => {
|
|||
return []
|
||||
|
||||
const results = [
|
||||
...hashtags.value.slice(0, 3).map(hashtag => ({ type: 'hashtag', hashtag, to: getTagRoute(hashtag.name) })),
|
||||
...accounts.value.map(account => ({ type: 'account', account, to: getAccountRoute(account) })),
|
||||
...statuses.value.map(status => ({ type: 'status', status, to: getStatusRoute(status) })),
|
||||
...hashtags.value.slice(0, 3).map<HashTagResult>(hashtag => ({
|
||||
type: 'hashtag',
|
||||
id: hashtag.id,
|
||||
hashtag,
|
||||
to: getTagRoute(hashtag.name),
|
||||
})),
|
||||
...accounts.value.map<AccountResult>(account => ({
|
||||
type: 'account',
|
||||
id: account.id,
|
||||
account,
|
||||
to: getAccountRoute(account),
|
||||
})),
|
||||
...statuses.value.map<StatusResult>(status => ({
|
||||
type: 'status',
|
||||
id: status.id,
|
||||
status,
|
||||
to: getStatusRoute(status),
|
||||
})),
|
||||
|
||||
// Disable until search page is implemented
|
||||
// {
|
||||
|
@ -52,15 +69,14 @@ const activate = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" relative px4 py2 group>
|
||||
<div bg-base border="~ base" h10 rounded-full flex="~ row" items-center relative focus-within:box-shadow-outline>
|
||||
<div i-ri:search-2-line mx4 absolute pointer-events-none text-secondary mt="1px" class="rtl-flip" />
|
||||
<div ref="el" relative group>
|
||||
<div bg-base border="~ base" h10 px-4 rounded-3 flex="~ row" items-center relative focus-within:box-shadow-outline gap-3>
|
||||
<div i-ri:search-2-line pointer-events-none text-secondary mt="1px" class="rtl-flip" />
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
h-full
|
||||
ps-10
|
||||
rounded-full
|
||||
rounded-3
|
||||
w-full
|
||||
bg-transparent
|
||||
outline="focus:none"
|
||||
|
@ -74,13 +90,18 @@ const activate = () => {
|
|||
>
|
||||
</div>
|
||||
<!-- Results -->
|
||||
<div p4 left-0 top-10 absolute w-full z10 group-focus-within="pointer-events-auto visible" invisible pointer-events-none>
|
||||
<div w-full bg-base border="~ base" rounded max-h-100 overflow-auto py2>
|
||||
<div left-0 top-12 absolute w-full z10 group-focus-within="pointer-events-auto visible" invisible pointer-events-none>
|
||||
<div w-full bg-base border="~ base" rounded-3 max-h-100 overflow-auto py2>
|
||||
<span v-if="query.length === 0" block text-center text-sm text-secondary>
|
||||
{{ t('search.search_desc') }}
|
||||
</span>
|
||||
<template v-if="!loading">
|
||||
<SearchResult v-for="(result, i) in results" :key="result.to" :active="index === parseInt(i.toString())" :result="result" :tabindex="focused ? 0 : -1" />
|
||||
<SearchResult
|
||||
v-for="(result, i) in results" :key="result.id"
|
||||
:active="index === parseInt(i.toString())"
|
||||
:result="result"
|
||||
:tabindex="focused ? 0 : -1"
|
||||
/>
|
||||
</template>
|
||||
<div v-else>
|
||||
<SearchResultSkeleton />
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import type { Account, Status } from 'masto'
|
||||
import type { RouteLocation } from 'vue-router'
|
||||
|
||||
export interface SearchResult {
|
||||
type: 'account' | 'hashtag' | 'action' | 'status'
|
||||
to: string
|
||||
label?: string
|
||||
account?: Account
|
||||
status?: Status
|
||||
hashtag?: any
|
||||
action?: {
|
||||
label: string
|
||||
export type BuildResult<K extends keyof any, T> = {
|
||||
[P in K]: T
|
||||
} & {
|
||||
id: string
|
||||
type: K
|
||||
to: RouteLocation & {
|
||||
href: string
|
||||
}
|
||||
}
|
||||
export type HashTagResult = BuildResult<'hashtag', any>
|
||||
export type AccountResult = BuildResult<'account', Account>
|
||||
export type StatusResult = BuildResult<'status', Status>
|
||||
|
||||
export type SearchResult = HashTagResult | AccountResult | StatusResult
|
||||
|
|
|
@ -9,7 +9,7 @@ const fontSize = useFontSizeRef()
|
|||
<template>
|
||||
<select v-model="fontSize">
|
||||
<option v-for="size in sizes" :key="size" :value="size" :selected="fontSize === size">
|
||||
{{ `${size}${size === DEFAULT_FONT_SIZE ? $t('settings.interface.default') : ''}` }}
|
||||
{{ `${$t(`settings.interface.size_label.${size}`)}${size === DEFAULT_FONT_SIZE ? $t('settings.interface.default') : ''}` }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
|
|
@ -6,6 +6,9 @@ const props = defineProps<{
|
|||
icon?: string
|
||||
to?: string | Record<string, string>
|
||||
command?: boolean
|
||||
disabled?: boolean
|
||||
external?: true
|
||||
large?: true
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
@ -32,9 +35,13 @@ useCommand({
|
|||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:disabled="disabled"
|
||||
:to="to"
|
||||
:external="external"
|
||||
exact-active-class="text-primary"
|
||||
:class="disabled ? 'op25 pointer-events-none ' : ''"
|
||||
block w-full group focus:outline-none
|
||||
:tabindex="disabled ? -1 : null"
|
||||
@click="to ? $scrollToTop() : undefined"
|
||||
>
|
||||
<div
|
||||
|
@ -49,7 +56,10 @@ useCommand({
|
|||
:class="$slots.description ? 'w-12 h-12' : ''"
|
||||
>
|
||||
<slot name="icon">
|
||||
<div v-if="icon" :class="icon" md:text-size-inherit text-xl />
|
||||
<div
|
||||
v-if="icon"
|
||||
:class="[icon, large ? 'text-xl mr-1' : 'text-xl md:text-size-inherit']"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
<div space-y-1>
|
||||
|
@ -70,7 +80,7 @@ useCommand({
|
|||
{{ content }}
|
||||
</slot>
|
||||
</p>
|
||||
<div v-if="to" i-ri:arrow-right-s-line text-xl text-secondary-light class="rtl-flip" />
|
||||
<div v-if="to" :class="!external ? 'i-ri:arrow-right-s-line' : 'i-ri:external-link-line'" text-xl text-secondary-light class="rtl-flip" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { UpdateCredentialsParams } from 'masto'
|
||||
import { accountFieldIcons, getAccountFieldIcon } from '~/composables/masto/icons'
|
||||
|
||||
const { form } = defineModel<{
|
||||
form: {
|
||||
|
@ -25,7 +26,7 @@ const chooseIcon = (i: number, text: string) => {
|
|||
<div v-for="i in 4" :key="i" flex="~ gap3" items-center>
|
||||
<CommonDropdown ref="dropdown" placement="left">
|
||||
<CommonTooltip content="Pick a icon">
|
||||
<button btn-action-icon>
|
||||
<button type="button" btn-action-icon>
|
||||
<div :class="fieldIcons[i - 1] || 'i-ri:question-mark'" />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
|
@ -37,9 +38,9 @@ const chooseIcon = (i: number, text: string) => {
|
|||
:content="text"
|
||||
>
|
||||
<template v-if="text !== 'Joined'">
|
||||
<div btn-action-icon @click="chooseIcon(i - 1, text)">
|
||||
<button type="button" btn-action-icon @click="chooseIcon(i - 1, text)">
|
||||
<div text-xl :class="icon" />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</CommonTooltip>
|
||||
</div>
|
||||
|
@ -47,17 +48,13 @@ const chooseIcon = (i: number, text: string) => {
|
|||
</CommonDropdown>
|
||||
<input
|
||||
v-model="form.fieldsAttributes[i - 1].name"
|
||||
type="text"
|
||||
p2 border-rounded w-full bg-transparent
|
||||
outline-none border="~ base"
|
||||
placeholder="Label"
|
||||
type="text" placeholder="Label"
|
||||
input-base
|
||||
>
|
||||
<input
|
||||
v-model="form.fieldsAttributes[i - 1].value"
|
||||
type="text"
|
||||
p2 border-rounded w-full bg-transparent
|
||||
outline-none border="~ base"
|
||||
placeholder="Content"
|
||||
type="text" placeholder="Content"
|
||||
input-base
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -64,7 +64,7 @@ const reply = () => {
|
|||
color="text-green" hover="text-green" group-hover="bg-green/10"
|
||||
icon="i-ri:repeat-line"
|
||||
active-icon="i-ri:repeat-fill"
|
||||
:active="status.reblogged"
|
||||
:active="!!status.reblogged"
|
||||
:disabled="isLoading.reblogged"
|
||||
:command="command"
|
||||
@click="toggleReblog()"
|
||||
|
@ -88,7 +88,7 @@ const reply = () => {
|
|||
color="text-rose" hover="text-rose" group-hover="bg-rose/10"
|
||||
icon="i-ri:heart-3-line"
|
||||
active-icon="i-ri:heart-3-fill"
|
||||
:active="status.favourited"
|
||||
:active="!!status.favourited"
|
||||
:disabled="isLoading.favourited"
|
||||
:command="command"
|
||||
@click="toggleFavourite()"
|
||||
|
@ -111,7 +111,7 @@ const reply = () => {
|
|||
color="text-yellow" hover="text-yellow" group-hover="bg-yellow/10"
|
||||
icon="i-ri:bookmark-line"
|
||||
active-icon="i-ri:bookmark-fill"
|
||||
:active="status.bookmarked"
|
||||
:active="!!status.bookmarked"
|
||||
:disabled="isLoading.bookmarked"
|
||||
:command="command"
|
||||
@click="toggleBookmark()"
|
||||
|
|
|
@ -91,7 +91,7 @@ const deleteAndRedraft = async () => {
|
|||
await openPublishDialog('dialog', await getDraftFromStatus(status), true)
|
||||
|
||||
// Go to the new status, if the page is the old status
|
||||
if (lastPublishDialogStatus.value && route.matched.some(m => m.path === '/:server?/@:account/:status'))
|
||||
if (lastPublishDialogStatus.value && route.name === 'status')
|
||||
router.push(getStatusRoute(lastPublishDialogStatus.value))
|
||||
}
|
||||
|
||||
|
@ -126,7 +126,7 @@ async function editStatus() {
|
|||
|
||||
<template #popper>
|
||||
<div flex="~ col">
|
||||
<template v-if="isZenMode">
|
||||
<template v-if="userSettings.zenMode">
|
||||
<CommonDropdownItem
|
||||
:text="$t('action.reply')"
|
||||
icon="i-ri:chat-3-line"
|
||||
|
@ -186,9 +186,8 @@ async function editStatus() {
|
|||
@click="toggleMute()"
|
||||
/>
|
||||
|
||||
<NuxtLink :to="status.url" external target="_blank">
|
||||
<NuxtLink v-if="status.url" :to="status.url" external target="_blank">
|
||||
<CommonDropdownItem
|
||||
v-if="status.url"
|
||||
:text="$t('menu.open_in_original_site')"
|
||||
icon="i-ri:arrow-right-up-line"
|
||||
:command="command"
|
||||
|
|
|
@ -35,9 +35,12 @@ const aspectRatio = computed(() => {
|
|||
})
|
||||
|
||||
const objectPosition = computed(() => {
|
||||
return [attachment.meta?.focus?.x, attachment.meta?.focus?.y]
|
||||
.map(v => v ? `${v * 100}%` : '50%')
|
||||
.join(' ')
|
||||
const focusX = attachment.meta?.focus?.x || 0
|
||||
const focusY = attachment.meta?.focus?.y || 0
|
||||
const x = ((focusX / 2) + 0.5) * 100
|
||||
const y = ((focusY / -2) + 0.5) * 100
|
||||
|
||||
return `${x}% ${y}%`
|
||||
})
|
||||
|
||||
const typeExtsMap = {
|
||||
|
@ -126,6 +129,7 @@ useIntersectionObserver(video, (entries) => {
|
|||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
focus:outline-none
|
||||
focus:ring="2 primary inset"
|
||||
rounded-lg
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import type { Status } from 'masto'
|
||||
import type { Status, StatusEdit } from 'masto'
|
||||
|
||||
const { status, withAction = true } = defineProps<{
|
||||
status: Status
|
||||
status: Status | StatusEdit
|
||||
withAction?: boolean
|
||||
}>()
|
||||
|
||||
const { translation } = useTranslation(status)
|
||||
</script>
|
||||
|
||||
|
@ -12,14 +13,15 @@ const { translation } = useTranslation(status)
|
|||
<div class="status-body" whitespace-pre-wrap break-words :class="{ 'with-action': withAction }">
|
||||
<ContentRich
|
||||
v-if="status.content"
|
||||
class="line-compact"
|
||||
:content="status.content"
|
||||
:emojis="status.emojis"
|
||||
:lang="status.language"
|
||||
:lang="'language' in status && status.language"
|
||||
/>
|
||||
<div v-else />
|
||||
<template v-if="translation.visible">
|
||||
<div my2 h-px border="b base" bg-base />
|
||||
<ContentRich :content="translation.text" :emojis="status.emojis" />
|
||||
<ContentRich class="line-compact" :content="translation.text" :emojis="status.emojis" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -90,26 +90,30 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
ref="el"
|
||||
relative flex flex-col gap-1 pl-3 pr-4 pt-1
|
||||
class="pb-1.5"
|
||||
transition-100
|
||||
:class="{ 'hover:bg-active': hover, 'border-t border-base': newer && !directReply }"
|
||||
:class="{ 'hover:bg-active': hover }"
|
||||
tabindex="0"
|
||||
focus:outline-none focus-visible:ring="2 primary"
|
||||
:lang="status.language ?? undefined"
|
||||
@click="onclick"
|
||||
@keydown.enter="onclick"
|
||||
>
|
||||
<div v-if="newer && !directReply" w-auto h-1px bg-border />
|
||||
<div flex justify-between>
|
||||
<slot name="meta">
|
||||
<div v-if="rebloggedBy && !collapseRebloggedBy" relative text-secondary ws-nowrap flex="~" items-center pt1 pb0.5 px-1px bg-base>
|
||||
<div i-ri:repeat-fill me-46px text-primary w-16px h-16px />
|
||||
<div absolute top-1 ms-24px w-32px h-32px rounded-full>
|
||||
<AccountAvatar :account="rebloggedBy" />
|
||||
<AccountHoverWrapper :account="rebloggedBy">
|
||||
<NuxtLink :to="getAccountRoute(rebloggedBy)">
|
||||
<AccountAvatar :account="rebloggedBy" />
|
||||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
</div>
|
||||
<AccountInlineInfo font-bold :account="rebloggedBy" :avatar="false" text-sm />
|
||||
</div>
|
||||
<div v-else />
|
||||
</slot>
|
||||
<StatusReplyingTo v-if="!directReply && !collapseReplyingTo" :status="status" :simplified="simplifyReplyingTo" :class="faded ? 'text-secondary-light' : ''" pt1 />
|
||||
<StatusReplyingTo v-if="!directReply && !collapseReplyingTo" :status="status" :simplified="!!simplifyReplyingTo" :class="faded ? 'text-secondary-light' : ''" pt1 />
|
||||
</div>
|
||||
<div flex gap-3 :class="{ 'text-secondary': faded }">
|
||||
<div relative>
|
||||
|
@ -122,7 +126,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
</NuxtLink>
|
||||
</AccountHoverWrapper>
|
||||
<div v-if="connectReply" w-full h-full flex justify-center>
|
||||
<div h-full class="w-2.5px" bg-border />
|
||||
<div class="w-2.5px" bg-primary-light />
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ col 1" min-w-0>
|
||||
|
@ -134,7 +138,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
<StatusReplyingTo :collapsed="true" :status="status" :class="faded ? 'text-secondary-light' : ''" />
|
||||
</div>
|
||||
<div flex-auto />
|
||||
<div v-if="!isZenMode" text-sm text-secondary flex="~ row nowrap" hover:underline>
|
||||
<div v-if="!userSettings.zenMode" text-sm text-secondary flex="~ row nowrap" hover:underline>
|
||||
<AccountBotIndicator v-if="status.account.bot" me-2 />
|
||||
<div flex>
|
||||
<CommonTooltip :content="createdAt">
|
||||
|
@ -151,7 +155,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
</div>
|
||||
<StatusContent :status="status" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
|
||||
<div>
|
||||
<StatusActions v-if="(actions !== false && !isZenMode)" :status="status" />
|
||||
<StatusActions v-if="(actions !== false && !userSettings.zenMode)" :status="status" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -23,8 +23,8 @@ const isFiltered = $computed(() => filterPhrase && (context && context !== 'deta
|
|||
<div
|
||||
space-y-3
|
||||
:class="{
|
||||
'pt2 pb0.5 px3.5 bg-fade border-1 border-primary-light rounded-5 mx--1': isDM,
|
||||
'ms--3.5 mt--1': isDM && context !== 'details',
|
||||
'pt2 pb0.5 px3.5 bg-fade border-1 border-primary-light rounded-5 me--1': isDM,
|
||||
'ms--3.5 mt--1 ms--1': isDM && context !== 'details',
|
||||
}"
|
||||
>
|
||||
<StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { Status } from 'masto'
|
||||
import { statusVisibilities } from '~/composables/masto/icons'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
status: Status
|
||||
|
@ -17,7 +18,7 @@ const status = $computed(() => {
|
|||
|
||||
const createdAt = useFormattedDateTime(status.createdAt)
|
||||
|
||||
const visibility = $computed(() => STATUS_VISIBILITIES.find(v => v.value === status.visibility)!)
|
||||
const visibility = $computed(() => statusVisibilities.find(v => v.value === status.visibility)!)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
@ -29,7 +30,7 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :id="`status-${status.id}`" flex flex-col gap-2 pt2 pb1 px-4 relative :lang="status.language ?? undefined">
|
||||
<div :id="`status-${status.id}`" flex flex-col gap-2 pt2 pb1 ps-3 pe-4 relative :lang="status.language ?? undefined">
|
||||
<StatusActionsMore :status="status" absolute inset-ie-2 top-2 />
|
||||
<NuxtLink :to="getAccountRoute(status.account)" rounded-full hover:bg-active transition-100 pe5 me-a>
|
||||
<AccountHoverWrapper :account="status.account">
|
||||
|
@ -54,7 +55,12 @@ const isDM = $computed(() => status.visibility === 'direct')
|
|||
<div v-if="status.application?.name">
|
||||
·
|
||||
</div>
|
||||
<div v-if="status.application?.name">
|
||||
<div v-if="status.application?.website && status.application.name">
|
||||
<NuxtLink :to="status.application.website">
|
||||
{{ status.application.name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div v-else-if="status.application?.name">
|
||||
{{ status.application?.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import type { Status } from 'masto'
|
||||
import type { Status, StatusEdit } from 'masto'
|
||||
|
||||
const { status } = defineProps<{
|
||||
status: Status
|
||||
status: Status | StatusEdit
|
||||
fullSize?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
|
|
@ -21,7 +21,7 @@ const isSquare = $computed(() => (
|
|||
))
|
||||
const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.card.url).hostname)
|
||||
|
||||
const gitHubCards = $(computedEager(() => useFeatureFlags().experimentalGitHubCards))
|
||||
const gitHubCards = $(useFeatureFlag('experimentalGitHubCards'))
|
||||
|
||||
// TODO: regex test the card.title value
|
||||
const isMastodonLink = true
|
||||
|
|
|
@ -20,6 +20,8 @@ interface Meta {
|
|||
}
|
||||
}
|
||||
|
||||
const specialRoutes = ['orgs', 'sponsors', 'stars']
|
||||
|
||||
const meta = $computed(() => {
|
||||
const { url } = props.card
|
||||
const path = url.split('https://github.com/')[1]
|
||||
|
@ -27,13 +29,15 @@ const meta = $computed(() => {
|
|||
// Supported paths
|
||||
// /user
|
||||
// /user/repo
|
||||
// /user/repo/issues/number.*
|
||||
// /user/repo/pull/number.*
|
||||
// /orgs/user.*
|
||||
// /user/repo/issues/number
|
||||
// /user/repo/pull/number
|
||||
// /orgs/user
|
||||
// /sponsors/user
|
||||
// /stars/user
|
||||
|
||||
const firstName = path.match(/([\w-]+)(\/|$)/)?.[1]
|
||||
const secondName = path.match(/[\w-]+\/([\w-]+)/)?.[1]
|
||||
const firstIsUser = firstName !== 'orgs' && firstName !== 'sponsors'
|
||||
const firstIsUser = firstName && !specialRoutes.includes(firstName)
|
||||
const user = firstIsUser ? firstName : secondName
|
||||
const repo = firstIsUser ? secondName : undefined
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ const account = isSelf ? computed(() => status.account) : useAccountById(status.
|
|||
<AccountInlineInfo v-else :account="account" :link="false" mx-0.5 />
|
||||
</template>
|
||||
</template>
|
||||
<div i-ph:chats-fill text-primary text-lg />
|
||||
<div i-ri:question-answer-line text-secondary-light text-lg />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -22,10 +22,7 @@ const { edit } = defineProps<{
|
|||
{{ edit.spoilerText }}
|
||||
</template>
|
||||
<StatusBody :status="edit" />
|
||||
<StatusMedia
|
||||
v-if="edit.mediaAttachments.length"
|
||||
:status="edit"
|
||||
/>
|
||||
<StatusMedia v-if="edit.mediaAttachments.length" :status="edit" />
|
||||
</StatusSpoiler>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import type { Status } from 'masto'
|
||||
const paginator = useMasto().timelines.iterateHome()
|
||||
const stream = useMasto().stream.streamUser()
|
||||
onBeforeUnmount(() => stream?.then(s => s.disconnect()))
|
||||
|
@ -8,6 +7,6 @@ onBeforeUnmount(() => stream?.then(s => s.disconnect()))
|
|||
<template>
|
||||
<div>
|
||||
<PublishWidget draft-key="home" border="b base" />
|
||||
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="timelineWithReorderedReplies" context="home" />
|
||||
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderedTimeline" context="home" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -12,11 +12,11 @@ const { paginator, stream } = defineProps<{
|
|||
}>()
|
||||
|
||||
const { formatNumber } = useHumanReadableNumber()
|
||||
const virtualScroller = $(computedEager(() => useFeatureFlags().experimentalVirtualScroll))
|
||||
const virtualScroller = $(useFeatureFlag('experimentalVirtualScroll'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonPaginator v-bind="{ paginator, stream, preprocess }" :virtual-scroller="virtualScroller">
|
||||
<CommonPaginator v-bind="{ paginator, stream, preprocess }" :virtual-scroller="virtualScroller" :is-account-timeline="context === 'account'">
|
||||
<template #updater="{ number, update }">
|
||||
<button py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="update">
|
||||
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
|
||||
|
|
67
components/tiptap/TiptapHashtagList.vue
Normal file
67
components/tiptap/TiptapHashtagList.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<script setup lang="ts">
|
||||
import type { Tag } from 'masto'
|
||||
import CommonScrollIntoView from '../common/CommonScrollIntoView.vue'
|
||||
|
||||
const { items, command } = defineProps<{
|
||||
items: Tag[]
|
||||
command: Function
|
||||
isPending?: boolean
|
||||
}>()
|
||||
|
||||
let selectedIndex = $ref(0)
|
||||
|
||||
watch(items, () => {
|
||||
selectedIndex = 0
|
||||
})
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
selectedIndex = ((selectedIndex + items.length) - 1) % items.length
|
||||
return true
|
||||
}
|
||||
else if (event.key === 'ArrowDown') {
|
||||
selectedIndex = (selectedIndex + 1) % items.length
|
||||
return true
|
||||
}
|
||||
else if (event.key === 'Enter') {
|
||||
selectItem(selectedIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function selectItem(index: number) {
|
||||
const item = items[index]
|
||||
if (item)
|
||||
command({ id: item.name })
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onKeyDown,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isPending || items.length" relative bg-base text-base shadow border="~ base rounded" text-sm py-2 overflow-x-hidden overflow-y-auto max-h-100>
|
||||
<template v-if="isPending">
|
||||
<div flex gap-1 items-center p2 animate-pulse>
|
||||
<div i-ri:loader-2-line animate-spin />
|
||||
<span>Fetching...</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="items.length">
|
||||
<CommonScrollIntoView
|
||||
v-for="(item, index) in items" :key="index"
|
||||
:active="index === selectedIndex"
|
||||
as="button"
|
||||
:class="index === selectedIndex ? 'bg-active' : 'text-secondary'"
|
||||
block m0 w-full text-left px2 py1
|
||||
@click="selectItem(index)"
|
||||
>
|
||||
<SearchHashtagInfo :hashtag="item" />
|
||||
</CommonScrollIntoView>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<VDropdown :distance="0" placement="top-start">
|
||||
<button btn-action-icon :aria-label="$t('action.switch_account')">
|
||||
<div :class="{ 'hidden lg:block': currentUser }" i-ri:more-2-line />
|
||||
<AccountAvatar v-if="currentUser" lg:hidden :account="currentUser.account" w-9 h-9 />
|
||||
<div :class="{ 'hidden xl:block': currentUser }" i-ri:more-2-line />
|
||||
<AccountAvatar v-if="currentUser" xl:hidden :account="currentUser.account" w-9 h-9 square />
|
||||
</button>
|
||||
<template #popper="{ hide }">
|
||||
<UserSwitcher @click="hide" />
|
||||
|
|
|
@ -25,7 +25,7 @@ const switchUser = (user: UserLogin) => {
|
|||
hover="filter-none op100"
|
||||
@click="switchUser(user)"
|
||||
>
|
||||
<AccountAvatar w-13 h-13 :account="user.account" />
|
||||
<AccountAvatar w-13 h-13 :account="user.account" square />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<p text-sm text-secondary>
|
||||
{{ $t('user.sign_in_desc') }}
|
||||
</p>
|
||||
<button btn-solid text-center mt-2 @click="openSigninDialog()">
|
||||
<button btn-solid rounded-3 text-center mt-2 @click="openSigninDialog()">
|
||||
{{ $t('action.sign_in') }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -33,18 +33,12 @@ const switchUser = (user: UserLogin) => {
|
|||
aria-label="Switch user"
|
||||
@click="switchUser(user)"
|
||||
>
|
||||
<AccountInfo :account="user.account" :hover-card="false" />
|
||||
<AccountInfo :account="user.account" :hover-card="false" square />
|
||||
<div flex-auto />
|
||||
<div v-if="user.token === currentUser?.token" i-ri:check-line text-primary mya text-2xl />
|
||||
</button>
|
||||
</template>
|
||||
<div border="t base" pt2>
|
||||
<NuxtLink to="/settings">
|
||||
<CommonDropdownItem
|
||||
:text="$t('nav.settings')"
|
||||
icon="i-ri:settings-4-line"
|
||||
/>
|
||||
</NuxtLink>
|
||||
<CommonDropdownItem
|
||||
:text="$t('user.add_existing')"
|
||||
icon="i-ri:user-add-line"
|
||||
|
|
|
@ -205,7 +205,7 @@ export const useCommandRegistry = defineStore('command', () => {
|
|||
}
|
||||
})
|
||||
|
||||
export const useCommand = (cmd: CommandProvider) => {
|
||||
export function useCommand(cmd: CommandProvider) {
|
||||
const registry = useCommandRegistry()
|
||||
|
||||
const register = () => registry.register(cmd)
|
||||
|
@ -217,7 +217,7 @@ export const useCommand = (cmd: CommandProvider) => {
|
|||
tryOnScopeDispose(cleanup)
|
||||
}
|
||||
|
||||
export const useCommands = (cmds: () => CommandProvider[]) => {
|
||||
export function useCommands(cmds: () => CommandProvider[]) {
|
||||
const registry = useCommandRegistry()
|
||||
|
||||
const commands = computed(cmds)
|
||||
|
@ -245,25 +245,11 @@ export const provideGlobalCommands = () => {
|
|||
const masto = useMasto()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
useCommand({
|
||||
scope: 'Actions',
|
||||
|
||||
visible: () => currentUser.value,
|
||||
|
||||
name: () => t('action.compose'),
|
||||
icon: 'i-ri:quill-pen-line',
|
||||
description: () => t('command.compose_desc'),
|
||||
|
||||
onActivate() {
|
||||
openPublishDialog()
|
||||
},
|
||||
})
|
||||
|
||||
useCommand({
|
||||
scope: 'Navigation',
|
||||
|
||||
name: () => t('nav.settings'),
|
||||
icon: 'i-ri:settings-4-line',
|
||||
icon: 'i-ri:settings-3-line',
|
||||
|
||||
onActivate() {
|
||||
router.push('/settings')
|
||||
|
@ -285,10 +271,10 @@ export const provideGlobalCommands = () => {
|
|||
scope: 'Preferences',
|
||||
|
||||
name: () => t('command.toggle_zen_mode'),
|
||||
icon: () => isZenMode.value ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line',
|
||||
icon: () => userSettings.value.zenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line',
|
||||
|
||||
onActivate() {
|
||||
toggleZenMode()
|
||||
userSettings.value.zenMode = !userSettings.value.zenMode
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
// @unimport-disable
|
||||
import type { Emoji } from 'masto'
|
||||
import type { Node } from 'ultrahtml'
|
||||
import { TEXT_NODE, parse, render, walkSync } from 'ultrahtml'
|
||||
import { ELEMENT_NODE, TEXT_NODE, h, parse, render } from 'ultrahtml'
|
||||
import { findAndReplaceEmojisInText } from '@iconify/utils'
|
||||
import { emojiRegEx, getEmojiAttributes } from '../config/emojis'
|
||||
|
||||
const decoder = process.client ? document.createElement('textarea') : null as any as HTMLTextAreaElement
|
||||
const decoder = process.client ? document.createElement('textarea') : null
|
||||
export function decodeHtml(text: string) {
|
||||
if (!decoder)
|
||||
// not available when SSR
|
||||
return text
|
||||
decoder.innerHTML = text
|
||||
return decoder.value
|
||||
}
|
||||
|
@ -16,53 +19,43 @@ export function decodeHtml(text: string) {
|
|||
* with interop of custom emojis and inline Markdown syntax
|
||||
*/
|
||||
export function parseMastodonHTML(html: string, customEmojis: Record<string, Emoji> = {}, markdown = true, forTiptap = false) {
|
||||
// unicode emojis to images, but only if not converting HTML for Tiptap
|
||||
let processed = forTiptap ? html : replaceUnicodeEmoji(html)
|
||||
|
||||
// custom emojis
|
||||
processed = processed.replace(/:([\w-]+?):/g, (_, name) => {
|
||||
const emoji = customEmojis[name]
|
||||
if (emoji)
|
||||
return `<img src="${emoji.url}" alt=":${name}:" class="custom-emoji" data-emoji-id="${name}" />`
|
||||
return `:${name}:`
|
||||
})
|
||||
|
||||
if (markdown) {
|
||||
// handle code blocks
|
||||
processed = processed
|
||||
// Handle code blocks
|
||||
html = html
|
||||
.replace(/>(```|~~~)(\w*)([\s\S]+?)\1/g, (_1, _2, lang, raw) => {
|
||||
const code = htmlToText(raw)
|
||||
const classes = lang ? ` class="language-${lang}"` : ''
|
||||
return `><pre><code${classes}>${code}</code></pre>`
|
||||
})
|
||||
|
||||
walkSync(parse(processed), (node) => {
|
||||
if (node.type !== TEXT_NODE)
|
||||
return
|
||||
const replacements = [
|
||||
[/\*\*\*(.*?)\*\*\*/g, '<b><em>$1</em></b>'],
|
||||
[/\*\*(.*?)\*\*/g, '<b>$1</b>'],
|
||||
[/\*(.*?)\*/g, '<em>$1</em>'],
|
||||
[/~~(.*?)~~/g, '<del>$1</del>'],
|
||||
[/`([^`]+?)`/g, '<code>$1</code>'],
|
||||
] as any
|
||||
|
||||
for (const [re, replacement] of replacements) {
|
||||
for (const match of node.value.matchAll(re)) {
|
||||
if (node.loc) {
|
||||
const start = match.index! + node.loc[0].start
|
||||
const end = start + match[0].length + node.loc[0].start
|
||||
processed = processed.slice(0, start) + match[0].replace(re, replacement) + processed.slice(end)
|
||||
}
|
||||
else {
|
||||
processed = processed.replace(match[0], match[0].replace(re, replacement))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return parse(processed)
|
||||
// Always sanitize the raw HTML data *after* it has been modified
|
||||
const basicClasses = filterClasses(/^(h-\S*|p-\S*|u-\S*|dt-\S*|e-\S*|mention|hashtag|ellipsis|invisible)$/u)
|
||||
return transformSync(parse(html), [
|
||||
sanitize({
|
||||
// Allow basic elements as seen in https://github.com/mastodon/mastodon/blob/17f79082b098e05b68d6f0d38fabb3ac121879a9/lib/sanitize_ext/sanitize_config.rb
|
||||
br: {},
|
||||
p: {},
|
||||
a: {
|
||||
href: filterHref(),
|
||||
class: basicClasses,
|
||||
rel: set('nofollow noopener noreferrer'),
|
||||
target: set('_blank'),
|
||||
},
|
||||
span: {
|
||||
class: basicClasses,
|
||||
},
|
||||
// Allow elements potentially created for Markdown code blocks above
|
||||
pre: {},
|
||||
code: {
|
||||
class: filterClasses(/^language-\w+$/),
|
||||
},
|
||||
}),
|
||||
// Unicode emojis to images, but only if not converting HTML for Tiptap
|
||||
!forTiptap ? replaceUnicodeEmoji() : noopTransform(),
|
||||
markdown ? formatMarkdown() : noopTransform(),
|
||||
replaceCustomEmoji(customEmojis),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -130,12 +123,210 @@ export function treeToText(input: Node): string {
|
|||
return pre + body + post
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace unicode emojis with locally hosted images
|
||||
*/
|
||||
export function replaceUnicodeEmoji(html: string) {
|
||||
return findAndReplaceEmojisInText(emojiRegEx, html, (match) => {
|
||||
const attrs = getEmojiAttributes(match)
|
||||
return `<img src="${attrs.src}" alt="${attrs.alt}" class="${attrs.class}" />`
|
||||
}) || html
|
||||
// A tree transform function takes an ultrahtml Node object and returns
|
||||
// new content that will replace the given node in the tree.
|
||||
// Returning a null removes the node from the tree.
|
||||
// Strings get converted to text nodes.
|
||||
// The input node's children have been transformed before the node itself
|
||||
// gets transformed.
|
||||
type Transform = (node: Node) => (Node | string)[] | Node | string | null
|
||||
|
||||
// Helpers for transforming (filtering, modifying, ...) a parsed HTML tree
|
||||
// by running the given chain of transform functions one-by-one.
|
||||
function transformSync(doc: Node, transforms: Transform[]) {
|
||||
function visit(node: Node, transform: Transform, isRoot = false) {
|
||||
if (Array.isArray(node.children)) {
|
||||
const children = [] as (Node | string)[]
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const result = visit(node.children[i], transform)
|
||||
if (Array.isArray(result))
|
||||
children.push(...result)
|
||||
|
||||
else if (result)
|
||||
children.push(result)
|
||||
}
|
||||
|
||||
node.children = children.map((value) => {
|
||||
if (typeof value === 'string')
|
||||
return { type: TEXT_NODE, value, parent: node }
|
||||
value.parent = node
|
||||
return value
|
||||
})
|
||||
}
|
||||
return isRoot ? node : transform(node)
|
||||
}
|
||||
|
||||
for (const transform of transforms)
|
||||
doc = visit(doc, transform, true) as Node
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
// A transformation that does nothing. Useful for conditional transform chains.
|
||||
function noopTransform(): Transform {
|
||||
return node => node
|
||||
}
|
||||
|
||||
// A tree transform for sanitizing elements & their attributes.
|
||||
type AttrSanitizers = Record<string, (value: string | undefined) => string | undefined>
|
||||
function sanitize(allowedElements: Record<string, AttrSanitizers>): Transform {
|
||||
return (node) => {
|
||||
if (node.type !== ELEMENT_NODE)
|
||||
return node
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(allowedElements, node.name))
|
||||
return null
|
||||
|
||||
const attrSanitizers = allowedElements[node.name]
|
||||
const attrs = {} as Record<string, string>
|
||||
for (const [name, func] of Object.entries(attrSanitizers)) {
|
||||
const value = func(node.attributes[name])
|
||||
if (value !== undefined)
|
||||
attrs[name] = value
|
||||
}
|
||||
node.attributes = attrs
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
function filterClasses(allowed: RegExp) {
|
||||
return (c: string | undefined) => {
|
||||
if (!c)
|
||||
return undefined
|
||||
|
||||
return c.split(/\s/g).filter(cls => allowed.test(cls)).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
function set(value: string) {
|
||||
return () => value
|
||||
}
|
||||
|
||||
function filterHref() {
|
||||
const LINK_PROTOCOLS = new Set([
|
||||
'http:',
|
||||
'https:',
|
||||
'dat:',
|
||||
'dweb:',
|
||||
'ipfs:',
|
||||
'ipns:',
|
||||
'ssb:',
|
||||
'gopher:',
|
||||
'xmpp:',
|
||||
'magnet:',
|
||||
'gemini:',
|
||||
])
|
||||
|
||||
return (href: string | undefined) => {
|
||||
if (href === undefined)
|
||||
return undefined
|
||||
|
||||
// Allow relative links
|
||||
if (href.startsWith('/') || href.startsWith('.'))
|
||||
return href
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(href)
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof TypeError)
|
||||
return undefined
|
||||
throw err
|
||||
}
|
||||
|
||||
if (LINK_PROTOCOLS.has(url.protocol))
|
||||
return url.toString()
|
||||
return '#'
|
||||
}
|
||||
}
|
||||
|
||||
function replaceUnicodeEmoji(): Transform {
|
||||
return (node) => {
|
||||
if (node.type !== TEXT_NODE)
|
||||
return node
|
||||
|
||||
let start = 0
|
||||
|
||||
const matches = [] as (string | Node)[]
|
||||
findAndReplaceEmojisInText(emojiRegEx, node.value, (match, result) => {
|
||||
const attrs = getEmojiAttributes(match)
|
||||
matches.push(result.slice(start))
|
||||
matches.push(h('img', { src: attrs.src, alt: attrs.alt, class: attrs.class }))
|
||||
start = result.length + match.match.length
|
||||
return undefined
|
||||
})
|
||||
if (matches.length === 0)
|
||||
return node
|
||||
|
||||
matches.push(node.value.slice(start))
|
||||
return matches.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
function replaceCustomEmoji(customEmojis: Record<string, Emoji>): Transform {
|
||||
return (node) => {
|
||||
if (node.type !== TEXT_NODE)
|
||||
return node
|
||||
|
||||
const split = node.value.split(/:([\w-]+?):/g)
|
||||
if (split.length === 1)
|
||||
return node
|
||||
|
||||
return split.map((name, i) => {
|
||||
if (i % 2 === 0)
|
||||
return name
|
||||
|
||||
const emoji = customEmojis[name]
|
||||
if (!emoji)
|
||||
return `:${name}:`
|
||||
|
||||
return h('img', { 'src': emoji.url, 'alt': `:${name}:`, 'class': 'custom-emoji', 'data-emoji-id': name })
|
||||
}).filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
function formatMarkdown(): Transform {
|
||||
const replacements: [RegExp, (c: (string | Node)[]) => Node][] = [
|
||||
[/\*\*\*(.*?)\*\*\*/g, c => h('b', null, [h('em', null, c)])],
|
||||
[/\*\*(.*?)\*\*/g, c => h('b', null, c)],
|
||||
[/\*(.*?)\*/g, c => h('em', null, c)],
|
||||
[/~~(.*?)~~/g, c => h('del', null, c)],
|
||||
[/`([^`]+?)`/g, c => h('code', null, c)],
|
||||
]
|
||||
|
||||
function process(value: string) {
|
||||
const results = [] as (string | Node)[]
|
||||
|
||||
let start = 0
|
||||
while (true) {
|
||||
let found: { match: RegExpMatchArray; replacer: (c: (string | Node)[]) => Node } | undefined
|
||||
|
||||
for (const [re, replacer] of replacements) {
|
||||
re.lastIndex = start
|
||||
|
||||
const match = re.exec(value)
|
||||
if (match) {
|
||||
if (!found || match.index < found.match.index!)
|
||||
found = { match, replacer }
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
break
|
||||
|
||||
results.push(value.slice(start, found.match.index))
|
||||
results.push(found.replacer(process(found.match[1])))
|
||||
start = found.match.index! + found.match[0].length
|
||||
}
|
||||
|
||||
results.push(value.slice(start))
|
||||
return results.filter(Boolean)
|
||||
}
|
||||
|
||||
return (node) => {
|
||||
if (node.type !== TEXT_NODE)
|
||||
return node
|
||||
return process(node.value)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { Attachment, Status, StatusEdit } from 'masto'
|
||||
import type { Draft } from '~/types'
|
||||
import { STORAGE_KEY_FIRST_VISIT, STORAGE_KEY_ZEN_MODE } from '~/constants'
|
||||
import { STORAGE_KEY_FIRST_VISIT } from '~/constants'
|
||||
|
||||
export const mediaPreviewList = ref<Attachment[]>([])
|
||||
export const mediaPreviewIndex = ref(0)
|
||||
|
@ -11,7 +11,6 @@ export const dialogDraftKey = ref<string>()
|
|||
export const commandPanelInput = ref('')
|
||||
|
||||
export const isFirstVisit = useLocalStorage(STORAGE_KEY_FIRST_VISIT, !process.mock)
|
||||
export const isZenMode = useLocalStorage(STORAGE_KEY_ZEN_MODE, false)
|
||||
|
||||
export const isSigninDialogOpen = ref(false)
|
||||
export const isPublishDialogOpen = ref(false)
|
||||
|
@ -22,8 +21,6 @@ export const isCommandPanelOpen = ref(false)
|
|||
|
||||
export const lastPublishDialogStatus = ref<Status | null>(null)
|
||||
|
||||
export const toggleZenMode = useToggle(isZenMode)
|
||||
|
||||
export function openSigninDialog() {
|
||||
isSigninDialogOpen.value = true
|
||||
}
|
||||
|
@ -57,14 +54,33 @@ if (isPreviewHelpOpen.value) {
|
|||
})
|
||||
}
|
||||
|
||||
function restoreMediaPreviewFromState() {
|
||||
mediaPreviewList.value = JSON.parse(history.state?.mediaPreviewList ?? '[]')
|
||||
mediaPreviewIndex.value = history.state?.mediaPreviewIndex ?? 0
|
||||
isMediaPreviewOpen.value = history.state?.mediaPreview ?? false
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
window.addEventListener('popstate', restoreMediaPreviewFromState)
|
||||
|
||||
restoreMediaPreviewFromState()
|
||||
}
|
||||
|
||||
export function openMediaPreview(attachments: Attachment[], index = 0) {
|
||||
mediaPreviewList.value = attachments
|
||||
mediaPreviewIndex.value = index
|
||||
isMediaPreviewOpen.value = true
|
||||
|
||||
history.pushState({
|
||||
...history.state,
|
||||
mediaPreview: true,
|
||||
mediaPreviewList: JSON.stringify(attachments),
|
||||
mediaPreviewIndex: index,
|
||||
}, '')
|
||||
}
|
||||
|
||||
export function closeMediaPreview() {
|
||||
isMediaPreviewOpen.value = false
|
||||
history.back()
|
||||
}
|
||||
|
||||
export function openEditHistoryDialog(edit: StatusEdit) {
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
import { STORAGE_KEY_FEATURE_FLAGS } from '~/constants'
|
||||
|
||||
export interface FeatureFlags {
|
||||
experimentalVirtualScroll: boolean
|
||||
experimentalGitHubCards: boolean
|
||||
experimentalUserPicker: boolean
|
||||
}
|
||||
export type FeatureFlagsMap = Record<string, FeatureFlags>
|
||||
|
||||
export function getDefaultFeatureFlags(): FeatureFlags {
|
||||
return {
|
||||
experimentalVirtualScroll: false,
|
||||
experimentalGitHubCards: true,
|
||||
experimentalUserPicker: true,
|
||||
}
|
||||
}
|
||||
|
||||
export const currentUserFeatureFlags = process.server
|
||||
? computed(getDefaultFeatureFlags)
|
||||
: useUserLocalStorage(STORAGE_KEY_FEATURE_FLAGS, getDefaultFeatureFlags)
|
||||
|
||||
export function useFeatureFlags() {
|
||||
const featureFlags = currentUserFeatureFlags.value
|
||||
|
||||
return featureFlags
|
||||
}
|
||||
|
||||
export function toggleFeatureFlag(key: keyof FeatureFlags) {
|
||||
const featureFlags = currentUserFeatureFlags.value
|
||||
|
||||
if (featureFlags[key])
|
||||
featureFlags[key] = !featureFlags[key]
|
||||
else
|
||||
featureFlags[key] = true
|
||||
}
|
||||
|
||||
const userPicker = eagerComputed(() => useFeatureFlags().experimentalUserPicker)
|
||||
export const showUserPicker = computed(() => useUsers().value.length > 1 && userPicker.value)
|
|
@ -24,7 +24,6 @@ export const useImageGesture = (
|
|||
|
||||
const { set } = useSpring(motionProperties as Partial<PermissiveMotionProperties>)
|
||||
|
||||
// @ts-expect-error we need to fix types: just suppress it for now
|
||||
const handlers: Handlers = {
|
||||
onPinch({ offset: [d] }) {
|
||||
set({ scale: 1 + d / 200 })
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
// @unocss-include
|
||||
export const accountFieldIcons: Record<string, string> = Object.fromEntries(Object.entries({
|
||||
Alipay: 'i-ri:alipay-fill',
|
||||
Bilibili: 'i-ri:bilibili-fill',
|
||||
Birth: 'i-ri:calendar-line',
|
||||
Blog: 'i-ri:newspaper-line',
|
||||
City: 'i-ri:map-pin-2-line',
|
||||
Dingding: 'i-ri:dingding-fill',
|
||||
Discord: 'i-ri:discord-fill',
|
||||
Douban: 'i-ri:douban-fill',
|
||||
Facebook: 'i-ri:facebook-fill',
|
||||
GitHub: 'i-ri:github-fill',
|
||||
GitLab: 'i-ri:gitlab-fill',
|
||||
Home: 'i-ri:home-2-line',
|
||||
Instagram: 'i-ri:instagram-line',
|
||||
Joined: 'i-ri:user-add-line',
|
||||
Language: 'i-ri:translate-2',
|
||||
Languages: 'i-ri:translate-2',
|
||||
LinkedIn: 'i-ri:linkedin-box-fill',
|
||||
Location: 'i-ri:map-pin-2-line',
|
||||
Mastodon: 'i-ri:mastodon-line',
|
||||
Medium: 'i-ri:medium-fill',
|
||||
Patreon: 'i-ri:patreon-fill',
|
||||
PayPal: 'i-ri:paypal-fill',
|
||||
PlayStation: 'i-ri:playstation-fill',
|
||||
Portfolio: 'i-ri:link',
|
||||
QQ: 'i-ri:qq-fill',
|
||||
Site: 'i-ri:link',
|
||||
Sponsors: 'i-ri:heart-3-line',
|
||||
Spotify: 'i-ri:spotify-fill',
|
||||
Steam: 'i-ri:steam-fill',
|
||||
Switch: 'i-ri:switch-fill',
|
||||
Telegram: 'i-ri:telegram-fill',
|
||||
Tumblr: 'i-ri:tumblr-fill',
|
||||
Twitch: 'i-ri:twitch-line',
|
||||
Twitter: 'i-ri:twitter-line',
|
||||
Website: 'i-ri:link',
|
||||
WeChat: 'i-ri:wechat-fill',
|
||||
Weibo: 'i-ri:weibo-fill',
|
||||
Xbox: 'i-ri:xbox-fill',
|
||||
YouTube: 'i-ri:youtube-line',
|
||||
Zhihu: 'i-ri:zhihu-fill',
|
||||
}).sort(([a], [b]) => a.localeCompare(b)))
|
||||
|
||||
const accountFieldIconsLowercase = Object.fromEntries(
|
||||
Object.entries(accountFieldIcons).map(([k, v]) =>
|
||||
[k.toLowerCase(), v],
|
||||
),
|
||||
)
|
||||
|
||||
export const getAccountFieldIcon = (value: string) => {
|
||||
const name = value.trim().toLowerCase()
|
||||
return accountFieldIconsLowercase[name] || undefined
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
import { InjectionKeyFontSize } from '~/constants/symbols'
|
||||
import { InjectionKeyDropdownContext, InjectionKeyFontSize } from '~/constants/symbols'
|
||||
|
||||
export function useFontSizeRef() {
|
||||
return inject(InjectionKeyFontSize)!
|
||||
}
|
||||
|
||||
export function useDropdownContext() {
|
||||
return inject(InjectionKeyDropdownContext, undefined)
|
||||
}
|
||||
|
|
|
@ -7,25 +7,11 @@ export const useMasto = () => useNuxtApp().$masto as ElkMasto
|
|||
|
||||
export const isMastoInitialised = computed(() => process.client && useMasto().loggedIn.value)
|
||||
|
||||
// @unocss-include
|
||||
export const STATUS_VISIBILITIES = [
|
||||
{
|
||||
value: 'public',
|
||||
icon: 'i-ri:global-line',
|
||||
},
|
||||
{
|
||||
value: 'unlisted',
|
||||
icon: 'i-ri:lock-unlock-line',
|
||||
},
|
||||
{
|
||||
value: 'private',
|
||||
icon: 'i-ri:lock-line',
|
||||
},
|
||||
{
|
||||
value: 'direct',
|
||||
icon: 'i-ri:at-line',
|
||||
},
|
||||
] as const
|
||||
export const onMastoInit = (cb: () => unknown) => {
|
||||
watchOnce(isMastoInitialised, () => {
|
||||
cb()
|
||||
}, { immediate: isMastoInitialised.value })
|
||||
}
|
||||
|
||||
export function getDisplayName(account?: Account, options?: { rich?: boolean }) {
|
||||
const displayName = account?.displayName || account?.username || ''
|
||||
|
@ -172,20 +158,3 @@ async function fetchRelationships() {
|
|||
for (let i = 0; i < requested.length; i++)
|
||||
requested[i][1].value = relationships[i]
|
||||
}
|
||||
|
||||
const maxDistance = 10
|
||||
export function timelineWithReorderedReplies(items: Status[]) {
|
||||
const newItems = [...items]
|
||||
// TODO: Basic reordering, we should get something more efficient and robust
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
for (let k = 1; k <= maxDistance && i - k >= 0; k++) {
|
||||
const inReplyToId = newItems[i - k].inReplyToId ?? newItems[i - k].reblog?.inReplyToId
|
||||
if (inReplyToId && (inReplyToId === newItems[i].reblog?.id || inReplyToId === newItems[i].id)) {
|
||||
const item = newItems.splice(i, 1)[0]
|
||||
newItems.splice(i - k, 0, item)
|
||||
k = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return newItems
|
||||
}
|
||||
|
|
74
composables/masto/icons.ts
Normal file
74
composables/masto/icons.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
// @unocss-include
|
||||
export const accountFieldIcons: Record<string, string> = Object.fromEntries(Object.entries({
|
||||
Alipay: 'i-ri:alipay-line',
|
||||
Bilibili: 'i-ri:bilibili-line',
|
||||
Birth: 'i-ri:calendar-line',
|
||||
Blog: 'i-ri:newspaper-line',
|
||||
City: 'i-ri:map-pin-2-line',
|
||||
Dingding: 'i-ri:dingding-line',
|
||||
Discord: 'i-ri:discord-line',
|
||||
Douban: 'i-ri:douban-line',
|
||||
Facebook: 'i-ri:facebook-line',
|
||||
GitHub: 'i-ri:github-line',
|
||||
GitLab: 'i-ri:gitlab-line',
|
||||
Home: 'i-ri:home-2-line',
|
||||
Instagram: 'i-ri:instagram-line',
|
||||
Joined: 'i-ri:user-add-line',
|
||||
Language: 'i-ri:translate-2',
|
||||
Languages: 'i-ri:translate-2',
|
||||
LinkedIn: 'i-ri:linkedin-box-line',
|
||||
Location: 'i-ri:map-pin-2-line',
|
||||
Mastodon: 'i-ri:mastodon-line',
|
||||
Medium: 'i-ri:medium-line',
|
||||
Patreon: 'i-ri:patreon-line',
|
||||
PayPal: 'i-ri:paypal-line',
|
||||
PlayStation: 'i-ri:playstation-line',
|
||||
Portfolio: 'i-ri:link',
|
||||
Pronouns: 'i-ri:contacts-line',
|
||||
QQ: 'i-ri:qq-line',
|
||||
Site: 'i-ri:link',
|
||||
Sponsors: 'i-ri:heart-3-line',
|
||||
Spotify: 'i-ri:spotify-line',
|
||||
Steam: 'i-ri:steam-line',
|
||||
Switch: 'i-ri:switch-line',
|
||||
Telegram: 'i-ri:telegram-line',
|
||||
Tumblr: 'i-ri:tumblr-line',
|
||||
Twitch: 'i-ri:twitch-line',
|
||||
Twitter: 'i-ri:twitter-line',
|
||||
Website: 'i-ri:link',
|
||||
WeChat: 'i-ri:wechat-line',
|
||||
Weibo: 'i-ri:weibo-line',
|
||||
Xbox: 'i-ri:xbox-line',
|
||||
YouTube: 'i-ri:youtube-line',
|
||||
Zhihu: 'i-ri:zhihu-line',
|
||||
}).sort(([a], [b]) => a.localeCompare(b)))
|
||||
|
||||
const accountFieldIconsLowercase = Object.fromEntries(
|
||||
Object.entries(accountFieldIcons).map(([k, v]) =>
|
||||
[k.toLowerCase(), v],
|
||||
),
|
||||
)
|
||||
|
||||
export const getAccountFieldIcon = (value: string) => {
|
||||
const name = value.trim().toLowerCase()
|
||||
return accountFieldIconsLowercase[name] || undefined
|
||||
}
|
||||
|
||||
export const statusVisibilities = [
|
||||
{
|
||||
value: 'public',
|
||||
icon: 'i-ri:global-line',
|
||||
},
|
||||
{
|
||||
value: 'unlisted',
|
||||
icon: 'i-ri:lock-unlock-line',
|
||||
},
|
||||
{
|
||||
value: 'private',
|
||||
icon: 'i-ri:lock-line',
|
||||
},
|
||||
{
|
||||
value: 'direct',
|
||||
icon: 'i-ri:at-line',
|
||||
},
|
||||
] as const
|
|
@ -86,7 +86,7 @@ export function usePaginator<T>(
|
|||
}, 1000)
|
||||
|
||||
if (!isMastoInitialised.value) {
|
||||
watchOnce(isMastoInitialised, () => {
|
||||
onMastoInit(() => {
|
||||
state.value = 'idle'
|
||||
loadNext()
|
||||
})
|
||||
|
|
36
composables/settings/featureFlags.ts
Normal file
36
composables/settings/featureFlags.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import type { Ref } from 'vue'
|
||||
import { userSettings } from '.'
|
||||
|
||||
export interface FeatureFlags {
|
||||
experimentalVirtualScroll: boolean
|
||||
experimentalGitHubCards: boolean
|
||||
experimentalUserPicker: boolean
|
||||
}
|
||||
export type FeatureFlagsMap = Record<string, FeatureFlags>
|
||||
|
||||
const DEFAULT_FEATURE_FLAGS: FeatureFlags = {
|
||||
experimentalVirtualScroll: false,
|
||||
experimentalGitHubCards: true,
|
||||
experimentalUserPicker: true,
|
||||
}
|
||||
|
||||
export function useFeatureFlag<T extends keyof FeatureFlags>(name: T): Ref<FeatureFlags[T]> {
|
||||
return computed({
|
||||
get() {
|
||||
return getFeatureFlag(name)
|
||||
},
|
||||
set(value) {
|
||||
if (userSettings.value)
|
||||
userSettings.value.featureFlags[name] = value
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getFeatureFlag<T extends keyof FeatureFlags>(name: T): FeatureFlags[T] {
|
||||
return userSettings.value?.featureFlags?.[name] ?? DEFAULT_FEATURE_FLAGS[name]
|
||||
}
|
||||
|
||||
export function toggleFeatureFlag(key: keyof FeatureFlags) {
|
||||
const flag = useFeatureFlag(key)
|
||||
flag.value = !flag.value
|
||||
}
|
21
composables/settings/index.ts
Normal file
21
composables/settings/index.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import type { FeatureFlags } from './featureFlags'
|
||||
import type { ColorMode, FontSize } from '~/types'
|
||||
import { STORAGE_KEY_SETTINGS } from '~/constants'
|
||||
|
||||
export interface UserSettings {
|
||||
featureFlags: Partial<FeatureFlags>
|
||||
colorMode?: ColorMode
|
||||
fontSize?: FontSize
|
||||
lang?: string
|
||||
zenMode?: boolean
|
||||
}
|
||||
|
||||
export function getDefaultUserSettings(): UserSettings {
|
||||
return {
|
||||
featureFlags: {},
|
||||
}
|
||||
}
|
||||
|
||||
export const userSettings = process.server
|
||||
? computed(getDefaultUserSettings)
|
||||
: useUserLocalStorage(STORAGE_KEY_SETTINGS, getDefaultUserSettings)
|
|
@ -1,48 +1,33 @@
|
|||
import { pwaInfo } from 'virtual:pwa-info'
|
||||
import type { Link } from '@unhead/schema'
|
||||
import type { Directions } from 'vue-i18n-routing'
|
||||
import { APP_NAME } from '~/constants'
|
||||
import { buildInfo } from 'virtual:build-info'
|
||||
import type { LocaleObject } from '#i18n'
|
||||
|
||||
export function setupPageHeader() {
|
||||
const isDev = process.dev
|
||||
const isPreview = useRuntimeConfig().public.env === 'staging'
|
||||
const { locale, locales, t } = useI18n()
|
||||
|
||||
const i18n = useI18n()
|
||||
|
||||
const link: Link[] = []
|
||||
|
||||
if (pwaInfo && pwaInfo.webManifest) {
|
||||
const { webManifest } = pwaInfo
|
||||
if (webManifest) {
|
||||
const { href, useCredentials } = webManifest
|
||||
if (useCredentials) {
|
||||
link.push({
|
||||
rel: 'manifest',
|
||||
href,
|
||||
crossorigin: 'use-credentials',
|
||||
})
|
||||
}
|
||||
else {
|
||||
link.push({
|
||||
rel: 'manifest',
|
||||
href,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const localeMap = (i18n.locales.value as LocaleObject[]).reduce((acc, l) => {
|
||||
const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
|
||||
acc[l.code!] = l.dir ?? 'auto'
|
||||
return acc
|
||||
}, {} as Record<string, Directions>)
|
||||
|
||||
useHeadFixed({
|
||||
htmlAttrs: {
|
||||
lang: () => i18n.locale.value,
|
||||
dir: () => localeMap[i18n.locale.value] ?? 'auto',
|
||||
lang: () => locale.value,
|
||||
dir: () => localeMap[locale.value] ?? 'auto',
|
||||
},
|
||||
titleTemplate: title => `${title ? `${title} | ` : ''}${APP_NAME}${isDev ? ' (dev)' : isPreview ? ' (preview)' : ''}`,
|
||||
link,
|
||||
titleTemplate: (title) => {
|
||||
let titleTemplate = title ? `${title} | ` : ''
|
||||
titleTemplate += t('app_name')
|
||||
if (buildInfo.env !== 'release')
|
||||
titleTemplate += ` (${buildInfo.env})`
|
||||
return titleTemplate
|
||||
},
|
||||
link: process.client && useRuntimeConfig().public.pwaEnabled
|
||||
? () => [{
|
||||
key: 'webmanifest',
|
||||
rel: 'manifest',
|
||||
href: `/manifest-${locale.value}.webmanifest`,
|
||||
}]
|
||||
: [],
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,30 +1,40 @@
|
|||
import type { Account, Status } from 'masto'
|
||||
import type { Account, CreateStatusParams, Status } from 'masto'
|
||||
import { STORAGE_KEY_DRAFTS } from '~/constants'
|
||||
import type { Draft, DraftMap } from '~/types'
|
||||
import type { Mutable } from '~/types/utils'
|
||||
|
||||
export const currentUserDrafts = process.server ? computed<DraftMap>(() => ({})) : useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({}))
|
||||
|
||||
export function getDefaultDraft(options: Partial<Draft['params'] & Omit<Draft, 'params'>> = {}): Draft {
|
||||
export const builtinDraftKeys = [
|
||||
'dialog',
|
||||
'home',
|
||||
]
|
||||
|
||||
export function getDefaultDraft(options: Partial<Mutable<CreateStatusParams> & Omit<Draft, 'params'>> = {}): Draft {
|
||||
const {
|
||||
status = '',
|
||||
inReplyToId,
|
||||
visibility = 'public',
|
||||
attachments = [],
|
||||
initialText = '',
|
||||
sensitive = false,
|
||||
spoilerText = '',
|
||||
|
||||
status,
|
||||
inReplyToId,
|
||||
visibility,
|
||||
sensitive,
|
||||
spoilerText,
|
||||
language,
|
||||
} = options
|
||||
|
||||
return {
|
||||
params: {
|
||||
status,
|
||||
inReplyToId,
|
||||
visibility,
|
||||
sensitive,
|
||||
spoilerText,
|
||||
},
|
||||
attachments,
|
||||
initialText,
|
||||
params: {
|
||||
status: status || '',
|
||||
inReplyToId,
|
||||
visibility: visibility || 'public',
|
||||
sensitive: sensitive ?? false,
|
||||
spoilerText: spoilerText || '',
|
||||
language: language || 'en',
|
||||
},
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -36,6 +46,7 @@ export async function getDraftFromStatus(status: Status): Promise<Draft> {
|
|||
attachments: status.mediaAttachments,
|
||||
sensitive: status.sensitive,
|
||||
spoilerText: status.spoilerText,
|
||||
language: status.language,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -72,25 +83,27 @@ export const isEmptyDraft = (draft: Draft | null | undefined) => {
|
|||
}
|
||||
|
||||
export function useDraft(
|
||||
draftKey: string,
|
||||
draftKey?: string,
|
||||
initial: () => Draft = () => getDefaultDraft({}),
|
||||
) {
|
||||
const draft = computed({
|
||||
get() {
|
||||
if (!currentUserDrafts.value[draftKey])
|
||||
currentUserDrafts.value[draftKey] = initial()
|
||||
return currentUserDrafts.value[draftKey]
|
||||
},
|
||||
set(val) {
|
||||
currentUserDrafts.value[draftKey] = val
|
||||
},
|
||||
})
|
||||
const draft = draftKey
|
||||
? computed({
|
||||
get() {
|
||||
if (!currentUserDrafts.value[draftKey])
|
||||
currentUserDrafts.value[draftKey] = initial()
|
||||
return currentUserDrafts.value[draftKey]
|
||||
},
|
||||
set(val) {
|
||||
currentUserDrafts.value[draftKey] = val
|
||||
},
|
||||
})
|
||||
: ref(initial())
|
||||
|
||||
const isEmpty = computed(() => isEmptyDraft(draft.value))
|
||||
|
||||
onUnmounted(async () => {
|
||||
// Remove draft if it's empty
|
||||
if (isEmpty.value) {
|
||||
if (isEmpty.value && draftKey) {
|
||||
await nextTick()
|
||||
delete currentUserDrafts.value[draftKey]
|
||||
}
|
||||
|
@ -111,3 +124,12 @@ export function directMessageUser(account: Account) {
|
|||
visibility: 'direct',
|
||||
}), true)
|
||||
}
|
||||
|
||||
export function clearEmptyDrafts() {
|
||||
for (const key in currentUserDrafts.value) {
|
||||
if (builtinDraftKeys.includes(key))
|
||||
continue
|
||||
if (!currentUserDrafts.value[key].params || isEmptyDraft(currentUserDrafts.value[key]))
|
||||
delete currentUserDrafts.value[key]
|
||||
}
|
||||
}
|
||||
|
|
50
composables/timeline.ts
Normal file
50
composables/timeline.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import type { Status } from 'masto'
|
||||
|
||||
const maxDistance = 10
|
||||
const maxSteps = 1000
|
||||
|
||||
// Checks if (b) is a reply to (a)
|
||||
function areStatusesConsecutive(a: Status, b: Status) {
|
||||
const inReplyToId = b.inReplyToId ?? b.reblog?.inReplyToId
|
||||
return !!inReplyToId && (inReplyToId === a.reblog?.id || inReplyToId === a.id)
|
||||
}
|
||||
|
||||
export function reorderedTimeline(items: Status[]) {
|
||||
let steps = 0
|
||||
const newItems = [...items]
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
for (let k = 1; k <= maxDistance && i - k >= 0; k++) {
|
||||
// Prevent infinite loops
|
||||
steps++
|
||||
if (steps > maxSteps)
|
||||
return newItems
|
||||
|
||||
// Check if the [i-k] item is a reply to the [i] item
|
||||
// This means that they are in the wrong order
|
||||
|
||||
if (areStatusesConsecutive(newItems[i], newItems[i - k])) {
|
||||
const item = newItems.splice(i, 1)[0]
|
||||
newItems.splice(i - k, 0, item) // insert older item before the newer one
|
||||
k = 0
|
||||
}
|
||||
else if (k > 1) {
|
||||
// Check if the [i] item is a reply to the [i-k] item
|
||||
// This means that they are in the correct order but there are posts between them
|
||||
if (areStatusesConsecutive(newItems[i - k], newItems[i])) {
|
||||
// If the next statuses are already ordered, move them all
|
||||
let j = i
|
||||
for (; j < items.length - 1; j++) {
|
||||
if (!areStatusesConsecutive(newItems[j], newItems[j + 1]))
|
||||
break
|
||||
}
|
||||
const orderedCount = j - i + 1
|
||||
const itemsToMove = newItems.splice(i, orderedCount)
|
||||
// insert older item after the newer one
|
||||
newItems.splice(i - k + 1, 0, ...itemsToMove)
|
||||
k = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newItems
|
||||
}
|
|
@ -12,13 +12,13 @@ import Code from '@tiptap/extension-code'
|
|||
import { Plugin } from 'prosemirror-state'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { HashSuggestion, MentionSuggestion } from './tiptap/suggestion'
|
||||
import { HashtagSuggestion, MentionSuggestion } from './tiptap/suggestion'
|
||||
import { CodeBlockShiki } from './tiptap/shiki'
|
||||
import { CustomEmoji } from './tiptap/custom-emoji'
|
||||
import { Emoji } from './tiptap/emoji'
|
||||
|
||||
export interface UseTiptapOptions {
|
||||
content: Ref<string | undefined>
|
||||
content: Ref<string>
|
||||
placeholder: Ref<string | undefined>
|
||||
onSubmit: () => void
|
||||
onFocus: () => void
|
||||
|
@ -54,9 +54,9 @@ export function useTiptap(options: UseTiptapOptions) {
|
|||
suggestion: MentionSuggestion,
|
||||
}),
|
||||
Mention
|
||||
.extend({ name: 'hastag' })
|
||||
.extend({ name: 'hashtag' })
|
||||
.configure({
|
||||
suggestion: HashSuggestion,
|
||||
suggestion: HashtagSuggestion,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder.value,
|
||||
|
|
|
@ -2,9 +2,39 @@ import {
|
|||
Node,
|
||||
mergeAttributes,
|
||||
nodeInputRule,
|
||||
nodePasteRule,
|
||||
} from '@tiptap/core'
|
||||
import { emojiRegEx, getEmojiAttributes } from '~/config/emojis'
|
||||
|
||||
const createEmojiRule = <NR extends typeof nodeInputRule | typeof nodePasteRule>(
|
||||
nodeRule: NR,
|
||||
type: Parameters<NR>[0]['type'],
|
||||
): ReturnType<NR>[] => {
|
||||
const rule = nodeRule({
|
||||
find: emojiRegEx as RegExp,
|
||||
type,
|
||||
getAttributes: (match) => {
|
||||
const [native] = match
|
||||
return getEmojiAttributes(native)
|
||||
},
|
||||
}) as ReturnType<NR>
|
||||
|
||||
// Error catch for unsupported emoji
|
||||
const handler = rule.handler.bind(rule)
|
||||
rule.handler = (...args) => {
|
||||
try {
|
||||
return handler(...args)
|
||||
}
|
||||
catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
rule,
|
||||
]
|
||||
}
|
||||
|
||||
export const Emoji = Node.create({
|
||||
name: 'em-emoji',
|
||||
|
||||
|
@ -50,26 +80,10 @@ export const Emoji = Node.create({
|
|||
},
|
||||
|
||||
addInputRules() {
|
||||
const inputRule = nodeInputRule({
|
||||
find: emojiRegEx as RegExp,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [native] = match
|
||||
return getEmojiAttributes(native)
|
||||
},
|
||||
})
|
||||
// Error catch for unsupported emoji
|
||||
const handler = inputRule.handler.bind(inputRule)
|
||||
inputRule.handler = (...args) => {
|
||||
try {
|
||||
return handler(...args)
|
||||
}
|
||||
catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return [
|
||||
inputRule,
|
||||
]
|
||||
return createEmojiRule(nodeInputRule, this.type)
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return createEmojiRule(nodePasteRule, this.type)
|
||||
},
|
||||
})
|
||||
|
|
|
@ -3,7 +3,9 @@ import tippy from 'tippy.js'
|
|||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
import type { SuggestionOptions } from '@tiptap/suggestion'
|
||||
import { PluginKey } from 'prosemirror-state'
|
||||
import type { Component } from 'vue'
|
||||
import TiptapMentionList from '~/components/tiptap/TiptapMentionList.vue'
|
||||
import TiptapHashtagList from '~/components/tiptap/TiptapHashtagList.vue'
|
||||
|
||||
export const MentionSuggestion: Partial<SuggestionOptions> = {
|
||||
pluginKey: new PluginKey('mention'),
|
||||
|
@ -17,29 +19,32 @@ export const MentionSuggestion: Partial<SuggestionOptions> = {
|
|||
|
||||
return results.value.accounts
|
||||
},
|
||||
render: createSuggestionRenderer(),
|
||||
render: createSuggestionRenderer(TiptapMentionList),
|
||||
}
|
||||
|
||||
export const HashSuggestion: Partial<SuggestionOptions> = {
|
||||
export const HashtagSuggestion: Partial<SuggestionOptions> = {
|
||||
pluginKey: new PluginKey('hashtag'),
|
||||
char: '#',
|
||||
items({ query }) {
|
||||
// TODO: query
|
||||
return [
|
||||
'TODO HASH QUERY',
|
||||
].filter(item => item.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5)
|
||||
async items({ query }) {
|
||||
if (query.length === 0)
|
||||
return []
|
||||
|
||||
const paginator = useMasto().search({ q: query, type: 'hashtags', limit: 25, resolve: true })
|
||||
const results = await paginator.next()
|
||||
|
||||
return results.value.hashtags
|
||||
},
|
||||
render: createSuggestionRenderer(),
|
||||
render: createSuggestionRenderer(TiptapHashtagList),
|
||||
}
|
||||
|
||||
function createSuggestionRenderer(): SuggestionOptions['render'] {
|
||||
function createSuggestionRenderer(component: Component): SuggestionOptions['render'] {
|
||||
return () => {
|
||||
let component: VueRenderer
|
||||
let renderer: VueRenderer
|
||||
let popup: Instance
|
||||
|
||||
return {
|
||||
onStart(props) {
|
||||
component = new VueRenderer(TiptapMentionList, {
|
||||
renderer = new VueRenderer(component, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
@ -50,7 +55,7 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
|||
popup = tippy(document.body, {
|
||||
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
content: renderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
|
@ -60,11 +65,11 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
|||
|
||||
// Use arrow function here because Nuxt will transform it incorrectly as Vue hook causing the build to fail
|
||||
onBeforeUpdate: (props) => {
|
||||
component.updateProps({ ...props, isPending: true })
|
||||
renderer.updateProps({ ...props, isPending: true })
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({ ...props, isPending: false })
|
||||
renderer.updateProps({ ...props, isPending: false })
|
||||
|
||||
if (!props.clientRect)
|
||||
return
|
||||
|
@ -79,12 +84,12 @@ function createSuggestionRenderer(): SuggestionOptions['render'] {
|
|||
popup?.hide()
|
||||
return true
|
||||
}
|
||||
return component?.ref?.onKeyDown(props.event)
|
||||
return renderer?.ref?.onKeyDown(props.event)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup?.destroy()
|
||||
component?.destroy()
|
||||
renderer?.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { Status } from 'masto'
|
||||
import type { Status, StatusEdit } from 'masto'
|
||||
|
||||
export interface TranslationResponse {
|
||||
translatedText: string
|
||||
|
@ -24,15 +24,18 @@ export async function translateText(text: string, from?: string | null, to?: str
|
|||
return translatedText
|
||||
}
|
||||
|
||||
const translations = new WeakMap<Status, { visible: boolean; text: string }>()
|
||||
const translations = new WeakMap<Status | StatusEdit, { visible: boolean; text: string }>()
|
||||
|
||||
export function useTranslation(status: Status) {
|
||||
export function useTranslation(status: Status | StatusEdit) {
|
||||
if (!translations.has(status))
|
||||
translations.set(status, reactive({ visible: false, text: '' }))
|
||||
|
||||
const translation = translations.get(status)!
|
||||
|
||||
async function toggle() {
|
||||
if (!('language' in status))
|
||||
return
|
||||
|
||||
if (!translation.text)
|
||||
translation.text = await translateText(status.content, status.language)
|
||||
|
||||
|
|
|
@ -60,6 +60,37 @@ export const currentInstance = computed<null | Instance>(() => currentUser.value
|
|||
export const publicServer = ref(DEFAULT_SERVER)
|
||||
export const currentServer = computed<string>(() => currentUser.value?.server || publicServer.value)
|
||||
|
||||
// when multiple tabs: we need to reload window when sign in, switch account or sign out
|
||||
if (process.client) {
|
||||
const windowReload = () => {
|
||||
document.visibilityState === 'visible' && window.location.reload()
|
||||
}
|
||||
watch(currentUserId, async (id, oldId) => {
|
||||
// when sign in or switch account
|
||||
if (id) {
|
||||
if (id === currentUser.value?.account?.id) {
|
||||
// when sign in, the other tab will not have the user, idb is not reactive
|
||||
const newUser = users.value.find(user => user.account?.id === id)
|
||||
// if the user is there, then we are switching account
|
||||
if (newUser) {
|
||||
// check if the change is on current tab: if so, don't reload
|
||||
if (document.hasFocus() || document.visibilityState === 'visible')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('visibilitychange', windowReload, { capture: true })
|
||||
}
|
||||
// when sign out
|
||||
else if (oldId) {
|
||||
const oldUser = users.value.find(user => user.account?.id === oldId)
|
||||
// when sign out, the other tab will not have the user, idb is not reactive
|
||||
if (oldUser)
|
||||
window.addEventListener('visibilitychange', windowReload, { capture: true })
|
||||
}
|
||||
}, { immediate: true, flush: 'post' })
|
||||
}
|
||||
|
||||
export const currentUserHandle = computed(() => currentUser.value?.account.id
|
||||
? `${currentUser.value.account.acct}@${currentInstance.value?.uri || currentServer.value}`
|
||||
: '[anonymous]',
|
||||
|
|
47
config/env.ts
Normal file
47
config/env.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import Git from 'simple-git'
|
||||
import { isDevelopment } from 'std-env'
|
||||
|
||||
export { version } from '../package.json'
|
||||
|
||||
/**
|
||||
* Environment variable `PULL_REQUEST` provided by Netlify.
|
||||
* @see {@link https://docs.netlify.com/configure-builds/environment-variables/#git-metadata}
|
||||
*
|
||||
* Whether triggered by a GitHub PR
|
||||
*/
|
||||
export const isPR = process.env.PULL_REQUEST === 'true'
|
||||
|
||||
/**
|
||||
* Environment variable `BRANCH` provided by Netlify.
|
||||
* @see {@link https://docs.netlify.com/configure-builds/environment-variables/#git-metadata}
|
||||
*
|
||||
* Git branch
|
||||
*/
|
||||
export const gitBranch = process.env.BRANCH
|
||||
|
||||
/**
|
||||
* Environment variable `CONTEXT` provided by Netlify.
|
||||
* @see {@link https://docs.netlify.com/configure-builds/environment-variables/#build-metadata}
|
||||
*
|
||||
* Whether triggered by PR, `deploy-preview` or `dev`.
|
||||
*/
|
||||
export const isPreview = isPR || process.env.CONTEXT === 'deploy-preview' || process.env.CONTEXT === 'dev'
|
||||
|
||||
const git = Git()
|
||||
export const getGitInfo = async () => {
|
||||
const branch = gitBranch || await git.revparse(['--abbrev-ref', 'HEAD'])
|
||||
const commit = await git.revparse(['HEAD'])
|
||||
return { branch, commit }
|
||||
}
|
||||
|
||||
export const getEnv = async () => {
|
||||
const { commit, branch } = await getGitInfo()
|
||||
const env = isDevelopment
|
||||
? 'dev'
|
||||
: isPreview
|
||||
? 'preview'
|
||||
: branch === 'main'
|
||||
? 'canary'
|
||||
: 'release'
|
||||
return { commit, branch, env } as const
|
||||
}
|
|
@ -41,6 +41,11 @@ const locales: LocaleObjectData[] = [
|
|||
file: 'ja-JP.json',
|
||||
name: '日本語',
|
||||
},
|
||||
{
|
||||
code: 'nl-NL',
|
||||
file: 'nl-NL.json',
|
||||
name: 'Nederlands',
|
||||
},
|
||||
{
|
||||
code: 'es-ES',
|
||||
file: 'es-ES.json',
|
||||
|
@ -76,6 +81,9 @@ const datetimeFormats = Object.values(locales).reduce((acc, data) => {
|
|||
}
|
||||
else {
|
||||
acc[data.code] = {
|
||||
shortDate: {
|
||||
dateStyle: 'short',
|
||||
},
|
||||
short: {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { isCI, isDevelopment } from 'std-env'
|
||||
import type { VitePWANuxtOptions } from '../modules/pwa/types'
|
||||
|
||||
const isPreview = process.env.PULL_REQUEST === 'true'
|
||||
|
||||
export const pwa: VitePWANuxtOptions = {
|
||||
mode: isCI ? 'production' : 'development',
|
||||
// disable PWA only when in preview mode
|
||||
|
@ -13,39 +11,10 @@ export const pwa: VitePWANuxtOptions = {
|
|||
strategies: 'injectManifest',
|
||||
injectRegister: false,
|
||||
includeManifestIcons: false,
|
||||
manifest: {
|
||||
scope: '/',
|
||||
id: '/',
|
||||
name: `Elk${isCI ? isPreview ? ' (preview)' : '' : ' (dev)'}`,
|
||||
short_name: `Elk${isCI ? isPreview ? ' (preview)' : '' : ' (dev)'}`,
|
||||
description: `A nimble Mastodon Web Client${isCI ? isPreview ? ' (preview)' : '' : ' (development)'}`,
|
||||
theme_color: '#ffffff',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
/*
|
||||
{
|
||||
src: 'logo.svg',
|
||||
sizes: '250x250',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
*/
|
||||
],
|
||||
},
|
||||
manifest: false,
|
||||
injectManifest: {
|
||||
// fonts/seguiemj.ttf is 2.77 MB, and won't be precached
|
||||
maximumFileSizeToCacheInBytes: 3000000,
|
||||
globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm}'],
|
||||
globIgnores: ['emojis/twemoji/*.svg'],
|
||||
globPatterns: ['**/*.{js,json,css,html,txt,svg,png,ico,webp,woff,woff2,ttf,eot,otf,wasm,webmanifest}'],
|
||||
globIgnores: ['emojis/**'],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: process.env.VITE_DEV_PWA === 'true',
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue