mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 13:29:17 +00:00
wip: notifications
This commit is contained in:
@@ -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;
|
||||
|
||||
+118
-1
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -3,4 +3,6 @@ import type { Edge, Node } from '@vue-flow/core'
|
||||
export type GraphStore = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
allNodes: Node[];
|
||||
allEdges: Edge[];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user