Merge branch 'main' into feature/date-math
This commit is contained in:
@ -69,10 +69,10 @@ watchEffect(() => {
|
||||
}
|
||||
|
||||
// if there is a href we assume the user wants an external link via a link element
|
||||
// we also set the attribute rel to "noopener" but make it possible to overwrite this by the user.
|
||||
// we also set a predefined value for the attribute rel, but make it possible to overwrite this by the user.
|
||||
if ('href' in attrs) {
|
||||
nodeName = 'a'
|
||||
bindings = {rel: 'noopener'}
|
||||
bindings = {rel: 'noreferrer noopener nofollow'}
|
||||
}
|
||||
|
||||
componentNodeName.value = nodeName
|
||||
|
@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useNow } from '@vueuse/core'
|
||||
|
||||
import LogoFull from '@/assets/logo-full.svg?component'
|
||||
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
|
||||
|
||||
const Logo = computed(() => new Date().getMonth() === 5 ? LogoFullPride : LogoFull)
|
||||
const now = useNow()
|
||||
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
@click="$store.commit('toggleMenu')"
|
||||
<BaseButton
|
||||
class="menu-show-button"
|
||||
@click="$store.commit('toggleMenu')"
|
||||
@shortkey="() => $store.commit('toggleMenu')"
|
||||
v-shortcut="'Control+e'"
|
||||
:title="$t('keyboardShortcuts.toggleMenu')"
|
||||
@ -10,11 +9,14 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
import {store} from '@/store'
|
||||
import {useStore} from 'vuex'
|
||||
|
||||
const menuActive = computed(() => store.menuActive)
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const store = useStore()
|
||||
const menuActive = computed(() => store.state.menuActive)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@ -22,11 +24,6 @@ $lineWidth: 2rem;
|
||||
$size: $lineWidth + 1rem;
|
||||
|
||||
.menu-show-button {
|
||||
// FIXME: create general button component
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
|
||||
min-height: $size;
|
||||
width: $size;
|
||||
|
||||
|
@ -32,12 +32,13 @@
|
||||
</a>
|
||||
<notifications/>
|
||||
<div class="user">
|
||||
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
|
||||
<dropdown class="is-right" ref="usernameDropdown">
|
||||
<template #trigger>
|
||||
<x-button
|
||||
variant="secondary"
|
||||
:shadow="false">
|
||||
:shadow="false"
|
||||
>
|
||||
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
|
||||
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
|
||||
<span class="icon is-small">
|
||||
<icon icon="chevron-down"/>
|
||||
@ -45,92 +46,96 @@
|
||||
</x-button>
|
||||
</template>
|
||||
|
||||
<router-link :to="{name: 'user.settings'}" class="dropdown-item">
|
||||
<BaseButton
|
||||
:to="{name: 'user.settings'}"
|
||||
class="dropdown-item"
|
||||
>
|
||||
{{ $t('user.settings.title') }}
|
||||
</router-link>
|
||||
<a
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="imprintUrl"
|
||||
:href="imprintUrl"
|
||||
class="dropdown-item"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
v-if="imprintUrl">
|
||||
>
|
||||
{{ $t('navigation.imprint') }}
|
||||
</a>
|
||||
<a
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="privacyPolicyUrl"
|
||||
:href="privacyPolicyUrl"
|
||||
class="dropdown-item"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
v-if="privacyPolicyUrl">
|
||||
>
|
||||
{{ $t('navigation.privacy') }}
|
||||
</a>
|
||||
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
@click="$store.commit('keyboardShortcutsActive', true)"
|
||||
class="dropdown-item"
|
||||
>
|
||||
{{ $t('keyboardShortcuts.title') }}
|
||||
</a>
|
||||
<router-link :to="{name: 'about'}" class="dropdown-item">
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:to="{name: 'about'}"
|
||||
class="dropdown-item"
|
||||
>
|
||||
{{ $t('about.title') }}
|
||||
</router-link>
|
||||
<a @click="logout()" class="dropdown-item">
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
@click="logout()"
|
||||
class="dropdown-item"
|
||||
>
|
||||
{{ $t('user.auth.logout') }}
|
||||
</a>
|
||||
</BaseButton>
|
||||
</dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
import {CURRENT_LIST, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
|
||||
<script setup langs="ts">
|
||||
import {ref, computed, onMounted, nextTick} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
import {useRouter} from 'vue-router'
|
||||
|
||||
import {QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
|
||||
import Rights from '@/models/constants/rights.json'
|
||||
|
||||
import Update from '@/components/home/update.vue'
|
||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import Notifications from '@/components/notifications/notifications.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
|
||||
export default {
|
||||
name: 'topNavigation',
|
||||
components: {
|
||||
Notifications,
|
||||
Dropdown,
|
||||
ListSettingsDropdown,
|
||||
Update,
|
||||
Logo,
|
||||
MenuButton,
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
userInfo: state => state.auth.info,
|
||||
userAvatar: state => state.auth.avatarUrl,
|
||||
userAuthenticated: state => state.auth.authenticated,
|
||||
currentList: CURRENT_LIST,
|
||||
background: 'background',
|
||||
imprintUrl: state => state.config.legal.imprintUrl,
|
||||
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
|
||||
canWriteCurrentList: state => state.currentList.maxRight > Rights.READ,
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
if (typeof this.$refs.usernameDropdown === 'undefined' || typeof this.$refs.listTitle === 'undefined') {
|
||||
return
|
||||
}
|
||||
const store = useStore()
|
||||
|
||||
const usernameWidth = this.$refs.usernameDropdown.$el.clientWidth
|
||||
this.$refs.listTitle.style.setProperty('--nav-username-width', `${usernameWidth}px`)
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
logout() {
|
||||
this.$store.dispatch('auth/logout')
|
||||
this.$router.push({name: 'user.login'})
|
||||
},
|
||||
openQuickActions() {
|
||||
this.$store.commit(QUICK_ACTIONS_ACTIVE, true)
|
||||
},
|
||||
},
|
||||
const userInfo = computed(() => store.state.auth.info)
|
||||
const userAvatar = computed(() => store.state.auth.avatarUrl)
|
||||
const currentList = computed(() => store.state.currentList)
|
||||
const background = computed(() => store.state.background)
|
||||
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
|
||||
const canWriteCurrentList = computed(() => store.state.currentList.maxRight > Rights.READ)
|
||||
|
||||
const usernameDropdown = ref()
|
||||
const listTitle = ref()
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (typeof usernameDropdown.value === 'undefined' || typeof listTitle.value === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const usernameWidth = usernameDropdown.value.$el.clientWidth
|
||||
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
function logout() {
|
||||
store.dispatch('auth/logout')
|
||||
router.push({name: 'user.login'})
|
||||
}
|
||||
|
||||
function openQuickActions() {
|
||||
store.commit(QUICK_ACTIONS_ACTIVE, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -246,6 +251,7 @@ $hamburger-menu-icon-width: 28px;
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
height: 40px;
|
||||
margin-right: var(--button-padding-horizontal);
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger .button) {
|
@ -66,7 +66,7 @@ const showIconOnly = computed(() => props.icon !== '' && typeof slots.default ==
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
height: $button-height;
|
||||
min-height: $button-height;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: inline-flex;
|
||||
|
||||
|
@ -39,79 +39,66 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Message from '@/components/misc/message'
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {parseURL} from 'ufo'
|
||||
|
||||
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
|
||||
import {success} from '@/message'
|
||||
|
||||
export default {
|
||||
name: 'apiConfig',
|
||||
components: {
|
||||
Message,
|
||||
import Message from '@/components/misc/message.vue'
|
||||
|
||||
const props = defineProps({
|
||||
configureOpen: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
configureApi: false,
|
||||
apiUrl: window.API_URL,
|
||||
errorMsg: '',
|
||||
successMsg: '',
|
||||
})
|
||||
const emit = defineEmits(['foundApi'])
|
||||
|
||||
const apiUrl = ref(window.API_URL)
|
||||
const configureApi = ref(apiUrl.value === '')
|
||||
|
||||
const apiDomain = computed(() => parseURL(apiUrl.value).host || parseURL(window.location.href).host)
|
||||
|
||||
|
||||
watch(() => props.configureOpen, (value) => {
|
||||
configureApi.value = value
|
||||
}, { immediate: true })
|
||||
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
async function setApiUrl() {
|
||||
if (apiUrl.value === '') {
|
||||
// Don't try to check and set an empty url
|
||||
errorMsg.value = t('apiConfig.urlRequired')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await checkAndSetApiUrl(apiUrl.value)
|
||||
|
||||
if (url === '') {
|
||||
// If the config setter function could not figure out a url
|
||||
throw new Error('URL cannot be empty.')
|
||||
}
|
||||
},
|
||||
emits: ['foundApi'],
|
||||
created() {
|
||||
if (this.apiUrl === '') {
|
||||
this.configureApi = true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
apiDomain() {
|
||||
return parseURL(this.apiUrl).host || parseURL(window.location.href).host
|
||||
},
|
||||
},
|
||||
props: {
|
||||
configureOpen: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
configureOpen: {
|
||||
handler(value) {
|
||||
this.configureApi = value
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async setApiUrl() {
|
||||
if (this.apiUrl === '') {
|
||||
// Don't try to check and set an empty url
|
||||
this.errorMsg = this.$t('apiConfig.urlRequired')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await checkAndSetApiUrl(this.apiUrl)
|
||||
|
||||
if (url === '') {
|
||||
// If the config setter function could not figure out a url
|
||||
throw new Error('URL cannot be empty.')
|
||||
}
|
||||
|
||||
// Set it + save it to local storage to save us the hoops
|
||||
this.errorMsg = ''
|
||||
this.$message.success({message: this.$t('apiConfig.success', {domain: this.apiDomain})})
|
||||
this.configureApi = false
|
||||
this.apiUrl = url
|
||||
this.$emit('foundApi', this.apiUrl)
|
||||
} catch (e) {
|
||||
// Still not found, url is still invalid
|
||||
this.successMsg = ''
|
||||
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
|
||||
}
|
||||
},
|
||||
},
|
||||
// Set it + save it to local storage to save us the hoops
|
||||
errorMsg.value = ''
|
||||
apiUrl.value = url
|
||||
success({message: t('apiConfig.success', {domain: apiDomain.value})})
|
||||
configureApi.value = false
|
||||
emit('foundApi', apiUrl.value)
|
||||
} catch (e) {
|
||||
// Still not found, url is still invalid
|
||||
successMsg.value = ''
|
||||
errorMsg.value = t('apiConfig.error', {domain: apiDomain.value})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -23,7 +23,7 @@
|
||||
}"
|
||||
>
|
||||
<BaseButton
|
||||
@click="emit('close')"
|
||||
@click="$emit('close')"
|
||||
class="close"
|
||||
>
|
||||
<icon icon="times"/>
|
||||
|
47
src/components/tasks/partials/createdUpdated.vue
Normal file
47
src/components/tasks/partials/createdUpdated.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<p class="created">
|
||||
<time :datetime="formatISO(task.created)" v-tooltip="formatDate(task.created)">
|
||||
<i18n-t keypath="task.detail.created">
|
||||
<span>{{ formatDateSince(task.created) }}</span>
|
||||
{{ task.createdBy.getDisplayName() }}
|
||||
</i18n-t>
|
||||
</time>
|
||||
<template v-if="+new Date(task.created) !== +new Date(task.updated)">
|
||||
<br/>
|
||||
<!-- Computed properties to show the actual date every time it gets updated -->
|
||||
<time :datetime="formatISO(task.updated)" v-tooltip="updatedFormatted">
|
||||
<i18n-t keypath="task.detail.updated">
|
||||
<span>{{ updatedSince }}</span>
|
||||
</i18n-t>
|
||||
</time>
|
||||
</template>
|
||||
<template v-if="task.done">
|
||||
<br/>
|
||||
<time :datetime="formatISO(task.doneAt)" v-tooltip="doneFormatted">
|
||||
<i18n-t keypath="task.detail.doneAt">
|
||||
<span>{{ doneSince }}</span>
|
||||
</i18n-t>
|
||||
</time>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, toRefs} from 'vue'
|
||||
import TaskModel from '@/models/task'
|
||||
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
|
||||
|
||||
const props = defineProps({
|
||||
task: {
|
||||
type: TaskModel,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const {task} = toRefs(props)
|
||||
|
||||
const updatedSince = computed(() => formatDateSince(task.value.updated))
|
||||
const updatedFormatted = computed(() => formatDateLong(task.value.updated))
|
||||
const doneSince = computed(() => formatDateSince(task.value.doneAt))
|
||||
const doneFormatted = computed(() => formatDateLong(task.value.doneAt))
|
||||
</script>
|
@ -138,7 +138,6 @@ $task-background: var(--white);
|
||||
border: 3px solid transparent;
|
||||
|
||||
font-size: .9rem;
|
||||
margin: .5rem;
|
||||
padding: .4rem;
|
||||
border-radius: $radius;
|
||||
background: $task-background;
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="task-relations">
|
||||
<x-button
|
||||
v-if="Object.keys(relatedTasks).length > 0"
|
||||
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
|
||||
@click="showNewRelationForm = !showNewRelationForm"
|
||||
class="is-pulled-right add-task-relation-button"
|
||||
:class="{'is-active': showNewRelationForm}"
|
||||
|
Reference in New Issue
Block a user