Files
deck/src/components/Controls.vue
Luka Trovic 327bfff315 feat: use outline icons
Signed-off-by: Luka Trovic <luka@nextcloud.com>
2025-07-15 20:57:36 +02:00

606 lines
17 KiB
Vue

<!--
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="controls">
<NcModal v-if="showAddCardModal" class="card-selector" @close="clickHideAddCardModel">
<CreateNewCardCustomPicker show-created-notice @cancel="clickHideAddCardModel" />
</NcModal>
<div v-if="overviewName" class="board-title">
<div class="board-bullet icon-calendar-dark" />
<h2 dir="auto">
{{ overviewName }}
</h2>
<NcActions>
<NcActionButton icon="icon-add" @click="clickShowAddCardModel">
{{ t('deck', 'Add card') }}
</NcActionButton>
</NcActions>
</div>
<div v-else-if="board" class="board-title">
<div :style="{backgroundColor: '#' + board.color}" class="board-bullet" />
<h2 dir="auto">
{{ board.title }}
</h2>
<p v-if="showArchived">
({{ t('deck', 'Archived cards') }})
</p>
</div>
<div class="board-actions">
<SessionList v-if="isNotifyPushEnabled && presentUsers.length"
:sessions="presentUsers" />
<!-- Hide but not remove for now as search might change in the future -->
<div v-if="false" class="deck-search">
<input id="deck-search-input"
ref="search"
:tabindex="0"
type="search"
class="icon-search"
:value="searchQuery"
@focus="$store.dispatch('toggleShortcutLock', true)"
@blur="$store.dispatch('toggleShortcutLock', false)"
@input="$store.commit('setSearchQuery', $event.target.value)">
</div>
<div v-if="board && canManage && !showArchived && !board.archived"
id="stack-add"
v-click-outside="hideAddStack">
<NcActions v-if="!isAddStackVisible">
<NcActionButton @click.stop="showAddStack">
{{ t('deck', 'Add list') }}
<template #icon>
<TableColumnPlusAfter :size="20" />
</template>
</NcActionButton>
</NcActions>
<form v-else @submit.prevent="addNewStack()">
<label for="new-stack-input-main" class="hidden-visually">{{ t('deck', 'Add list') }}</label>
<input id="new-stack-input-main"
v-model="newStackTitle"
v-focus
type="text"
class="no-close"
:placeholder="t('deck', 'List name')"
required
@focus="$store.dispatch('toggleShortcutLock', true)"
@blur="$store.dispatch('toggleShortcutLock', false)">
<input :title="t('deck', 'Add list')"
class="icon-confirm"
type="submit"
value="">
</form>
</div>
<div v-if="board" class="board-action-buttons">
<div class="board-action-buttons__filter">
<NcPopover :placement="'bottom-end'"
:aria-label="t('deck', 'Active filters')"
:name="t('deck', 'Active filters')"
@show="filterVisible=true"
@hide="filterVisible=false">
<!-- We cannot use NcActions here are the popover trigger does not update on reactive icons -->
<template #trigger>
<NcButton ref="filterPopover"
:title="t('deck', 'Apply filter')"
:aria-label="t('deck', 'Apply filter')"
class="filter-button"
:type="isFilterActive ? 'primary' : 'tertiary'">
<template #icon>
<FilterIcon v-if="isFilterActive" :size="20" decorative />
<FilterOffIcon v-else :size="20" decorative />
</template>
</NcButton>
</template>
<div v-if="filterVisible" class="filter">
<h3>{{ t('deck', 'Filter by tag') }}</h3>
<div v-for="label in labelsSorted" :key="label.id" class="filter--item">
<input :id="label.id"
v-model="filter.tags"
type="checkbox"
class="checkbox"
:value="label.id"
@change="setFilter">
<label :for="label.id"><span class="label" :style="labelStyle(label)">{{ label.title }}</span></label>
</div>
<h3>{{ t('deck', 'Filter by assigned user') }}</h3>
<div class="filter--item">
<input id="unassigned"
v-model="filter.unassigned"
type="checkbox"
class="checkbox"
value="unassigned"
@change="setFilter"
@click="beforeSetFilter">
<label for="unassigned">{{ t('deck', 'Unassigned') }}</label>
</div>
<div v-for="user in board.users" :key="user.uid" class="filter--item">
<input :id="user.uid"
v-model="filter.users"
type="checkbox"
class="checkbox"
:value="user.uid"
@change="setFilter">
<label :for="user.uid"><NcAvatar :user="user.uid" :size="24" :disable-menu="true" /> {{ user.displayname }}</label>
</div>
<h3>{{ t('deck', 'Filter by status') }}</h3>
<div class="filter--item">
<input id="filter-option-both"
v-model="filter.completed"
type="radio"
class="radio"
value="both"
@change="setFilter"
@click="beforeSetFilter">
<label for="filter-option-both">{{ t('deck', 'Open and completed') }}</label>
</div>
<div class="filter--item">
<input id="filter-option-open"
v-model="filter.completed"
type="radio"
class="radio"
value="open"
@change="setFilter"
@click="beforeSetFilter">
<label for="filter-option-open">{{ t('deck', 'Open') }}</label>
</div>
<div class="filter--item">
<input id="filter-option-completed"
v-model="filter.completed"
type="radio"
class="radio"
value="completed"
@change="setFilter"
@click="beforeSetFilter">
<label for="filter-option-completed">{{ t('deck', 'Completed') }}</label>
</div>
<h3>{{ t('deck', 'Filter by due date') }}</h3>
<div class="filter--item">
<input id="overdue"
v-model="filter.due"
type="radio"
class="radio"
value="overdue"
@change="setFilter"
@click="beforeSetFilter">
<label for="overdue">{{ t('deck', 'Overdue') }}</label>
</div>
<div class="filter--item">
<input id="dueToday"
v-model="filter.due"
type="radio"
class="radio"
value="dueToday"
@change="setFilter"
@click="beforeSetFilter">
<label for="dueToday">{{ t('deck', 'Next 24 hours') }}</label>
</div>
<div class="filter--item">
<input id="dueWeek"
v-model="filter.due"
type="radio"
class="radio"
value="dueWeek"
@change="setFilter"
@click="beforeSetFilter">
<label for="dueWeek">{{ t('deck', 'Next 7 days') }}</label>
</div>
<div class="filter--item">
<input id="dueMonth"
v-model="filter.due"
type="radio"
class="radio"
value="dueMonth"
@change="setFilter"
@click="beforeSetFilter">
<label for="dueMonth">{{ t('deck', 'Next 30 days') }}</label>
</div>
<div class="filter--item">
<input id="noDue"
v-model="filter.due"
type="radio"
class="radio"
value="noDue"
@change="setFilter"
@click="beforeSetFilter">
<label for="noDue">{{ t('deck', 'No due date') }}</label>
</div>
<NcButton :disabled="!isFilterActive" :wide="true" @click="clearFilter">
{{ t('deck', 'Clear filter') }}
</NcButton>
</div>
</NcPopover>
</div>
<NcActions :aria-label="t('deck', 'View Modes')"
:name="t('deck', 'Toggle View Modes')">
<NcActionButton @click="toggleShowArchived">
<template #icon>
<ArchiveIcon :size="20" decorative />
</template>
{{ showArchived ? t('deck', 'Hide archived cards') : t('deck', 'Show archived cards') }}
</NcActionButton>
<NcActionButton v-if="compactMode"
@click="toggleCompactMode">
<ArrowExpandVerticalIcon slot="icon" :size="20" decorative />
{{ t('deck', 'Toggle compact mode') }}
</NcActionButton>
<NcActionButton v-else
@click="toggleCompactMode">
<ArrowCollapseVerticalIcon slot="icon" :size="20" decorative />
{{ t('deck', 'Toggle compact mode') }}
</NcActionButton>
<NcActionButton @click="toggleShowCardCover">
<template #icon>
<ImageIcon :size="20" decorative />
</template>
{{ showCardCover ? t('deck', 'Hide card cover images') : t('deck', 'Show card cover images') }}
</NcActionButton>
</NcActions>
<!-- FIXME: NcActionRouter currently doesn't work as an inline action -->
<NcActions v-if="isFullApp">
<NcActionButton icon="icon-menu-sidebar"
:aria-label="t('deck', 'Open details')"
:name="t('deck', 'Details')"
@click="toggleDetailsView" />
</NcActions>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { NcActions, NcActionButton, NcAvatar, NcButton, NcPopover, NcModal } from '@nextcloud/vue'
import labelStyle from '../mixins/labelStyle.js'
import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue'
import ImageIcon from 'vue-material-design-icons/ImageMultipleOutline.vue'
import FilterIcon from 'vue-material-design-icons/FilterOutline.vue'
import FilterOffIcon from 'vue-material-design-icons/FilterOffOutline.vue'
import TableColumnPlusAfter from 'vue-material-design-icons/TableColumnPlusAfter.vue'
import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical.vue'
import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical.vue'
import SessionList from './SessionList.vue'
import { isNotifyPushEnabled } from '../sessions.js'
import CreateNewCardCustomPicker from '../views/CreateNewCardCustomPicker.vue'
import { getCurrentUser } from '@nextcloud/auth'
export default {
name: 'Controls',
components: {
CreateNewCardCustomPicker,
NcModal,
NcActions,
NcActionButton,
NcButton,
NcPopover,
NcAvatar,
ArchiveIcon,
ImageIcon,
FilterIcon,
FilterOffIcon,
ArrowCollapseVerticalIcon,
ArrowExpandVerticalIcon,
TableColumnPlusAfter,
SessionList,
},
mixins: [labelStyle],
props: {
board: {
type: Object,
required: false,
default: null,
},
overviewName: {
type: String,
required: false,
default: null,
},
},
data() {
return {
newStackTitle: '',
stack: '',
filterVisible: false,
showArchived: false,
isAddStackVisible: false,
filter: { tags: [], users: [], due: '', unassigned: false, completed: 'both' },
showAddCardModal: false,
defaultPageTitle: false,
isNotifyPushEnabled: isNotifyPushEnabled(),
}
},
computed: {
...mapGetters([
'canEdit',
'canManage',
]),
...mapState({
isFullApp: state => state.isFullApp,
compactMode: state => state.compactMode,
showCardCover: state => state.showCardCover,
searchQuery: state => state.searchQuery,
}),
detailsRoute() {
return {
name: 'board.details',
}
},
isFilterActive() {
return this.filter.tags.length !== 0 || this.filter.users.length !== 0 || this.filter.due !== '' || this.filter.completed !== 'both'
},
labelsSorted() {
return [...this.board.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
presentUsers() {
if (!this.board) return []
// get user object including displayname from the list of all users with acces
return this.board.users.filter((user) => this.board.activeSessions.includes(user.uid))
},
},
watch: {
board(current, previous) {
if (current?.id !== previous?.id) {
this.clearFilter()
}
if (current) {
this.setPageTitle(current.title)
}
},
},
beforeMount() {
subscribe('deck:board:show-new-card', this.clickShowAddCardModel)
subscribe('deck:board:toggle-filter-popover', this.triggerOpenFilters)
subscribe('deck:board:clear-filter', this.triggerClearFilter)
subscribe('deck:board:toggle-filter-by-me', this.triggerFilterByMe)
},
beforeDestroy() {
unsubscribe('deck:board:show-new-card', this.clickShowAddCardModel)
unsubscribe('deck:board:toggle-filter-popover', this.triggerOpenFilters)
unsubscribe('deck:board:clear-filter', this.triggerClearFilter)
unsubscribe('deck:board:toggle-filter-by-me', this.triggerFilterByMe)
this.setPageTitle('')
},
methods: {
beforeSetFilter(e) {
if (this.filter.due === e.target.value) {
this.filter.due = ''
this.$store.dispatch('setFilter', { ...this.filter })
}
if (e.target.value === 'unassigned') {
this.filter.users = []
this.$store.dispatch('setFilter', { ...this.filter })
} else {
this.filter.completed = 'both'
this.$store.dispatch('setFilter', { ...this.filter })
}
this.$store.dispatch('setFilter', { ...this.filter })
},
setFilter() {
if (this.filter.users.length > 0) {
this.filter.unassigned = false
}
this.$nextTick(() => this.$store.dispatch('setFilter', { ...this.filter }))
},
toggleNav() {
this.$store.dispatch('toggleNav')
},
toggleCompactMode() {
this.$store.dispatch('toggleCompactMode')
},
toggleShowCardCover() {
this.$store.dispatch('toggleShowCardCover')
},
toggleShowArchived() {
this.$store.dispatch('toggleShowArchived')
this.showArchived = !this.showArchived
},
addNewStack() {
this.stack = { title: this.newStackTitle }
this.$store.dispatch('createStack', this.stack)
this.newStackTitle = ''
this.stack = null
this.isAddStackVisible = false
},
showAddStack() {
this.isAddStackVisible = true
},
hideAddStack() {
this.isAddStackVisible = false
},
toggleDetailsView() {
if (this.$route.name === 'board.details') {
this.$router.push({ name: 'board' })
} else {
this.$router.push({ name: 'board.details' })
}
},
clearFilter() {
const filterReset = { tags: [], users: [], due: '', completed: 'both' }
this.$store.dispatch('setFilter', { ...filterReset })
this.filter = filterReset
},
clickShowAddCardModel() {
this.showAddCardModal = true
},
clickHideAddCardModel() {
this.showAddCardModal = false
},
setPageTitle(title) {
if (!this.isFullApp) {
return
}
if (this.defaultPageTitle === false) {
this.defaultPageTitle = window.document.title
if (this.defaultPageTitle.indexOf(' - Deck - ') !== -1) {
this.defaultPageTitle = this.defaultPageTitle.substring(this.defaultPageTitle.indexOf(' - Deck - ') + 3)
}
if (this.defaultPageTitle.indexOf('Deck - ') !== 0) {
this.defaultPageTitle = 'Deck - ' + this.defaultPageTitle
}
}
let newTitle = this.defaultPageTitle
if (title !== '') {
newTitle = `${title} - ${newTitle}`
}
window.document.title = newTitle
},
triggerOpenFilters() {
this.$refs.filterPopover.$el.click()
},
triggerOpenSearch() {
this.$refs.search.focus()
},
triggerClearFilter() {
this.clearFilter()
},
triggerFilterByMe() {
if (this.isFilterActive) {
this.clearFilter()
} else {
this.filter.users = [getCurrentUser().uid]
this.setFilter()
}
},
},
}
</script>
<style lang="scss" scoped>
.controls {
display: flex;
margin: calc(var(--default-grid-baseline) * 2);
height: var(--default-clickable-area);
padding-left: var(--default-clickable-area);
.board-title {
display: flex;
align-items: center;
h2 {
margin: 0;
margin-right: 10px;
font-size: 18px;
}
.board-bullet {
display: inline-block;
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background-color: transparent;
margin: var(--default-grid-baseline);
}
}
#stack-add form {
display: flex;
#new-stack-input-main {
margin-right: 8px;
}
.icon-confirm {
border: 2px solid var(--color-border-maxcontrast) !important;
border-left: none !important;
}
&:focus-within, &:focus, &:focus-visible,
&:hover {
.icon-confirm {
border-color: var(--color-main-text) !important;
}
}
}
}
#app-navigation-toggle-custom {
position: static;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
cursor: pointer;
opacity: 1;
display: inline-block !important;
}
.board-actions {
flex-grow: 1;
order: 100;
display: flex;
justify-content: flex-end;
}
.board-action-buttons {
display: flex;
}
.deck-search {
display: flex;
align-items: center;
justify-content: center;
input[type=search] {
background-position: 5px;
padding-left: 24px !important;
}
}
.filter--item {
input + label {
display: block;
padding: var(--default-grid-baseline) 0;
.avatardiv {
vertical-align: middle;
margin-bottom: 2px;
margin-right: 3px;
}
.label {
padding: 5px;
border-radius: 3px;
}
}
}
.filter {
width: 240px;
max-height: calc(100vh - 150px);
position: relative;
overflow: auto;
padding: 8px;
}
.filter h3 {
margin-top: 0px;
margin-bottom: 5px;
}
.filter-button {
padding: 0;
border-radius: 50%;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
&[data-popper-shown] {
background-color: var(--color-background-hover);
&.button-vue--vue-primary {
background-color: var(--color-primary-element);
}
}
}
</style>
<style lang="scss">
.popover:focus {
outline: 2px solid var(--color-main-text);
}
</style>