diff --git a/src/boot/routes.js b/src/boot/routes.js index 715b394e..1ab8209d 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -22,6 +22,7 @@ import About from 'components/about/about.vue' import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue' import Lists from 'components/lists/lists.vue' import ListTimeline from 'components/list_timeline/list_timeline.vue' +import ListEdit from 'components/list_edit/list_edit.vue' export default (store) => { const validateAuthenticatedRoute = (to, from, next) => { @@ -73,7 +74,8 @@ export default (store) => { { name: 'about', path: '/about', component: About }, { name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile }, { name: 'lists', path: '/lists', component: Lists }, - { name: 'list-timeline', path: '/lists/:id', component: ListTimeline } + { name: 'list-timeline', path: '/lists/:id', component: ListTimeline }, + { name: 'list-edit', path: '/lists/:id/edit', component: ListEdit } ] if (store.state.instance.pleromaChatMessagesAvailable) { diff --git a/src/components/list_card/list_card.js b/src/components/list_card/list_card.js index 42e56aff..6546796c 100644 --- a/src/components/list_card/list_card.js +++ b/src/components/list_card/list_card.js @@ -1,3 +1,12 @@ +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faEllipsisH +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faEllipsisH +) + const ListCard = { props: [ 'list' diff --git a/src/components/list_card/list_card.vue b/src/components/list_card/list_card.vue index 5b4fefc8..752a7eb5 100644 --- a/src/components/list_card/list_card.vue +++ b/src/components/list_card/list_card.vue @@ -1,10 +1,21 @@ <template> - <router-link - :to="listLink(list.id)" - class="list-card" - > - {{ list.title }} - </router-link> + <div class="list-card"> + <router-link + :to="listLink(list.id)" + class="list-name" + > + {{ list.title }} + </router-link> + <router-link + :to="listLink(list.id) + '/edit'" + class="button-list-edit" + > + <FAIcon + class="fa-scale-110 fa-old-padding" + icon="ellipsis-h" + /> + </router-link> + </div> </template> <script src="./list_card.js"></script> @@ -13,10 +24,13 @@ @import '../../_variables.scss'; .list-card { + display: flex; +} + +.list-name, +.button-list-edit { margin: 0; padding: 1em; - display: flex; - flex: 1 0; color: $fallback--link; color: var(--link, $fallback--link); @@ -30,4 +44,8 @@ --lightText: var(--selectedMenuLightText, $fallback--lightText); } } + +.list-name { + flex-grow: 1; +} </style> diff --git a/src/components/list_edit/list_edit.js b/src/components/list_edit/list_edit.js new file mode 100644 index 00000000..0e03dbcb --- /dev/null +++ b/src/components/list_edit/list_edit.js @@ -0,0 +1,106 @@ +import { mapState, mapGetters } from 'vuex' +import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faSearch, + faChevronLeft +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faSearch, + faChevronLeft +) + +const ListNew = { + components: { + BasicUserCard, + UserAvatar + }, + data () { + return { + title: '', + suggestions: [], + userIds: [], + selectedUserIds: [], + loading: false, + query: '' + } + }, + created () { + this.$store.state.api.backendInteractor.getList({ id: this.id }) + .then((data) => { this.title = data.title }) + this.$store.state.api.backendInteractor.getListAccounts({ id: this.id }) + .then((data) => { this.selectedUserIds = data }) + }, + computed: { + id () { + return this.$route.params.id + }, + users () { + return this.userIds.map(userId => this.findUser(userId)) + }, + availableUsers () { + if (this.query.length !== 0) { + return this.users + } else { + return this.suggestions + } + }, + ...mapState({ + currentUser: state => state.users.currentUser, + backendInteractor: state => state.api.backendInteractor + }), + ...mapGetters(['findUser']) + }, + methods: { + onInput () { + this.search(this.query) + }, + selectUser (user, event) { + if (this.selectedUserIds.includes(user.id)) { + this.removeUser(user.id) + event.target.classList.remove('selected') + } else { + this.addUser(user) + event.target.classList.add('selected') + } + }, + addUser (user) { + this.selectedUserIds.push(user.id) + }, + removeUser (userId) { + this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId) + }, + search (query) { + if (!query) { + this.loading = false + return + } + + this.loading = true + this.userIds = [] + this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: true }) + .then(data => { + this.loading = false + this.userIds = data.accounts.map(a => a.id) + }) + }, + updateList () { + // the API has two different endpoints for "updating the list name" and + // "updating the accounts on the list". + this.$store.state.api.backendInteractor.updateList({ id: this.id, title: this.title }) + this.$store.state.api.backendInteractor.addAccountsToList({ + id: this.id, accountIds: this.selectedUserIds + }).then(() => { + this.$router.push({ name: 'list-timeline', params: { id: this.id } }) + }) + }, + deleteList () { + this.$store.state.api.backendInteractor.deleteList({ id: this.id }) + .then(this.$router.push({ name: 'lists' })) + } + } +} + +export default ListNew diff --git a/src/components/list_edit/list_edit.vue b/src/components/list_edit/list_edit.vue new file mode 100644 index 00000000..cadd25da --- /dev/null +++ b/src/components/list_edit/list_edit.vue @@ -0,0 +1,106 @@ +<template> + <div class="panel-default panel list-edit"> + <div + ref="header" + class="panel-heading" + > + <button + @click="$router.back" + class="button-unstyled go-back-button" + > + <FAIcon + size="lg" + icon="chevron-left" + /> + </button> + </div> + <div class="input-wrap"> + <input + ref="title" + v-model="title" + :placeholder="$t('lists.title')" + /> + </div> + <div class="input-wrap"> + <div class="input-search"> + <FAIcon + class="search-icon fa-scale-110 fa-old-padding" + icon="search" + /> + </div> + <input + ref="search" + v-model="query" + :placeholder="$t('lists.search')" + @input="onInput" + > + </div> + <div class="member-list"> + <div + v-for="user in availableUsers" + :key="user.id" + class="member" + > + <div @click.capture.prevent="selectUser(user, $event)"> + <BasicUserCard :user="user" /> + </div> + </div> + </div> + <button + :disabled="title && title.length === 0" + class="btn button-default" + @click="updateList" + > + {{ $t('lists.save') }} + </button> + <button + class="btn button-default" + @click="deleteList" + > + {{ $t('lists.delete') }} + </button> + </div> +</template> + +<script src="./list_edit.js"></script> + +<style lang="scss"> +@import '../../_variables.scss'; + +.list-edit { + .input-wrap { + display: flex; + margin: 0.7em 0.5em 0.7em 0.5em; + + input { + width: 100%; + } + } + + .search-icon { + margin-right: 0.3em; + } + + .member-list { + padding-bottom: 0.7rem; + } + + .basic-user-card:hover, + .basic-user-card.selected { + cursor: pointer; + background-color: var(--selectedPost, $fallback--lightBg); + } + + .go-back-button { + text-align: center; + line-height: 1; + height: 100%; + align-self: start; + width: var(--__panel-heading-height-inner); + } + + .btn { + margin: 0.5em; + } +} +</style> diff --git a/src/i18n/en.json b/src/i18n/en.json index e113400f..3430620b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -952,7 +952,9 @@ "new": "New List", "title": "List title", "search": "Search users", - "create": "Create" + "create": "Create", + "save": "Save changes", + "delete": "Delete list" }, "file_type": { "audio": "Audio", diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index ff6c00d5..7f2fc5ac 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -50,6 +50,7 @@ const MASTODON_STATUS_CONTEXT_URL = id => `/api/v1/statuses/${id}/context` const MASTODON_USER_URL = '/api/v1/accounts' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` +const MASTODON_LIST_URL = id => `/api/v1/lists/${id}` const MASTODON_LIST_TIMELINE_URL = id => `/api/v1/timelines/list/${id}` const MASTODON_LIST_ACCOUNTS_URL = id => `/api/v1/lists/${id}/accounts` const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}` @@ -403,6 +404,31 @@ const createList = ({ title, credentials }) => { }).then((data) => data.json()) } +const getList = ({ id, credentials }) => { + const url = MASTODON_LIST_URL(id) + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) +} + +const updateList = ({ id, title, credentials }) => { + const url = MASTODON_LIST_URL(id) + const headers = authHeaders(credentials) + headers['Content-Type'] = 'application/json' + + return fetch(url, { + method: 'PUT', + headers: headers, + body: JSON.stringify({ title }) + }) +} + +const getListAccounts = ({ id, credentials }) => { + const url = MASTODON_LIST_ACCOUNTS_URL(id) + return fetch(url, { headers: authHeaders(credentials) }) + .then((data) => data.json()) + .then((data) => data.map(({ id }) => id)) +} + const addAccountsToList = ({ id, accountIds, credentials }) => { const url = MASTODON_LIST_ACCOUNTS_URL(id) const headers = authHeaders(credentials) @@ -415,6 +441,14 @@ const addAccountsToList = ({ id, accountIds, credentials }) => { }) } +const deleteList = ({ id, credentials }) => { + const url = MASTODON_LIST_URL(id) + return fetch(url, { + method: 'DELETE', + headers: authHeaders(credentials) + }) +} + const fetchConversation = ({ id, credentials }) => { let urlContext = MASTODON_STATUS_CONTEXT_URL(id) return fetch(urlContext, { headers: authHeaders(credentials) }) @@ -1389,7 +1423,11 @@ const apiService = { fetchFollowRequests, fetchLists, createList, + getList, + updateList, + getListAccounts, addAccountsToList, + deleteList, approveUser, denyUser, suggestions,