wip: notifications

This commit is contained in:
Nikolai Giman
2026-03-22 20:01:05 +01:00
parent 5c4859743e
commit c403673f4d
108 changed files with 5662 additions and 582 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "56915832145",
"project_id": "taskview-60696",
"storage_bucket": "taskview-60696.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:56915832145:android:d41d716f9c4d5ae3e77770",
"android_client_info": {
"package_name": "com.handscreamgnl.taskview.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAuoLpIkde2DJGihz-XDPKqGG2uPYUew60"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
@@ -10,6 +10,10 @@
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/AppTheme">
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@mipmap/ic_launcher" />
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
@@ -15,6 +15,7 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
AAA632B22F6BB598007C705D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = AAA632B12F6BB598007C705D /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -28,6 +29,8 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
AAA632B02F69F429007C705D /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
AAA632B12F6BB598007C705D /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -45,6 +48,7 @@
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
AAA632B12F6BB598007C705D /* GoogleService-Info.plist */,
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
@@ -62,6 +66,7 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
AAA632B02F69F429007C705D /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
@@ -145,6 +150,7 @@
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
AAA632B22F6BB598007C705D /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -295,6 +301,7 @@
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.20.1;
DEVELOPMENT_TEAM = H2W2SG48JT;
@@ -318,6 +325,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.20.1;
DEVELOPMENT_TEAM = H2W2SG48JT;
@@ -1,6 +1,15 @@
{
"originHash" : "e5bfee72a3975177edbeb9593e2d4a7624097d5319fbcc2894685c2c76545af8",
"originHash" : "8f5bd6b6b1090df0485cca11f072bcf87ec1695afdf1e51b55370128ec9e9f4b",
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "alamofire",
"kind" : "remoteSourceControl",
@@ -10,6 +19,15 @@
"version" : "5.11.1"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
}
},
{
"identity" : "bigint",
"kind" : "remoteSourceControl",
@@ -28,6 +46,105 @@
"version" : "8.1.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk.git",
"state" : {
"revision" : "52548902007e7beda99fcdca31f62eccc4befe38",
"version" : "12.11.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "7b722132d1f2ab421c88a2e5c384c984cebd0d00",
"version" : "3.4.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "1657f705bc70255ff9d66dfcc697a85db164998f",
"version" : "12.11.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "a883ddb9fd464216133a5ab441f1ae8995978573",
"version" : "5.1.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
}
},
{
"identity" : "version",
"kind" : "remoteSourceControl",
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
+16
View File
@@ -46,4 +46,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
// PUSH NOTIFICATIONS
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationCenter.default.post(
name: .capacitorDidRegisterForRemoteNotifications,
object: deviceToken)
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(
name: .capacitorDidFailToRegisterForRemoteNotifications,
object: error)
}
}
+4
View File
@@ -12,10 +12,12 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.1.0"),
.package(name: "CapacitorFirebaseMessaging", path: "../../../../node_modules/.pnpm/@capacitor-firebase+messaging@8.1.0_@capacitor+core@8.1.0_firebase@12.10.0/node_modules/@capacitor-firebase/messaging"),
.package(name: "CapacitorApp", path: "../../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app"),
.package(name: "CapacitorBrowser", path: "../../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser"),
.package(name: "CapacitorDevice", path: "../../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device"),
.package(name: "CapacitorPreferences", path: "../../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences"),
.package(name: "CapacitorPushNotifications", path: "../../../../node_modules/.pnpm/@capacitor+push-notifications@8.0.2_@capacitor+core@8.1.0/node_modules/@capacitor/push-notifications"),
.package(name: "CapacitorSplashScreen", path: "../../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen"),
.package(name: "CapgoCapacitorUpdater", path: "../../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.8_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater")
],
@@ -25,10 +27,12 @@ let package = Package(
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorFirebaseMessaging", package: "CapacitorFirebaseMessaging"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorBrowser", package: "CapacitorBrowser"),
.product(name: "CapacitorDevice", package: "CapacitorDevice"),
.product(name: "CapacitorPreferences", package: "CapacitorPreferences"),
.product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"),
.product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"),
.product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater")
]
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyBWOhsTMJJYeXdOjT1yMxhzpSY59Ub3i5g</string>
<key>GCM_SENDER_ID</key>
<string>56915832145</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.handscream.taskview.app</string>
<key>PROJECT_ID</key>
<string>taskview-60696</string>
<key>STORAGE_BUCKET</key>
<string>taskview-60696.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:56915832145:ios:37ef8848ef493495e77770</string>
</dict>
</plist>
+3
View File
@@ -20,6 +20,7 @@
"test:e2e:skip-build": "SKIP_BUILD=1 playwright test"
},
"dependencies": {
"@capacitor-firebase/messaging": "^8.1.0",
"@capacitor/android": "^8.1.0",
"@capacitor/app": "8.0.1",
"@capacitor/browser": "^8.0.1",
@@ -27,6 +28,7 @@
"@capacitor/device": "8.0.1",
"@capacitor/ios": "^8.1.0",
"@capacitor/preferences": "8.0.1",
"@capacitor/push-notifications": "^8.0.2",
"@capacitor/splash-screen": "^8.0.1",
"@capgo/capacitor-updater": "^8.43.2",
"@dagrejs/dagre": "^1.1.5",
@@ -58,6 +60,7 @@
"axios": "^1.13.4",
"centrifuge": "^5.5.3",
"date-fns": "^4.1.0",
"firebase": "^12.10.0",
"pinia": "^2.3.1",
"qs": "^6.14.1",
"scule": "^1.3.0",
+99 -65
View File
@@ -1,78 +1,86 @@
<template>
<UPopover
v-model:open="isOpen"
:content="{ align: 'end', side: 'right', sideOffset: 8 }"
<UButton
icon="i-lucide-bell"
color="neutral"
variant="ghost"
size="xl"
class="relative"
:aria-label="t('notifications.title')"
@click="isOpen = true"
>
<UButton
icon="i-lucide-bell"
color="neutral"
variant="ghost"
size="xl"
class="relative"
:aria-label="t('notifications.title')"
>
<template v-if="notificationsStore.unreadCount > 0" #trailing>
<UBadge
:label="notificationsStore.unreadCount > 99 ? '99+' : String(notificationsStore.unreadCount)"
color="error"
size="xs"
class="absolute -top-1 -right-1 min-w-5 justify-center"
/>
</template>
</UButton>
<template v-if="notificationsStore.unreadCount > 0" #trailing>
<UBadge
:label="notificationsStore.unreadCount > 99 ? '99+' : String(notificationsStore.unreadCount)"
color="error"
size="xs"
class="absolute -top-1 -right-1 min-w-5 justify-center"
/>
</template>
</UButton>
<template #content>
<div class="w-80 max-h-96 flex flex-col">
<div class="flex items-center justify-between p-3 border-b border-default">
<span class="text-sm font-medium">{{ t('notifications.title') }}</span>
<UButton
v-if="notificationsStore.unreadCount > 0"
:label="t('notifications.markAllRead')"
variant="link"
size="xs"
@click="handleMarkAllRead"
/>
<UModal
v-model:open="isOpen"
:fullscreen="isMobile"
:ui="{
overlay: 'sm:items-center sm:justify-center',
content: 'sm:max-w-lg sm:max-h-[80vh] sm:rounded-lg sm:m-auto w-full',
body: 'p-0!',
footer: 'p-4!',
}"
>
<template #header>
<div class="flex items-center justify-between w-full">
<h3 class="font-semibold">{{ t('notifications.title') }}</h3>
<UButton
v-if="notificationsStore.unreadCount > 0"
:label="t('notifications.markAllRead')"
variant="link"
size="xs"
@click="handleMarkAllRead"
/>
</div>
</template>
<template #body>
<div class="overflow-y-auto flex-1">
<div
v-if="notificationsStore.notifications.length === 0"
class="p-6 text-center text-sm text-dimmed"
>
{{ t('notifications.empty') }}
</div>
<div class="overflow-y-auto flex-1">
<div
v-if="notificationsStore.notifications.length === 0"
class="p-6 text-center text-sm text-dimmed"
>
{{ t('notifications.empty') }}
</div>
<div
v-for="notification in notificationsStore.notifications"
:key="notification.id"
class="p-3 border-b border-default last:border-b-0 cursor-pointer hover:bg-elevated/50 transition-colors"
:class="{ 'bg-elevated/25': !notification.read }"
@click="handleNotificationClick(notification)"
>
<div class="flex items-start gap-2">
<span
v-if="!notification.read"
class="mt-1.5 size-2 rounded-full bg-primary shrink-0"
/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ notification.title }}</p>
<p
v-if="notification.body"
class="text-xs text-dimmed mt-0.5 line-clamp-2"
>
{{ notification.body }}
</p>
<p class="text-xs text-dimmed mt-1">
{{ formatDate(notification.createdAt) }}
</p>
</div>
<div
v-for="notification in notificationsStore.notifications"
:key="notification.id"
class="p-3 border-b border-default last:border-b-0 cursor-pointer hover:bg-elevated/50 transition-colors"
:class="{ 'bg-elevated/25': !notification.read }"
@click="handleNotificationClick(notification)"
>
<div class="flex items-start gap-2">
<UIcon
:name="notificationIcon(notification.type)"
class="mt-0.5 size-4 shrink-0"
:class="notification.read ? 'text-dimmed' : 'text-primary'"
/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ notification.title }}</p>
<p
v-if="notification.body"
class="text-xs text-dimmed mt-0.5 line-clamp-2"
>
{{ notification.body }}
</p>
<p class="text-xs text-dimmed mt-1">
{{ formatDate(notification.createdAt) }}
</p>
</div>
</div>
</div>
<div
v-if="notificationsStore.hasMore && notificationsStore.notifications.length > 0"
class="p-2 border-t border-default"
class="p-2"
>
<UButton
:label="t('notifications.loadMore')"
@@ -86,7 +94,18 @@
</div>
</div>
</template>
</UPopover>
<template #footer>
<div class="flex justify-end w-full">
<UButton
:label="t('common.close')"
color="neutral"
variant="outline"
@click="isOpen = false"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
@@ -94,8 +113,10 @@ import { ref, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import type { Notification } from 'taskview-api'
import { NotificationType } from 'taskview-api'
import { useNotificationsStore } from '@/stores/notifications.store'
import { useUserStore } from '@/stores/user.store'
import { useTaskView } from '@/composables/useTaskView'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
import { formatDistanceToNow } from 'date-fns'
@@ -103,8 +124,21 @@ const { t } = useI18n()
const router = useRouter()
const notificationsStore = useNotificationsStore()
const userStore = useUserStore()
const { isMobile } = useTaskView()
const isOpen = ref(false)
const notificationIconMap: Partial<Record<NotificationType, string>> = {
[NotificationType.DEADLINE]: 'i-lucide-clock',
[NotificationType.ASSIGN]: 'i-lucide-user-plus',
[NotificationType.MENTION]: 'i-lucide-at-sign',
[NotificationType.COMMENT]: 'i-lucide-message-circle',
[NotificationType.STATUS_CHANGE]: 'i-lucide-arrow-right-left',
}
function notificationIcon(type: NotificationType) {
return notificationIconMap[type] || 'i-lucide-bell'
}
function formatDate(dateStr: string) {
try {
return formatDistanceToNow(new Date(dateStr), { addSuffix: true })
@@ -4,6 +4,8 @@
{{ t('account.title') }}
</h1>
<NotificationSettings />
<UPageCard class="w-full">
<div class="flex flex-col gap-4">
<h2 class="text-lg font-semibold">
@@ -33,6 +35,7 @@ import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user.store'
import DeleteAccountButton from './parts/DeleteAccountButton.vue'
import DeleteAccountCodeModal from './parts/DeleteAccountCodeModal.vue'
import NotificationSettings from './parts/NotificationSettings.vue'
const { t } = useI18n()
const userStore = useUserStore()
@@ -0,0 +1,109 @@
<template>
<UPageCard class="w-full">
<div class="flex flex-col gap-4">
<div>
<h2 class="text-lg font-semibold">{{ t('notifications.settings.title') }}</h2>
<p class="text-sm text-dimmed mt-1">{{ t('notifications.settings.description') }}</p>
</div>
<div v-if="loading" class="flex justify-center py-4">
<UIcon name="i-lucide-loader-2" class="size-5 animate-spin" />
</div>
<div v-else class="flex flex-col gap-0 divide-y divide-default">
<div
v-for="item in notificationTypes"
:key="item.type"
class="py-4 first:pt-0 last:pb-0"
>
<div class="flex flex-col gap-3">
<div>
<p class="text-sm font-medium">{{ item.label }}</p>
<p class="text-xs text-dimmed">{{ item.description }}</p>
</div>
<div class="flex gap-4">
<label
v-for="channel in channels"
:key="channel.key"
class="flex items-center gap-2 cursor-pointer"
>
<UCheckbox
:model-value="isEnabled(item.type, channel.key)"
@update:model-value="toggle(item.type, channel.key, $event as boolean)"
/>
<span class="text-sm">{{ channel.label }}</span>
</label>
</div>
</div>
</div>
</div>
</div>
</UPageCard>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { NotificationType, NotificationChannel } from 'taskview-api'
import type { NotificationPreferencesSettings } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
const { t } = useI18n()
const toast = useToast()
const loading = ref(true)
const settings = ref<NotificationPreferencesSettings>({})
const notificationTypes = computed(() => [
{
type: NotificationType.DEADLINE,
label: t('notifications.settings.types.deadline'),
description: t('notifications.settings.types.deadlineDescription'),
},
{
type: NotificationType.ASSIGN,
label: t('notifications.settings.types.assign'),
description: t('notifications.settings.types.assignDescription'),
},
])
const channels = computed(() => [
{ key: NotificationChannel.PUSH, label: t('notifications.settings.push') },
{ key: NotificationChannel.WEBSOCKET, label: t('notifications.settings.websocket') },
])
function isEnabled(type: NotificationType, channel: NotificationChannel): boolean {
return settings.value.global?.[type]?.channels?.[channel] !== false
}
function toggle(type: NotificationType, channel: NotificationChannel, enabled: boolean) {
if (!settings.value.global) settings.value.global = {}
if (!settings.value.global[type]) settings.value.global[type] = {}
if (!settings.value.global[type]!.channels) settings.value.global[type]!.channels = {}
settings.value.global[type]!.channels![channel] = enabled
save()
}
async function save() {
try {
const result = await $tvApi.notifications.savePreferences(settings.value)
if (result) {
toast.add({ title: t('notifications.settings.saved'), color: 'success' })
}
} catch {
toast.add({ title: t('common.error'), color: 'error' })
}
}
onMounted(async () => {
try {
const result = await $tvApi.notifications.getPreferences()
if (result) {
settings.value = result.settings
}
} catch {
toast.add({ title: t('common.error'), color: 'error' })
}
loading.value = false
})
</script>
@@ -38,56 +38,62 @@
@click="showAddDialog = true"
/>
<UModal v-model:open="showAddDialog">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">
{{ t('server.addNewServer') }}
</h3>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
@click="showAddDialog = false"
/>
</div>
</template>
<UModal
v-model:open="showAddDialog"
:fullscreen="isMobile"
:ui="{
content: 'sm:max-w-md sm:rounded-lg sm:m-auto w-full',
footer: 'p-4!',
}"
>
<template #header>
<div class="flex items-center justify-between w-full">
<h3 class="text-lg font-semibold">
{{ t('server.addNewServer') }}
</h3>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
@click="showAddDialog = false"
/>
</div>
</template>
<div class="space-y-4">
<UFormField :label="t('server.serverUrl')">
<UInput
v-model="newServerUrl"
placeholder="https://api.example.com"
class="w-full"
@keyup.enter="handleAddServer"
/>
</UFormField>
<p class="text-xs text-muted">
{{ t('server.serverUrlHint') }}
</p>
</div>
<template #body>
<div class="space-y-4">
<UFormField :label="t('server.serverUrl')">
<UInput
v-model="newServerUrl"
placeholder="https://api.example.com"
class="w-full"
autofocus
@keyup.enter="handleAddServer"
/>
</UFormField>
<p class="text-xs text-muted">
{{ t('server.serverUrlHint') }}
</p>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
@click="showAddDialog = false"
>
{{ t('common.cancel') }}
</UButton>
<UButton
:disabled="!isValidUrl"
variant="outline"
@click="handleAddServer"
>
{{ t('common.add') }}
</UButton>
</div>
</template>
</UCard>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton
color="neutral"
variant="outline"
@click="showAddDialog = false"
>
{{ t('common.cancel') }}
</UButton>
<UButton
:disabled="!isValidUrl"
variant="outline"
@click="handleAddServer"
>
{{ t('common.add') }}
</UButton>
</div>
</template>
</UModal>
</div>
@@ -96,9 +102,12 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core'
import { useAdditionalServer } from '@/composables/useAdditionalServer'
const { t } = useI18n()
const bp = useBreakpoints(breakpointsTailwind)
const isMobile = bp.smaller('lg')
const mainServer = ref('')
const allServers = ref<string[]>([])
@@ -0,0 +1,42 @@
<template>
<TvListFilter v-model="listIds" />
<TvUserFilter v-model="assigneeIds" />
<UButton
v-if="hasActiveFilters"
icon="i-lucide-x"
:label="showResetLabel ? t('filters.reset') : undefined"
class="shrink-0"
:class="{ '[&>span:last-child]:hidden lg:[&>span:last-child]:inline': showResetLabel }"
variant="soft"
color="error"
size="sm"
@click="resetFilters"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import TvListFilter from './TvListFilter.vue'
import TvUserFilter from './TvUserFilter.vue'
const props = withDefaults(defineProps<{
showResetLabel?: boolean
}>(), {
showResetLabel: false,
})
const listIds = defineModel<number[]>('listIds', { default: () => [] })
const assigneeIds = defineModel<number[]>('assigneeIds', { default: () => [] })
const { t } = useI18n()
const hasActiveFilters = computed(() => listIds.value.length > 0 || assigneeIds.value.length > 0)
const resetFilters = () => {
listIds.value = []
assigneeIds.value = []
}
defineExpose({ hasActiveFilters })
</script>
@@ -0,0 +1,57 @@
<template>
<USelectMenu
v-model="selected"
:items="options"
multiple
value-key="value"
:placeholder="t('tasks.allTasks')"
:search-input="false"
size="sm"
variant="subtle"
class="min-w-40 max-w-56 shrink-0 [&>button]:truncate"
>
<template #leading>
<UIcon
:name="model.length === 0 ? 'i-lucide-layers' : 'i-lucide-list'"
class="size-4"
/>
</template>
</USelectMenu>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGoalListsStore } from '@/stores/goal-lists.store'
const ALL_VALUE = -1
const model = defineModel<number[]>({ default: () => [] })
const { t } = useI18n()
const goalListsStore = useGoalListsStore()
const options = computed(() => [
{ label: t('tasks.allTasks'), value: ALL_VALUE },
...goalListsStore.lists.map((list) => ({
label: list.name,
value: list.id,
})),
])
const hadAll = computed(() => model.value.length === 0)
const selected = computed({
get() {
return model.value.length === 0 ? [ALL_VALUE] : model.value
},
set(val: number[]) {
const allJustSelected = val.includes(ALL_VALUE) && !hadAll.value
if (allJustSelected || val.length === 0) {
model.value = []
} else {
model.value = val.filter((v) => v !== ALL_VALUE)
}
},
})
</script>
@@ -0,0 +1,57 @@
<template>
<USelectMenu
v-model="selected"
:items="options"
multiple
value-key="value"
:placeholder="t('filters.allUsers')"
:search-input="false"
size="sm"
variant="subtle"
class="min-w-40 max-w-56 shrink-0 [&>button]:truncate"
>
<template #leading>
<UIcon
:name="model.length === 0 ? 'i-lucide-users' : 'i-lucide-user'"
class="size-4"
/>
</template>
</USelectMenu>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCollaborationStore } from '@/stores/collaboration.store'
const ALL_VALUE = -1
const model = defineModel<number[]>({ default: () => [] })
const { t } = useI18n()
const collaborationStore = useCollaborationStore()
const options = computed(() => [
{ label: t('filters.allUsers'), value: ALL_VALUE },
...collaborationStore.users.map((user) => ({
label: user.email,
value: user.id,
})),
])
const hadAll = computed(() => model.value.length === 0)
const selected = computed({
get() {
return model.value.length === 0 ? [ALL_VALUE] : model.value
},
set(val: number[]) {
const allJustSelected = val.includes(ALL_VALUE) && !hadAll.value
if (allJustSelected || val.length === 0) {
model.value = []
} else {
model.value = val.filter((v) => v !== ALL_VALUE)
}
},
})
</script>
@@ -98,6 +98,28 @@ import AddNewTaskToGraph from './AddNewTaskToGraph.vue'
import { useLayout } from './composables/useLayout'
import TaskNode from './TaskNode.vue'
const listIds = defineModel<number[]>('listIds', { default: () => [] })
const assigneeIds = defineModel<number[]>('assigneeIds', { default: () => [] })
const applyFilters = () => {
let filtered = [...store.allNodes]
if (listIds.value.length > 0) {
filtered = filtered.filter((n) => listIds.value.includes(n.data.task.goalListId))
}
if (assigneeIds.value.length > 0) {
filtered = filtered.filter((n) =>
n.data.task.assignedUsers?.some((id: number) => assigneeIds.value.includes(id))
)
}
const nodeIds = new Set(filtered.map((n) => n.id))
store.nodes = filtered
store.edges = store.allEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
setTimeout(() => layoutGraph(layoutDirection.value), 50)
}
watch(listIds, applyFilters, { deep: true })
watch(assigneeIds, applyFilters, { deep: true })
const {
fitView,
onConnect,
@@ -41,7 +41,7 @@ const deleteOpen = ref(false)
const ui = {
itemLeadingIcon: 'size-4',
item: 'items-center',
}
} as DropdownMenuItem['ui']
const menuItems = computed<DropdownMenuItem[][]>(() => [
[
{
@@ -96,6 +96,17 @@
:to="{ name: 'integrations', params: { projectId: selectedProject?.id } }"
@click="contextMenu?.close()"
/>
<!-- Webhooks -->
<UButton
v-if="canViewIntegrations"
:label="t('contextMenu.webhooks')"
icon="i-lucide-webhook"
variant="ghost"
color="neutral"
class="w-full justify-start"
:to="{ name: 'webhooks', params: { projectId: selectedProject?.id } }"
@click="contextMenu?.close()"
/>
<USeparator class="my-1" />
@@ -53,6 +53,7 @@
<NoteEditor
:key="task.id"
:content="task.note || ''"
:content-type="task.sourceUrl ? 'markdown' : 'html'"
:placeholder="t('tasks.addNote')"
@save="updateNote"
/>
@@ -4,6 +4,7 @@
v-if="canViewTaskNote"
#default="{ editor }"
v-model="initialContent"
:content-type="contentType"
:placeholder="placeholder"
:extensions="extensions"
:editable="canEditTaskNote"
@@ -39,6 +40,7 @@ const extensions = [
const props = defineProps<{
content: string
contentType?: 'html' | 'markdown'
placeholder?: string
debounce?: number
}>()
@@ -32,7 +32,7 @@
<UInputTime
v-if="showStartTime"
v-model="startTimeModel"
hour-format="24"
:hour-cycle="24"
/>
<UButton
v-if="hasStartDate"
@@ -79,7 +79,7 @@
<UInputTime
v-if="showEndTime"
v-model="endTimeModel"
hour-format="24"
:hour-cycle="24"
/>
<UButton
v-if="hasEndDate"
@@ -219,28 +219,32 @@ function useDeferredLoading(source: typeof loadingStart, deferred: typeof deferr
})
}
// Parse date string (YYYY-MM-DD) to CalendarDate
function parseDateString(dateStr: string | null | undefined): CalendarDate | undefined {
if (!dateStr) return undefined
const [year, month, day] = dateStr.split('-').map(Number)
return new CalendarDate(year, month, day)
}
// Parse UTC date + optional time from server into local CalendarDate + Time
function parseFromServer(dateStr: string | null | undefined, timeStr: string | null | undefined): { date: CalendarDate | undefined; time: Time | undefined } {
if (!dateStr) return { date: undefined, time: undefined }
// Parse time string (HH:mm:ss+TZ) to Time
function parseTimeString(timeStr: string | null | undefined): Time | undefined {
if (!timeStr) return undefined
const match = timeStr.match(/^(\d{2}):(\d{2})/)
if (match) {
return new Time(parseInt(match[1], 10), parseInt(match[2], 10))
if (timeStr) {
const match = timeStr.match(/^(\d{2}):(\d{2})/)
if (match) {
const utc = new Date(`${dateStr}T${match[1]}:${match[2]}:00Z`)
return {
date: new CalendarDate(utc.getFullYear(), utc.getMonth() + 1, utc.getDate()),
time: new Time(utc.getHours(), utc.getMinutes()),
}
}
}
return undefined
const [year, month, day] = dateStr.split('-').map(Number)
return { date: new CalendarDate(year, month, day), time: undefined }
}
// Use shallowRef to avoid Vue stripping private fields from class instances
const startDateModel = shallowRef<CalendarDate | undefined>(parseDateString(props.task.startDate))
const endDateModel = shallowRef<CalendarDate | undefined>(parseDateString(props.task.endDate))
const startTimeModel = shallowRef<Time | undefined>(parseTimeString(props.task.startTime))
const endTimeModel = shallowRef<Time | undefined>(parseTimeString(props.task.endTime))
const initStart = parseFromServer(props.task.startDate, props.task.startTime)
const initEnd = parseFromServer(props.task.endDate, props.task.endTime)
const startDateModel = shallowRef<CalendarDate | undefined>(initStart.date)
const endDateModel = shallowRef<CalendarDate | undefined>(initEnd.date)
const startTimeModel = shallowRef<Time | undefined>(initStart.time)
const endTimeModel = shallowRef<Time | undefined>(initEnd.time)
const showStartTime = ref(!!props.task.startTime)
const showEndTime = ref(!!props.task.endTime)
const startPopoverOpen = ref(false)
@@ -250,12 +254,30 @@ const endPopoverOpen = ref(false)
// so the watchers below don't fire extra save requests
let syncing = false
// user unchecked "select time" — reset the time value
// user toggled "select time" checkbox
watch(showStartTime, (val) => {
if (!val) startTimeModel.value = undefined
if (!val) {
startTimeModel.value = undefined
} else if (!startDateModel.value) {
syncing = true
const now = getTodayCalendarDate()
startDateModel.value = endDateModel.value && endDateModel.value.compare(now) < 0
? endDateModel.value
: now
nextTick(() => { syncing = false })
}
})
watch(showEndTime, (val) => {
if (!val) endTimeModel.value = undefined
if (!val) {
endTimeModel.value = undefined
} else if (!endDateModel.value) {
syncing = true
const now = getTodayCalendarDate()
endDateModel.value = startDateModel.value && startDateModel.value.compare(now) > 0
? startDateModel.value
: now
nextTick(() => { syncing = false })
}
})
// these two watchers exist because UCalendar updates the model via v-model
@@ -305,10 +327,12 @@ watch(
() => props.task,
(task) => {
syncing = true
startDateModel.value = parseDateString(task.startDate)
endDateModel.value = parseDateString(task.endDate)
startTimeModel.value = parseTimeString(task.startTime)
endTimeModel.value = parseTimeString(task.endTime)
const start = parseFromServer(task.startDate, task.startTime)
const end = parseFromServer(task.endDate, task.endTime)
startDateModel.value = start.date
endDateModel.value = end.date
startTimeModel.value = start.time
endTimeModel.value = end.time
showStartTime.value = !!task.startTime
showEndTime.value = !!task.endTime
nextTick(() => { syncing = false })
@@ -346,47 +370,33 @@ function formatTimeForDisplay(time: Time): string {
return `${String(time.hour).padStart(2, '0')}:${String(time.minute).padStart(2, '0')}`
}
function getTimeZone(): string {
const date = new Date()
const timezoneOffsetInMinutes = date.getTimezoneOffset()
const offsetHours = Math.floor(Math.abs(timezoneOffsetInMinutes) / 60)
const offsetMinutes = Math.abs(timezoneOffsetInMinutes) % 60
const offsetSign = timezoneOffsetInMinutes <= 0 ? '+' : '-'
return `${offsetSign}${String(offsetHours).padStart(2, '0')}:${String(offsetMinutes).padStart(2, '0')}`
}
function formatDateForApi(calendarDate: CalendarDate): string {
return calendarDate.toString() // Returns YYYY-MM-DD
}
function formatTimeForApi(time: Time): string {
const timeZone = getTimeZone()
const timeStr = `${String(time.hour).padStart(2, '0')}:${String(time.minute).padStart(2, '0')}:00`
return `${timeStr}${timeZone}`
// Convert local date+time to UTC for API, returns { date, time } strings
// When time is null, date is sent as-is (calendar date, no timezone shift)
function toApiDateTime(date: CalendarDate, time?: Time): { date: string; time: string | null } {
if (!time) return { date: date.toString(), time: null }
const local = new Date(date.year, date.month - 1, date.day, time.hour, time.minute, 0)
const y = local.getUTCFullYear()
const m = String(local.getUTCMonth() + 1).padStart(2, '0')
const d = String(local.getUTCDate()).padStart(2, '0')
const h = String(local.getUTCHours()).padStart(2, '0')
const min = String(local.getUTCMinutes()).padStart(2, '0')
return { date: `${y}-${m}-${d}`, time: `${h}:${min}:00` }
}
// Save functions
async function saveStartDateTime() {
if (!startDateModel.value) return
loadingStart.value = true
await tasksStore.saveDateForTask({
id: props.task.id,
startDate: formatDateForApi(startDateModel.value as CalendarDate),
startTime: startTimeModel.value ? formatTimeForApi(startTimeModel.value as Time) : null,
})
const { date, time } = toApiDateTime(startDateModel.value, startTimeModel.value)
await tasksStore.saveDateForTask({ id: props.task.id, startDate: date, startTime: time })
loadingStart.value = false
}
async function saveEndDateTime() {
if (!endDateModel.value) return
loadingEnd.value = true
await tasksStore.saveDateForTask({
id: props.task.id,
endDate: formatDateForApi(endDateModel.value as CalendarDate),
endTime: endTimeModel.value ? formatTimeForApi(endTimeModel.value as Time) : null,
})
const { date, time } = toApiDateTime(endDateModel.value, endTimeModel.value)
await tasksStore.saveDateForTask({ id: props.task.id, endDate: date, endTime: time })
loadingEnd.value = false
}
@@ -462,8 +472,8 @@ async function setQuickDate(type: 'today' | 'tomorrow' | 'yesterday') {
})
syncing = true
startDateModel.value = parseDateString(date)
endDateModel.value = parseDateString(date)
startDateModel.value = parseFromServer(date, null).date
endDateModel.value = parseFromServer(date, null).date
nextTick(() => { syncing = false })
activeQuickAction.value = null
@@ -499,8 +509,8 @@ async function setQuickRange(type: 'week' | 'month') {
})
syncing = true
startDateModel.value = parseDateString(start)
endDateModel.value = parseDateString(end)
startDateModel.value = parseFromServer(start, null).date
endDateModel.value = parseFromServer(end, null).date
nextTick(() => { syncing = false })
activeQuickAction.value = null
@@ -190,15 +190,29 @@ const listName = computed(() => {
const formattedDate = computed(() => {
if (!props.task.endDate) return ''
return formatDate(new Date(props.task.endDate), 'DD.MMM.YYYY')
if (props.task.endTime) {
const match = props.task.endTime.match(/^(\d{2}):(\d{2})/)
if (match) {
const utc = new Date(`${props.task.endDate}T${match[1]}:${match[2]}:00Z`)
return formatDate(utc, 'DD.MMM.YYYY HH:mm')
}
}
const [y, m, d] = props.task.endDate.split('-').map(Number)
return formatDate(new Date(y, m - 1, d), 'DD.MMM.YYYY')
})
const isOverdue = computed(() => {
if (!props.task.endDate || localComplete.value) return false
const end = new Date(props.task.endDate)
const now = new Date()
now.setHours(0, 0, 0, 0)
return end < now
if (props.task.endTime) {
const match = props.task.endTime.match(/^(\d{2}):(\d{2})/)
if (match) {
const utc = new Date(`${props.task.endDate}T${match[1]}:${match[2]}:00Z`)
return utc < new Date()
}
}
const [y, m, d] = props.task.endDate.split('-').map(Number)
const end = new Date(y, m - 1, d, 23, 59, 59)
return end < new Date()
})
const assigneeEmails = computed(() => {
@@ -4,7 +4,7 @@
<USelectMenu
v-if="canViewKanban"
:search-input="false"
:disabled="!canManageKanban || true"
:disabled="!canManageKanban"
:model-value="selectedItem"
:items="statusItems"
:placeholder="t('tasks.selectStatus')"
@@ -0,0 +1,137 @@
<template>
<div class="p-4">
<div
v-if="!hasGoalSelected"
class="flex flex-col items-center justify-center h-64 text-muted"
>
<UIcon
name="i-lucide-webhook"
class="size-12 mb-4"
/>
<p>{{ t('webhooks.selectProject') }}</p>
</div>
<template v-else>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">
{{ projectName }}
</h2>
<UButton
v-if="canManageIntegrations"
:label="t('webhooks.add')"
icon="i-lucide-plus"
color="primary"
@click="isAddModalOpen = true"
/>
</div>
<div
v-if="loading"
class="flex items-center justify-center h-32"
>
<p>{{ t('common.loading') }}</p>
</div>
<div
v-else-if="webhooksStore.webhooks.length === 0"
class="flex flex-col items-center justify-center h-64 text-muted"
>
<UIcon
name="i-lucide-webhook"
class="size-12 mb-4"
/>
<p>{{ t('webhooks.empty') }}</p>
</div>
<div
v-else
class="flex flex-col gap-3"
>
<WebhookItem
v-for="item in webhooksStore.webhooks"
:key="item.id"
:webhook="item"
:can-manage="canManageIntegrations"
@toggle="handleToggle"
@delete="handleDelete"
@show-deliveries="openDeliveries"
@edit="openEdit"
/>
</div>
</template>
<AddWebhookModal
v-model:open="isAddModalOpen"
:goal-id="projectId"
@created="fetchData"
/>
<EditWebhookModal
v-model:open="isEditModalOpen"
:webhook="selectedWebhook"
@saved="fetchData"
/>
<WebhookDeliveries
v-model:open="isDeliveriesOpen"
:webhook-id="selectedWebhookId"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { WebhookItem as WebhookItemType } from 'taskview-api'
import { useWebhooksStore } from '@/stores/webhooks.store'
import { useGoalsStore } from '@/stores/goals.store'
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import WebhookItem from './parts/WebhookItem.vue'
import AddWebhookModal from './parts/AddWebhookModal.vue'
import EditWebhookModal from './parts/EditWebhookModal.vue'
import WebhookDeliveries from './parts/WebhookDeliveries.vue'
const { t } = useI18n()
const { projectId } = useAppRouteInfo()
const goalsStore = useGoalsStore()
const webhooksStore = useWebhooksStore()
const { canManageIntegrations } = useGoalPermissions()
const loading = ref(false)
const isAddModalOpen = ref(false)
const isEditModalOpen = ref(false)
const isDeliveriesOpen = ref(false)
const selectedWebhookId = ref(0)
const selectedWebhook = ref<WebhookItemType | null>(null)
const hasGoalSelected = computed(() => projectId.value > 0)
const projectName = computed(() => goalsStore.goalMap.get(projectId.value)?.name ?? '')
async function fetchData() {
if (!hasGoalSelected.value) return
loading.value = true
await webhooksStore.fetchWebhooks(projectId.value)
loading.value = false
}
watch(projectId, fetchData, { immediate: true })
async function handleToggle(id: number, isActive: boolean) {
await webhooksStore.updateWebhook({ id, isActive })
}
async function handleDelete(id: number) {
await webhooksStore.deleteWebhook(id)
}
function openDeliveries(webhookId: number) {
selectedWebhookId.value = webhookId
isDeliveriesOpen.value = true
}
function openEdit(webhook: WebhookItemType) {
selectedWebhook.value = webhook
isEditModalOpen.value = true
}
</script>
@@ -0,0 +1,139 @@
<template>
<UModal v-model:open="isOpen" :fullscreen="isMobile">
<template #header>
<h3 class="text-lg font-semibold">
{{ t('webhooks.add') }}
</h3>
</template>
<template #body>
<div class="flex flex-col gap-4">
<UFormField :label="t('webhooks.url')">
<UInput
v-model="url"
:placeholder="t('webhooks.urlPlaceholder')"
class="w-full"
/>
</UFormField>
<UFormField :label="t('webhooks.events')">
<USelectMenu
v-model="selectedEvents"
:items="eventOptions"
multiple
value-key="value"
:placeholder="t('webhooks.selectEvents')"
class="w-full"
/>
</UFormField>
</div>
</template>
<template #footer>
<div class="w-full flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
variant="ghost"
@click="isOpen = false"
/>
<UButton
:label="t('common.add')"
color="primary"
:disabled="!url || selectedEvents.length === 0"
:loading="saving"
@click="handleCreate"
/>
</div>
</template>
</UModal>
<UModal v-model:open="showSecret" :fullscreen="isMobile">
<template #header>
<h3 class="text-lg font-semibold">
{{ t('webhooks.secretTitle') }}
</h3>
</template>
<template #body>
<p class="text-sm text-muted mb-3">
{{ t('webhooks.secretDescription') }}
</p>
<div class="flex items-center justify-between gap-2 p-3 bg-elevated rounded-lg">
<span class="font-mono text-sm break-all">{{ createdSecret }}</span>
<UButton
icon="i-lucide-copy"
variant="ghost"
size="xs"
class="shrink-0"
@click="copySecret(createdSecret)"
/>
</div>
</template>
<template #footer>
<div class="w-full flex justify-end">
<UButton
:label="t('common.done')"
color="primary"
@click="showSecret = false"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useClipboard } from '@vueuse/core'
import { WEBHOOK_EVENTS } from 'taskview-api'
import { useWebhooksStore } from '@/stores/webhooks.store'
import { useTaskView } from '@/composables/useTaskView'
const props = defineProps<{
goalId: number
}>()
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
created: []
}>()
const { t } = useI18n()
const { isMobile } = useTaskView()
const webhooksStore = useWebhooksStore()
const { copy } = useClipboard()
const url = ref('')
const selectedEvents = ref<string[]>([])
const saving = ref(false)
const showSecret = ref(false)
const createdSecret = ref('')
const eventOptions = WEBHOOK_EVENTS.map((e) => ({
label: e,
value: e,
}))
function copySecret(secret: string) {
copy(secret)
}
async function handleCreate() {
saving.value = true
try {
const result = await webhooksStore.createWebhook({
goalId: props.goalId,
url: url.value,
events: selectedEvents.value,
})
if (result) {
isOpen.value = false
createdSecret.value = result.secret
showSecret.value = true
url.value = ''
selectedEvents.value = []
emit('created')
}
} finally {
saving.value = false
}
}
</script>
@@ -0,0 +1,101 @@
<template>
<UModal v-model:open="isOpen" :fullscreen="isMobile">
<template #header>
<h3 class="text-lg font-semibold">
{{ t('webhooks.edit') }}
</h3>
</template>
<template #body>
<div class="flex flex-col gap-4">
<UFormField :label="t('webhooks.url')">
<UInput
v-model="url"
:placeholder="t('webhooks.urlPlaceholder')"
class="w-full"
/>
</UFormField>
<UFormField :label="t('webhooks.events')">
<USelectMenu
v-model="selectedEvents"
:items="eventOptions"
multiple
value-key="value"
:placeholder="t('webhooks.selectEvents')"
class="w-full"
/>
</UFormField>
</div>
</template>
<template #footer>
<div class="w-full flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
variant="ghost"
@click="isOpen = false"
/>
<UButton
:label="t('common.save')"
color="primary"
:disabled="!url || selectedEvents.length === 0"
:loading="saving"
@click="handleSave"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { WEBHOOK_EVENTS } from 'taskview-api'
import type { WebhookItem } from 'taskview-api'
import { useWebhooksStore } from '@/stores/webhooks.store'
import { useTaskView } from '@/composables/useTaskView'
const props = defineProps<{
webhook: WebhookItem | null
}>()
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
saved: []
}>()
const { t } = useI18n()
const { isMobile } = useTaskView()
const webhooksStore = useWebhooksStore()
const url = ref('')
const selectedEvents = ref<string[]>([])
const saving = ref(false)
const eventOptions = WEBHOOK_EVENTS.map((e) => ({
label: e,
value: e,
}))
watch(isOpen, (open) => {
if (open && props.webhook) {
url.value = props.webhook.url
selectedEvents.value = [...props.webhook.events]
}
})
async function handleSave() {
if (!props.webhook) return
saving.value = true
try {
await webhooksStore.updateWebhook({
id: props.webhook.id,
url: url.value,
events: selectedEvents.value,
})
isOpen.value = false
emit('saved')
} finally {
saving.value = false
}
}
</script>
@@ -0,0 +1,47 @@
<template>
<UModal v-model:open="isOpen" :fullscreen="isMobile" :ui="{ content: 'sm:max-w-3xl' }">
<template #header>
<h3 class="text-lg font-semibold">
{{ t('webhooks.payload') }}
</h3>
</template>
<template #body>
<pre class="p-3 bg-elevated rounded-lg text-xs whitespace-pre-wrap break-all">{{ formattedPayload }}</pre>
</template>
<template #footer>
<div class="w-full flex justify-end gap-2">
<UButton
icon="i-lucide-copy"
:label="t('webhooks.copy')"
variant="ghost"
:ui="{ leadingIcon: 'size-4!' }"
@click="copy(formattedPayload)"
/>
<UButton
:label="t('common.close')"
variant="ghost"
@click="isOpen = false"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useClipboard } from '@vueuse/core'
import { useTaskView } from '@/composables/useTaskView'
const props = defineProps<{
payload: unknown
}>()
const isOpen = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
const { isMobile } = useTaskView()
const { copy } = useClipboard()
const formattedPayload = computed(() => JSON.stringify(props.payload, null, 2))
</script>
@@ -0,0 +1,211 @@
<template>
<UModal v-model:open="isOpen" :fullscreen="isMobile" :ui="{ content: 'sm:max-w-3xl', body: 'p-0!' }">
<template #header>
<div class="flex items-center justify-between w-full gap-3">
<h3 class="text-lg font-semibold">
{{ t('webhooks.deliveries') }}
</h3>
<USelectMenu
v-model="statusFilter"
:items="statusOptions"
value-key="value"
:placeholder="t('webhooks.allStatuses')"
:search-input="false"
size="sm"
variant="subtle"
class="w-36 shrink-0"
/>
</div>
</template>
<template #body>
<div
v-if="webhooksStore.deliveriesLoading && webhooksStore.deliveries.length === 0"
class="flex items-center justify-center h-32"
>
<p>{{ t('common.loading') }}</p>
</div>
<div
v-else-if="webhooksStore.deliveries.length === 0"
class="flex flex-col items-center justify-center h-32 text-muted"
>
<p>{{ t('webhooks.noDeliveries') }}</p>
</div>
<div v-else ref="scrollContainer">
<UTable
:data="webhooksStore.deliveries"
:columns="columns"
sticky
:loading="webhooksStore.deliveriesLoading && webhooksStore.deliveries.length > 0"
:ui="{ thead: 'sticky top-0 z-10' }"
class="max-h-80"
>
<template #event-cell="{ row }">
<span class="font-mono text-xs">{{ row.original.event }}</span>
</template>
<template #status-cell="{ row }">
<UBadge
:color="statusColor(row.original.status)"
variant="subtle"
size="xs"
>
{{ row.original.status }}
</UBadge>
</template>
<template #responseCode-cell="{ row }">
{{ row.original.responseCode ?? '—' }}
</template>
<template #createdAt-cell="{ row }">
<span class="text-muted whitespace-nowrap">{{ formatTime(row.original.createdAt) }}</span>
</template>
<template #actions-cell="{ row }">
<div class="flex items-center gap-1">
<UButton
icon="i-lucide-eye"
variant="ghost"
size="xs"
:title="t('webhooks.viewPayload')"
@click="openPayload(row.original)"
/>
<UButton
v-if="row.original.status === 'failed'"
icon="i-lucide-refresh-cw"
variant="ghost"
size="xs"
:loading="retrying === row.original.id"
@click="retryDelivery(row.original)"
/>
</div>
</template>
</UTable>
</div>
</template>
<template #footer>
<div class="w-full flex justify-end">
<UButton
:label="t('common.close')"
variant="ghost"
@click="isOpen = false"
/>
</div>
</template>
</UModal>
<PayloadViewer
v-model:open="showPayload"
:payload="selectedPayload"
/>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useInfiniteScroll } from '@vueuse/core'
import type { TableColumn } from '@nuxt/ui'
import { useWebhooksStore } from '@/stores/webhooks.store'
import { useTaskView } from '@/composables/useTaskView'
import PayloadViewer from './PayloadViewer.vue'
import type { WebhookDeliveryItem } from 'taskview-api'
const props = defineProps<{
webhookId: number
}>()
const isOpen = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
const { isMobile } = useTaskView()
const webhooksStore = useWebhooksStore()
const retrying = ref<number | null>(null)
const showPayload = ref(false)
const selectedPayload = ref<unknown>(null)
const scrollContainer = ref<HTMLElement | null>(null)
const statusFilter = ref('all')
const hasMore = ref(true)
const ALL_VALUE = 'all'
const statusOptions = [
{ label: t('webhooks.allStatuses'), value: ALL_VALUE },
{ label: 'success', value: 'success' },
{ label: 'failed', value: 'failed' },
{ label: 'pending', value: 'pending' },
]
const columns: TableColumn<WebhookDeliveryItem>[] = [
{ accessorKey: 'event', header: t('webhooks.event') },
{ accessorKey: 'status', header: t('webhooks.status') },
{ accessorKey: 'responseCode', header: t('webhooks.httpCode') },
{ accessorKey: 'attempts', header: t('webhooks.attempts') },
{ accessorKey: 'createdAt', header: t('webhooks.time') },
{ id: 'actions', header: '' },
]
const currentStatus = () => statusFilter.value === ALL_VALUE ? undefined : statusFilter.value
async function loadInitial() {
hasMore.value = true
await webhooksStore.fetchDeliveries(props.webhookId, { status: currentStatus() })
hasMore.value = webhooksStore.deliveries.length >= 20
await nextTick()
setupInfiniteScroll()
}
async function loadMore() {
if (!hasMore.value || webhooksStore.deliveriesLoading) return
const last = webhooksStore.deliveries[webhooksStore.deliveries.length - 1]
if (!last) return
const prevCount = webhooksStore.deliveries.length
await webhooksStore.fetchDeliveries(props.webhookId, {
cursor: last.id,
status: currentStatus(),
append: true,
})
hasMore.value = webhooksStore.deliveries.length - prevCount >= 20
}
function setupInfiniteScroll() {
if (!scrollContainer.value) return
useInfiniteScroll(scrollContainer, () => {
loadMore()
}, {
distance: 200,
canLoadMore: () => hasMore.value && !webhooksStore.deliveriesLoading,
})
}
watch(isOpen, (open) => {
if (open && props.webhookId) {
loadInitial()
}
})
watch(statusFilter, () => {
if (isOpen.value) {
loadInitial()
}
})
function statusColor(status: string) {
if (status === 'success') return 'success' as const
if (status === 'failed') return 'error' as const
return 'warning' as const
}
function formatTime(dateStr: string | null) {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleString()
}
function openPayload(delivery: WebhookDeliveryItem) {
selectedPayload.value = delivery.payload
showPayload.value = true
}
async function retryDelivery(delivery: WebhookDeliveryItem) {
retrying.value = delivery.id
await webhooksStore.retryDelivery(delivery.id)
await loadInitial()
retrying.value = null
}
</script>
@@ -0,0 +1,188 @@
<template>
<div class="flex items-center justify-between p-4 border border-default rounded-lg">
<div class="flex items-center gap-3 min-w-0">
<UIcon
name="i-lucide-webhook"
class="size-6 shrink-0"
:class="webhook.isActive ? 'text-primary' : 'text-muted'"
/>
<div class="min-w-0">
<p class="font-medium truncate">
{{ webhook.url }}
</p>
<div class="flex items-center gap-1 mt-1 flex-wrap">
<UBadge
v-for="event in webhook.events"
:key="event"
variant="subtle"
size="xs"
>
{{ event }}
</UBadge>
</div>
</div>
</div>
<div
v-if="canManage"
class="flex items-center gap-2 shrink-0 ml-2"
>
<UButton
icon="i-lucide-pencil"
variant="ghost"
size="md"
:title="t('webhooks.edit')"
@click="emit('edit', webhook)"
/>
<UButton
icon="i-lucide-send"
variant="ghost"
size="md"
:loading="testing"
:title="t('webhooks.test')"
@click="handleTest"
/>
<UButton
icon="i-lucide-history"
variant="ghost"
size="md"
:title="t('webhooks.deliveries')"
@click="emit('showDeliveries', webhook.id)"
/>
<UButton
icon="i-lucide-key-round"
variant="ghost"
size="md"
:title="t('webhooks.rotateSecret')"
@click="showRotateConfirm = true"
/>
<USwitch
:model-value="webhook.isActive"
size="md"
@update:model-value="handleToggle"
/>
<UButton
icon="i-lucide-trash-2"
variant="ghost"
color="error"
size="md"
@click="emit('delete', webhook.id)"
/>
</div>
</div>
<UModal v-model:open="showRotateConfirm" :fullscreen="isMobile">
<template #header>
<h3 class="text-lg font-semibold">
{{ t('webhooks.rotateSecret') }}
</h3>
</template>
<template #body>
<p class="text-sm">
{{ t('webhooks.rotateConfirm') }}
</p>
</template>
<template #footer>
<div class="w-full flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
variant="ghost"
@click="showRotateConfirm = false"
/>
<UButton
:label="t('webhooks.rotateSecret')"
color="error"
:loading="rotating"
@click="handleRotateSecret"
/>
</div>
</template>
</UModal>
<UModal v-model:open="showRotatedSecret" :fullscreen="isMobile">
<template #header>
<h3 class="text-lg font-semibold">
{{ t('webhooks.secretTitle') }}
</h3>
</template>
<template #body>
<p class="text-sm text-muted mb-3">
{{ t('webhooks.secretDescription') }}
</p>
<div class="flex items-center justify-between gap-2 p-3 bg-elevated rounded-lg">
<span class="font-mono text-sm break-all">{{ rotatedSecret }}</span>
<UButton
icon="i-lucide-copy"
variant="ghost"
size="xs"
class="shrink-0"
@click="copy(rotatedSecret)"
/>
</div>
</template>
<template #footer>
<div class="w-full flex justify-end">
<UButton
:label="t('common.done')"
color="primary"
@click="showRotatedSecret = false"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useClipboard } from '@vueuse/core'
import type { WebhookItem } from 'taskview-api'
import { useWebhooksStore } from '@/stores/webhooks.store'
import { useTaskView } from '@/composables/useTaskView'
const props = defineProps<{
webhook: WebhookItem
canManage: boolean
}>()
const emit = defineEmits<{
toggle: [id: number, isActive: boolean]
delete: [id: number]
showDeliveries: [id: number]
edit: [webhook: WebhookItem]
}>()
const { t } = useI18n()
const { isMobile } = useTaskView()
const { copy } = useClipboard()
const webhooksStore = useWebhooksStore()
const testing = ref(false)
const showRotateConfirm = ref(false)
const showRotatedSecret = ref(false)
const rotatedSecret = ref('')
const rotating = ref(false)
async function handleTest() {
testing.value = true
await webhooksStore.testDelivery(props.webhook.id)
testing.value = false
}
function handleToggle(isActive: boolean) {
emit('toggle', props.webhook.id, isActive)
}
async function handleRotateSecret() {
rotating.value = true
try {
const secret = await webhooksStore.rotateSecret(props.webhook.id)
if (secret) {
showRotateConfirm.value = false
rotatedSecret.value = secret
showRotatedSecret.value = true
}
} finally {
rotating.value = false
}
}
</script>
@@ -1,10 +1,12 @@
import { watch, type Ref } from 'vue'
import { useCollaborationStore } from '@/stores/collaboration.store'
import { useGoalListsStore } from '@/stores/goal-lists.store'
import { useKanbanStore } from '@/stores/kanban.store'
import { useTagsStore } from '@/stores/tag.store'
export function useProjectDataLoader(projectId: Ref<number>) {
const collaborationStore = useCollaborationStore()
const goalListsStore = useGoalListsStore()
const kanbanStore = useKanbanStore()
const tagsStore = useTagsStore()
@@ -15,6 +17,7 @@ export function useProjectDataLoader(projectId: Ref<number>) {
Promise.all([
kanbanStore.fetchStatuses(id),
collaborationStore.fetchCollaborationUsersForGoal(id),
goalListsStore.fetchLists(id),
])
}
}, { immediate: true })
@@ -0,0 +1,59 @@
import { ref } from 'vue'
import { Capacitor } from '@capacitor/core'
import { FirebaseMessaging } from '@capacitor-firebase/messaging'
import { $tvApi } from '@/plugins/axios'
import { useNotificationsStore } from '@/stores/notifications.store'
import { useRouter } from 'vue-router'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
const registered = ref(false)
let initialized = false
export function usePushNotifications() {
const supported = Capacitor.isNativePlatform()
const router = useRouter()
async function init() {
console.log('-------------------------------- init push notifications --------------------------------')
if (initialized || !supported) return
initialized = true
const permission = await FirebaseMessaging.requestPermissions()
if (permission.receive !== 'granted') return
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const { token } = await FirebaseMessaging.getToken()
if (token) {
const platform = Capacitor.getPlatform() as 'android' | 'ios'
await $tvApi.notifications.registerDevice(token, platform, timezone)
registered.value = true
}
FirebaseMessaging.addListener('tokenReceived', async ({ token }) => {
const platform = Capacitor.getPlatform() as 'android' | 'ios'
await $tvApi.notifications.registerDevice(token, platform, timezone)
registered.value = true
})
FirebaseMessaging.addListener('notificationReceived', (_notification) => {
const notificationsStore = useNotificationsStore()
notificationsStore.fetchNotifications(true)
})
FirebaseMessaging.addListener('notificationActionPerformed', (action) => {
const data = action.notification.data as Record<string, string> | undefined
if (data?.taskId && data?.goalId) {
router.push({
name: 'user',
params: {
projectId: data.goalId,
listId: data.goalListId || ALL_TASKS_LIST_ID,
taskId: data.taskId,
},
})
}
})
}
return { supported, registered, init }
}
+44 -41
View File
@@ -1,3 +1,4 @@
// @ts-nocheck
import { Preferences } from '@capacitor/preferences'
import { SplashScreen } from '@capacitor/splash-screen'
import { type BundleInfo, CapacitorUpdater } from '@capgo/capacitor-updater'
@@ -21,61 +22,63 @@ function isNewerVersion(server: string, current: string): boolean {
}
export async function useUpdater(canUpdate: boolean = false) {
try {
const BRANCH_MODE: 'prod' | 'dev' = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
console.log(canUpdate);
return;
// try {
// const BRANCH_MODE: 'prod' | 'dev' = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
const updateInfo = await $api.get<AppResponse<{ version: string; file: string; branch: 'prod' | 'dev' }[]>>(
`${useTaskViewMainUrl()}/module/updates/info`,
)
// const updateInfo = await $api.get<AppResponse<{ version: string; file: string; branch: 'prod' | 'dev' }[]>>(
// `${useTaskViewMainUrl()}/module/updates/info`,
// )
const { value: currentVersion } = await Preferences.get({ key: 'app_version' })
// const { value: currentVersion } = await Preferences.get({ key: 'app_version' })
const last = updateInfo.data.response.find((i) => i.branch === BRANCH_MODE)
// const last = updateInfo.data.response.find((i) => i.branch === BRANCH_MODE)
console.log('branch', BRANCH_MODE)
// console.log('branch', BRANCH_MODE)
if (!last) {
console.log('[Update] No new updates for this branch.', BRANCH_MODE)
return
}
// if (!last) {
// console.log('[Update] No new updates for this branch.', BRANCH_MODE)
// return
// }
console.log('currentVersion', currentVersion, 'updateInfo', last)
// console.log('currentVersion', currentVersion, 'updateInfo', last)
if (!currentVersion || isNewerVersion(last.version, currentVersion) || (version && version?.version !== last.version)) {
console.log('[Update] Downloading new version:', last.version)
// if (!currentVersion || isNewerVersion(last.version, currentVersion) || (version && version?.version !== last.version)) {
// console.log('[Update] Downloading new version:', last.version)
version = await CapacitorUpdater.download({
version: last.version,
url: `${useTaskViewMainUrl()}/module/updates/download?branch=${BRANCH_MODE}`,
})
} else {
console.log('[Update] No new updates need to download.')
}
// version = await CapacitorUpdater.download({
// version: last.version,
// url: `${useTaskViewMainUrl()}/module/updates/download?branch=${BRANCH_MODE}`,
// })
// } else {
// console.log('[Update] No new updates need to download.')
// }
if (canUpdate && version) {
try {
console.log('[Update] New version available:', last.version)
// if (canUpdate && version) {
// try {
// console.log('[Update] New version available:', last.version)
SplashScreen.show()
// SplashScreen.show()
console.log('[Update] Version:', version)
// console.log('[Update] Version:', version)
await CapacitorUpdater.next(version)
// await CapacitorUpdater.next(version)
await Preferences.set({ key: 'app_version', value: last.version })
// await Preferences.set({ key: 'app_version', value: last.version })
await CapacitorUpdater.set(version)
// await CapacitorUpdater.set(version)
console.log('[set version]', last.version)
// console.log('[set version]', last.version)
console.log('[Update] Update applied. Will take effect after restart.')
} catch (err: unknown) {
console.log(err)
} finally {
SplashScreen.hide()
}
}
} catch (err) {
console.warn('[Update] Error checking for update:', err)
}
// console.log('[Update] Update applied. Will take effect after restart.')
// } catch (err: unknown) {
// console.log(err)
// } finally {
// SplashScreen.hide()
// }
// }
// } catch (err) {
// console.warn('[Update] Error checking for update:', err)
// }
}
+4
View File
@@ -47,9 +47,11 @@ import { useI18n } from 'vue-i18n'
import ProjectsSidebar from '@/components/features/projects/ProjectsSidebar.vue'
import NotificationBell from '@/components/NotificationBell.vue'
import { useCentrifugo } from '@/composables/useCentrifugo'
import { usePushNotifications } from '@/composables/usePushNotifications'
const { isSidebarOpen, isSidebarCollapsed } = useDashboard()
const { connect: connectCentrifugo } = useCentrifugo()
const { init: initPush } = usePushNotifications()
const { t } = useI18n()
const appStore = useAppStore()
@@ -75,8 +77,10 @@ App.addListener('appStateChange', async ({ isActive }) => {
})
onMounted(async () => {
console.log('-------------------------------- onMounted push notifications --------------------------------')
appStore.initTaskDetailDisplayMode()
connectCentrifugo()
initPush()
await CapacitorUpdater.notifyAppReady()
console.log('notifyAppReady', APP_VERSION)
+41
View File
@@ -97,6 +97,7 @@ export default {
graph: 'Graph',
collaboration: 'Collaboration',
integrations: 'Integrations',
webhooks: 'Webhooks',
edit: 'Edit',
moveToArchive: 'Move to Archive',
restoreFromArchive: 'Restore from Archive',
@@ -286,6 +287,33 @@ export default {
sync: 'Sync',
syncing: 'Syncing...',
},
webhooks: {
title: 'Webhooks',
selectProject: 'Select a project to manage webhooks',
add: 'Add Webhook',
empty: 'No webhooks yet. Add a webhook to receive event notifications.',
url: 'URL',
urlPlaceholder: 'https://example.com/webhook',
events: 'Events',
selectEvents: 'Select events',
test: 'Test',
deliveries: 'Deliveries',
noDeliveries: 'No deliveries yet',
rotateSecret: 'Rotate secret',
secretTitle: 'Webhook Secret',
secretDescription: 'Copy this secret and store it securely. It will not be shown again.',
event: 'Event',
status: 'Status',
httpCode: 'HTTP Code',
attempts: 'Attempts',
time: 'Time',
payload: 'Payload',
viewPayload: 'View payload',
copy: 'Copy',
edit: 'Edit',
allStatuses: 'All statuses',
rotateConfirm: 'Are you sure you want to rotate the secret? The current secret will stop working immediately and all webhook consumers will need to be updated with the new secret.',
},
main: 'Main',
mainScreen: {
welcome: 'Welcome',
@@ -422,6 +450,19 @@ export default {
empty: 'No notifications',
markAllRead: 'Mark all read',
loadMore: 'Load more',
settings: {
title: 'Notification Settings',
description: 'Choose which notifications you want to receive and how.',
push: 'Push',
websocket: 'In-app',
types: {
deadline: 'Deadlines',
deadlineDescription: 'When a task deadline is reached',
assign: 'Assignments',
assignDescription: 'When you are assigned to a task',
},
saved: 'Settings saved',
},
},
server: {
selectServer: 'Server',
+5
View File
@@ -64,6 +64,11 @@ const router = createRouter({
name: 'integrations',
component: () => import('./pages/user/integrations.vue'),
},
{
path: ':projectId/webhooks',
name: 'webhooks',
component: () => import('./pages/user/webhooks.vue'),
},
{
path: 'account',
name: 'account',
+37 -3
View File
@@ -3,26 +3,56 @@
id="graph"
>
<template #header>
<UDashboardNavbar :title="`${projectName} - ${t('graph.title')}`">
<UDashboardNavbar
:title="`${projectName} - ${t('graph.title')}`"
:ui="{title: 'shrink-0'}"
>
<template #leading>
<TvCollapseSidebarDesktop />
</template>
<template #right>
<div class="w-full">
<UButton
icon="i-lucide-filter"
:color="showFilters ? 'primary' : 'neutral'"
variant="soft"
size="lg"
:ui="{leadingIcon: 'size-4'}"
@click="showFilters = !showFilters"
/>
</div>
</template>
</UDashboardNavbar>
</template>
<template #body>
<ProjectGraph />
<div class="relative h-full">
<div
v-if="showFilters"
class="rounded absolute top-0 left-0 right-0 z-10 overflow-x-auto border-b border-default bg-elevated/90 backdrop-blur-sm px-3 py-2"
>
<div class="flex items-center justify-end gap-2 w-fit ml-auto">
<TvKanbanFilters
v-model:list-ids="selectedListIds"
v-model:assignee-ids="selectedAssigneeIds"
show-reset-label
/>
</div>
</div>
<ProjectGraph v-model:list-ids="selectedListIds" v-model:assignee-ids="selectedAssigneeIds" class="h-full w-full" />
</div>
</template>
</UDashboardPanel>
<TvTaskDetailOverlay />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ProjectGraph from '@/components/features/graph/ProjectGraph.vue'
import TvCollapseSidebarDesktop from '@/components/features/base/TvCollapseSidebarDesktop.vue'
import TvTaskDetailOverlay from '@/components/features/tasks/TvTaskDetailOverlay.vue'
import TvKanbanFilters from '@/components/features/base/TvKanbanFilters.vue'
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
import { useGoalsStore } from '@/stores/goals.store'
import { useProjectDataLoader } from '@/composables/useProjectDataLoader'
@@ -32,5 +62,9 @@ const { projectId } = useAppRouteInfo()
const goalsStore = useGoalsStore()
const projectName = computed(() => goalsStore.goalMap.get(projectId.value)?.name ?? '')
const showFilters = ref(false)
const selectedListIds = ref<number[]>([])
const selectedAssigneeIds = ref<number[]>([])
useProjectDataLoader(projectId)
</script>
+43 -1
View File
@@ -8,7 +8,38 @@
<template #leading>
<TvCollapseSidebarDesktop />
</template>
<template #right>
<div class="flex items-center gap-2 overflow-x-auto">
<div v-if="showFilters" class="hidden lg:contents">
<TvKanbanFilters
v-model:list-ids="selectedListIds"
v-model:assignee-ids="selectedAssigneeIds"
show-reset-label
/>
</div>
<UButton
icon="i-lucide-filter"
:color="showFilters ? 'primary' : 'neutral'"
variant="soft"
size="lg"
class="shrink-0"
:ui="{ leadingIcon: 'size-4' }"
@click="showFilters = !showFilters"
/>
</div>
</template>
</UDashboardNavbar>
<div
v-if="showFilters"
class="lg:hidden overflow-x-auto border-b border-default px-3 py-2"
>
<div class="flex items-center gap-2 w-fit ml-auto">
<TvKanbanFilters
v-model:list-ids="selectedListIds"
v-model:assignee-ids="selectedAssigneeIds"
/>
</div>
</div>
</template>
<template #body>
@@ -19,19 +50,30 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import KanbanBoard from '@/components/features/kanban/KanbanBoard.vue'
import TvCollapseSidebarDesktop from '@/components/features/base/TvCollapseSidebarDesktop.vue'
import TvTaskDetailOverlay from '@/components/features/tasks/TvTaskDetailOverlay.vue'
import TvKanbanFilters from '@/components/features/base/TvKanbanFilters.vue'
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
import { useProjectDataLoader } from '@/composables/useProjectDataLoader'
import { useGoalsStore } from '@/stores/goals.store'
import { useKanbanStore } from '@/stores/kanban.store'
const { t } = useI18n()
const { projectId } = useAppRouteInfo()
const goalsStore = useGoalsStore()
const kanbanStore = useKanbanStore()
const projectName = computed(() => goalsStore.goalMap.get(projectId.value)?.name ?? '')
const showFilters = ref(false)
const selectedListIds = ref<number[]>([])
const selectedAssigneeIds = ref<number[]>([])
watch([selectedListIds, selectedAssigneeIds], ([listIds, assigneeIds]) => {
kanbanStore.setFilters({ listIds, assigneeIds })
})
useProjectDataLoader(projectId)
</script>
+21
View File
@@ -0,0 +1,21 @@
<template>
<UDashboardPanel id="webhooks">
<template #header>
<UDashboardNavbar :title="t('webhooks.title')">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<WebhooksPanel />
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import WebhooksPanel from '@/components/features/webhooks/WebhooksPanel.vue'
const { t } = useI18n()
</script>
+14 -10
View File
@@ -3,6 +3,7 @@ import { TvApi } from 'taskview-api'
import type { App } from 'vue'
import $api from '@/helpers/axios'
import LocalStorage from '@/helpers/LocalStorage'
import { LS_KEY_MAIN_SERVER } from '@/composables/useAdditionalServer'
let $ls: LocalStorage
const $tvApi: TvApi = new TvApi($api)
@@ -18,10 +19,13 @@ const api = {
$ls = app.config.globalProperties.$ls
// const apiToken = await $ls.getToken();
// if (apiToken) {
// $tvApi = new TvApi($api);
// }
const savedServer = await $ls.getValue(LS_KEY_MAIN_SERVER)
console.log('savedServer', savedServer, LS_KEY_MAIN_SERVER)
if (savedServer) {
$api.defaults.baseURL = savedServer
$api.defaults.headers.common['ngrok-skip-browser-warning'] = "1"
$tvApi.setBaseUrl(savedServer)
}
console.debug('Install axios')
await $ls.checkTokenAndSetForAxios()
@@ -72,12 +76,12 @@ const api = {
return new Promise((resolve, reject) => {
$axios
.post<{
access: string;
refresh: string;
}>(
'/module/auth/refresh/token',
qs.stringify({ refreshToken }),
)
access: string;
refresh: string;
}>(
'/module/auth/refresh/token',
qs.stringify({ refreshToken }),
)
.then((data) => {
console.log('We got refresh data tokens', data)
$ls.setToken(data.data.access)
+21 -14
View File
@@ -12,6 +12,8 @@ export const useGraphStore = defineStore('use-graph-store', {
return {
nodes: [],
edges: [],
allNodes: [],
allEdges: [],
}
},
actions: {
@@ -30,24 +32,28 @@ export const useGraphStore = defineStore('use-graph-store', {
tasksStore.fetchAllTasks(goalId, 1, 1, true),
])
this.nodes = tasksStore.tasks.map((task) => ({
const nodes = tasksStore.tasks.map((task) => ({
id: task.id.toString(),
data: { task, label: `${task.description} == ${task.id}` },
position: task.nodeGraphPosition ?? { x: 0, y: 0 },
type: 'task',
style: { resize: 'none' },
style: { resize: 'none' as const },
}))
this.allNodes = nodes
this.nodes = [...nodes]
await this.fetchAllEdges(goalId)
},
async addNode(task: TaskItem) {
this.nodes.push({
const node = {
id: task.id.toString(),
data: { task, label: `${task.description} == ${task.id}` },
position: task.nodeGraphPosition ?? { x: 0, y: 0 },
type: 'task',
style: { resize: 'none' },
})
style: { resize: 'none' as const },
}
this.allNodes.push({ ...node })
this.nodes.push(node)
return task.id.toString()
},
@@ -63,6 +69,7 @@ export const useGraphStore = defineStore('use-graph-store', {
target: edge.toTaskId.toString(),
}
this.allEdges.push({ ...newEdge })
this.edges.push(newEdge)
return newEdge
@@ -71,29 +78,29 @@ export const useGraphStore = defineStore('use-graph-store', {
async fetchAllEdges(goalId: number) {
const result = await $tvApi.graph.fetchAllEdges(goalId).catch((err: unknown) => logError(err))
if (!result) return
this.edges = result.map((edge) => ({
const edges = result.map((edge) => ({
id: edge.id.toString(),
source: edge.fromTaskId.toString(),
target: edge.toTaskId.toString(),
}))
this.allEdges = edges
this.edges = [...edges]
},
removeTask(taskId: number) {
const nodeId = taskId.toString()
const nodeIndex = this.nodes.findIndex((n) => n.id === nodeId)
if (nodeIndex !== -1) {
this.nodes.splice(nodeIndex, 1)
}
this.allNodes = this.allNodes.filter((n) => n.id !== nodeId)
this.allEdges = this.allEdges.filter((e) => e.source !== nodeId && e.target !== nodeId)
this.nodes = this.nodes.filter((n) => n.id !== nodeId)
this.edges = this.edges.filter((e) => e.source !== nodeId && e.target !== nodeId)
},
async deleteEdge(id: number) {
const result = await $tvApi.graph.deleteEdge(id).catch((err: unknown) => logError(err))
if (!result) return
const index = this.edges.findIndex((edge) => edge.id === id.toString())
if (index !== -1) {
this.edges.splice(index, 1)
}
const idStr = id.toString()
this.allEdges = this.allEdges.filter((e) => e.id !== idStr)
this.edges = this.edges.filter((e) => e.id !== idStr)
},
syncTask(task: { id: number; description?: string }) {
+25 -6
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import type { GoalItem, KanbanArgDeleteColumn, KanbanArgUpdateColumn, KanbanArgUpdateTasksOrder, KanbanColumnItem, TaskArgAdd, KanbanArgAddColumn } from 'taskview-api'
import type { GoalItem, KanbanArgDeleteColumn, KanbanArgUpdateColumn, KanbanArgUpdateTasksOrder, KanbanColumnItem, KanbanFilters, TaskArgAdd, KanbanArgAddColumn } from 'taskview-api'
import { DEFAULT_ID } from '@/types/app.types'
import type { KanbanStoreState } from '@/types/kanban.types'
import { $tvApi } from '@/plugins/axios'
@@ -11,11 +11,12 @@ export const useKanbanStore = defineStore('kanban', {
tasksData: {},
statuses: [],
goalId: DEFAULT_ID,
filters: {},
}
},
actions: {
async fetchTasksForColumn(goalId: GoalItem['id'], columnId: KanbanColumnItem['id'] | null, cursor: string | number | null) {
const result = await $tvApi.kanban.fetchTasksForColumn(goalId, columnId, cursor).catch(console.error)
const result = await $tvApi.kanban.fetchTasksForColumn(goalId, columnId, cursor, this.filters).catch(console.error)
if (!result) {
if (!this.tasksData[columnId ?? DEFAULT_ID]) {
@@ -134,14 +135,24 @@ export const useKanbanStore = defineStore('kanban', {
},
syncTask(task: TaskItem) {
const targetColumnId = task.statusId ?? DEFAULT_ID
for (const columnId of Object.keys(this.tasksData)) {
const column = this.tasksData[Number(columnId)]
if (!column) continue
const localTask = column.tasks.find((t) => t.id === task.id)
if (localTask) {
Object.assign(localTask, task)
return
const index = column.tasks.findIndex((t) => t.id === task.id)
if (index === -1) continue
if (Number(columnId) === targetColumnId) {
Object.assign(column.tasks[index], task)
} else {
column.tasks.splice(index, 1)
const targetColumn = this.tasksData[targetColumnId]
if (targetColumn) {
targetColumn.tasks.unshift(task)
}
}
return
}
},
@@ -157,6 +168,14 @@ export const useKanbanStore = defineStore('kanban', {
}
},
async setFilters(filters: KanbanFilters) {
this.filters = filters
this.tasksData = {}
for (const status of this.statuses) {
await this.fetchTasksForColumn(this.goalId, status.id === DEFAULT_ID ? null : status.id, null)
}
},
async deleteStatus(data: KanbanArgDeleteColumn) {
const result = await $tvApi.kanban.deleteColumn(data)
if (!result) {
+8 -1
View File
@@ -220,7 +220,7 @@ export const useTasksStore = defineStore('tasks', {
if (taskResult.parentId) {
const subTask = parentTask.subtasks.find((sub) => sub.id === +taskResult.id)
if (subTask) {
subTask.complete = taskResult.complete
Object.assign(subTask, taskResult)
}
} else {
//we updating main task
@@ -255,6 +255,13 @@ export const useTasksStore = defineStore('tasks', {
const localTask = this.tasks.find(({ id }) => id === task.id)
if (localTask) {
Object.assign(localTask, task)
} else if (task.parentId) {
const parent = this.tasks.find(({ id }) => id === task.parentId)
?? (this.selectedTask?.id === task.parentId ? this.selectedTask : null)
const subtask = parent?.subtasks.find((s) => s.id === task.id)
if (subtask) {
Object.assign(subtask, task)
}
}
this.updateSelectedTask(task)
return true
+80
View File
@@ -0,0 +1,80 @@
import { defineStore } from 'pinia'
import type { WebhookItem, WebhookDeliveryItem } from 'taskview-api'
import { $tvApi } from '@/plugins/axios'
interface WebhooksStoreState {
webhooks: WebhookItem[]
deliveries: WebhookDeliveryItem[]
loading: boolean
deliveriesLoading: boolean
}
export const useWebhooksStore = defineStore('webhooks', {
state: (): WebhooksStoreState => ({
webhooks: [],
deliveries: [],
loading: false,
deliveriesLoading: false,
}),
actions: {
async fetchWebhooks(goalId: number): Promise<void> {
if (this.loading) return
this.loading = true
const result = await $tvApi.webhooks.fetch(goalId)
this.webhooks = result || []
this.loading = false
},
async createWebhook(data: { goalId: number; url: string; events: string[] }): Promise<{ webhook: WebhookItem; secret: string } | null> {
const result = await $tvApi.webhooks.create(data)
if (!result) return null
this.webhooks.push(result.webhook)
return result
},
async updateWebhook(data: { id: number; url?: string; events?: string[]; isActive?: boolean }): Promise<void> {
const result = await $tvApi.webhooks.update(data)
if (!result) return
const index = this.webhooks.findIndex((w) => w.id === data.id)
if (index !== -1) {
this.webhooks[index] = result
}
},
async deleteWebhook(id: number): Promise<void> {
const result = await $tvApi.webhooks.delete({ id })
if (!result) return
const index = this.webhooks.findIndex((w) => w.id === id)
if (index !== -1) {
this.webhooks.splice(index, 1)
}
},
async rotateSecret(id: number): Promise<string | null> {
const result = await $tvApi.webhooks.rotateSecret(id)
return result?.secret ?? null
},
async testDelivery(id: number): Promise<{ success: boolean; responseCode?: number } | null> {
return await $tvApi.webhooks.testDelivery(id)
},
async fetchDeliveries(webhookId: number, options?: { cursor?: number; status?: string; append?: boolean }): Promise<void> {
this.deliveriesLoading = true
const result = await $tvApi.webhooks.fetchDeliveries(webhookId, {
cursor: options?.cursor,
status: options?.status,
})
if (options?.append) {
this.deliveries.push(...(result || []))
} else {
this.deliveries = result || []
}
this.deliveriesLoading = false
},
async retryDelivery(deliveryId: number): Promise<{ success: boolean; responseCode?: number } | null> {
return await $tvApi.webhooks.retryDelivery(deliveryId)
},
},
})
+2
View File
@@ -3,4 +3,6 @@ import type { Edge, Node } from '@vue-flow/core'
export type GraphStore = {
nodes: Node[];
edges: Edge[];
allNodes: Node[];
allEdges: Edge[];
};
+2 -1
View File
@@ -1,4 +1,4 @@
import type { GoalItem, KanbanColumnItem, KanbanResponseTasksForColumn } from 'taskview-api'
import type { GoalItem, KanbanColumnItem, KanbanFilters, KanbanResponseTasksForColumn } from 'taskview-api'
export type KanbanStoreState = {
tasksData: {
@@ -7,4 +7,5 @@ export type KanbanStoreState = {
// TODO: rename to columns
statuses: KanbanColumnItem[];
goalId: GoalItem['id'];
filters: KanbanFilters;
};
+6
View File
@@ -30,6 +30,9 @@ export default defineConfig({
vue(),
ui({
ui: {
inputTime: {
slots: { base: 'text-base!' }
},
drawer: {
slots: {
container: 'pb-[calc(var(--tv-safe-area-inset-bottom)+16px)]'
@@ -153,4 +156,7 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5174
}
})