Merge pull request #79 from Gimanh/fix/settings-ui

chore: version
This commit is contained in:
Nikolai Giman
2026-07-07 23:23:08 +02:00
committed by GitHub
28 changed files with 1085 additions and 333 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+5 -222
View File
@@ -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>
+2
View File
@@ -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,
+9
View File
@@ -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',
+9
View File
@@ -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',
+9
View File
@@ -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',
+9
View File
@@ -605,6 +605,15 @@ export default {
logoutFailed: 'Не удалось выйти',
logoutFailedDescription: 'Попробуйте очистить кэш браузера, чтобы удалить токен авторизации',
},
settings: {
title: 'Настройки',
account: 'Аккаунт',
appearance: 'Оформление',
layout: 'Макет',
about: 'О приложении',
system: 'Системная',
version: 'Версия',
},
analytics: {
page: {
title: 'Аналитика',
+5
View File
@@ -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',
+21
View File
@@ -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>