mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 13:29:17 +00:00
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.49.0",
|
||||
"version": "1.49.1",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AnalyticsDataset, AnalyticsSeriesPayload, LocalizedText } from 'ta
|
||||
import type { AmountPerTagMonthSectionRow } from '../row.types'
|
||||
import { UNTAGGED_TAG_ID } from '../../types'
|
||||
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged' }
|
||||
const UNTAGGED_LABEL: LocalizedText = { ru: 'Без тегов', en: 'Untagged', de: 'Ohne Tags', es: 'Sin etiquetas' }
|
||||
|
||||
export type BuildTagAmountPayloadArgs = {
|
||||
rows: AmountPerTagMonthSectionRow[]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-monorepo",
|
||||
"version": "1.49.0",
|
||||
"version": "1.49.1",
|
||||
"private": true,
|
||||
"description": "TaskView CE monorepo containing web, API, and packages",
|
||||
"workspaces": [
|
||||
|
||||
@@ -23,6 +23,8 @@ export type AnalyticsRange = {
|
||||
export type LocalizedText = {
|
||||
ru: string
|
||||
en: string
|
||||
de?: string
|
||||
es?: string
|
||||
}
|
||||
|
||||
export type AnalyticsUnit =
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "web-nuxt-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "1.49.0",
|
||||
"version": "1.49.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build",
|
||||
|
||||
@@ -38,85 +38,33 @@
|
||||
/>
|
||||
</template>
|
||||
</UButton>
|
||||
|
||||
<template #chip-leading="{ item }">
|
||||
<div class="inline-flex items-center justify-center shrink-0 size-5">
|
||||
<span
|
||||
class="rounded-full ring ring-bg bg-(--chip-light) dark:bg-(--chip-dark) size-2"
|
||||
:style="{
|
||||
'--chip-light': `var(--color-${(item as any).chip}-500)`,
|
||||
'--chip-dark': `var(--color-${(item as any).chip}-400)`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app.store'
|
||||
import { useLogout } from '@/composables/useLogout'
|
||||
import { saveLocale } from '@/plugins/i18n'
|
||||
import { useUpdater } from '@/composables/useUpdater'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
|
||||
import { $ls } from '@/plugins/axios'
|
||||
import avatarImg from '@/assets/images/avatar-1.jpeg'
|
||||
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
}>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const userStore = useUserStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
const { switchOrg } = useOrgSwitcher()
|
||||
|
||||
const prodOrDev = ref<'prod' | 'dev'>('prod')
|
||||
let counter = 0
|
||||
let timeout: number
|
||||
let timeoutChangeProdOrDev: number
|
||||
|
||||
onMounted(async () => {
|
||||
prodOrDev.value = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
counter++
|
||||
|
||||
if (timeout) clearTimeout(timeout)
|
||||
if (timeoutChangeProdOrDev) clearTimeout(timeoutChangeProdOrDev)
|
||||
|
||||
timeout = window.setTimeout(() => {
|
||||
counter = 0
|
||||
}, 500)
|
||||
|
||||
if (counter === 7) {
|
||||
timeoutChangeProdOrDev = window.setTimeout(async () => {
|
||||
if ((await $ls.getValue('update_loading')) !== 'dev') {
|
||||
await $ls.setValue('update_loading', 'dev')
|
||||
prodOrDev.value = 'dev'
|
||||
} else {
|
||||
await $ls.setValue('update_loading', 'prod')
|
||||
prodOrDev.value = 'prod'
|
||||
}
|
||||
console.log(await $ls.getValue('update_loading'))
|
||||
await useUpdater(true)
|
||||
clearTimeout(timeoutChangeProdOrDev)
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
const success = await useLogout()
|
||||
if (success) {
|
||||
@@ -151,7 +99,6 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
{
|
||||
label: orgStore.currentOrg?.name || t('userMenu.switchOrganization'),
|
||||
icon: 'i-lucide-building-2',
|
||||
|
||||
children: orgStore.organizations.map(org => ({
|
||||
label: org.name,
|
||||
icon: org.id === orgStore.currentOrg?.id ? 'i-lucide-check' : undefined,
|
||||
@@ -164,42 +111,12 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
] : [],
|
||||
[
|
||||
{
|
||||
label: t('userMenu.accountSettings'),
|
||||
label: t('settings.title'),
|
||||
icon: 'i-lucide-settings',
|
||||
onSelect() {
|
||||
router.push({ name: 'account' })
|
||||
router.push({ name: 'settings' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.uiCustomization'),
|
||||
icon: 'i-lucide-sliders-horizontal',
|
||||
onSelect() {
|
||||
router.push({ name: 'ui-customization' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.organizations'),
|
||||
icon: 'i-lucide-building-2',
|
||||
onSelect() {
|
||||
router.push({ name: 'organizations' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.analytics'),
|
||||
icon: 'i-lucide-bar-chart-3',
|
||||
onSelect() {
|
||||
router.push({ name: 'analytics' })
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.timeReports'),
|
||||
icon: 'i-lucide-clock-4',
|
||||
onSelect() {
|
||||
router.push({ name: 'time-reports' })
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: t('userMenu.appearance'),
|
||||
icon: 'i-lucide-sun-moon',
|
||||
@@ -219,147 +136,13 @@ const items = computed<DropdownMenuItem[][]>(() => [
|
||||
icon: 'i-lucide-moon',
|
||||
type: 'checkbox',
|
||||
checked: colorMode.value === 'dark',
|
||||
onUpdateChecked(checked: boolean) {
|
||||
if (checked) {
|
||||
colorMode.value = 'dark'
|
||||
}
|
||||
},
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
colorMode.value = 'dark'
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('userMenu.language'),
|
||||
icon: 'i-lucide-languages',
|
||||
children: [
|
||||
{
|
||||
label: 'English',
|
||||
type: 'checkbox',
|
||||
checked: locale.value === 'en',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
saveLocale('en')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Русский',
|
||||
type: 'checkbox',
|
||||
checked: locale.value === 'ru',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
saveLocale('ru')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Deutsch',
|
||||
type: 'checkbox',
|
||||
checked: locale.value === 'de',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
saveLocale('de')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Español',
|
||||
type: 'checkbox',
|
||||
checked: locale.value === 'es',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
saveLocale('es')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('userMenu.taskDetailView'),
|
||||
icon: 'i-lucide-panel-right',
|
||||
children: [
|
||||
{
|
||||
label: t('userMenu.taskDetailSlideover'),
|
||||
icon: 'i-lucide-panel-right',
|
||||
type: 'checkbox',
|
||||
checked: appStore.taskDetailDisplayMode === 'slideover',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setTaskDetailDisplayMode('slideover')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.taskDetailModal'),
|
||||
icon: 'i-lucide-square',
|
||||
type: 'checkbox',
|
||||
checked: appStore.taskDetailDisplayMode === 'modal',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setTaskDetailDisplayMode('modal')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('userMenu.sidebarView'),
|
||||
icon: 'i-lucide-panel-left',
|
||||
children: [
|
||||
{
|
||||
label: t('userMenu.sidebarViewSecond'),
|
||||
icon: 'i-lucide-layout-list',
|
||||
type: 'checkbox',
|
||||
checked: appStore.sidebarView === 'second',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setSidebarView('second')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('userMenu.sidebarViewFirst'),
|
||||
icon: 'i-lucide-panel-left',
|
||||
type: 'checkbox',
|
||||
checked: appStore.sidebarView === 'first',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
appStore.setSidebarView('first')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: t('userMenu.site'),
|
||||
icon: 'i-lucide-globe',
|
||||
to: 'https://taskview.tech/',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
label: t('userMenu.documentation'),
|
||||
icon: 'i-lucide-book-open',
|
||||
to: 'https://taskview.tech/docs/',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
label: t('userMenu.github'),
|
||||
icon: 'simple-icons:github',
|
||||
to: 'https://github.com/Gimanh/taskview-community',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
label: t('userMenu.docker'),
|
||||
icon: 'simple-icons:docker',
|
||||
to: 'https://hub.docker.com/u/gimanhead',
|
||||
target: '_blank',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: `v ${APP_VERSION}${prodOrDev.value === 'dev' ? '_d' : ''}`,
|
||||
icon: 'i-lucide-info',
|
||||
onSelect(e: Event) {
|
||||
e.preventDefault()
|
||||
checkForUpdates()
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { isSidebarOpen } = useDashboard()
|
||||
const { isUserRoute, isAccountRoute, hasProject, projectId } = useAppRouteInfo()
|
||||
const { isUserRoute, isSettingsRoute, hasProject, projectId } = useAppRouteInfo()
|
||||
const goalsStore = useGoalsStore()
|
||||
|
||||
const currentGoal = computed(() =>
|
||||
@@ -105,11 +105,11 @@ const navItems = computed<NavItem[]>(() => {
|
||||
// so drop Settings there — it stays reachable from the global tabs.
|
||||
if (!hasProject.value) {
|
||||
items.push({
|
||||
key: 'account',
|
||||
key: 'settings',
|
||||
label: t('account.nav'),
|
||||
icon: 'i-lucide-settings',
|
||||
to: { name: 'account' },
|
||||
active: () => isAccountRoute.value,
|
||||
to: { name: 'settings' },
|
||||
active: () => isSettingsRoute.value,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="mx-auto flex w-full max-w-2xl flex-col gap-6 p-3 pb-24 lg:p-6 lg:pb-6">
|
||||
<SettingsProfileHeader
|
||||
:name="identity.name"
|
||||
:email="identity.email"
|
||||
/>
|
||||
|
||||
<SettingsSection
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
:title="section.title"
|
||||
>
|
||||
<template
|
||||
v-for="item in section.items"
|
||||
:key="item.key"
|
||||
>
|
||||
<SettingsNavRow
|
||||
v-if="item.kind === 'nav'"
|
||||
:item="item"
|
||||
/>
|
||||
<SettingsSelectRow
|
||||
v-else
|
||||
:item="item"
|
||||
/>
|
||||
</template>
|
||||
<SettingsVersionRow v-if="section.key === 'about'" />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsLogoutRow />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsHub } from './composables/useSettingsHub'
|
||||
import SettingsProfileHeader from './parts/SettingsProfileHeader.vue'
|
||||
import SettingsSection from './parts/SettingsSection.vue'
|
||||
import SettingsNavRow from './parts/SettingsNavRow.vue'
|
||||
import SettingsSelectRow from './parts/SettingsSelectRow.vue'
|
||||
import SettingsVersionRow from './parts/SettingsVersionRow.vue'
|
||||
import SettingsLogoutRow from './parts/SettingsLogoutRow.vue'
|
||||
|
||||
const { identity, sections } = useSettingsHub()
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
import type { Locale } from '@/locales'
|
||||
import type { SidebarView, TaskDetailDisplayMode } from '@/types/global-app.types'
|
||||
import { useAppStore } from '@/stores/app.store'
|
||||
import { useUserStore } from '@/stores/user.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useOrgSwitcher } from '@/composables/useOrgSwitcher'
|
||||
import { saveLocale } from '@/plugins/i18n'
|
||||
import type { SettingsSection } from '../types'
|
||||
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'Русский', value: 'ru' },
|
||||
{ label: 'Deutsch', value: 'de' },
|
||||
{ label: 'Español', value: 'es' },
|
||||
]
|
||||
|
||||
export function useSettingsHub() {
|
||||
const { t, locale } = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
const { switchOrg } = useOrgSwitcher()
|
||||
|
||||
const identity = computed(() => ({
|
||||
name: userStore.login || userStore.email,
|
||||
email: userStore.email,
|
||||
}))
|
||||
|
||||
const sections = computed<SettingsSection[]>(() => {
|
||||
const account: SettingsSection = {
|
||||
key: 'account',
|
||||
title: t('settings.account'),
|
||||
items: [
|
||||
{ kind: 'nav', key: 'account', icon: 'i-lucide-user-cog', color: 'emerald', title: t('userMenu.accountSettings'), to: { name: 'account' } },
|
||||
{ kind: 'nav', key: 'organizations', icon: 'i-lucide-building-2', color: 'violet', title: t('userMenu.organizations'), to: { name: 'organizations' } },
|
||||
],
|
||||
}
|
||||
|
||||
if (orgStore.organizations.length > 1) {
|
||||
account.items.unshift({
|
||||
kind: 'select',
|
||||
key: 'org-switch',
|
||||
icon: 'i-lucide-arrow-left-right',
|
||||
color: 'indigo',
|
||||
title: t('userMenu.switchOrganization'),
|
||||
options: orgStore.organizations.map((o) => ({ label: o.name, value: String(o.id) })),
|
||||
get: () => String(orgStore.currentOrg?.id ?? ''),
|
||||
set: (value) => {
|
||||
const org = orgStore.organizations.find((o) => String(o.id) === value)
|
||||
if (org) switchOrg(org)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const appearance: SettingsSection = {
|
||||
key: 'appearance',
|
||||
title: t('settings.appearance'),
|
||||
items: [
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'theme',
|
||||
icon: 'i-lucide-sun-moon',
|
||||
color: 'amber',
|
||||
title: t('userMenu.appearance'),
|
||||
options: [
|
||||
{ label: t('userMenu.light'), value: 'light' },
|
||||
{ label: t('userMenu.dark'), value: 'dark' },
|
||||
{ label: t('settings.system'), value: 'auto' },
|
||||
],
|
||||
get: () => colorMode.value,
|
||||
set: (value) => { colorMode.value = value as typeof colorMode.value },
|
||||
},
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'language',
|
||||
icon: 'i-lucide-languages',
|
||||
color: 'sky',
|
||||
title: t('userMenu.language'),
|
||||
options: LANGUAGE_OPTIONS,
|
||||
get: () => locale.value,
|
||||
set: (value) => saveLocale(value as Locale),
|
||||
},
|
||||
{ kind: 'nav', key: 'ui-customization', icon: 'i-lucide-sliders-horizontal', color: 'rose', title: t('userMenu.uiCustomization'), to: { name: 'ui-customization' } },
|
||||
],
|
||||
}
|
||||
|
||||
const layout: SettingsSection = {
|
||||
key: 'layout',
|
||||
title: t('settings.layout'),
|
||||
items: [
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'task-detail',
|
||||
icon: 'i-lucide-panel-right',
|
||||
color: 'indigo',
|
||||
title: t('userMenu.taskDetailView'),
|
||||
options: [
|
||||
{ label: t('userMenu.taskDetailSlideover'), value: 'slideover' },
|
||||
{ label: t('userMenu.taskDetailModal'), value: 'modal' },
|
||||
],
|
||||
get: () => appStore.taskDetailDisplayMode,
|
||||
set: (value) => appStore.setTaskDetailDisplayMode(value as TaskDetailDisplayMode),
|
||||
},
|
||||
{
|
||||
kind: 'select',
|
||||
key: 'sidebar-view',
|
||||
icon: 'i-lucide-panel-left',
|
||||
color: 'teal',
|
||||
title: t('userMenu.sidebarView'),
|
||||
options: [
|
||||
{ label: t('userMenu.sidebarViewSecond'), value: 'second' },
|
||||
{ label: t('userMenu.sidebarViewFirst'), value: 'first' },
|
||||
],
|
||||
get: () => appStore.sidebarView,
|
||||
set: (value) => appStore.setSidebarView(value as SidebarView),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const about: SettingsSection = {
|
||||
key: 'about',
|
||||
title: t('settings.about'),
|
||||
items: [
|
||||
{ kind: 'nav', key: 'site', icon: 'i-lucide-globe', color: 'zinc', title: t('userMenu.site'), href: 'https://taskview.tech/' },
|
||||
{ kind: 'nav', key: 'docs', icon: 'i-lucide-book-open', color: 'zinc', title: t('userMenu.documentation'), href: 'https://taskview.tech/docs/' },
|
||||
{ kind: 'nav', key: 'github', icon: 'simple-icons:github', color: 'zinc', title: t('userMenu.github'), href: 'https://github.com/Gimanh/taskview-community' },
|
||||
{ kind: 'nav', key: 'docker', icon: 'simple-icons:docker', color: 'zinc', title: t('userMenu.docker'), href: 'https://hub.docker.com/u/gimanhead' },
|
||||
],
|
||||
}
|
||||
|
||||
return [account, appearance, layout, about]
|
||||
})
|
||||
|
||||
return { identity, sections }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<span
|
||||
class="flex items-center justify-center shrink-0 size-9 rounded-xl"
|
||||
:class="tint"
|
||||
>
|
||||
<UIcon
|
||||
:name="icon"
|
||||
class="size-5"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SettingsTint } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string
|
||||
color: SettingsTint
|
||||
}>()
|
||||
|
||||
const TILE_TINTS: Record<SettingsTint, string> = {
|
||||
emerald: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400',
|
||||
violet: 'bg-violet-100 text-violet-600 dark:bg-violet-950 dark:text-violet-400',
|
||||
indigo: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-950 dark:text-indigo-400',
|
||||
blue: 'bg-blue-100 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
|
||||
cyan: 'bg-cyan-100 text-cyan-600 dark:bg-cyan-950 dark:text-cyan-400',
|
||||
amber: 'bg-amber-100 text-amber-600 dark:bg-amber-950 dark:text-amber-400',
|
||||
sky: 'bg-sky-100 text-sky-600 dark:bg-sky-950 dark:text-sky-400',
|
||||
rose: 'bg-rose-100 text-rose-600 dark:bg-rose-950 dark:text-rose-400',
|
||||
teal: 'bg-teal-100 text-teal-600 dark:bg-teal-950 dark:text-teal-400',
|
||||
zinc: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400',
|
||||
}
|
||||
|
||||
const tint = computed(() => TILE_TINTS[props.color])
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-2xl bg-elevated/40 border border-default px-3 py-2.5 text-left transition-colors hover:bg-error/10 focus-visible:bg-error/10 focus:outline-none"
|
||||
@click="onLogout"
|
||||
>
|
||||
<SettingsIconTile
|
||||
icon="i-lucide-log-out"
|
||||
color="rose"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-error">
|
||||
{{ t('userMenu.logout') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLogout } from '@/composables/useLogout'
|
||||
import SettingsIconTile from './SettingsIconTile.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
async function onLogout() {
|
||||
const success = await useLogout()
|
||||
if (success) {
|
||||
router.push('/')
|
||||
} else {
|
||||
toast.add({
|
||||
title: t('userMenu.logoutFailed'),
|
||||
description: t('userMenu.logoutFailedDescription'),
|
||||
color: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<ULink
|
||||
:to="item.to"
|
||||
:href="item.href"
|
||||
:target="isExternal ? '_blank' : undefined"
|
||||
:rel="isExternal ? 'noopener noreferrer' : undefined"
|
||||
class="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-elevated focus-visible:bg-elevated focus:outline-none"
|
||||
>
|
||||
<SettingsIconTile
|
||||
:icon="item.icon"
|
||||
:color="item.color"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<UIcon
|
||||
:name="isExternal ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'"
|
||||
class="size-4 shrink-0 text-dimmed"
|
||||
/>
|
||||
</ULink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SettingsNavRowItem } from '../types'
|
||||
import SettingsIconTile from './SettingsIconTile.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
item: SettingsNavRowItem
|
||||
}>()
|
||||
|
||||
const isExternal = computed(() => !!props.item.href)
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4 rounded-2xl bg-elevated/40 border border-default p-4">
|
||||
<UAvatar
|
||||
:alt="name"
|
||||
icon="i-lucide-user"
|
||||
size="xl"
|
||||
class="ring-2 ring-primary/40"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate text-base font-semibold text-highlighted">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="email && email !== name"
|
||||
class="truncate text-sm text-muted"
|
||||
>
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: string
|
||||
email: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2
|
||||
v-if="title"
|
||||
class="px-3 text-xs font-semibold uppercase tracking-wider text-muted"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<div class="overflow-hidden rounded-2xl bg-elevated/40 border border-default divide-y divide-default">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<SettingsIconTile
|
||||
:icon="item.icon"
|
||||
:color="item.color"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<USelect
|
||||
:model-value="item.get()"
|
||||
:items="item.options"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
class="w-40 max-w-[45%]"
|
||||
:ui="{ base: 'rounded-lg' }"
|
||||
@update:model-value="item.set($event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SettingsSelectRowItem } from '../types'
|
||||
import SettingsIconTile from './SettingsIconTile.vue'
|
||||
|
||||
defineProps<{
|
||||
item: SettingsSelectRowItem
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-elevated focus-visible:bg-elevated focus:outline-none"
|
||||
@click="onTap"
|
||||
>
|
||||
<SettingsIconTile
|
||||
icon="i-lucide-info"
|
||||
color="zinc"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
|
||||
{{ t('settings.version') }}
|
||||
</span>
|
||||
<span class="shrink-0 text-sm text-muted tabular-nums">
|
||||
v{{ appVersion }}{{ prodOrDev === 'dev' ? '_d' : '' }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUpdater } from '@/composables/useUpdater'
|
||||
import { $ls } from '@/plugins/axios'
|
||||
import SettingsIconTile from './SettingsIconTile.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const appVersion = APP_VERSION
|
||||
const prodOrDev = ref<'prod' | 'dev'>('prod')
|
||||
let counter = 0
|
||||
let resetTimer: number
|
||||
let toggleTimer: number
|
||||
|
||||
onMounted(async () => {
|
||||
prodOrDev.value = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
|
||||
})
|
||||
|
||||
async function onTap() {
|
||||
counter++
|
||||
|
||||
if (resetTimer) clearTimeout(resetTimer)
|
||||
if (toggleTimer) clearTimeout(toggleTimer)
|
||||
|
||||
resetTimer = window.setTimeout(() => {
|
||||
counter = 0
|
||||
}, 500)
|
||||
|
||||
if (counter === 7) {
|
||||
toggleTimer = window.setTimeout(async () => {
|
||||
const next = (await $ls.getValue('update_loading')) === 'dev' ? 'prod' : 'dev'
|
||||
await $ls.setValue('update_loading', next)
|
||||
prodOrDev.value = next
|
||||
await useUpdater(true)
|
||||
clearTimeout(toggleTimer)
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export type SettingsTint =
|
||||
| 'emerald'
|
||||
| 'violet'
|
||||
| 'indigo'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'amber'
|
||||
| 'sky'
|
||||
| 'rose'
|
||||
| 'teal'
|
||||
| 'zinc'
|
||||
|
||||
type SettingsRowBase = {
|
||||
key: string
|
||||
icon: string
|
||||
color: SettingsTint
|
||||
title: string
|
||||
}
|
||||
|
||||
export type SettingsNavRowItem = SettingsRowBase & {
|
||||
kind: 'nav'
|
||||
to?: RouteLocationRaw
|
||||
href?: string
|
||||
}
|
||||
|
||||
export type SettingsSelectOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SettingsSelectRowItem = SettingsRowBase & {
|
||||
kind: 'select'
|
||||
options: SettingsSelectOption[]
|
||||
get: () => string
|
||||
set: (value: string) => void
|
||||
}
|
||||
|
||||
export type SettingsRowItem = SettingsNavRowItem | SettingsSelectRowItem
|
||||
|
||||
export type SettingsSection = {
|
||||
key: string
|
||||
title: string
|
||||
items: SettingsRowItem[]
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
</div>
|
||||
|
||||
<SidebarInboxLink />
|
||||
<SidebarWorkspaceLinks />
|
||||
|
||||
<USeparator />
|
||||
|
||||
@@ -37,6 +38,7 @@ import { useDashboard } from '@/composables/useDashboard'
|
||||
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
|
||||
import ProjectsSidebar from '@/components/features/projects/ProjectsSidebar.vue'
|
||||
import SidebarInboxLink from '@/components/sidebars/SidebarInboxLink.vue'
|
||||
import SidebarWorkspaceLinks from '@/components/sidebars/SidebarWorkspaceLinks.vue'
|
||||
import NotificationBell from '@/components/NotificationBell.vue'
|
||||
import ActiveTimerIndicator from '@/components/ActiveTimerIndicator.vue'
|
||||
import UserMenu from '@/components/UserMenu.vue'
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<SidebarWorkspaceLinks />
|
||||
<SidebarOrgSelect />
|
||||
</div>
|
||||
|
||||
@@ -57,6 +58,7 @@ import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
|
||||
import ActiveTimerIndicator from '@/components/ActiveTimerIndicator.vue'
|
||||
import NotificationBell from '@/components/NotificationBell.vue'
|
||||
import SidebarInboxLink from '@/components/sidebars/SidebarInboxLink.vue'
|
||||
import SidebarWorkspaceLinks from '@/components/sidebars/SidebarWorkspaceLinks.vue'
|
||||
import SidebarProjectSelect from './dashboard-second/SidebarProjectSelect.vue'
|
||||
import SidebarTools from './dashboard-second/SidebarTools.vue'
|
||||
import SidebarOrgSelect from './dashboard-second/SidebarOrgSelect.vue'
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<TvGoalLikeItem
|
||||
v-for="link in links"
|
||||
:key="link.name"
|
||||
variant="taskview"
|
||||
:to="{ name: link.name }"
|
||||
:active="route.name === link.name"
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<UIcon
|
||||
:name="link.icon"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<span>{{ link.label }}</span>
|
||||
</div>
|
||||
</TvGoalLikeItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TvGoalLikeItem from '@/components/features/base/TvGoalLikeItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const links = computed(() => [
|
||||
{ name: 'analytics', icon: 'i-lucide-bar-chart-3', label: t('userMenu.analytics') },
|
||||
{ name: 'time-reports', icon: 'i-lucide-clock-4', label: t('userMenu.timeReports') },
|
||||
])
|
||||
</script>
|
||||
@@ -6,6 +6,7 @@ export const useAppRouteInfo = () => {
|
||||
const orgSlug = computed(() => String(route.params.orgSlug || ''))
|
||||
const isUserRoute = computed(() => route.name === 'user')
|
||||
const isAccountRoute = computed(() => route.name === 'account')
|
||||
const isSettingsRoute = computed(() => route.name === 'settings')
|
||||
const projectId = computed(() => Number(route.params.projectId) || -1)
|
||||
const hasProject = computed(() => projectId.value > 0)
|
||||
const listId = computed(() => Number(route.params.listId) || -1)
|
||||
@@ -16,6 +17,7 @@ export const useAppRouteInfo = () => {
|
||||
orgSlug,
|
||||
isUserRoute,
|
||||
isAccountRoute,
|
||||
isSettingsRoute,
|
||||
hasProject,
|
||||
projectId,
|
||||
listId,
|
||||
|
||||
@@ -618,6 +618,15 @@ export default {
|
||||
logoutFailed: 'Abmeldung fehlgeschlagen',
|
||||
logoutFailedDescription: 'Versuchen Sie, den Browser-Cache zu leeren, um das Autorisierungstoken zu entfernen',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
account: 'Konto',
|
||||
appearance: 'Erscheinungsbild',
|
||||
layout: 'Layout',
|
||||
about: 'Über',
|
||||
system: 'System',
|
||||
version: 'Version',
|
||||
},
|
||||
analytics: {
|
||||
page: {
|
||||
title: 'Analytik',
|
||||
|
||||
@@ -632,6 +632,15 @@ export default {
|
||||
logoutFailed: 'Logout failed',
|
||||
logoutFailedDescription: 'Try clearing browser cache to remove the authorization token',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
account: 'Account',
|
||||
appearance: 'Appearance',
|
||||
layout: 'Layout',
|
||||
about: 'About',
|
||||
system: 'System',
|
||||
version: 'Version',
|
||||
},
|
||||
analytics: {
|
||||
page: {
|
||||
title: 'Analytics',
|
||||
|
||||
@@ -618,6 +618,15 @@ export default {
|
||||
logoutFailed: 'Error al cerrar sesión',
|
||||
logoutFailedDescription: 'Intenta borrar la caché del navegador para eliminar el token de autorización',
|
||||
},
|
||||
settings: {
|
||||
title: 'Configuración',
|
||||
account: 'Cuenta',
|
||||
appearance: 'Apariencia',
|
||||
layout: 'Diseño',
|
||||
about: 'Acerca de',
|
||||
system: 'Sistema',
|
||||
version: 'Versión',
|
||||
},
|
||||
analytics: {
|
||||
page: {
|
||||
title: 'Analíticas',
|
||||
|
||||
@@ -605,6 +605,15 @@ export default {
|
||||
logoutFailed: 'Не удалось выйти',
|
||||
logoutFailedDescription: 'Попробуйте очистить кэш браузера, чтобы удалить токен авторизации',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
account: 'Аккаунт',
|
||||
appearance: 'Оформление',
|
||||
layout: 'Макет',
|
||||
about: 'О приложении',
|
||||
system: 'Системная',
|
||||
version: 'Версия',
|
||||
},
|
||||
analytics: {
|
||||
page: {
|
||||
title: 'Аналитика',
|
||||
|
||||
@@ -79,6 +79,11 @@ const router = createRouter({
|
||||
name: 'messaging',
|
||||
component: () => import('./pages/user/messaging.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('./pages/user/settings.vue'),
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
name: 'account',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<UDashboardPanel id="settings">
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="t('settings.title')">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body>
|
||||
<SettingsScreen />
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SettingsScreen from '@/components/features/settings/SettingsScreen.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
Reference in New Issue
Block a user