feat: finish list CRUD (#1532)

Co-authored-by: userquin <userquin@gmail.com>
closes https://github.com/elk-zone/elk/issues/372
This commit is contained in:
Evan Boehs 2023-02-02 16:02:39 -05:00 committed by GitHub
parent 809d4a6eb3
commit e58d09b6cc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 383 additions and 150 deletions

View file

@ -91,56 +91,58 @@ const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
</component>
<div p4 mt--18 flex flex-col gap-4>
<div relative>
<div flex="~ col gap-2 1">
<button :class="{ 'rounded-full': !isSelf, 'squircle': isSelf }" w-30 h-30 p1 bg-base border-bg-base z-2 @click="previewAvatar">
<div flex justify-between>
<button shrink-0 :class="{ 'rounded-full': !isSelf, 'squircle': isSelf }" w-30 h-30 p1 bg-base border-bg-base z-2 @click="previewAvatar">
<AccountAvatar :square="isSelf" :account="account" hover:opacity-90 transition-opacity />
</button>
<div flex="~ col gap1">
<div flex justify-between>
<AccountDisplayName :account="account" font-bold sm:text-2xl text-xl />
<AccountBotIndicator v-if="account.bot" show-label />
</div>
<AccountHandle :account="account" />
<div inset-ie-0 flex="~ wrap row-reverse" gap-2 items-center pt18 justify-start>
<!-- Edit profile -->
<NuxtLink
v-if="isSelf"
to="/settings/profile/appearance"
gap-1 items-center border="1" rounded-full flex="~ gap2 center" font-500 min-w-30 h-fit px3 py1
hover="border-primary text-primary bg-active"
>
{{ $t('settings.profile.appearance.title') }}
</NuxtLink>
<AccountFollowButton :account="account" :command="command" />
<span inset-ie-0 flex gap-2 items-center>
<AccountMoreButton :account="account" :command="command" />
<CommonTooltip v-if="!isSelf && relationship?.following" :content="getNotificationIconTitle()">
<button
:aria-pressed="isNotifiedOnPost"
:aria-label="t('account.notifications_on_post_enable', { username: `@${account.username}` })"
rounded-full text-sm p2 border-1 transition-colors
:class="isNotifiedOnPost ? 'text-primary border-primary hover:bg-red/20 hover:text-red hover:border-red' : 'border-base hover:text-primary'"
@click="toggleNotifications"
>
<span v-if="isNotifiedOnPost" i-ri:notification-4-fill block text-current />
<span v-else i-ri-notification-4-line block text-current />
</button>
</CommonTooltip>
<CommonTooltip :content="$t('list.modify_account')">
<VDropdown v-if="!isSelf && relationship?.following">
<button
:aria-label="$t('list.modify_account')"
rounded-full text-sm p2 border-1 transition-colors
border-base hover:text-primary
>
<span i-ri:play-list-add-fill block text-current />
</button>
<template #popper>
<ListLists :user-id="account.id" />
</template>
</VDropdown>
</CommonTooltip>
</span>
</div>
</div>
<div absolute top-18 inset-ie-0 flex gap-2 items-center>
<AccountMoreButton :account="account" :command="command" />
<CommonTooltip v-if="!isSelf && relationship?.following" :content="getNotificationIconTitle()">
<button
:aria-pressed="isNotifiedOnPost"
:aria-label="t('account.notifications_on_post_enable', { username: `@${account.username}` })"
rounded-full text-sm p2 border-1 transition-colors
:class="isNotifiedOnPost ? 'text-primary border-primary hover:bg-red/20 hover:text-red hover:border-red' : 'border-base hover:text-primary'"
@click="toggleNotifications"
>
<span v-if="isNotifiedOnPost" i-ri:notification-4-fill block text-current />
<span v-else i-ri-notification-4-line block text-current />
</button>
</CommonTooltip>
<CommonTooltip :content="$t('list.modify_account')">
<VDropdown v-if="!isSelf && relationship?.following">
<button
:aria-label="$t('list.modify_account')"
rounded-full text-sm p2 border-1 transition-colors
border-base hover:text-primary
>
<span i-ri:play-list-add-fill block text-current />
</button>
<template #popper>
<ListLists :user-id="account.id" />
</template>
</VDropdown>
</CommonTooltip>
<AccountFollowButton :account="account" :command="command" />
<!-- Edit profile -->
<NuxtLink
v-if="isSelf"
to="/settings/profile/appearance"
gap-1 items-center border="1" rounded-full flex="~ gap2 center" font-500 min-w-30 h-fit px3 py1
hover="border-primary text-primary bg-active"
>
{{ $t('settings.profile.appearance.title') }}
</NuxtLink>
<div flex="~ col gap1" pt2>
<div flex justify-between>
<AccountDisplayName :account="account" font-bold sm:text-2xl text-xl />
<AccountBotIndicator v-if="account.bot" show-label />
</div>
<AccountHandle :account="account" />
</div>
</div>
<div v-if="account.note" max-h-100 overflow-y-auto>

View file

@ -51,6 +51,23 @@ nuxtApp.hook('elk-logo:click', () => {
update()
nuxtApp.$scrollToTop()
})
function createEntry(item: any) {
items.value = [...items.value, preprocess?.([item]) ?? item]
}
function updateEntry(item: any) {
const id = item[keyProp]
const index = items.value.findIndex(i => (i as any)[keyProp] === id)
if (index > -1)
items.value = [...items.value.slice(0, index), preprocess?.([item]) ?? item, ...items.value.slice(index + 1)]
}
function removeEntry(entryId: any) {
items.value = items.value.filter(i => (i as any)[keyProp] !== entryId)
}
defineExpose({ createEntry, removeEntry, updateEntry })
</script>
<template>

View file

@ -0,0 +1,169 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const emit = defineEmits<{
(e: 'listUpdated', list: mastodon.v1.List): void
(e: 'listRemoved', id: string): void
}>()
const { list } = $defineProps<{
list: mastodon.v1.List
}>()
const { modelValue } = defineModel<{
modelValue: string
}>()
modelValue.value = list.title
const { t } = useI18n()
const client = useMastoClient()
let isEditing = $ref<boolean>(false)
let busy = $ref<boolean>(false)
let deleteBusy = $ref<boolean>(false)
const enableSaveButton = computed(() => list.title !== modelValue.value)
const edit = ref()
const input = ref()
const prepareEdit = () => {
isEditing = true
nextTick(() => {
input.value.focus()
})
}
const cancelEdit = (updateTitle = true) => {
isEditing = false
if (updateTitle)
modelValue.value = list.title
nextTick(() => {
edit.value.focus()
})
}
async function finishEditing() {
if (busy || !isEditing || !enableSaveButton.value)
return
busy = true
await nextTick()
try {
const updateList = await client.v1.lists.update(list.id, {
title: modelValue.value,
})
cancelEdit(false)
emit('listUpdated', updateList)
}
finally {
busy = false
}
}
async function removeList() {
if (deleteBusy)
return
deleteBusy = true
await nextTick()
const confirmDelete = await openConfirmDialog({
title: t('confirm.delete_list.title'),
confirm: t('confirm.delete_list.confirm'),
cancel: t('confirm.delete_list.cancel'),
})
if (confirmDelete === 'confirm') {
await nextTick()
try {
await client.v1.lists.remove(list.id)
emit('listRemoved', list.id)
}
finally {
deleteBusy = false
}
}
else {
deleteBusy = false
}
}
onBeforeUnmount(() => cancelEdit(false))
</script>
<template>
<form hover:bg-active flex justify-between items-center @submit.prevent="finishEditing">
<div
v-if="isEditing"
bg-base border="~ base" h10 m2 ps-1 pe-4 rounded-3 w-full flex="~ row"
items-center relative focus-within:box-shadow-outline gap-3
>
<CommonTooltip v-if="isEditing" :content="$t('list.cancel_edit')">
<button
type="button"
rounded-full text-sm p2 transition-colors
hover:text-primary
@click="cancelEdit(true)"
>
<span block text-current i-ri:close-fill />
</button>
</CommonTooltip>
<input
ref="input"
v-model="modelValue"
rounded-3
w-full
bg-transparent
outline="focus:none"
pe-4
pb="1px"
flex-1
placeholder-text-secondary
@keydown.esc="cancelEdit(true)"
>
</div>
<NuxtLink v-else :to="`list/${list.id}`" block grow p4>
{{ list.title }}
</NuxtLink>
<div mr4 flex gap2>
<CommonTooltip v-if="isEditing" :content="$t('list.save')">
<button
type="submit"
text-sm p2 border-1 transition-colors
border-dark hover:text-primary
btn-action-icon
:disabled="deleteBusy || !enableSaveButton || busy"
>
<template v-if="isEditing">
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else block text-current i-ri:save-2-fill class="rtl-flip" />
</template>
</button>
</CommonTooltip>
<CommonTooltip v-else :content="$t('list.edit')">
<button
ref="edit"
type="button"
text-sm p2 border-1 transition-colors
border-dark hover:text-primary
btn-action-icon
@click.prevent="prepareEdit"
>
<span block text-current i-ri:edit-2-line class="rtl-flip" />
</button>
</CommonTooltip>
<CommonTooltip :content="$t('list.delete')">
<button
type="button"
text-sm p2 border-1 transition-colors
border-dark hover:text-primary
btn-action-icon
:disabled="isEditing"
@click.prevent="removeList"
>
<span v-if="deleteBusy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else block text-current i-ri:delete-bin-2-line class="rtl-flip" />
</button>
</CommonTooltip>
</div>
</form>
</template>

View file

@ -116,6 +116,11 @@
"cancel": "No",
"confirm": "Yes"
},
"delete_list": {
"cancel": "Cancel",
"confirm": "Delete",
"title": "Are you sure you want to delete this list?"
},
"delete_posts": {
"cancel": "Cancel",
"confirm": "Delete",
@ -177,8 +182,14 @@
},
"list": {
"add_account": "Add account to list",
"cancel_edit": "Cancel editing",
"create": "Create",
"delete": "Delete this list",
"edit": "Edit this list",
"list_title_placeholder": "List title",
"modify_account": "Modify lists with account",
"remove_account": "Remove account from list"
"remove_account": "Remove account from list",
"save": "Save changes"
},
"menu": {
"block_account": "Block {0}",

View file

@ -1,55 +0,0 @@
<script setup lang="ts">
import type { CommonRouteTabOption } from '~/components/common/CommonRouteTabs.vue'
const list = $computed(() => useRoute().params.list as string)
const server = $computed(() => useRoute().params.server as string)
const { t } = useI18n()
const tabs = $computed<CommonRouteTabOption[]>(() => [
{
to: {
name: 'list',
params: { server, list },
},
display: t('tab.list'),
icon: 'i-ri:list-unordered',
},
{
to: {
name: 'list-accounts',
params: { server, list },
},
display: t('tab.accounts'),
icon: 'i-ri:user-line',
},
],
)
const { client } = $(useMasto())
const { data: listInfo, refresh } = $(await useAsyncData(() => client.v1.lists.fetch(list), { default: () => shallowRef() }))
if (listInfo) {
useHeadFixed({
title: () => `${listInfo.title}`,
})
}
onReactivated(() => {
// Silently update data when reentering the page
// The user will see the previous content first, and any changes will be updated to the UI when the request is completed
refresh()
})
</script>
<template>
<MainContent back>
<template #title>
<span text-lg font-bold>{{ listInfo ? listInfo.title : t('nav.list') }}</span>
</template>
<template #header>
<CommonRouteTabs replace :options="tabs" />
</template>
<NuxtPage v-if="isHydrated" />
</MainContent>
</template>

View file

@ -1,35 +0,0 @@
<script setup lang="ts">
definePageMeta({
name: 'list',
})
const params = useRoute().params
const listId = $(computedEager(() => params.list as string))
const { client } = $(useMasto())
const { data: listInfo, refresh } = $(await useAsyncData(() => client.v1.lists.fetch(listId), { default: () => shallowRef() }))
const paginator = client.v1.timelines.listList(listId)
const stream = useStreaming(client => client.v1.stream.streamListTimeline(listId))
if (listInfo) {
useHeadFixed({
title: () => `${listInfo.title}`,
})
}
onReactivated(() => {
// Silently update data when reentering the page
// The user will see the previous content first, and any changes will be updated to the UI when the request is completed
refresh()
})
</script>
<template>
<MainContent back>
<template #title>
<span text-lg font-bold>{{ listInfo ? listInfo.title : $t('nav.list') }}</span>
</template>
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderedTimeline" context="home" />
</MainContent>
</template>

View file

@ -1,17 +1,56 @@
<script setup lang="ts">
definePageMeta({
name: 'list',
})
import type { CommonRouteTabOption } from '~/components/common/CommonRouteTabs.vue'
const params = useRoute().params
const listId = $(computedEager(() => params.list as string))
const list = $computed(() => useRoute().params.list as string)
const server = $computed(() => useRoute().params.server as string)
const { t } = useI18n()
const route = useRoute()
const tabs = $computed<CommonRouteTabOption[]>(() => [
{
to: {
name: 'list',
params: { server, list },
},
display: t('tab.posts'),
icon: 'i-ri:list-unordered',
},
{
to: {
name: 'list-accounts',
params: { server, list },
},
display: t('tab.accounts'),
icon: 'i-ri:user-line',
},
],
)
const { client } = $(useMasto())
const { data: listInfo, refresh } = $(await useAsyncData(() => client.v1.lists.fetch(list), { default: () => shallowRef() }))
const paginator = client.v1.timelines.listList(listId)
const stream = useStreaming(client => client.v1.stream.streamListTimeline(listId))
if (listInfo) {
useHeadFixed({
title: () => `${listInfo.title} | ${route.fullPath.endsWith('/accounts') ? t('tab.accounts') : t('tab.posts')} | ${t('nav.lists')}`,
})
}
onReactivated(() => {
// Silently update data when reentering the page
// The user will see the previous content first, and any changes will be updated to the UI when the request is completed
refresh()
})
</script>
<template>
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderedTimeline" context="home" />
<MainContent back>
<template #title>
<span text-lg font-bold>{{ listInfo ? listInfo.title : t('nav.list') }}</span>
</template>
<template #header>
<CommonRouteTabs replace :options="tabs" />
</template>
<NuxtPage v-if="isHydrated" />
</MainContent>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
definePageMeta({
name: 'list',
})
const params = useRoute().params
const listId = $(computedEager(() => params.list as string))
const client = useMastoClient()
const paginator = client.v1.timelines.listList(listId)
const stream = useStreaming(client => client.v1.stream.streamListTimeline(listId))
</script>
<template>
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderedTimeline" context="home" />
</template>

View file

@ -1,12 +1,44 @@
<script lang="ts" setup>
import type { mastodon } from 'masto'
const { t } = useI18n()
const { client } = $(useMasto())
const client = useMastoClient()
const paginator = client.v1.lists.list()
useHeadFixed({
title: () => t('nav.lists'),
})
const paginatorRef = ref()
let busy = $ref<boolean>(false)
const createText = ref('')
const enableSubmit = computed(() => createText.value.length > 0)
async function createList() {
if (busy || !enableSubmit.value)
return
busy = true
await nextTick()
try {
const newEntry = await client.v1.lists.create({
title: createText.value,
})
paginatorRef.value.createEntry(newEntry)
createText.value = ''
}
finally {
busy = false
}
}
function updateEntry(list: mastodon.v1.List) {
paginatorRef.value.updateEntry(list)
}
function removeEntry(id: string) {
paginatorRef.value.removeEntry(id)
}
</script>
<template>
@ -18,11 +50,47 @@ useHeadFixed({
</NuxtLink>
</template>
<slot>
<CommonPaginator :paginator="paginator">
<CommonPaginator ref="paginatorRef" :paginator="paginator">
<template #default="{ item }">
<NuxtLink :to="`list/${item.id}`" block p4 hover:bg-active flex justify-between>
{{ item.title }}
</NuxtLink>
<ListEntry
:list="item"
@list-updated="updateEntry"
@list-removed="removeEntry"
/>
</template>
<template #done>
<form
border="t base"
p-4 w-full
flex="~ wrap" relative gap-3
@submit.prevent="createList"
>
<div
bg-base border="~ base" flex-1 h10 ps-1 pe-4 rounded-2 w-full flex="~ row"
items-center relative focus-within:box-shadow-outline gap-3
>
<input
v-model="createText"
bg-transparent
outline="focus:none"
px-4
pb="1px"
flex-1
placeholder-text-secondary
:placeholder="$t('list.list_title_placeholder')"
@keypress.enter="createList"
>
</div>
<div flex="~ col" gap-y-4 gap-x-2 sm="~ justify-between flex-row">
<button flex="~ row" gap-x-2 items-center btn-solid :disabled="!enableSubmit || busy">
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
<span block i-ri:loader-2-fill aria-hidden="true" />
</span>
<span v-else aria-hidden="true" block i-material-symbols:playlist-add-rounded class="rtl-flip" />
{{ $t('list.create') }}
</button>
</div>
</form>
</template>
</CommonPaginator>
</slot>