feat: command palette (#200)
Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
This commit is contained in:
parent
07622e9606
commit
59802f0896
|
@ -1,11 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Account } from 'masto'
|
import type { Account } from 'masto'
|
||||||
|
|
||||||
const { account } = defineProps<{
|
const { account, command } = defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isSelf = $computed(() => currentUser.value?.account.id === account.id)
|
const isSelf = $computed(() => currentUser.value?.account.id === account.id)
|
||||||
|
const enable = $computed(() => !isSelf && currentUser.value)
|
||||||
let relationship = $(useRelationship(account))
|
let relationship = $(useRelationship(account))
|
||||||
|
|
||||||
async function toggleFollow() {
|
async function toggleFollow() {
|
||||||
|
@ -18,11 +20,24 @@ async function toggleFollow() {
|
||||||
relationship!.following = !relationship!.following
|
relationship!.following = !relationship!.following
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
scope: 'Actions',
|
||||||
|
|
||||||
|
order: -2,
|
||||||
|
|
||||||
|
visible: () => command && enable,
|
||||||
|
|
||||||
|
name: () => `${relationship?.following ? 'Unfollow' : 'Follow'} ${getShortHandle(account)}`,
|
||||||
|
icon: 'i-ri:star-line',
|
||||||
|
|
||||||
|
onActivate: () => toggleFollow(),
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
v-if="!isSelf && currentUser"
|
v-if="enable"
|
||||||
flex gap-1 items-center h-fit rounded hover="op100 text-white b-orange" group btn-base
|
flex gap-1 items-center h-fit rounded hover="op100 text-white b-orange" group btn-base
|
||||||
:disabled="relationship?.requested"
|
:disabled="relationship?.requested"
|
||||||
@click="toggleFollow"
|
@click="toggleFollow"
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { Account, Field } from 'masto'
|
||||||
|
|
||||||
const { account } = defineProps<{
|
const { account } = defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const createdAt = $(useFormattedDateTime(() => account.createdAt, {
|
const createdAt = $(useFormattedDateTime(() => account.createdAt, {
|
||||||
|
@ -89,8 +90,8 @@ watchEffect(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div absolute top="1/2" right-0 translate-y="-1/2" flex gap-2 items-center>
|
<div absolute top="1/2" right-0 translate-y="-1/2" flex gap-2 items-center>
|
||||||
<AccountMoreButton :account="account" />
|
<AccountMoreButton :account="account" :command="command" />
|
||||||
<AccountFollowButton :account="account" />
|
<AccountFollowButton :account="account" :command="command" />
|
||||||
<!-- <button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group>
|
<!-- <button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group>
|
||||||
<div rounded p2 group-hover="bg-rose/10">
|
<div rounded p2 group-hover="bg-rose/10">
|
||||||
<div i-ri:bell-line />
|
<div i-ri:bell-line />
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { Account } from 'masto'
|
||||||
|
|
||||||
const { account } = defineProps<{
|
const { account } = defineProps<{
|
||||||
account: Account
|
account: Account
|
||||||
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
let relationship = $(useRelationship(account))
|
let relationship = $(useRelationship(account))
|
||||||
|
|
||||||
|
@ -35,7 +36,7 @@ const toggleBlockDomain = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonDropdown>
|
<CommonDropdown :eager-mount="command">
|
||||||
<button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group>
|
<button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group>
|
||||||
<div rounded-5 p2 group-hover="bg-purple/10">
|
<div rounded-5 p2 group-hover="bg-purple/10">
|
||||||
<div i-ri:more-2-fill />
|
<div i-ri:more-2-fill />
|
||||||
|
@ -44,73 +45,111 @@ const toggleBlockDomain = async () => {
|
||||||
|
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<NuxtLink :to="account.url" target="_blank">
|
<NuxtLink :to="account.url" target="_blank">
|
||||||
<CommonDropdownItem icon="i-ri:arrow-right-up-line">
|
<CommonDropdownItem
|
||||||
{{ $t('menu.open_in_original_site') }}
|
:text="$t('menu.open_in_original_site')"
|
||||||
</CommonDropdownItem>
|
icon="i-ri:arrow-right-up-line"
|
||||||
|
:command="command"
|
||||||
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
<template v-if="!isSelf">
|
<template v-if="!isSelf">
|
||||||
<CommonDropdownItem icon="i-ri:at-line" @click="mentionUser(account)">
|
<CommonDropdownItem
|
||||||
{{ $t('menu.mention_account', [`@${account.acct}`]) }}
|
:text="$t('menu.mention_account', [`@${account.acct}`])"
|
||||||
</CommonDropdownItem>
|
icon="i-ri:at-line"
|
||||||
<CommonDropdownItem icon="i-ri:message-3-line" @click="directMessageUser(account)">
|
:command="command"
|
||||||
{{ $t('menu.direct_message_account', [`@${account.acct}`]) }}
|
@click="mentionUser(account)"
|
||||||
</CommonDropdownItem>
|
/>
|
||||||
|
<CommonDropdownItem
|
||||||
|
:text="$t('menu.direct_message_account', [`@${account.acct}`])"
|
||||||
|
icon="i-ri:message-3-line"
|
||||||
|
:command="command"
|
||||||
|
@click="directMessageUser(account)"
|
||||||
|
/>
|
||||||
|
|
||||||
<CommonDropdownItem v-if="!relationship?.muting" icon="i-ri:volume-up-fill" @click="toggleMute">
|
<CommonDropdownItem
|
||||||
{{ $t('menu.mute_account', [`@${account.acct}`]) }}
|
v-if="!relationship?.muting"
|
||||||
</CommonDropdownItem>
|
:text="$t('menu.mute_account', [`@${account.acct}`])"
|
||||||
<CommonDropdownItem v-else icon="i-ri:volume-mute-line" @click="toggleMute">
|
icon="i-ri:volume-up-fill"
|
||||||
{{ $t('menu.unmute_account', [`@${account.acct}`]) }}
|
:command="command"
|
||||||
</CommonDropdownItem>
|
@click="toggleMute"
|
||||||
|
/>
|
||||||
|
<CommonDropdownItem
|
||||||
|
v-else
|
||||||
|
:text="$t('menu.unmute_account', [`@${account.acct}`])"
|
||||||
|
icon="i-ri:volume-mute-line"
|
||||||
|
:command="command"
|
||||||
|
@click="toggleMute"
|
||||||
|
/>
|
||||||
|
|
||||||
<CommonDropdownItem v-if="!relationship?.blocking" icon="i-ri:forbid-2-line" @click="toggleBlockUser">
|
<CommonDropdownItem
|
||||||
{{ $t('menu.block_account', [`@${account.acct}`]) }}
|
v-if="!relationship?.blocking"
|
||||||
</CommonDropdownItem>
|
:text="$t('menu.block_account', [`@${account.acct}`])"
|
||||||
<CommonDropdownItem v-else icon="i-ri:checkbox-circle-line" @click="toggleBlockUser">
|
icon="i-ri:forbid-2-line"
|
||||||
{{ $t('menu.unblock_account', [`@${account.acct}`]) }}
|
:command="command"
|
||||||
</CommonDropdownItem>
|
@click="toggleBlockUser"
|
||||||
|
/>
|
||||||
|
<CommonDropdownItem
|
||||||
|
v-else
|
||||||
|
:text="$t('menu.unblock_account', [`@${account.acct}`])"
|
||||||
|
icon="i-ri:checkbox-circle-line"
|
||||||
|
:command="command"
|
||||||
|
@click="toggleBlockUser"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-if="getServerName(account) !== currentServer">
|
<template v-if="getServerName(account) !== currentServer">
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-if="!relationship?.domainBlocking"
|
v-if="!relationship?.domainBlocking"
|
||||||
|
:text="$t('menu.block_domain', [getServerName(account)])"
|
||||||
icon="i-ri:shut-down-line"
|
icon="i-ri:shut-down-line"
|
||||||
|
:command="command"
|
||||||
@click="toggleBlockDomain"
|
@click="toggleBlockDomain"
|
||||||
>
|
/>
|
||||||
{{ $t('menu.block_domain', [getServerName(account)]) }}
|
<CommonDropdownItem
|
||||||
</CommonDropdownItem>
|
v-else
|
||||||
<CommonDropdownItem v-else icon="i-ri:restart-line" @click="toggleBlockDomain">
|
:text="$t('menu.unblock_domain', [getServerName(account)])"
|
||||||
{{ $t('menu.unblock_domain', [getServerName(account)]) }}
|
icon="i-ri:restart-line"
|
||||||
</CommonDropdownItem>
|
:command="command"
|
||||||
|
@click="toggleBlockDomain"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<NuxtLink to="/pinned">
|
<NuxtLink to="/pinned">
|
||||||
<CommonDropdownItem icon="i-ri:pushpin-line">
|
<CommonDropdownItem
|
||||||
Pinned
|
text="Pinned"
|
||||||
</CommonDropdownItem>
|
icon="i-ri:pushpin-line"
|
||||||
|
:command="command"
|
||||||
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/favourites">
|
<NuxtLink to="/favourites">
|
||||||
<CommonDropdownItem icon="i-ri:heart-3-line">
|
<CommonDropdownItem
|
||||||
Favourites
|
text="Favourites"
|
||||||
</CommonDropdownItem>
|
icon="i-ri:heart-3-line"
|
||||||
|
:command="command"
|
||||||
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/mutes">
|
<NuxtLink to="/mutes">
|
||||||
<CommonDropdownItem icon="i-ri:volume-mute-line">
|
<CommonDropdownItem
|
||||||
Muted users
|
text="Muted users"
|
||||||
</CommonDropdownItem>
|
icon="i-ri:volume-mute-line"
|
||||||
|
:command="command"
|
||||||
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/blocks">
|
<NuxtLink to="/blocks">
|
||||||
<CommonDropdownItem icon="i-ri:forbid-2-line">
|
<CommonDropdownItem
|
||||||
Blocked users
|
text="Blocked users"
|
||||||
</CommonDropdownItem>
|
icon="i-ri:forbid-2-line"
|
||||||
|
:command="command"
|
||||||
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/domain_blocks">
|
<NuxtLink to="/domain_blocks">
|
||||||
<CommonDropdownItem icon="i-ri:shut-down-line">
|
<CommonDropdownItem
|
||||||
Blocked domains
|
text="Blocked domains"
|
||||||
</CommonDropdownItem>
|
icon="i-ri:shut-down-line"
|
||||||
|
:command="command"
|
||||||
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
39
components/command/Key.vue
Normal file
39
components/command/Key.vue
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isMac = useIsMac()
|
||||||
|
|
||||||
|
const keys = $computed(() => props.name.toLowerCase().split('+'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center px-1">
|
||||||
|
<template v-for="(key, index) in keys" :key="key">
|
||||||
|
<div v-if="index > 0" class="inline-block px-.5">
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="p-1 grid place-items-center rounded-lg shadow-sm"
|
||||||
|
text="xs secondary"
|
||||||
|
border="1 base"
|
||||||
|
>
|
||||||
|
<div v-if="key === 'enter'" i-material-symbols:keyboard-return-rounded />
|
||||||
|
<div v-else-if="key === 'meta' && isMac" i-material-symbols:keyboard-command-key />
|
||||||
|
<div v-else-if="key === 'meta' && !isMac" i-material-symbols:window-sharp />
|
||||||
|
<div v-else-if="key === 'alt' && isMac" i-material-symbols:keyboard-option-key-rounded />
|
||||||
|
<div v-else-if="key === 'arrowup'" i-ri:arrow-up-line />
|
||||||
|
<div v-else-if="key === 'arrowdown'" i-ri:arrow-down-line />
|
||||||
|
<div v-else-if="key === 'arrowleft'" i-ri:arrow-left-line />
|
||||||
|
<div v-else-if="key === 'arrowright'" i-ri:arrow-right-line />
|
||||||
|
<template v-else-if="key === 'escape'">
|
||||||
|
ESC
|
||||||
|
</template>
|
||||||
|
<div v-else :class="{ 'px-.5': key.length === 1 }">
|
||||||
|
{{ key[0].toUpperCase() + key.slice(1) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
253
components/command/Panel.vue
Normal file
253
components/command/Panel.vue
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CommandScope, QueryIndexedCommand } from '@/composables/command'
|
||||||
|
|
||||||
|
const isMac = useIsMac()
|
||||||
|
const registry = useCommandRegistry()
|
||||||
|
|
||||||
|
const inputEl = $ref<HTMLInputElement>()
|
||||||
|
const resultEl = $ref<HTMLDivElement>()
|
||||||
|
|
||||||
|
let show = $ref(false)
|
||||||
|
let scopes = $ref<CommandScope[]>([])
|
||||||
|
let input = $ref('')
|
||||||
|
|
||||||
|
// listen to ctrl+/ on windows/linux or cmd+/ on mac
|
||||||
|
useEventListener('keydown', async (e: KeyboardEvent) => {
|
||||||
|
if (e.key === '/' && (isMac.value ? e.metaKey : e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
show = true
|
||||||
|
scopes = []
|
||||||
|
input = '>'
|
||||||
|
await nextTick()
|
||||||
|
inputEl?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onKeyStroke('Escape', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
show = false
|
||||||
|
}, { target: document })
|
||||||
|
|
||||||
|
const commandMode = $computed(() => input.startsWith('>'))
|
||||||
|
const result = $computed(() => commandMode
|
||||||
|
? registry.query(scopes.map(s => s.id).join('.'), input.slice(1))
|
||||||
|
: { length: 0, items: [], grouped: {} })
|
||||||
|
let active = $ref(0)
|
||||||
|
watch($$(result), (n, o) => {
|
||||||
|
if (n.length !== o.length || !n.items.every((i, idx) => i === o.items[idx]))
|
||||||
|
active = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const findItemEl = (index: number) =>
|
||||||
|
resultEl?.querySelector(`[data-index="${index}"]`) as HTMLDivElement | null
|
||||||
|
const onCommandActivate = (item: QueryIndexedCommand) => {
|
||||||
|
if (item.onActivate) {
|
||||||
|
item.onActivate()
|
||||||
|
show = false
|
||||||
|
}
|
||||||
|
else if (item.onComplete) {
|
||||||
|
scopes.push(item.onComplete())
|
||||||
|
input = '>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onCommandComplete = (item: QueryIndexedCommand) => {
|
||||||
|
if (item.onComplete) {
|
||||||
|
scopes.push(item.onComplete())
|
||||||
|
input = '>'
|
||||||
|
}
|
||||||
|
else if (item.onActivate) {
|
||||||
|
item.onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const intoView = (index: number) => {
|
||||||
|
const el = findItemEl(index)
|
||||||
|
if (el)
|
||||||
|
el.scrollIntoView({ block: 'nearest' })
|
||||||
|
}
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowUp': {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
active = Math.max(0, active - 1)
|
||||||
|
|
||||||
|
intoView(active)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowDown': {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
active = Math.min(result.length - 1, active + 1)
|
||||||
|
|
||||||
|
intoView(active)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Home': {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
active = 0
|
||||||
|
|
||||||
|
intoView(active)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'End': {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
active = result.length - 1
|
||||||
|
|
||||||
|
intoView(active)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Enter': {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const cmd = result.items[active]
|
||||||
|
if (cmd)
|
||||||
|
onCommandActivate(cmd)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Tab': {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const cmd = result.items[active]
|
||||||
|
if (cmd)
|
||||||
|
onCommandComplete(cmd)
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Backspace': {
|
||||||
|
if (input === '>' && scopes.length) {
|
||||||
|
e.preventDefault()
|
||||||
|
scopes.pop()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Overlay -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="transform opacity-0"
|
||||||
|
enter-to-class="transform opacity-100"
|
||||||
|
leave-active-class="transition duration-100 ease-in"
|
||||||
|
leave-from-class="transform opacity-100"
|
||||||
|
leave-to-class="transform opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="z-100 fixed inset-0 opacity-70 bg-base"
|
||||||
|
@click="show = false"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Panel -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-65 ease-out"
|
||||||
|
enter-from-class="transform scale-95"
|
||||||
|
enter-to-class="transform scale-100"
|
||||||
|
leave-active-class="transition duration-100 ease-in"
|
||||||
|
leave-from-class="transform scale-100"
|
||||||
|
leave-to-class="transform scale-95"
|
||||||
|
>
|
||||||
|
<div v-if="show" class="z-100 fixed inset-0 grid place-items-center pointer-events-none">
|
||||||
|
<div
|
||||||
|
class="flex flex-col w-50vw h-50vh rounded-md bg-base shadow-lg pointer-events-auto"
|
||||||
|
border="1 base"
|
||||||
|
>
|
||||||
|
<!-- Input -->
|
||||||
|
<label class="flex mx-3 my-1 items-center">
|
||||||
|
<div mx-1 i-ri:search-line />
|
||||||
|
|
||||||
|
<div v-for="scope in scopes" :key="scope.id" class="flex items-center mx-1 gap-2">
|
||||||
|
<div class="text-sm">{{ scope.display }}</div>
|
||||||
|
<span class="text-secondary">/</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="input"
|
||||||
|
class="focus:outline-none flex-1 p-2 rounded bg-base"
|
||||||
|
placeholder="Search"
|
||||||
|
@keydown="onKeyDown"
|
||||||
|
>
|
||||||
|
|
||||||
|
<CommandKey name="Escape" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="w-full border-b-1 border-base" />
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div ref="resultEl" class="flex-1 mx-1 overflow-y-auto">
|
||||||
|
<template v-for="[scope, group] in result.grouped" :key="scope">
|
||||||
|
<div class="mt-2 px-2 py-1 text-sm text-secondary">
|
||||||
|
{{ scope }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="cmd in group" :key="cmd.index">
|
||||||
|
<div
|
||||||
|
class="flex px-3 py-2 my-1 items-center rounded-lg hover:bg-active transition-all duration-65 ease-in-out cursor-pointer scroll-m-10"
|
||||||
|
:class="{ 'bg-active': active === cmd.index }"
|
||||||
|
:data-index="cmd.index"
|
||||||
|
@click="onCommandActivate(cmd)"
|
||||||
|
>
|
||||||
|
<div v-if="cmd.icon" mr-2 :class="cmd.icon" />
|
||||||
|
|
||||||
|
<div class="flex-1 flex items-baseline gap-2">
|
||||||
|
<div :class="{ 'font-medium': active === cmd.index }">
|
||||||
|
{{ cmd.name }}
|
||||||
|
</div>
|
||||||
|
<div v-if="cmd.description" class="text-xs text-secondary">
|
||||||
|
{{ cmd.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="cmd.onComplete"
|
||||||
|
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
|
||||||
|
:class="active === cmd.index ? 'opacity-100' : 'opacity-0'"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-secondary">
|
||||||
|
Complete
|
||||||
|
</div>
|
||||||
|
<CommandKey name="Tab" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="cmd.onActivate"
|
||||||
|
class="flex items-center gap-1 transition-all duration-65 ease-in-out"
|
||||||
|
:class="active === cmd.index ? 'opacity-100' : 'opacity-0'"
|
||||||
|
>
|
||||||
|
<div class="text-xs text-secondary">
|
||||||
|
Activate
|
||||||
|
</div>
|
||||||
|
<CommandKey name="Enter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full border-b-1 border-base" />
|
||||||
|
|
||||||
|
<!-- 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+/" /> to activate command mode.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
7
components/command/Root.vue
Normal file
7
components/command/Root.vue
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
provideGlobalCommands()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CommandPanel />
|
||||||
|
</template>
|
|
@ -1,13 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { options } = defineProps<{
|
const { options, command } = defineProps<{
|
||||||
options: string[] | { name: string; display: string }[]
|
options: string[] | {
|
||||||
|
name: string
|
||||||
|
icon?: string
|
||||||
|
display: string
|
||||||
|
}[]
|
||||||
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { modelValue } = defineModel<{
|
const { modelValue } = defineModel<{
|
||||||
modelValue: string
|
modelValue: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = $computed(() => {
|
||||||
return options.map((option) => {
|
return options.map((option) => {
|
||||||
if (typeof option === 'string')
|
if (typeof option === 'string')
|
||||||
return { name: option, display: option }
|
return { name: option, display: option }
|
||||||
|
@ -19,6 +24,17 @@ const tabs = computed(() => {
|
||||||
function toValidName(otpion: string) {
|
function toValidName(otpion: string) {
|
||||||
return otpion.toLowerCase().replace(/[^a-zA-Z0-9]/g, '-')
|
return otpion.toLowerCase().replace(/[^a-zA-Z0-9]/g, '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useCommands(() => command
|
||||||
|
? tabs.map(tab => ({
|
||||||
|
scope: 'Tabs',
|
||||||
|
|
||||||
|
name: tab.display,
|
||||||
|
icon: tab.icon ?? 'i-ri:file-list-2-line',
|
||||||
|
|
||||||
|
onActivate: () => modelValue.value = tab.name,
|
||||||
|
}))
|
||||||
|
: [])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,31 +1,58 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { dropdownContextKey } from './ctx'
|
import { dropdownContextKey } from './ctx'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
|
text?: string
|
||||||
description?: string
|
description?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
checked?: boolean
|
checked?: boolean
|
||||||
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits(['click'])
|
const emit = defineEmits(['click'])
|
||||||
|
|
||||||
const { hide } = inject(dropdownContextKey, undefined) || {}
|
const { hide } = inject(dropdownContextKey, undefined) || {}
|
||||||
|
|
||||||
|
const el = ref<HTMLDivElement>()
|
||||||
|
|
||||||
const handleClick = (evt: MouseEvent) => {
|
const handleClick = (evt: MouseEvent) => {
|
||||||
hide?.()
|
hide?.()
|
||||||
emit('click', evt)
|
emit('click', evt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
scope: 'Actions',
|
||||||
|
|
||||||
|
order: -1,
|
||||||
|
visible: () => props.command && props.text,
|
||||||
|
|
||||||
|
name: () => props.text!,
|
||||||
|
icon: () => props.icon ?? 'i-ri:question-line',
|
||||||
|
description: () => props.description,
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
const clickEvent = new MouseEvent('click', {
|
||||||
|
view: window,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
el.value?.dispatchEvent(clickEvent)
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
flex gap-3 items-center cursor-pointer px4 py3 hover-bg-active
|
v-bind="$attrs" ref="el"
|
||||||
v-bind="$attrs"
|
flex gap-3 items-center cursor-pointer px4 py3
|
||||||
|
hover-bg-active
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<div v-if="icon" :class="icon" />
|
<div v-if="icon" :class="icon" />
|
||||||
<div flex="~ col">
|
<div flex="~ col">
|
||||||
<div text-15px>
|
<div text-15px>
|
||||||
<slot />
|
<slot>
|
||||||
|
{{ text }}
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div text-3 text-secondary>
|
<div text-3 text-secondary>
|
||||||
<slot name="description">
|
<slot name="description">
|
||||||
|
|
|
@ -15,11 +15,18 @@ const { t } = useI18n()
|
||||||
<NavSideItem :text="t('nav_side.conversations')" to="/conversations" icon="i-ri:at-line" />
|
<NavSideItem :text="t('nav_side.conversations')" to="/conversations" icon="i-ri:at-line" />
|
||||||
<NavSideItem :text="t('nav_side.favourites')" to="/favourites" icon="i-ri:heart-3-line" />
|
<NavSideItem :text="t('nav_side.favourites')" to="/favourites" icon="i-ri:heart-3-line" />
|
||||||
<NavSideItem :text="t('nav_side.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " />
|
<NavSideItem :text="t('nav_side.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " />
|
||||||
<NavSideItem :to="getAccountPath(currentUser.account)" icon="i-ri:list-check-2-line">
|
<NavSideItem
|
||||||
|
:text="currentUser.account.displayName"
|
||||||
|
:to="getAccountPath(currentUser.account)"
|
||||||
|
icon="i-ri:account-circle-line"
|
||||||
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<AccountAvatar :account="currentUser.account" h="1.2em" />
|
<AccountAvatar :account="currentUser.account" h="1.2em" />
|
||||||
</template>
|
</template>
|
||||||
<ContentRich :content="getDisplayName(currentUser.account, { rich: true }) || t('nav_side.profile')" :emojis="currentUser.account.emojis" />
|
<ContentRich
|
||||||
|
:content="getDisplayName(currentUser.account, { rich: true }) || t('nav_side.profile')"
|
||||||
|
:emojis="currentUser.account.emojis"
|
||||||
|
/>
|
||||||
</NavSideItem>
|
</NavSideItem>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
text?: string
|
text?: string
|
||||||
icon: string
|
icon: string
|
||||||
to: string
|
to: string
|
||||||
|
@ -9,6 +9,19 @@ defineSlots<{
|
||||||
icon: {}
|
icon: {}
|
||||||
default: {}
|
default: {}
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
scope: 'Navigation',
|
||||||
|
|
||||||
|
name: () => props.text ?? props.to,
|
||||||
|
icon: () => props.icon,
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
router.push(props.to)
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
text?: string | number
|
text?: string | number
|
||||||
content: string
|
content: string
|
||||||
color: string
|
color: string
|
||||||
|
@ -10,20 +10,44 @@ defineProps<{
|
||||||
active?: boolean
|
active?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
as?: string
|
as?: string
|
||||||
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const el = ref<HTMLDivElement>()
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
scope: 'Actions',
|
||||||
|
|
||||||
|
order: -2,
|
||||||
|
visible: () => props.command && !props.disabled,
|
||||||
|
|
||||||
|
name: () => props.content,
|
||||||
|
icon: () => props.icon,
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
const clickEvent = new MouseEvent('click', {
|
||||||
|
view: window,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
})
|
||||||
|
el.value?.dispatchEvent(clickEvent)
|
||||||
|
},
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="as || 'button'" w-fit
|
:is="as || 'button'"
|
||||||
flex gap-1 items-center rounded group
|
v-bind="$attrs" ref="el"
|
||||||
:hover="hover" focus:outline-none :focus-visible="hover"
|
w-fit flex gap-1 items-center
|
||||||
|
rounded group :hover="hover"
|
||||||
|
focus:outline-none
|
||||||
|
:focus-visible="hover"
|
||||||
:class="active ? [color] : 'text-secondary'"
|
:class="active ? [color] : 'text-secondary'"
|
||||||
v-bind="$attrs"
|
|
||||||
>
|
>
|
||||||
<CommonTooltip placement="bottom" :content="content">
|
<CommonTooltip placement="bottom" :content="content">
|
||||||
<div rounded-full p2 :group-hover="groupHover" :group-focus-visible="groupHover" group-focus-visible:ring="2 current">
|
<div rounded-full p2 :group-hover="groupHover" :group-focus-visible="groupHover" group-focus-visible:ring="2 current">
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Status } from 'masto'
|
import type { Status } from 'masto'
|
||||||
|
|
||||||
const { status: _status, details } = defineProps<{
|
const { status: _status, details, command } = defineProps<{
|
||||||
status: Status
|
status: Status
|
||||||
details?: boolean
|
details?: boolean
|
||||||
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
let status = $ref<Status>({ ..._status })
|
let status = $ref<Status>({ ..._status })
|
||||||
|
|
||||||
|
@ -134,6 +135,7 @@ function editStatus() {
|
||||||
:text="status.repliesCount"
|
:text="status.repliesCount"
|
||||||
color="text-blue" hover="text-blue" group-hover="bg-blue/10"
|
color="text-blue" hover="text-blue" group-hover="bg-blue/10"
|
||||||
icon="i-ri:chat-3-line"
|
icon="i-ri:chat-3-line"
|
||||||
|
:command="command"
|
||||||
@click="reply"
|
@click="reply"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -147,6 +149,7 @@ function editStatus() {
|
||||||
active-icon="i-ri:repeat-fill"
|
active-icon="i-ri:repeat-fill"
|
||||||
:active="status.reblogged"
|
:active="status.reblogged"
|
||||||
:disabled="isLoading.reblogged"
|
:disabled="isLoading.reblogged"
|
||||||
|
:command="command"
|
||||||
@click="toggleReblog()"
|
@click="toggleReblog()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -160,6 +163,7 @@ function editStatus() {
|
||||||
active-icon="i-ri:heart-3-fill"
|
active-icon="i-ri:heart-3-fill"
|
||||||
:active="status.favourited"
|
:active="status.favourited"
|
||||||
:disabled="isLoading.favourited"
|
:disabled="isLoading.favourited"
|
||||||
|
:command="command"
|
||||||
@click="toggleFavourite()"
|
@click="toggleFavourite()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -172,11 +176,12 @@ function editStatus() {
|
||||||
active-icon="i-ri:bookmark-fill"
|
active-icon="i-ri:bookmark-fill"
|
||||||
:active="status.bookmarked"
|
:active="status.bookmarked"
|
||||||
:disabled="isLoading.bookmarked"
|
:disabled="isLoading.bookmarked"
|
||||||
|
:command="command"
|
||||||
@click="toggleBookmark()"
|
@click="toggleBookmark()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommonDropdown flex-none ml3 placement="bottom">
|
<CommonDropdown flex-none ml3 placement="bottom" :eager-mount="command">
|
||||||
<StatusActionButton
|
<StatusActionButton
|
||||||
content="More"
|
content="More"
|
||||||
color="text-purple" hover="text-purple" group-hover="bg-purple/10"
|
color="text-purple" hover="text-purple" group-hover="bg-purple/10"
|
||||||
|
@ -185,59 +190,69 @@ function editStatus() {
|
||||||
|
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<div flex="~ col">
|
<div flex="~ col">
|
||||||
<CommonDropdownItem icon="i-ri:link" @click="copyLink">
|
<CommonDropdownItem
|
||||||
Copy link to this post
|
text="Copy link to this post"
|
||||||
</CommonDropdownItem>
|
icon="i-ri:link"
|
||||||
|
:command="command"
|
||||||
|
@click="copyLink"
|
||||||
|
/>
|
||||||
|
|
||||||
<NuxtLink :to="status.url" target="_blank">
|
<NuxtLink :to="status.url" target="_blank">
|
||||||
<CommonDropdownItem v-if="status.url" icon="i-ri:arrow-right-up-line">
|
<CommonDropdownItem
|
||||||
Open in original site
|
v-if="status.url"
|
||||||
</CommonDropdownItem>
|
text="Open in original site"
|
||||||
|
icon="i-ri:arrow-right-up-line"
|
||||||
|
:command="command"
|
||||||
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<CommonDropdownItem v-if="isTranslationEnabled && status.language !== languageCode" icon="i-ri:translate" @click="toggleTranslation">
|
<CommonDropdownItem
|
||||||
<template v-if="!translation.visible">
|
v-if="isTranslationEnabled && status.language !== languageCode"
|
||||||
Translate post
|
:text="translation.visible ? 'Show untranslated' : 'Translate post'"
|
||||||
</template>
|
icon="i-ri:translate"
|
||||||
<template v-else>
|
:command="command"
|
||||||
Show untranslated
|
@click="toggleTranslation"
|
||||||
</template>
|
/>
|
||||||
</CommonDropdownItem>
|
|
||||||
|
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
<template v-if="isAuthor">
|
<template v-if="isAuthor">
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
|
:text="status.pinned ? 'Unpin on profile' : 'Pin on profile'"
|
||||||
icon="i-ri:pushpin-line"
|
icon="i-ri:pushpin-line"
|
||||||
|
:command="command"
|
||||||
@click="togglePin"
|
@click="togglePin"
|
||||||
>
|
/>
|
||||||
{{ status.pinned ? 'Unpin on profile' : 'Pin on profile' }}
|
|
||||||
</CommonDropdownItem>
|
|
||||||
|
|
||||||
<CommonDropdownItem icon="i-ri:edit-line" @click="editStatus">
|
|
||||||
Edit
|
|
||||||
</CommonDropdownItem>
|
|
||||||
|
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
icon="i-ri:delete-bin-line" text-red-600
|
text="Edit"
|
||||||
|
icon="i-ri:edit-line"
|
||||||
|
:command="command"
|
||||||
|
@click="editStatus"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CommonDropdownItem
|
||||||
|
text="Delete"
|
||||||
|
icon="i-ri:delete-bin-line"
|
||||||
|
text-red-600
|
||||||
|
:command="command"
|
||||||
@click="deleteStatus"
|
@click="deleteStatus"
|
||||||
>
|
/>
|
||||||
Delete
|
|
||||||
</CommonDropdownItem>
|
|
||||||
|
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
icon="i-ri:eraser-line" text-red-600
|
text="Delete & re-draft"
|
||||||
|
icon="i-ri:eraser-line"
|
||||||
|
text-red-600
|
||||||
|
:command="command"
|
||||||
@click="deleteAndRedraft"
|
@click="deleteAndRedraft"
|
||||||
>
|
/>
|
||||||
Delete & re-draft
|
|
||||||
</CommonDropdownItem>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
|
:text="`Mention @${status.account.acct}`"
|
||||||
icon="i-ri:at-line"
|
icon="i-ri:at-line"
|
||||||
|
:command="command"
|
||||||
@click="mentionUser(status.account)"
|
@click="mentionUser(status.account)"
|
||||||
>
|
/>
|
||||||
Mention @{{ status.account.acct }}
|
|
||||||
</CommonDropdownItem>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { Status } from 'masto'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
status: Status
|
status: Status
|
||||||
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const status = $computed(() => {
|
const status = $computed(() => {
|
||||||
|
@ -52,6 +53,6 @@ const visibility = $computed(() => STATUS_VISIBILITIES.find(v => v.value === sta
|
||||||
· {{ status.application?.name }}
|
· {{ status.application?.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusActions :status="status" details border="t base" pt-2 />
|
<StatusActions :status="status" details :command="command" border="t base" pt-2 />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
320
composables/command.ts
Normal file
320
composables/command.ts
Normal file
|
@ -0,0 +1,320 @@
|
||||||
|
import type { ComputedRef } from 'vue'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import type { LocaleObject } from '#i18n'
|
||||||
|
|
||||||
|
const scopes = [
|
||||||
|
'',
|
||||||
|
'Actions',
|
||||||
|
'Tabs',
|
||||||
|
'Navigation',
|
||||||
|
'Preferences',
|
||||||
|
'Account',
|
||||||
|
|
||||||
|
'Languages',
|
||||||
|
'Switch account',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type CommandScopeNames = typeof scopes[number]
|
||||||
|
|
||||||
|
export interface CommandScope {
|
||||||
|
id: string
|
||||||
|
display: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandProvider {
|
||||||
|
parent?: string
|
||||||
|
scope?: CommandScopeNames
|
||||||
|
|
||||||
|
// smaller is higher priority
|
||||||
|
order?: number
|
||||||
|
visible?: () => unknown
|
||||||
|
|
||||||
|
icon: string | (() => string)
|
||||||
|
name: string | (() => string)
|
||||||
|
description?: string | (() => string | undefined)
|
||||||
|
|
||||||
|
bindings?: string[] | (() => string[])
|
||||||
|
|
||||||
|
onActivate?: () => void
|
||||||
|
onComplete?: () => CommandScope
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedCommand =
|
||||||
|
Exclude<CommandProvider, 'icon' | 'name' | 'description' | 'bindings'> & {
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
description: string | undefined
|
||||||
|
bindings: string[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryIndexedCommand = ResolvedCommand & {
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = <T extends Object | undefined>(i: T | (() => T)): T =>
|
||||||
|
typeof i === 'function' ? i() : i
|
||||||
|
|
||||||
|
export const provideCommandRegistry = () => {
|
||||||
|
const providers = reactive(new Set<CommandProvider>())
|
||||||
|
|
||||||
|
const commands = computed<ResolvedCommand[]>(() =>
|
||||||
|
[...providers]
|
||||||
|
.filter(command => command.visible ? command.visible() : true)
|
||||||
|
.map(provider => ({
|
||||||
|
...provider,
|
||||||
|
icon: r(provider.icon),
|
||||||
|
name: r(provider.name),
|
||||||
|
description: r(provider.description),
|
||||||
|
bindings: r(provider.bindings),
|
||||||
|
})))
|
||||||
|
|
||||||
|
let lastScope = ''
|
||||||
|
let lastFuse: Fuse<ResolvedCommand> | undefined
|
||||||
|
|
||||||
|
watch(commands, () => {
|
||||||
|
lastFuse = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
register: (provider: CommandProvider) => {
|
||||||
|
providers.add(provider)
|
||||||
|
},
|
||||||
|
remove: (provider: CommandProvider) => {
|
||||||
|
providers.delete(provider)
|
||||||
|
},
|
||||||
|
|
||||||
|
query: (scope: string, query: string) => {
|
||||||
|
const cmds = commands.value
|
||||||
|
.filter(cmd => (cmd.parent ?? '') === scope)
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const fuse = lastScope === scope && lastFuse
|
||||||
|
? lastFuse
|
||||||
|
: new Fuse(cmds, {
|
||||||
|
keys: ['scope', 'name', 'description'],
|
||||||
|
includeScore: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
lastScope = scope
|
||||||
|
lastFuse = fuse
|
||||||
|
|
||||||
|
const res = fuse.search(query)
|
||||||
|
.map(({ item }) => ({ ...item }))
|
||||||
|
|
||||||
|
// group by scope
|
||||||
|
const grouped = new Map<CommandScopeNames, QueryIndexedCommand[]>()
|
||||||
|
for (const cmd of res) {
|
||||||
|
const scope = cmd.scope ?? ''
|
||||||
|
if (!grouped.has(scope))
|
||||||
|
grouped.set(scope, [])
|
||||||
|
grouped
|
||||||
|
.get(scope)!
|
||||||
|
.push({
|
||||||
|
...cmd,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0
|
||||||
|
const indexed: QueryIndexedCommand[] = []
|
||||||
|
for (const items of grouped.values()) {
|
||||||
|
for (const cmd of items) {
|
||||||
|
cmd.index = index++
|
||||||
|
indexed.push(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
length: res.length,
|
||||||
|
items: indexed,
|
||||||
|
grouped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else {
|
||||||
|
const indexed = cmds.map((cmd, index) => ({ ...cmd, index }))
|
||||||
|
|
||||||
|
const grouped = new Map<CommandScopeNames, QueryIndexedCommand[]>(
|
||||||
|
scopes.map(scope => [scope, []]))
|
||||||
|
for (const cmd of indexed) {
|
||||||
|
const scope = cmd.scope ?? ''
|
||||||
|
grouped.get(scope)!.push(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0
|
||||||
|
const sorted: QueryIndexedCommand[] = []
|
||||||
|
for (const [scope, items] of grouped) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
grouped.delete(scope)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const o = (cmd: QueryIndexedCommand) => (cmd.order ?? 0) * 100 + cmd.index
|
||||||
|
items.sort((a, b) => o(a) - o(b))
|
||||||
|
for (const cmd of items) {
|
||||||
|
cmd.index = index++
|
||||||
|
sorted.push(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
length: indexed.length,
|
||||||
|
items: sorted,
|
||||||
|
grouped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommandRegistry = () => {
|
||||||
|
const { $command } = useNuxtApp()
|
||||||
|
const registry = $command as ReturnType<typeof provideCommandRegistry>
|
||||||
|
if (!registry)
|
||||||
|
throw new Error('Command registry not found')
|
||||||
|
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommand = (cmd: CommandProvider) => {
|
||||||
|
const registry = useCommandRegistry()
|
||||||
|
|
||||||
|
registry.register(cmd)
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
registry.remove(cmd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommands = (cmds: () => CommandProvider[]) => {
|
||||||
|
const registry = useCommandRegistry()
|
||||||
|
|
||||||
|
const commands = computed(cmds)
|
||||||
|
|
||||||
|
watch(commands, (n, o = []) => {
|
||||||
|
for (const cmd of o)
|
||||||
|
registry.remove(cmd)
|
||||||
|
for (const cmd of n)
|
||||||
|
registry.register(cmd)
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
commands.value.forEach(cmd => registry.remove(cmd))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const provideGlobalCommands = () => {
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const { locales } = useI18n() as { locales: ComputedRef<LocaleObject[]> }
|
||||||
|
const users = useUsers()
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
scope: 'Actions',
|
||||||
|
|
||||||
|
visible: () => currentUser.value,
|
||||||
|
|
||||||
|
name: 'Compose',
|
||||||
|
icon: 'i-ri:quill-pen-line',
|
||||||
|
description: 'Write a new post',
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
openPublishDialog()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
scope: 'Preferences',
|
||||||
|
|
||||||
|
name: 'Toggle dark mode',
|
||||||
|
icon: () => isDark.value ? 'i-ri:sun-line' : 'i-ri:moon-line',
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
toggleDark()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
scope: 'Preferences',
|
||||||
|
|
||||||
|
name: 'Toggle Zen mode',
|
||||||
|
icon: () => isZenMode.value ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line',
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
toggleZenMode()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
scope: 'Preferences',
|
||||||
|
|
||||||
|
name: 'Select language',
|
||||||
|
icon: 'i-ri:earth-line',
|
||||||
|
|
||||||
|
onComplete: () => ({
|
||||||
|
id: 'language',
|
||||||
|
display: 'Languages',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
useCommands(() => locales.value.map(l => ({
|
||||||
|
parent: 'language',
|
||||||
|
scope: 'Languages',
|
||||||
|
|
||||||
|
name: l.name!,
|
||||||
|
icon: 'i-ri:earth-line',
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
locale.value = l.code
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
|
||||||
|
useCommand({
|
||||||
|
scope: 'Account',
|
||||||
|
|
||||||
|
name: 'Sign in',
|
||||||
|
description: 'Add an existing account',
|
||||||
|
icon: 'i-ri:user-add-line',
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
openSigninDialog()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
useCommand({
|
||||||
|
scope: 'Account',
|
||||||
|
|
||||||
|
visible: () => users.value.length > 1,
|
||||||
|
|
||||||
|
name: 'Switch account',
|
||||||
|
description: 'Switch to another account',
|
||||||
|
icon: 'i-ri:user-shared-line',
|
||||||
|
|
||||||
|
onComplete: () => ({
|
||||||
|
id: 'account-switch',
|
||||||
|
display: 'Accounts',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
useCommands(() => users.value.map(user => ({
|
||||||
|
parent: 'account-switch',
|
||||||
|
scope: 'Switch account',
|
||||||
|
|
||||||
|
visible: () => user.account.id !== currentUser.value?.account.id,
|
||||||
|
|
||||||
|
name: `Switch to ${getFullHandle(user.account)}`,
|
||||||
|
icon: 'i-ri:user-shared-line',
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
loginTo(user)
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
useCommand({
|
||||||
|
scope: 'Account',
|
||||||
|
|
||||||
|
visible: () => currentUser.value,
|
||||||
|
|
||||||
|
name: () => `Sign out ${getFullHandle(currentUser.value!.account)}`,
|
||||||
|
icon: 'i-ri:logout-box-line',
|
||||||
|
|
||||||
|
onActivate() {
|
||||||
|
signout()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
3
composables/os.ts
Normal file
3
composables/os.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const useIsMac = () => computed(() =>
|
||||||
|
useRequestHeaders(['user-agent'])['user-agent']?.includes('Macintosh')
|
||||||
|
?? navigator?.platform?.includes('Mac') ?? false)
|
|
@ -43,5 +43,6 @@
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
|
<CommandRoot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
"@antfu/ni": "^0.18.8",
|
"@antfu/ni": "^0.18.8",
|
||||||
"@iconify-json/carbon": "^1.1.11",
|
"@iconify-json/carbon": "^1.1.11",
|
||||||
"@iconify-json/logos": "^1.1.19",
|
"@iconify-json/logos": "^1.1.19",
|
||||||
|
"@iconify-json/material-symbols": "^1.1.24",
|
||||||
"@iconify-json/ri": "^1.1.4",
|
"@iconify-json/ri": "^1.1.4",
|
||||||
"@iconify-json/twemoji": "^1.1.6",
|
"@iconify-json/twemoji": "^1.1.6",
|
||||||
"@nuxtjs/i18n": "^8.0.0-beta.6",
|
"@nuxtjs/i18n": "^8.0.0-beta.6",
|
||||||
|
@ -50,6 +51,7 @@
|
||||||
"focus-trap": "^7.1.0",
|
"focus-trap": "^7.1.0",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
"fs-extra": "^10.1.0",
|
"fs-extra": "^10.1.0",
|
||||||
|
"fuse.js": "^6.6.2",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"lint-staged": "^13.0.4",
|
"lint-staged": "^13.0.4",
|
||||||
"lru-cache": "^7.14.1",
|
"lru-cache": "^7.14.1",
|
||||||
|
|
|
@ -55,6 +55,7 @@ onReactivated(() => {
|
||||||
<StatusDetails
|
<StatusDetails
|
||||||
ref="main"
|
ref="main"
|
||||||
:status="status"
|
:status="status"
|
||||||
|
command
|
||||||
border="t base"
|
border="t base"
|
||||||
style="scroll-margin-top: 60px"
|
style="scroll-margin-top: 60px"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -26,7 +26,7 @@ onReactivated(() => {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="account">
|
<template v-if="account">
|
||||||
<AccountHeader :account="account" border="b base" />
|
<AccountHeader :account="account" command border="b base" />
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -13,16 +13,19 @@ const tabs = $computed(() => [
|
||||||
{
|
{
|
||||||
name: 'posts',
|
name: 'posts',
|
||||||
display: t('tab.posts'),
|
display: t('tab.posts'),
|
||||||
|
icon: 'i-ri:file-list-2-line',
|
||||||
paginator: paginatorPosts,
|
paginator: paginatorPosts,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'relies',
|
name: 'relies',
|
||||||
display: t('tab.posts_with_replies'),
|
display: t('tab.posts_with_replies'),
|
||||||
|
icon: 'i-ri:chat-3-line',
|
||||||
paginator: paginatorPostsWithReply,
|
paginator: paginatorPostsWithReply,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'media',
|
name: 'media',
|
||||||
display: t('tab.media'),
|
display: t('tab.media'),
|
||||||
|
icon: 'i-ri:camera-2-line',
|
||||||
paginator: paginatorMedia,
|
paginator: paginatorMedia,
|
||||||
},
|
},
|
||||||
] as const)
|
] as const)
|
||||||
|
@ -34,7 +37,7 @@ const paginator = $computed(() => tabs.find(t => t.name === tab)!.paginator)
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<CommonTabs v-model="tab" :options="tabs" />
|
<CommonTabs v-model="tab" :options="tabs" command />
|
||||||
<KeepAlive>
|
<KeepAlive>
|
||||||
<TimelinePaginator :key="tab" :paginator="paginator" />
|
<TimelinePaginator :key="tab" :paginator="paginator" />
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
|
|
7
plugins/command.ts
Normal file
7
plugins/command.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
command: provideCommandRegistry(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
|
@ -5,6 +5,7 @@ specifiers:
|
||||||
'@antfu/ni': ^0.18.8
|
'@antfu/ni': ^0.18.8
|
||||||
'@iconify-json/carbon': ^1.1.11
|
'@iconify-json/carbon': ^1.1.11
|
||||||
'@iconify-json/logos': ^1.1.19
|
'@iconify-json/logos': ^1.1.19
|
||||||
|
'@iconify-json/material-symbols': ^1.1.24
|
||||||
'@iconify-json/ri': ^1.1.4
|
'@iconify-json/ri': ^1.1.4
|
||||||
'@iconify-json/twemoji': ^1.1.6
|
'@iconify-json/twemoji': ^1.1.6
|
||||||
'@nuxtjs/i18n': ^8.0.0-beta.6
|
'@nuxtjs/i18n': ^8.0.0-beta.6
|
||||||
|
@ -35,6 +36,7 @@ specifiers:
|
||||||
focus-trap: ^7.1.0
|
focus-trap: ^7.1.0
|
||||||
form-data: ^4.0.0
|
form-data: ^4.0.0
|
||||||
fs-extra: ^10.1.0
|
fs-extra: ^10.1.0
|
||||||
|
fuse.js: ^6.6.2
|
||||||
js-yaml: ^4.1.0
|
js-yaml: ^4.1.0
|
||||||
lint-staged: ^13.0.4
|
lint-staged: ^13.0.4
|
||||||
lru-cache: ^7.14.1
|
lru-cache: ^7.14.1
|
||||||
|
@ -63,6 +65,7 @@ devDependencies:
|
||||||
'@antfu/ni': 0.18.8
|
'@antfu/ni': 0.18.8
|
||||||
'@iconify-json/carbon': 1.1.11
|
'@iconify-json/carbon': 1.1.11
|
||||||
'@iconify-json/logos': 1.1.19
|
'@iconify-json/logos': 1.1.19
|
||||||
|
'@iconify-json/material-symbols': 1.1.24
|
||||||
'@iconify-json/ri': 1.1.4
|
'@iconify-json/ri': 1.1.4
|
||||||
'@iconify-json/twemoji': 1.1.6
|
'@iconify-json/twemoji': 1.1.6
|
||||||
'@nuxtjs/i18n': 8.0.0-beta.6
|
'@nuxtjs/i18n': 8.0.0-beta.6
|
||||||
|
@ -83,7 +86,7 @@ devDependencies:
|
||||||
'@unocss/nuxt': 0.46.5
|
'@unocss/nuxt': 0.46.5
|
||||||
'@vitejs/plugin-vue': 3.2.0
|
'@vitejs/plugin-vue': 3.2.0
|
||||||
'@vue-macros/nuxt': 0.1.2_nuxt@3.0.0
|
'@vue-macros/nuxt': 0.1.2_nuxt@3.0.0
|
||||||
'@vueuse/integrations': 9.6.0_focus-trap@7.1.0
|
'@vueuse/integrations': 9.6.0_jp7nj67mlbnssz2yxhqonqfavi
|
||||||
'@vueuse/nuxt': 9.6.0_nuxt@3.0.0
|
'@vueuse/nuxt': 9.6.0_nuxt@3.0.0
|
||||||
blurhash: 2.0.4
|
blurhash: 2.0.4
|
||||||
browser-fs-access: 0.31.1
|
browser-fs-access: 0.31.1
|
||||||
|
@ -93,6 +96,7 @@ devDependencies:
|
||||||
focus-trap: 7.1.0
|
focus-trap: 7.1.0
|
||||||
form-data: 4.0.0
|
form-data: 4.0.0
|
||||||
fs-extra: 10.1.0
|
fs-extra: 10.1.0
|
||||||
|
fuse.js: 6.6.2
|
||||||
js-yaml: 4.1.0
|
js-yaml: 4.1.0
|
||||||
lint-staged: 13.0.4
|
lint-staged: 13.0.4
|
||||||
lru-cache: 7.14.1
|
lru-cache: 7.14.1
|
||||||
|
@ -632,6 +636,12 @@ packages:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@iconify-json/material-symbols/1.1.24:
|
||||||
|
resolution: {integrity: sha512-yg7KGVbrQ5NU/BZWj+rfEoeR3JA8ehCvY1SgrW+iuZjzZk3qm1sQcwQ5AktvEx/Hx9qnqQMMpscQJRPJxOq6rQ==}
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@iconify-json/ri/1.1.4:
|
/@iconify-json/ri/1.1.4:
|
||||||
resolution: {integrity: sha512-gAk2gQBVghgbMLOmbUCc3l4COMMH5sR3HhBqpjaPUFBbC4WsNNRyOD4RZgjlanU7DTgFrj4NarY5K2EkXaVxuw==}
|
resolution: {integrity: sha512-gAk2gQBVghgbMLOmbUCc3l4COMMH5sR3HhBqpjaPUFBbC4WsNNRyOD4RZgjlanU7DTgFrj4NarY5K2EkXaVxuw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -2365,7 +2375,7 @@ packages:
|
||||||
vue: 3.2.45
|
vue: 3.2.45
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@vueuse/integrations/9.6.0_focus-trap@7.1.0:
|
/@vueuse/integrations/9.6.0_jp7nj67mlbnssz2yxhqonqfavi:
|
||||||
resolution: {integrity: sha512-+rs2OWY/3spxoAGQMnlHQpxf8ErAYf4D1bT0aXaPnxphmtYgexm6KIjTFpBbcQnHwVi1g2ET1SJoQL16yDrgWA==}
|
resolution: {integrity: sha512-+rs2OWY/3spxoAGQMnlHQpxf8ErAYf4D1bT0aXaPnxphmtYgexm6KIjTFpBbcQnHwVi1g2ET1SJoQL16yDrgWA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
async-validator: '*'
|
async-validator: '*'
|
||||||
|
@ -2406,6 +2416,7 @@ packages:
|
||||||
'@vueuse/core': 9.6.0
|
'@vueuse/core': 9.6.0
|
||||||
'@vueuse/shared': 9.6.0
|
'@vueuse/shared': 9.6.0
|
||||||
focus-trap: 7.1.0
|
focus-trap: 7.1.0
|
||||||
|
fuse.js: 6.6.2
|
||||||
vue-demi: 0.13.11
|
vue-demi: 0.13.11
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
|
@ -4512,6 +4523,11 @@ packages:
|
||||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/fuse.js/6.6.2:
|
||||||
|
resolution: {integrity: sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/gauge/3.0.2:
|
/gauge/3.0.2:
|
||||||
resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
|
resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
Loading…
Reference in a new issue