Merge pull request #18 from Gimanh/feat/nuxt-ui-client

feat: web and mobile client in with nuxt-ui lib
This commit is contained in:
Gimanh
2026-02-21 22:16:40 +01:00
committed by GitHub
686 changed files with 19220 additions and 2280 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ SMTP_PASSWORD="smtp-password"
SMTP_ENCRYPTION="ssl"
SMTP_FROM_NAME="TaskViewApiServer"
SMTP_FROM_EMAIL="smtp"
CORS_ALLOWED_ORIGINS="http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://127.0.0.1:3000"
CORS_ALLOWED_ORIGINS="http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://localhost:8888,http://127.0.0.1:3000,http://127.0.0.1:8888"
# Constant value
APP_URL="https://taskview.handscream.com"
+3 -3
View File
@@ -14,7 +14,7 @@ services:
timeout: 5s
retries: 5
migration:
image: gimanhead/taskview-ce-db-migration:1.18.2
image: gimanhead/taskview-ce-db-migration:latest
restart: "no"
depends_on:
db:
@@ -23,12 +23,12 @@ services:
- ./.env.taskview
taskview-ce-webapp:
image: gimanhead/taskview-ce-webapp:1.18.2
image: gimanhead/taskview-ce-webapp:latest
restart: "no"
ports:
- "8888:80"
taskview-api-server:
image: gimanhead/taskview-ce-api-server:1.18.2
image: gimanhead/taskview-ce-api-server:latest
restart: "no"
ports:
- "1725:1401"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.19.6",
"version": "1.20.1",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"context7@claude-plugins-official": true
}
}
+13
View File
@@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+37
View File
@@ -0,0 +1,37 @@
name: ci
on: push
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [22]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm run lint
- name: Build
run: pnpm run build
- name: Typecheck
run: pnpm run typecheck
+38 -2
View File
@@ -1,2 +1,38 @@
!build-upload.sh
tsconfig.tsbuildinfo
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Auto-generated type declarations
auto-imports.d.ts
components.d.ts
tsconfig.app.tsbuildinfo
tsconfig.node.tsbuildinfo
# Environment variables
.env
Info.plist
e2e_logs
e2e_pgdata
e2e_updates
playwright-report
+2 -2
View File
@@ -54,8 +54,8 @@ captures/
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
+5 -8
View File
@@ -1,28 +1,25 @@
apply plugin: 'com.android.application'
android {
namespace "com.handscream.taskview.app"
compileSdk rootProject.ext.compileSdkVersion
namespace = "com.handscream.taskview.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.handscreamgnl.taskview.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1196
versionName "1.19.6"
versionCode 1201
versionName "1.20.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
setProperty("archivesBaseName", "TaskView-$versionName")
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.debug
}
}
}
+8 -9
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -8,23 +9,20 @@
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="taskview" />
</intent-filter>
</activity>
<provider
@@ -32,8 +30,9 @@
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+2 -2
View File
@@ -7,8 +7,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.3'
classpath 'com.google.gms:google-services:4.4.2'
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
+7 -7
View File
@@ -1,21 +1,21 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@7.4.5_@capacitor+core@7.4.5/node_modules/@capacitor/android/capacitor')
project(':capacitor-android').projectDir = new File('../../../node_modules/.pnpm/@capacitor+android@8.1.0_@capacitor+core@8.1.0/node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@7.0.1_@capacitor+core@7.4.5/node_modules/@capacitor/app/android')
project(':capacitor-app').projectDir = new File('../../../node_modules/.pnpm/@capacitor+app@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/app/android')
include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@7.0.3_@capacitor+core@7.4.5/node_modules/@capacitor/browser/android')
project(':capacitor-browser').projectDir = new File('../../../node_modules/.pnpm/@capacitor+browser@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/browser/android')
include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@7.0.1_@capacitor+core@7.4.5/node_modules/@capacitor/device/android')
project(':capacitor-device').projectDir = new File('../../../node_modules/.pnpm/@capacitor+device@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/device/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../../../node_modules/.pnpm/@capacitor+preferences@7.0.1_@capacitor+core@7.4.5/node_modules/@capacitor/preferences/android')
project(':capacitor-preferences').projectDir = new File('../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/preferences/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@7.0.4_@capacitor+core@7.4.5/node_modules/@capacitor/splash-screen/android')
project(':capacitor-splash-screen').projectDir = new File('../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.1.0/node_modules/@capacitor/splash-screen/android')
include ':capgo-capacitor-updater'
project(':capgo-capacitor-updater').projectDir = new File('../../../node_modules/.pnpm/@capgo+capacitor-updater@7.41.1_@capacitor+core@7.4.5/node_modules/@capgo/capacitor-updater/android')
project(':capgo-capacitor-updater').projectDir = new File('../../../node_modules/.pnpm/@capgo+capacitor-updater@8.43.2_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater/android')
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+14 -11
View File
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -83,7 +85,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -111,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -144,7 +147,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -152,7 +155,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -201,16 +204,16 @@ fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
+94 -92
View File
@@ -1,92 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+13 -13
View File
@@ -1,16 +1,16 @@
ext {
minSdkVersion = 23
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.12.1'
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
+24 -23
View File
@@ -1,30 +1,31 @@
import type { CapacitorConfig } from '@capacitor/cli';
import type { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'com.handscream.taskview.app',
appName: 'TaskView',
webDir: 'dist',
appId: 'com.handscream.taskview.app',
appName: 'TaskView',
webDir: 'dist',
zoomEnabled: false,
android: {
zoomEnabled: false,
android: {
zoomEnabled: false,
},
ios: {
zoomEnabled: false,
webContentsDebuggingEnabled: true,
},
server: {
hostname: 'app.taskview.tech',
},
plugins: {
CapacitorUpdater: {
autoUpdate: false,
},
ios: {
zoomEnabled: false,
CapacitorCookies: {
enabled: true,
},
server: {
hostname: 'app.taskview.tech',
CapacitorHttp: {
enabled: true,
},
plugins: {
CapacitorUpdater: {
autoUpdate: false,
},
CapacitorCookies: {
enabled: true,
},
CapacitorHttp: {
enabled: true,
},
},
};
},
}
export default config;
export default config
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test'
import { TEST_USER } from './fixtures/auth'
test.describe('Login', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('shows login page with welcome message', async ({ page }) => {
await expect(page.getByRole('heading', { level: 1 })).toContainText(/welcome|возвращением/i)
})
test('user can login with password and reach dashboard', async ({ page }) => {
// Switch to Password tab (default is Magic link)
await page.getByRole('tab', { name: /password|пароль/i }).click()
// Fill credentials
await page.getByTestId('login-input').fill(TEST_USER.login)
await page.getByTestId('password-input').fill(TEST_USER.password)
// Submit
await page.getByTestId('sign-in-button').click()
// Should redirect to user dashboard
await expect(page).toHaveURL(/\/user/)
// Dashboard should be visible (sidebar or main content)
await expect(page.locator('body')).not.toContainText(/invalid|неверный|error|ошибка/i)
})
})
+51
View File
@@ -0,0 +1,51 @@
# E2E test stack: PostgreSQL + migration + API
# Run from repo root: bash build-dockers.sh (builds images first)
# Then: docker compose -f se/web-nuxt-ui/e2e/docker-compose.yml up -d
#
# API is exposed on 1401 so web-nuxt-ui (useTaskViewMainUrl) can connect.
services:
db:
image: postgres:17
restart: unless-stopped
env_file:
- ../../dockers-check/.env.postgresql
volumes:
- ./e2e_pgdata:/var/lib/postgresql/data
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tvdbuser -d taskviewdb"]
interval: 5s
timeout: 5s
retries: 5
migration:
image: gimanhead/taskview-se-db-migration:latest
restart: "no"
depends_on:
db:
condition: service_healthy
env_file:
- ../../dockers-check/.env.taskview
api:
image: gimanhead/taskview-se-api-server:latest
restart: "no"
ports:
- "1401:1401"
depends_on:
db:
condition: service_healthy
migration:
condition: service_completed_successfully
env_file:
- ../../dockers-check/.env.taskview
volumes:
- ./e2e_logs:/usr/src/app/logs
- ./e2e_updates:/usr/src/app/updates
volumes:
e2e_pgdata:
e2e_logs:
e2e_updates:
+8
View File
@@ -0,0 +1,8 @@
/**
* E2E auth fixtures. Credentials match DB seed from community migrations
* (user / user1!#Q from app_permissions.sql).
*/
export const TEST_USER = {
login: 'user',
password: 'user1!#Q',
}
+25
View File
@@ -0,0 +1,25 @@
import { execSync } from 'node:child_process'
import { readFileSync, existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
function loadEnv(dir: string) {
const envPath = path.join(dir, '.env')
if (!existsSync(envPath)) return
for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
const m = line.match(/^([^#=]+)=(.*)$/)
if (m) process.env[m[1].trim()] = m[2].trim()
}
}
export default async function globalSetup() {
const e2eDir = path.dirname(fileURLToPath(import.meta.url))
const projectDir = path.join(e2eDir, '..')
loadEnv(projectDir)
const setupScript = path.join(e2eDir, 'docker-setup.sh')
execSync(`bash "${setupScript}"`, {
stdio: 'inherit',
cwd: path.join(e2eDir, '../../..'),
env: process.env,
})
}
+16
View File
@@ -0,0 +1,16 @@
import { execSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export default async function globalTeardown() {
const e2eDir = path.dirname(fileURLToPath(import.meta.url))
const teardownScript = path.join(e2eDir, 'docker-teardown.sh')
try {
execSync(`bash "${teardownScript}"`, {
stdio: 'inherit',
cwd: path.join(e2eDir, '../../..'),
})
} catch {
console.warn('[e2e] Teardown: some containers may have already been stopped')
}
}
+71
View File
@@ -0,0 +1,71 @@
import { test, expect } from '@playwright/test'
import { login } from './test-helpers'
async function addProject(page: import('@playwright/test').Page, name: string) {
const input = page.getByTestId('project-add-input').first()
await input.waitFor({ state: 'visible', timeout: 15000 })
await input.fill(name)
await input.press('Enter')
await expect(page.getByText(name).first()).toBeVisible({ timeout: 10000 })
}
async function openProjectMenu(page: import('@playwright/test').Page, projectName: string) {
const row = page.getByTestId(`project-row-${projectName}`)
await row.first().waitFor({ state: 'visible', timeout: 10000 })
await row.first().getByTestId('project-menu-trigger').click()
}
test.describe('Projects', () => {
test.setTimeout(15_000)
test.beforeEach(async ({ page }) => {
await page.goto('/')
await login(page)
})
test('user can add project', async ({ page }) => {
const projectName = `Test Project ${Date.now()}`
await addProject(page, projectName)
})
test('user can edit project', async ({ page }) => {
const projectName = `Edit Me ${Date.now()}`
await addProject(page, projectName)
const newName = `Edited ${Date.now()}`
await openProjectMenu(page, projectName)
await page.getByTestId('context-menu-edit').click()
await page.getByTestId('project-edit-name').fill(newName)
await page.getByTestId('project-edit-save').click()
await expect(page.getByText(newName).first()).toBeVisible({ timeout: 5000 })
})
test('user can delete project', async ({ page }) => {
const projectName = `Delete Me ${Date.now()}`
await addProject(page, projectName)
await openProjectMenu(page, projectName)
await page.getByTestId('context-menu-delete').click()
await page.getByRole('button', { name: /^yes|^да$/i }).click()
await expect(page.getByText(projectName)).toHaveCount(0)
})
test('user can archive project', async ({ page }) => {
const projectName = `Archive Me ${Date.now()}`
await addProject(page, projectName)
await openProjectMenu(page, projectName)
await page.getByTestId('context-menu-move-to-archive').click()
await page.getByTestId('archive-list-collapsible').click()
await expect(page.getByTestId(`project-row-${projectName}`)).toBeVisible()
})
test('user can unarchive project', async ({ page }) => {
const projectName = `Unarchive Me ${Date.now()}`
await addProject(page, projectName)
await openProjectMenu(page, projectName)
await page.getByTestId('context-menu-move-to-archive').click()
await page.getByTestId('archive-list-collapsible').click()
await openProjectMenu(page, projectName)
await page.getByTestId('context-menu-restore-from-archive').click({ force: true })
await expect(page.getByText(projectName).first()).toBeVisible({ timeout: 5000 })
})
})
+8
View File
@@ -0,0 +1,8 @@
import { TEST_USER } from './fixtures/auth'
export async function login(page: Page) {
await page.getByRole('tab', { name: /password|пароль/i }).click()
await page.getByTestId('login-input').fill(TEST_USER.login)
await page.getByTestId('password-input').fill(TEST_USER.password)
await page.getByTestId('sign-in-button').click()
}
+95
View File
@@ -0,0 +1,95 @@
import js from '@eslint/js'
import eslintPluginVue from 'eslint-plugin-vue'
import ts from 'typescript-eslint'
import { fileURLToPath } from 'node:url'
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...eslintPluginVue.configs['flat/recommended'],
{
files: ['*.vue', '**/*.vue'],
languageOptions: {
parserOptions: {
parser: '@typescript-eslint/parser',
tsconfigRootDir: fileURLToPath(new URL('.', import.meta.url)),
},
},
rules: {
'vue/multi-word-component-names': 'off',
'no-undef': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
// Formatting rules
'vue/script-indent': ['error', 2, { baseIndent: 0 }],
'vue/html-indent': ['error', 2],
'vue/max-attributes-per-line': [
'error',
{
singleline: 1,
multiline: 1,
},
],
'vue/first-attribute-linebreak': [
'error',
{
singleline: 'ignore',
multiline: 'below',
},
],
'vue/html-closing-bracket-newline': [
'error',
{
singleline: 'never',
multiline: 'always',
},
],
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'always',
component: 'always',
},
svg: 'always',
math: 'always',
},
],
'vue/attribute-hyphenation': ['error', 'always'],
'vue/v-bind-style': ['error', 'shorthand'],
'vue/v-on-style': ['error', 'shorthand'],
'vue/v-slot-style': ['error', 'shorthand'],
},
},
{
files: ['**/*.ts', '**/*.js', '**/*.vue'],
rules: {
semi: ['error', 'never'],
quotes: ['error', 'single'],
'comma-dangle': ['error', 'always-multiline'],
'keyword-spacing': ['error', { before: true, after: true }],
'space-before-blocks': ['error', 'always'],
'space-before-function-paren': ['error', { anonymous: 'always', named: 'never', asyncArrow: 'always' }],
'space-infix-ops': 'error',
'space-in-parens': ['error', 'never'],
'object-curly-spacing': ['error', 'always'],
'array-bracket-spacing': ['error', 'never'],
'comma-spacing': ['error', { before: false, after: true }],
'key-spacing': ['error', { beforeColon: false, afterColon: true }],
'arrow-spacing': ['error', { before: true, after: true }],
},
},
{
files: ['**/*.ts', '**/*.js'],
rules: {
indent: ['error', 2],
},
},
{
files: ['**/*.vue'],
rules: {
indent: 'off',
},
},
)
+25 -12
View File
@@ -1,14 +1,27 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/tv-logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Task View</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon"
type="image/svg+xml"
href="/logo.svg" />
<meta name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="preconnect"
href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=public-sans:400,500,600,700"
rel="stylesheet" />
<title>TaskView</title>
<meta name="description"
content="TaskView is a self-hosted project and task management platform focused on clarity, ownership, and control.">
</head>
<body>
<div id="app"
class="isolate"></div>
<script type="module"
src="/src/main.ts"></script>
</body>
</html>
+56 -98
View File
@@ -3,37 +3,31 @@
archiveVersion = 1;
classes = {
};
objectVersion = 48;
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
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 */; };
A75AC5A184BA226F87D9DFB4 /* Pods_TaskView.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C33826060E12180E2E6AE45B /* Pods_TaskView.framework */; };
AA2DDEAF2BD5EE2800137350 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA2DDEAE2BD5EE2800137350 /* PrivacyInfo.xcprivacy */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
3DDB5660BE7C0283C0A61E6B /* Pods-TaskView.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TaskView.release.xcconfig"; path = "Pods/Target Support Files/Pods-TaskView/Pods-TaskView.release.xcconfig"; sourceTree = "<group>"; };
4ED8FB2BBB1F064473F2BF99 /* Pods-TaskView.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TaskView.debug.xcconfig"; path = "Pods/Target Support Files/Pods-TaskView/Pods-TaskView.debug.xcconfig"; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* TaskView.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TaskView.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AA2DDEAE2BD5EE2800137350 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
C33826060E12180E2E6AE45B /* Pods_TaskView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TaskView.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -41,36 +35,26 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A75AC5A184BA226F87D9DFB4 /* Pods_TaskView.framework in Frameworks */,
4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup;
children = (
C33826060E12180E2E6AE45B /* Pods_TaskView.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
AA2DDEAE2BD5EE2800137350 /* PrivacyInfo.xcprivacy */,
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* TaskView.app */,
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
@@ -90,37 +74,27 @@
path = App;
sourceTree = "<group>";
};
7F8756D8B27F46E3366F6CEA /* Pods */ = {
isa = PBXGroup;
children = (
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
4ED8FB2BBB1F064473F2BF99 /* Pods-TaskView.debug.xcconfig */,
3DDB5660BE7C0283C0A61E6B /* Pods-TaskView.release.xcconfig */,
);
name = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* TaskView */ = {
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "TaskView" */;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = TaskView;
name = App;
packageProductDependencies = (
4D22ABE82AF431CB00220026 /* CapApp-SPM */,
);
productName = App;
productReference = 504EC3041FED79650016851F /* TaskView.app */;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -129,8 +103,8 @@
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -148,11 +122,14 @@
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */,
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* TaskView */,
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
@@ -163,7 +140,6 @@
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
AA2DDEAF2BD5EE2800137350 /* PrivacyInfo.xcprivacy in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
@@ -174,42 +150,6 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-TaskView-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TaskView/Pods-TaskView-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -243,6 +183,7 @@
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
@@ -289,7 +230,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -340,28 +281,30 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 4ED8FB2BBB1F064473F2BF99 /* Pods-TaskView.debug.xcconfig */;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.19.7;
CURRENT_PROJECT_VERSION = 1.20.1;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TaskView;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.19.7;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.20.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -373,18 +316,18 @@
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 3DDB5660BE7C0283C0A61E6B /* Pods-TaskView.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.19.7;
CURRENT_PROJECT_VERSION = 1.20.1;
DEVELOPMENT_TEAM = H2W2SG48JT;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TaskView;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.19.7;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.20.1;
PRODUCT_BUNDLE_IDENTIFIER = com.handscream.taskview.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -405,7 +348,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "TaskView" */ = {
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
@@ -415,6 +358,21 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "CapApp-SPM";
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
4D22ABE82AF431CB00220026 /* CapApp-SPM */ = {
isa = XCSwiftPackageProductDependency;
package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */;
productName = "CapApp-SPM";
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}
@@ -0,0 +1,51 @@
{
"originHash" : "e5bfee72a3975177edbeb9593e2d4a7624097d5319fbcc2894685c2c76545af8",
"pins" : [
{
"identity" : "alamofire",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Alamofire/Alamofire.git",
"state" : {
"revision" : "3f99050e75bbc6fe71fc323adabb039756680016",
"version" : "5.11.1"
}
},
{
"identity" : "bigint",
"kind" : "remoteSourceControl",
"location" : "https://github.com/attaswift/BigInt.git",
"state" : {
"revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe",
"version" : "5.7.0"
}
},
{
"identity" : "capacitor-swift-pm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
"state" : {
"revision" : "1f324fe7bdbdc8c5cc28bea48a231545a8c2e823",
"version" : "8.1.0"
}
},
{
"identity" : "version",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mrackwitz/Version.git",
"state" : {
"revision" : "fd4b0eb5756aa7f1c33977fb626cf37d2140a3a0",
"version" : "0.8.0"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
}
}
],
"version" : 3
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@@ -24,7 +24,7 @@
</scene>
</scenes>
<resources>
<image name="Splash" width="1639" height="1639"/>
<image name="Splash" width="1366" height="1366"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
+9
View File
@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
+37
View File
@@ -0,0 +1,37 @@
// swift-tools-version: 5.9
import PackageDescription
// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
let package = Package(
name: "CapApp-SPM",
platforms: [.iOS(.v15)],
products: [
.library(
name: "CapApp-SPM",
targets: ["CapApp-SPM"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.1.0"),
.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: "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.2_@capacitor+core@8.1.0/node_modules/@capgo/capacitor-updater")
],
targets: [
.target(
name: "CapApp-SPM",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorBrowser", package: "CapacitorBrowser"),
.product(name: "CapacitorDevice", package: "CapacitorDevice"),
.product(name: "CapacitorPreferences", package: "CapacitorPreferences"),
.product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"),
.product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater")
]
)
]
)
+5
View File
@@ -0,0 +1,5 @@
# CapApp-SPM
This SPM is used to host SPM dependencies for you Capacitor project
Do not modify the contents of it or there may be unintended consequences.
@@ -0,0 +1 @@
public let isCapacitorApp = true
+1
View File
@@ -0,0 +1 @@
CAPACITOR_DEBUG = true
+65 -80
View File
@@ -1,99 +1,84 @@
{
"name": "taskview-ce-webapp",
"version": "1.19.6",
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.20.1",
"scripts": {
"dev": "vite",
"build:dev": "BRANCH_MODE=dev pnpm run build",
"build": "run-p type-check build-only",
"preview": "vite preview",
"test:unit": "vitest --environment jsdom --root src/",
"test:e2e": "start-server-and-test preview :4173 'cypress run --e2e'",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'",
"build": "pnpm run typecheck && vite build",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit --project tsconfig.json",
"lint": "eslint . --config eslint.config.mjs --fix",
"lint:biome": "biome lint .",
"format": "biome format --write .",
"check": "biome check --write ."
"preview": "vite preview",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format": "eslint src --fix",
"typecheck": "vue-tsc -p ./tsconfig.app.json",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:install": "playwright install chromium",
"test:e2e:skip-build": "SKIP_BUILD=1 playwright test"
},
"dependencies": {
"@capacitor/android": "^7.0.0",
"@capacitor/app": "7.0.1",
"@capacitor/browser": "^7.0.3",
"@capacitor/core": "^7.0.0",
"@capacitor/device": "7.0.1",
"@capacitor/ios": "^7.0.0",
"@capacitor/preferences": "7.0.1",
"@capacitor/splash-screen": "^7.0.1",
"@capgo/capacitor-updater": "^7.7.7",
"@capacitor/android": "^8.1.0",
"@capacitor/app": "8.0.1",
"@capacitor/browser": "^8.0.1",
"@capacitor/core": "^8.1.0",
"@capacitor/device": "8.0.1",
"@capacitor/ios": "^8.1.0",
"@capacitor/preferences": "8.0.1",
"@capacitor/splash-screen": "^8.0.1",
"@capgo/capacitor-updater": "^8.43.2",
"@dagrejs/dagre": "^1.1.5",
"@tiptap/extension-color": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list-item": "^3.0.0",
"@tiptap/extension-placeholder": "^3.0.0",
"@tiptap/extension-text-style": "^3.0.0",
"@tiptap/pm": "^3.0.0",
"@tiptap/starter-kit": "^3.0.0",
"@tiptap/vue-3": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^8.24.1",
"@typescript-eslint/parser": "^8.24.1",
"@iconify-json/carbon": "^1.2.18",
"@iconify-json/lucide": "^1.2.90",
"@iconify-json/mdi": "^1.2.3",
"@iconify/vue": "^5.0.0",
"@internationalized/date": "^3.10.1",
"@nuxt/ui": "^4.4.0",
"@tanstack/table-core": "^8.21.3",
"@tiptap/core": "^3.18.0",
"@tiptap/extension-drag-handle-vue-3": "^3.18.0",
"@tiptap/extension-text-align": "^3.18.0",
"@tiptap/extension-underline": "^3.18.0",
"@tiptap/pm": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"@tiptap/vue-3": "^3.18.0",
"@types/qs": "^6.14.0",
"@unovis/ts": "^1.6.2",
"@unovis/vue": "^1.6.2",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.46.4",
"@vue-flow/core": "^1.48.1",
"@vue-flow/minimap": "^1.5.4",
"@vue-flow/node-resizer": "^1.5.0",
"@vue-flow/node-toolbar": "^1.1.1",
"@vue/eslint-config-typescript": "^14.4.0",
"@vueuse/components": "^10.9.0",
"@vueuse/core": "^10.9.0",
"echarts": "^5.5.1",
"globals": "^15.15.0",
"motion-v": "^1.1.0-alpha.1",
"pinia": "^2.0.28",
"@vueuse/core": "^14.1.0",
"arktype": "2.1.20",
"axios": "^1.13.4",
"date-fns": "^4.1.0",
"pinia": "^2.3.1",
"qs": "^6.14.1",
"scule": "^1.3.0",
"tailwindcss": "^4.1.18",
"taskview-api": "workspace:^",
"vue": "^3.2.45",
"vue-router": "4.3.2",
"vuedraggable": "^4.1.0"
"vue": "^3.5.27",
"vue-i18n": "^11.2.8",
"vue-router": "^4.6.4",
"vuedraggable": "^4.1.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^7.0.0",
"@date-io/date-fns": "^3.2.0",
"@mdi/js": "^7.1.96",
"@rushstack/eslint-patch": "^1.1.4",
"@types/jsdom": "^20.0.1",
"@types/node": "^18.19.119",
"@types/qs": "^6.9.7",
"@vitejs/plugin-vue": "^4.0.0",
"@vitejs/plugin-vue-jsx": "^3.0.0",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/test-utils": "^2.2.6",
"@vue/tsconfig": "0.7.0",
"autoprefixer": "^10.4.20",
"axios": "^1.2.3",
"check-password-strength": "^2.0.7",
"eslint": "9.20.1",
"eslint-plugin-vue": "^9.3.0",
"jsdom": "^20.0.3",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.49",
"prettier": "^2.7.1",
"qs": "^6.11.0",
"sass": "^1.69.7",
"sass-loader": "^13.3.3",
"start-server-and-test": "^1.15.2",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.3",
"typescript-eslint": "^8.22.0",
"vite": "^4.0.0",
"vitest": "^0.25.6",
"vue-i18n": "11.1.10",
"vue-tsc": "3.0.3",
"vuetify": "3.7.9"
"@capacitor/cli": "^8.1.0",
"@playwright/test": "^1.49.0",
"@vitejs/plugin-vue": "^6.0.3",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.7.0",
"playwright": "^1.49.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1",
"vite": "^7.3.1",
"vue-tsc": "^3.2.2"
},
"engines": {
"node": ">=24 <25"
}
"packageManager": "pnpm@10.28.1"
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
// globalSetup: './e2e/global-setup.ts',
// globalTeardown: './e2e/global-teardown.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: process.env.CI
? undefined
: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
timeout: 15_000,
expect: { timeout: 5_000 },
})

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+13
View File
@@ -0,0 +1,13 @@
{
"extends": [
"github>nuxt/renovate-config-nuxt"
],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [{
"matchDepTypes": ["resolutions"],
"enabled": false
}],
"postUpdateOptions": ["pnpmDedupe"]
}
+5 -45
View File
@@ -1,49 +1,9 @@
<template>
<component :is="layout">
<router-view />
</component>
<UApp>
<RouterView />
</UApp>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent } from 'vue';
import { useAdditionalServer } from '@/composition/useAdditionalServer';
import { isLoggedIn } from '@/helpers/app-helper';
export default defineComponent({
components: {
DefaultLayout: defineAsyncComponent(() => import('@/layout/DefaultLayout.vue')),
AdminLayout: defineAsyncComponent(() => import('@/layout/AdminLayout.vue')),
UserLayout: defineAsyncComponent(() => import('@/layout/UserLayout.vue')),
},
computed: {
layout() {
return isLoggedIn.value ? 'UserLayout' : 'DefaultLayout';
},
},
mounted() {
const handleViewportHeight = () => {
document.documentElement.style.setProperty('--viewport-height', `${window.innerHeight}px`);
console.log('viewport-height', window.innerHeight);
};
window.addEventListener('resize', handleViewportHeight);
handleViewportHeight();
this.blurWhenClickOutsideInput();
},
async created() {
const { mainServer, setMainServer } = await useAdditionalServer();
setMainServer(mainServer.value);
// console.log('mainServer', mainServer.value);
},
methods: {
//input blur for safari
blurWhenClickOutsideInput() {
if (document.body.firstElementChild) {
(document.body.firstElementChild as HTMLElement).tabIndex = 1;
}
},
},
});
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
+83
View File
@@ -0,0 +1,83 @@
@import "tailwindcss" theme(static);
@import "@nuxt/ui";
@theme static {
--font-sans: 'Public Sans', sans-serif;
}
@theme {
--tv-ui-bg-elevated: #fff;
--color-tv-ui-bg-elevated: var(--tv-ui-bg-elevated);
}
/* Safe area utilities */
@utility pt-safe {
padding-top: var(--tv-safe-area-inset-top);
}
@utility pb-safe {
padding-bottom: var(--tv-safe-area-inset-bottom);
}
@utility pl-safe {
padding-left: var(--tv-safe-area-inset-left);
}
@utility pr-safe {
padding-right: var(--tv-safe-area-inset-right);
}
@utility px-safe {
padding-left: var(--tv-safe-area-inset-left);
padding-right: var(--tv-safe-area-inset-right);
}
@utility py-safe {
padding-top: var(--tv-safe-area-inset-top);
padding-bottom: var(--tv-safe-area-inset-bottom);
}
@utility p-safe {
padding-top: var(--tv-safe-area-inset-top);
padding-right: var(--tv-safe-area-inset-right);
padding-bottom: var(--tv-safe-area-inset-bottom);
padding-left: var(--tv-safe-area-inset-left);
}
@utility mt-safe {
margin-top: var(--tv-safe-area-inset-top);
}
@utility mb-safe {
margin-bottom: var(--tv-safe-area-inset-bottom);
}
@utility ml-safe {
margin-left: var(--tv-safe-area-inset-left);
}
@utility mr-safe {
margin-right: var(--tv-safe-area-inset-right);
}
@layer base {
html {
touch-action: manipulation;
}
:root {
--tv-safe-area-inset-top: env(safe-area-inset-top, 0px);
--tv-safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--tv-safe-area-inset-left: env(safe-area-inset-left, 0px);
--tv-safe-area-inset-right: env(safe-area-inset-right, 0px);
/* --tv-safe-area-inset-top: 30px;
--tv-safe-area-inset-bottom: 30px;
--tv-safe-area-inset-left: 30px;
--tv-safe-area-inset-right: 30px; */
}
.dark {
--tv-ui-bg-elevated: var(--color-neutral-800);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

+264
View File
@@ -0,0 +1,264 @@
<template>
<UDropdownMenu
:items="items"
:content="{ align: 'center', collisionPadding: 12 }"
:ui="{ content: collapsed ? 'w-48' : 'w-(--reka-dropdown-menu-trigger-width)' }"
>
<UButton
v-bind="{
...user,
label: collapsed ? undefined : user?.name,
trailingIcon: collapsed ? undefined : 'i-lucide-chevrons-up-down'
}"
color="neutral"
variant="ghost"
block
:square="collapsed"
class="data-[state=open]:bg-elevated"
:ui="{
trailingIcon: 'text-dimmed'
}"
/>
<template #chip-leading="{ item }">
<div class="inline-flex items-center justify-center shrink-0 size-5">
<span
class="rounded-full ring ring-bg bg-(--chip-light) dark:bg-(--chip-dark) size-2"
:style="{
'--chip-light': `var(--color-${(item as any).chip}-500)`,
'--chip-dark': `var(--color-${(item as any).chip}-400)`
}"
/>
</div>
</template>
</UDropdownMenu>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { DropdownMenuItem } from '@nuxt/ui'
import { useColorMode } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app.store'
import { useLogout } from '@/composables/useLogout'
import { useUpdater } from '@/composables/useUpdater'
import { useUserStore } from '@/stores/user.store'
import { $ls } from '@/plugins/axios'
import avatarImg from '@/assets/images/avatar-1.jpeg'
defineProps<{
collapsed?: boolean
}>()
const { t, locale } = useI18n()
const colorMode = useColorMode()
const appStore = useAppStore()
const router = useRouter()
const toast = useToast()
const userStore = useUserStore()
const prodOrDev = ref<'prod' | 'dev'>('prod')
let counter = 0
let timeout: number
let timeoutChangeProdOrDev: number
onMounted(async () => {
prodOrDev.value = (await $ls.getValue('update_loading')) === 'dev' ? 'dev' : 'prod'
})
async function checkForUpdates() {
counter++
if (timeout) clearTimeout(timeout)
if (timeoutChangeProdOrDev) clearTimeout(timeoutChangeProdOrDev)
timeout = window.setTimeout(() => {
counter = 0
}, 500)
if (counter === 7) {
timeoutChangeProdOrDev = window.setTimeout(async () => {
if ((await $ls.getValue('update_loading')) !== 'dev') {
await $ls.setValue('update_loading', 'dev')
prodOrDev.value = 'dev'
} else {
await $ls.setValue('update_loading', 'prod')
prodOrDev.value = 'prod'
}
console.log(await $ls.getValue('update_loading'))
await useUpdater(true)
clearTimeout(timeoutChangeProdOrDev)
}, 2000)
}
}
async function handleLogout() {
const success = await useLogout()
if (success) {
router.push('/')
} else {
toast.add({
title: t('userMenu.logoutFailed'),
description: t('userMenu.logoutFailedDescription'),
color: 'error',
})
}
}
const user = computed(() => ({
name: userStore.email || userStore.login,
avatar: {
src: avatarImg,
alt: userStore.email || userStore.login,
},
}))
const items = computed<DropdownMenuItem[][]>(() => [
[
{
type: 'label',
label: user.value.name,
avatar: user.value.avatar,
},
],
[
// {
// label: t('userMenu.profile'),
// icon: 'i-lucide-user',
// },
{
label: t('userMenu.accountSettings'),
icon: 'i-lucide-settings',
onSelect() {
router.push({ name: 'account' })
},
},
],
[
{
label: t('userMenu.appearance'),
icon: 'i-lucide-sun-moon',
children: [
{
label: t('userMenu.light'),
icon: 'i-lucide-sun',
type: 'checkbox',
checked: colorMode.value === 'light',
onSelect(e: Event) {
e.preventDefault()
colorMode.value = 'light'
},
},
{
label: t('userMenu.dark'),
icon: 'i-lucide-moon',
type: 'checkbox',
checked: colorMode.value === 'dark',
onUpdateChecked(checked: boolean) {
if (checked) {
colorMode.value = 'dark'
}
},
onSelect(e: Event) {
e.preventDefault()
},
},
],
},
{
label: t('userMenu.language'),
icon: 'i-lucide-languages',
children: [
{
label: 'English',
type: 'checkbox',
checked: locale.value === 'en',
onSelect(e: Event) {
e.preventDefault()
locale.value = 'en'
},
},
{
label: 'Русский',
type: 'checkbox',
checked: locale.value === 'ru',
onSelect(e: Event) {
e.preventDefault()
locale.value = 'ru'
},
},
],
},
{
label: t('userMenu.taskDetailView'),
icon: 'i-lucide-panel-right',
children: [
{
label: t('userMenu.taskDetailSlideover'),
icon: 'i-lucide-panel-right',
type: 'checkbox',
checked: appStore.taskDetailDisplayMode === 'slideover',
onSelect(e: Event) {
e.preventDefault()
appStore.setTaskDetailDisplayMode('slideover')
},
},
{
label: t('userMenu.taskDetailModal'),
icon: 'i-lucide-square',
type: 'checkbox',
checked: appStore.taskDetailDisplayMode === 'modal',
onSelect(e: Event) {
e.preventDefault()
appStore.setTaskDetailDisplayMode('modal')
},
},
],
},
],
[
{
label: t('userMenu.site'),
icon: 'i-lucide-globe',
to: 'https://taskview.tech/',
target: '_blank',
},
{
label: t('userMenu.documentation'),
icon: 'i-lucide-book-open',
to: 'https://taskview.tech/docs/',
target: '_blank',
},
{
label: t('userMenu.github'),
icon: 'simple-icons:github',
to: 'https://github.com/Gimanh/taskview-community',
target: '_blank',
},
{
label: t('userMenu.docker'),
icon: 'simple-icons:docker',
to: 'https://hub.docker.com/u/gimanhead',
target: '_blank',
},
],
[
{
label: `v ${APP_VERSION}${prodOrDev.value === 'dev' ? '_d' : ''}`,
icon: 'i-lucide-info',
onSelect(e: Event) {
e.preventDefault()
checkForUpdates()
},
},
],
[
{
label: t('userMenu.logout'),
icon: 'i-lucide-log-out',
onSelect: handleLogout,
},
],
])
</script>
@@ -0,0 +1,49 @@
<template>
<div class="flex flex-col gap-6 p-4 lg:p-6 w-full max-w-full lg:max-w-2xl m-0 lg:mx-auto">
<h1 class="text-2xl font-bold">
{{ t('account.title') }}
</h1>
<UPageCard class="w-full">
<div class="flex flex-col gap-4">
<h2 class="text-lg font-semibold">
{{ t('account.management') }}
</h2>
<div class="text-muted">
{{ userStore.email }}
</div>
<div>
<DeleteAccountButton
@code-sent="showCodeModal = true"
@error="onCodeSendError"
/>
</div>
</div>
</UPageCard>
<DeleteAccountCodeModal v-model:open="showCodeModal" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user.store'
import DeleteAccountButton from './parts/DeleteAccountButton.vue'
import DeleteAccountCodeModal from './parts/DeleteAccountCodeModal.vue'
const { t } = useI18n()
const userStore = useUserStore()
const toast = useToast()
const showCodeModal = ref(false)
function onCodeSendError() {
toast.add({
title: t('account.codeSendError'),
color: 'error',
})
}
</script>
@@ -0,0 +1,33 @@
<template>
<UButton
color="error"
@click="handleClick"
>
{{ t('account.deleteAccount') }}
</UButton>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import $api from '@/helpers/axios'
const emit = defineEmits<{
codeSent: []
error: []
}>()
const { t } = useI18n()
async function handleClick() {
const answer = confirm(t('account.deleteConfirm'))
if (!answer) return
try {
await $api.post('/module/auth/delete/account/code')
emit('codeSent')
} catch (err) {
console.log(err)
emit('error')
}
}
</script>
@@ -0,0 +1,68 @@
<template>
<UModal v-model:open="open">
<template #content>
<div class="flex flex-col gap-4 p-6">
<UAlert
color="error"
icon="i-lucide-triangle-alert"
:title="t('account.deleteWarning')"
/>
<UInput
v-model="code"
:placeholder="t('account.enterCode')"
spellcheck="false"
/>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
@click="open = false"
>
{{ t('account.cancel') }}
</UButton>
<UButton
color="error"
:disabled="!code"
@click="handleConfirm"
>
{{ t('account.confirm') }}
</UButton>
</div>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import $api from '@/helpers/axios'
import { $ls } from '@/plugins/axios'
import type { AppResponse } from '@/types/global-app.types'
const open = defineModel<boolean>('open', { required: true })
const { t } = useI18n()
const router = useRouter()
const toast = useToast()
const code = ref('')
async function handleConfirm() {
const result = await $api
.post<AppResponse<{ del: boolean }>>('/module/auth/delete/account', { code: code.value })
.catch((err) => console.log(err))
if (result && result.data.response.del) {
toast.add({
title: t('account.deleted'),
color: 'success',
})
$ls.invalidateTokens()
router.push('/')
}
}
</script>
@@ -0,0 +1,142 @@
<template>
<div class="space-y-4">
<!-- Success state -->
<template v-if="isSent">
<div class="text-center space-y-4">
<div class="mx-auto w-12 h-12 rounded-full bg-success/10 flex items-center justify-center">
<UIcon
name="i-lucide-mail-check"
class="w-6 h-6 text-success"
/>
</div>
<div>
<h3 class="font-medium">
{{ t('auth.checkYourEmail') }}
</h3>
<p class="text-sm text-muted mt-1">
{{ t('auth.resetLinkSent') }}
<span class="font-medium">{{ state.email }}</span>
</p>
</div>
<UButton
:label="t('auth.backToLogin')"
variant="outline"
color="neutral"
block
@click="emit('back')"
/>
</div>
</template>
<!-- Form -->
<template v-else>
<div class="text-center mb-4">
<p class="text-sm text-muted">
{{ t('auth.forgotPasswordDescription') }}
</p>
</div>
<UForm
:state="state"
:schema="EmailSchema"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
:label="t('auth.email')"
name="email"
>
<UInput
v-model="state.email"
type="email"
:placeholder="t('auth.emailPlaceholder')"
icon="i-lucide-mail"
class="w-full"
/>
</UFormField>
<UButton
:label="t('auth.sendResetLink')"
type="submit"
color="primary"
block
:loading="isLoading"
/>
<UButton
:label="t('auth.backToLogin')"
variant="link"
color="neutral"
block
@click="emit('back')"
/>
</UForm>
</template>
</div>
</template>
<script setup lang="ts">
import { type } from 'arktype'
import qs from 'qs'
import { ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import $api from '@/helpers/axios'
const { t } = useI18n()
const emit = defineEmits<{
back: []
success: []
}>()
const toast = useToast()
const isLoading = ref(false)
const isSent = ref(false)
const EmailSchema = type({
email: type('string.email').configure({ message: t('auth.invalidEmail') }),
})
type RecoveryResponse = {
sent: boolean
}
const state = reactive({
email: '',
})
async function onSubmit() {
isLoading.value = true
try {
const result = await $api.post<RecoveryResponse>(
'/module/auth/email/recovery',
qs.stringify({ email: state.email }),
)
if (result.data.sent) {
isSent.value = true
toast.add({
title: t('auth.emailSent'),
description: t('auth.checkInboxForReset'),
color: 'success',
})
emit('success')
} else {
toast.add({
title: t('auth.error'),
description: t('auth.failedToSendResetLink'),
color: 'error',
})
}
} catch {
toast.add({
title: t('auth.error'),
description: t('auth.failedToSendResetLink'),
color: 'error',
})
} finally {
isLoading.value = false
}
}
</script>
@@ -0,0 +1,211 @@
<template>
<div class="space-y-4">
<UForm
:state="state"
:schema="activeSchema"
class="space-y-4"
@submit="handleSubmit"
>
<UFormField
:label="t('auth.email')"
name="email"
>
<UInput
v-model="state.email"
type="email"
:placeholder="t('auth.emailPlaceholder')"
icon="i-lucide-mail"
class="w-full"
/>
</UFormField>
<UFormField
v-if="showCodeField"
:label="t('auth.verificationCode')"
name="code"
>
<UInput
v-model="state.code"
placeholder="000000"
icon="i-lucide-key-round"
class="w-full text-center tracking-widest"
/>
</UFormField>
<p
v-if="showCodeField"
class="text-sm text-muted"
>
{{ t('auth.enterCode') }}
<span class="font-medium">{{ state.email }}</span>
</p>
<UButton
:label="showCodeField ? t('auth.verifyAndSignIn') : t('auth.sendCode')"
type="submit"
color="primary"
block
:loading="isLoading"
/>
<div
v-if="showCodeField"
class="flex items-center justify-between text-sm"
>
<UButton
:label="t('auth.back')"
variant="link"
color="neutral"
size="sm"
@click="goBack"
/>
<UButton
:label="t('auth.resendCode')"
variant="link"
color="neutral"
size="sm"
:loading="isLoading"
@click="resendCode"
/>
</div>
</UForm>
</div>
</template>
<script setup lang="ts">
import { type } from 'arktype'
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import $api from '@/helpers/axios'
import { $ls } from '@/plugins/axios'
import { redirectToUser } from './auth.helper'
const { t } = useI18n()
const router = useRouter()
const emit = defineEmits<{
success: [token: string]
}>()
const toast = useToast()
const showCodeField = ref(false)
const isLoading = ref(false)
const emailType = type('string.email').configure({ message: t('auth.invalidEmail') })
const codeType = type('string > 0').configure({ message: t('auth.codeMustBe6') })
const EmailSchema = type({
email: emailType,
})
const CodeSchema = type({
email: emailType,
code: codeType,
})
const activeSchema = computed(() => showCodeField.value ? CodeSchema : EmailSchema)
type LoginResponse = {
access: string
refresh: string
}
const state = reactive({
email: '',
code: '',
})
onMounted(async () => {
const savedEmail = await $ls.getValue('user-email')
if (savedEmail) {
state.email = savedEmail
}
})
async function handleSubmit() {
isLoading.value = true
try {
if (showCodeField.value) {
const result = await $api.post<LoginResponse>('/module/auth/login-by-code', {
code: state.code,
email: state.email,
})
if (result.data.access) {
$ls.setToken(result.data.access)
$ls.setRefreshToken(result.data.refresh)
await $ls.updateUserStoreByToken()
toast.add({
title: t('auth.success'),
description: t('auth.loggedIn'),
color: 'success',
})
emit('success', result.data.access)
redirectToUser(router)
}
} else {
await $api.post('/module/auth/send-login-code', { email: state.email })
await $ls.setValue('user-email', state.email)
toast.add({
title: t('auth.codeSent'),
description: t('auth.checkInbox', { email: state.email }),
color: 'success',
})
showCodeField.value = true
}
} catch (error: unknown) {
const axiosError = error as { response?: { status?: number } }
if (showCodeField.value) {
toast.add({
title: t('auth.error'),
description: axiosError.response?.status === 403 ? t('auth.invalidCode') : t('auth.loginFailed'),
color: 'error',
})
} else {
toast.add({
title: t('auth.error'),
description: t('auth.failedToSendCode'),
color: 'error',
})
}
} finally {
isLoading.value = false
}
}
function goBack() {
showCodeField.value = false
state.code = ''
}
async function resendCode() {
if (!state.email) return
isLoading.value = true
try {
await $api.post('/module/auth/send-login-code', { email: state.email })
toast.add({
title: t('auth.codeResent'),
description: t('auth.newCodeSent', { email: state.email }),
color: 'success',
})
} catch {
toast.add({
title: t('auth.error'),
description: t('auth.failedToResendCode'),
color: 'error',
})
} finally {
isLoading.value = false
}
}
</script>
@@ -0,0 +1,147 @@
<template>
<UForm
:state="state"
:schema="LoginSchema"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
:label="t('auth.login')"
name="login"
>
<UInput
v-model="state.login"
:placeholder="t('auth.loginPlaceholder')"
icon="i-lucide-user"
class="w-full"
data-testid="login-input"
/>
</UFormField>
<UFormField
:label="t('auth.passwordLabel')"
name="password"
>
<UInput
v-model="state.password"
:type="showPassword ? 'text' : 'password'"
:placeholder="t('auth.passwordPlaceholder')"
icon="i-lucide-lock"
class="w-full"
data-testid="password-input"
>
<template #trailing>
<UButton
:icon="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="link"
size="sm"
:padded="false"
@click="showPassword = !showPassword"
/>
</template>
</UInput>
</UFormField>
<div class="flex justify-end">
<UButton
:label="t('auth.forgotPassword')"
variant="link"
color="neutral"
size="sm"
@click="emit('forgotPassword')"
/>
</div>
<UButton
:label="t('auth.signIn')"
type="submit"
color="primary"
block
:loading="isLoading"
data-testid="sign-in-button"
/>
</UForm>
</template>
<script setup lang="ts">
import { type } from 'arktype'
import qs from 'qs'
import { ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import $api from '@/helpers/axios'
import { $ls } from '@/plugins/axios'
import { redirectToUser } from './auth.helper'
const { t } = useI18n()
const router = useRouter()
const emit = defineEmits<{
success: [token: string]
forgotPassword: []
}>()
const toast = useToast()
const isLoading = ref(false)
const showPassword = ref(false)
const LoginSchema = type({
login: type('string > 0').configure({ message: t('auth.loginRequired') }),
password: type('string > 0').configure({ message: t('auth.passwordRequired') }),
})
type LoginState = typeof LoginSchema.infer
type LoginResponse = {
access: string
refresh: string
}
const state = reactive<LoginState>({
login: '',
password: '',
})
async function onSubmit() {
isLoading.value = true
try {
const { login, password } = state
const result = await $api.post<LoginResponse>('/module/auth/login', qs.stringify({ login, password }))
if (result.data.access) {
$ls.setToken(result.data.access)
$ls.setRefreshToken(result.data.refresh)
await $ls.updateUserStoreByToken()
toast.add({
title: t('auth.success'),
description: t('auth.loggedIn'),
color: 'success',
})
emit('success', result.data.access)
redirectToUser(router)
}
} catch (error: unknown) {
const axiosError = error as { response?: { status?: number } }
if (axiosError.response?.status === 403) {
toast.add({
title: t('auth.error'),
description: t('auth.invalidCredentials'),
color: 'error',
})
} else {
toast.add({
title: t('auth.error'),
description: t('auth.loginFailed'),
color: 'error',
})
}
} finally {
isLoading.value = false
}
}
</script>
@@ -0,0 +1,127 @@
<template>
<div class="w-full max-w-sm mx-auto space-y-6">
<!-- Header -->
<div class="text-center">
<h1 class="text-2xl font-bold">
{{ t('auth.welcome') }}
</h1>
<p class="text-muted mt-1">
{{ t('auth.signInToAccount') }}
</p>
</div>
<!-- Forgot Password View -->
<template v-if="currentView === 'forgot'">
<ForgotPassword
@back="currentView = 'password'"
@success="currentView = 'password'"
/>
</template>
<!-- Login Views -->
<template v-else>
<SocialButtons />
<!-- Divider -->
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-default" />
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-default px-2 text-muted">{{ t('auth.orContinueWith') }}</span>
</div>
</div>
<!-- Tabs -->
<UTabs
v-model="currentView"
:items="tabs"
class="w-full"
@update:model-value="onTabChange"
>
<template #code>
<div class="pt-4">
<LoginByCode @success="handleSuccess" />
</div>
</template>
<template #password>
<div class="pt-4">
<LoginByPassword
@success="handleSuccess"
@forgot-password="currentView = 'forgot'"
/>
</div>
</template>
</UTabs>
</template>
<!-- Server Selector -->
<UCollapsible class="flex flex-col gap-2">
<UButton
class="group"
:label="t('server.selectServer')"
color="neutral"
variant="ghost"
icon="i-lucide-server"
trailing-icon="i-lucide-chevron-down"
:ui="{
trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200'
}"
block
/>
<template #content>
<ServerSelector class="p-2 border border-default rounded-lg" />
</template>
</UCollapsible>
<!-- Footer -->
<p class="text-center text-xs text-muted">
{{ t('auth.termsText') }}
<a
href="#"
class="underline hover:text-foreground"
>{{ t('auth.termsOfService') }}</a>
{{ t('auth.and') }}
<a
href="#"
class="underline hover:text-foreground"
>{{ t('auth.privacyPolicy') }}</a>.
</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import LoginByCode from './LoginByCode.vue'
import LoginByPassword from './LoginByPassword.vue'
import ForgotPassword from './ForgotPassword.vue'
import SocialButtons from './SocialButtons.vue'
import ServerSelector from './ServerSelector.vue'
const { t } = useI18n()
const emit = defineEmits<{
success: [token: string]
}>()
type View = 'code' | 'password' | 'forgot'
const currentView = ref<View>('code')
const tabs = computed(() => [
{ value: 'code', label: t('auth.magicLink'), slot: 'code' as const },
{ value: 'password', label: t('auth.password'), slot: 'password' as const },
])
function onTabChange(value: string | number) {
currentView.value = value as View
}
function handleSuccess(token: string) {
emit('success', token)
}
</script>
@@ -0,0 +1,231 @@
<template>
<div class="space-y-4">
<!-- Success state -->
<template v-if="isReset">
<div class="text-center space-y-4">
<div class="mx-auto w-12 h-12 rounded-full bg-success/10 flex items-center justify-center">
<UIcon
name="i-lucide-check-circle"
class="w-6 h-6 text-success"
/>
</div>
<div>
<h3 class="font-medium">
{{ t('auth.passwordReset') }}
</h3>
<p class="text-sm text-muted mt-1">
{{ t('auth.passwordResetSuccess') }}
</p>
</div>
<UButton
:label="t('auth.backToLogin')"
color="primary"
block
@click="goToLogin"
/>
</div>
</template>
<!-- Error state - invalid reset link -->
<template v-else-if="!isValidLink">
<div class="text-center space-y-4">
<div class="mx-auto w-12 h-12 rounded-full bg-error/10 flex items-center justify-center">
<UIcon
name="i-lucide-alert-circle"
class="w-6 h-6 text-error"
/>
</div>
<div>
<h3 class="font-medium">
{{ t('auth.invalidResetLink') }}
</h3>
<p class="text-sm text-muted mt-1">
{{ t('auth.invalidResetLinkDescription') }}
</p>
</div>
<UButton
:label="t('auth.backToLogin')"
variant="outline"
color="neutral"
block
@click="goToLogin"
/>
</div>
</template>
<!-- Form -->
<template v-else>
<div class="text-center mb-4">
<h3 class="font-medium">
{{ t('auth.setNewPassword') }}
</h3>
<p class="text-sm text-muted mt-1">
{{ t('auth.setNewPasswordDescription') }}
</p>
</div>
<UForm
:state="state"
:schema="PasswordSchema"
:validate="validatePasswordsMatch"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
:label="t('auth.newPassword')"
name="password"
>
<UInput
v-model="state.password"
:type="showPassword ? 'text' : 'password'"
:placeholder="t('auth.newPasswordPlaceholder')"
icon="i-lucide-lock"
class="w-full"
>
<template #trailing>
<UButton
:icon="showPassword ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="link"
size="sm"
:padded="false"
@click="showPassword = !showPassword"
/>
</template>
</UInput>
</UFormField>
<UFormField
:label="t('auth.confirmPassword')"
name="passwordRepeat"
>
<UInput
v-model="state.passwordRepeat"
:type="showPasswordRepeat ? 'text' : 'password'"
:placeholder="t('auth.confirmPasswordPlaceholder')"
icon="i-lucide-lock"
class="w-full"
>
<template #trailing>
<UButton
:icon="showPasswordRepeat ? 'i-lucide-eye-off' : 'i-lucide-eye'"
color="neutral"
variant="link"
size="sm"
:padded="false"
@click="showPasswordRepeat = !showPasswordRepeat"
/>
</template>
</UInput>
</UFormField>
<UButton
:label="t('auth.resetPassword')"
type="submit"
color="primary"
block
:loading="isLoading"
/>
<UButton
:label="t('auth.backToLogin')"
variant="link"
color="neutral"
block
@click="goToLogin"
/>
</UForm>
</template>
</div>
</template>
<script setup lang="ts">
import { type } from 'arktype'
import qs from 'qs'
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import $api from '@/helpers/axios'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const isLoading = ref(false)
const isReset = ref(false)
const showPassword = ref(false)
const showPasswordRepeat = ref(false)
const resetCode = computed(() => route.query.resetCode as string | undefined)
const login = computed(() => route.query.login as string | undefined)
const isValidLink = computed(() => !!resetCode.value && !!login.value)
const PasswordSchema = type({
password: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
passwordRepeat: type('string >= 6').configure({ message: t('auth.passwordTooShort') }),
})
type ResetResponse = {
reset: boolean
}
const state = reactive({
password: '',
passwordRepeat: '',
})
function validatePasswordsMatch(state: Partial<{ password: string; passwordRepeat: string }>) {
const errors = []
if (state.password && state.passwordRepeat && state.password !== state.passwordRepeat) {
errors.push({ name: 'passwordRepeat', message: t('auth.passwordsDoNotMatch') })
}
return errors
}
async function onSubmit() {
if (!isValidLink.value) return
isLoading.value = true
try {
const result = await $api.post<ResetResponse>(
'/module/auth/password/reset',
qs.stringify({
code: resetCode.value,
login: login.value,
password: state.password,
passwordRepeat: state.passwordRepeat,
}),
)
if (result.data.reset) {
isReset.value = true
toast.add({
title: t('auth.success'),
description: t('auth.passwordResetSuccess'),
color: 'success',
})
} else {
toast.add({
title: t('auth.error'),
description: t('auth.canNotResetPassword'),
color: 'error',
})
}
} catch {
toast.add({
title: t('auth.error'),
description: t('auth.canNotResetPassword'),
color: 'error',
})
} finally {
isLoading.value = false
}
}
function goToLogin() {
router.push('/')
}
</script>
@@ -0,0 +1,191 @@
<template>
<div class="space-y-2">
<USelectMenu
v-model="selectedServer"
:items="serverOptions"
:placeholder="t('server.selectServer')"
value-key="value"
class="w-full"
variant="subtle"
>
<template #item="{ item }">
<div class="flex items-center justify-between w-full gap-2">
<div class="flex items-center gap-2 min-w-0">
<UIcon
:name="item.isSystem ? 'i-lucide-server' : 'i-lucide-cloud'"
class="size-4 shrink-0"
/>
<span class="truncate">{{ item.label }}</span>
</div>
<UButton
v-if="!item.isSystem"
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
@click.stop="deleteServer(item.value)"
/>
</div>
</template>
</USelectMenu>
<UButton
:label="t('server.addServer')"
icon="i-lucide-plus"
variant="ghost"
size="sm"
class="w-full"
@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>
<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 #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>
</UModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAdditionalServer } from '@/composables/useAdditionalServer'
const { t } = useI18n()
const mainServer = ref('')
const allServers = ref<string[]>([])
const systemServer = ref('')
const setMainServer = ref<(server: string) => void>(() => {})
const addServerFn = ref<(server: string) => void>(() => {})
const deleteServerFn = ref<(server: string) => void>(() => {})
const showAddDialog = ref(false)
const newServerUrl = ref('')
const selectedServer = ref<string | undefined>(undefined)
onMounted(async () => {
const serverApi = await useAdditionalServer()
mainServer.value = serverApi.mainServer.value
allServers.value = serverApi.allServers.value
systemServer.value = serverApi.systemServer.value
setMainServer.value = serverApi.setMainServer
addServerFn.value = serverApi.addServer
deleteServerFn.value = serverApi.deleteServer
selectedServer.value = mainServer.value
})
const serverOptions = computed(() => {
const options = [
{
label: systemServer.value || t('server.defaultServer'),
value: systemServer.value,
isSystem: true,
},
]
allServers.value.forEach((server) => {
if (server !== systemServer.value) {
options.push({
label: server,
value: server,
isSystem: false,
})
}
})
return options
})
const isValidUrl = computed(() => {
if (!newServerUrl.value) return false
try {
const url = new URL(newServerUrl.value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
})
watch(selectedServer, (newServer) => {
if (newServer && newServer !== mainServer.value) {
setMainServer.value(newServer)
mainServer.value = newServer
}
})
function handleAddServer() {
if (!isValidUrl.value) return
const url = newServerUrl.value.trim().replace(/\/$/, '')
if (!allServers.value.includes(url) && url !== systemServer.value) {
addServerFn.value(url)
allServers.value = [...allServers.value, url]
}
selectedServer.value = url
setMainServer.value(url)
mainServer.value = url
newServerUrl.value = ''
showAddDialog.value = false
}
function deleteServer(server: string) {
deleteServerFn.value(server)
allServers.value = allServers.value.filter((s) => s !== server)
if (selectedServer.value === server) {
selectedServer.value = systemServer.value
mainServer.value = systemServer.value
}
}
</script>
@@ -0,0 +1,67 @@
<template>
<div class="space-y-3">
<UButton
:label="t('auth.continueWithGoogle')"
icon="i-lucide-chrome"
color="neutral"
variant="outline"
block
:loading="isLoading === 'google'"
:disabled="isLoading !== null"
@click="handleLogin('google')"
/>
<UButton
:label="t('auth.continueWithGithub')"
icon="i-lucide-github"
color="neutral"
variant="outline"
block
:loading="isLoading === 'github'"
:disabled="isLoading !== null"
@click="handleLogin('github')"
/>
<UButton
:label="t('auth.continueWithApple')"
icon="i-lucide-apple"
color="neutral"
variant="outline"
block
:loading="isLoading === 'apple'"
:disabled="isLoading !== null"
@click="handleLogin('apple')"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Capacitor } from '@capacitor/core'
import { Browser } from '@capacitor/browser'
import { useAdditionalServer } from '@/composables/useAdditionalServer'
type Provider = 'google' | 'github' | 'apple'
const { t } = useI18n()
const isLoading = ref<Provider | null>(null)
async function handleLogin(provider: Provider) {
isLoading.value = provider
setTimeout(() => {
isLoading.value = null
}, 2000)
const { mainServer } = await useAdditionalServer()
const platform = Capacitor.isNativePlatform() ? 'mobile' : 'web'
const url = `${mainServer.value}/module/auth/provider/${provider}?platform=${platform}`
if (Capacitor.isNativePlatform()) {
await Browser.open({ url })
} else {
window.location.href = url
}
}
</script>
@@ -0,0 +1,9 @@
import { useUserStore } from '@/stores/user.store'
import { Router } from 'vue-router'
export const redirectToUser = async (router: Router) => {
const userStore: ReturnType<typeof useUserStore> = useUserStore()
if (userStore.accessToken) {
await router.push({ name: 'user', params: { user: userStore.login } })
}
}
@@ -0,0 +1,19 @@
<template>
<UButton
:icon="isSidebarCollapsed ? 'i-lucide-panel-right' : 'i-lucide-panel-left'"
color="neutral"
variant="soft"
class="lg:flex hidden cursor-pointer"
@click="toggleSidebar"
/>
</template>
<script setup lang="ts">
import { useDashboard } from '@/composables/useDashboard'
const { isSidebarCollapsed } = useDashboard()
function toggleSidebar() {
isSidebarCollapsed.value = !isSidebarCollapsed.value
}
</script>
@@ -0,0 +1,64 @@
<template>
<UPopover
v-model:open="isOpen"
:content="{ side: 'bottom', align: 'start', updatePositionStrategy: 'always' }"
>
<template #default>
<div
class="fixed pointer-events-none"
:style="anchorStyle"
/>
</template>
<template #content>
<slot />
</template>
</UPopover>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const isOpen = defineModel<boolean>('open', { default: false })
const anchorPosition = ref({ x: 0, y: 0 })
const anchorStyle = computed(() => ({
left: `${anchorPosition.value.x}px`,
top: `${anchorPosition.value.y}px`,
width: '1px',
height: '1px',
}))
function onScroll() {
if (isOpen.value) {
isOpen.value = false
}
}
onMounted(() => {
window.addEventListener('scroll', onScroll, true)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll, true)
})
function openAt(event: MouseEvent) {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
anchorPosition.value = {
x: rect.right,
y: rect.bottom,
}
isOpen.value = true
}
function close() {
isOpen.value = false
}
defineExpose({
openAt,
close,
})
</script>
@@ -0,0 +1,19 @@
<template>
<UPageCard
v-bind="{ ...props }"
class="shadow-sm"
:class="{ 'bg-primary/10 border border-primary/30': active }"
>
<div class="flex items-center justify-between">
<slot />
</div>
</UPageCard>
</template>
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
const props = defineProps<{
to?: RouteLocationRaw
active?: boolean
}>()
</script>
@@ -0,0 +1,110 @@
<template>
<nav class="fixed bottom-0 left-0 right-0 z-50 lg:hidden border-t border-default pb-safe">
<div class="flex items-center justify-between min-h-14">
<UButton
v-for="item in navItems"
:key="item.label"
:icon="item.icon"
:to="item.to"
color="neutral"
variant="ghost"
size="lg"
class="flex-1 h-full rounded-none"
:class="{ 'text-primary': item.active?.() }"
:ui="{
base: ' justify-center',
}"
@click="item.onClick?.()"
/>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDashboard } from '@/composables/useDashboard'
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
import { ALL_TASKS_LIST_ID } from 'taskview-api'
const route = useRoute()
const { t } = useI18n()
const { isSidebarOpen } = useDashboard()
const { isUserRoute, isAccountRoute, hasProject, projectId, hasList } = useAppRouteInfo()
const navItems = computed(() => {
if (isAccountRoute.value) {
return [
{
label: t('main'),
icon: 'i-lucide-house',
to: { name: 'user' },
active: (): boolean => false,
},
]
}
if (isUserRoute.value && !hasProject.value) {
return [
{
label: t('projects.title'),
icon: 'i-lucide-folder',
active: (): boolean => isSidebarOpen.value,
onClick: () => { isSidebarOpen.value = !isSidebarOpen.value },
},
{
label: t('account.title'),
icon: 'i-lucide-settings',
to: { name: 'account' },
active: (): boolean => false,
},
]
}
return [
{
label: t('main'),
icon: 'i-lucide-house',
to: { name: 'user' },
active: (): boolean => false,
},
{
label: t('projects.title'),
icon: 'i-lucide-folder',
active: (): boolean => isSidebarOpen.value,
onClick: () => { isSidebarOpen.value = !isSidebarOpen.value },
},
{
label: t('projects.kanban'),
icon: 'i-lucide-kanban',
to: { name: 'kanban', params: { projectId: projectId.value } },
active: (): boolean => route.name === 'kanban',
},
{
label: t('projects.graph'),
icon: 'i-lucide-git-fork',
to: { name: 'graph', params: { projectId: projectId.value } },
active: (): boolean => route.name === 'graph',
},
{
label: t('projects.collaboration'),
icon: 'i-lucide-users',
to: { name: 'collaboration', params: { projectId: projectId.value } },
active: (): boolean => route.name === 'collaboration',
},
{
label: t('lists.title'),
icon: 'i-lucide-list',
to: { name: 'user', params: { projectId: projectId.value } },
active: (): boolean => isUserRoute.value && hasProject.value && !hasList.value,
},
{
label: t('tasks.allTasks'),
icon: 'i-lucide-list-checks',
to: { name: 'user', params: { projectId: projectId.value, listId: ALL_TASKS_LIST_ID } },
active: (): boolean => isUserRoute.value && hasList.value,
},
]
})
</script>
@@ -0,0 +1,163 @@
<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-users"
class="size-12 mb-4"
/>
<p>{{ t('collaboration.selectProject') }}</p>
</div>
<template v-else>
<h2 class="text-lg font-semibold mb-4">
{{ projectName }}
</h2>
<UTabs
v-model="activeTab"
:items="tabs"
class="w-full"
>
<template #members>
<MembersList
:members="users"
:roles="roles"
:goal-id="projectId"
:loading="loading"
@invite="handleInvite"
@update-roles="handleUpdateMemberRoles"
@remove="handleRemoveMember"
/>
</template>
<template #roles>
<RolesList
:roles="roles"
:permissions="permissions"
:roles-permissions="rolesPermissions"
:goal-id="projectId"
:loading="loading"
@create="handleCreateRole"
@toggle-permission="handleTogglePermission"
@delete="handleDeleteRole"
/>
</template>
</UTabs>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
// import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useCollaborationStore } from '@/stores/collaboration.store'
import { useCollaborationRolesStore } from '@/stores/collaboration-roles.store'
import { useCollaborationPermissionsStore } from '@/stores/collaboration-permissions.store'
import MembersList from './parts/members/MembersList.vue'
import RolesList from './parts/roles/RolesList.vue'
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
import { useGoalsStore } from '@/stores/goals.store'
const { t } = useI18n()
const { projectId } = useAppRouteInfo()
const goalsStore = useGoalsStore()
const projectName = computed(() => {
const goal = goalsStore.goalMap.get(projectId.value)
return goal?.name ?? ''
})
const collaborationStore = useCollaborationStore()
const rolesStore = useCollaborationRolesStore()
const permissionsStore = useCollaborationPermissionsStore()
const { users } = storeToRefs(collaborationStore)
const { roles, rolesPermissions } = storeToRefs(rolesStore)
const { permissions } = storeToRefs(permissionsStore)
const activeTab = ref('members')
const loading = ref(false)
const hasGoalSelected = computed(() => projectId.value > 0)
const tabs = computed(() => [
{
label: t('collaboration.tabs.members'),
slot: 'members',
value: 'members',
},
{
label: t('collaboration.tabs.roles'),
slot: 'roles',
value: 'roles',
},
])
async function fetchData() {
if (!hasGoalSelected.value) {
return
}
loading.value = true
try {
await Promise.all([
collaborationStore.fetchCollaborationUsersForGoal(projectId.value),
rolesStore.fetchCollaborationRolesForGoal(projectId.value),
permissionsStore.fetchAllPermissions(),
rolesStore.fetchAllRolePermissionsForGoal(projectId.value),
])
} finally {
loading.value = false
}
}
watch(projectId, () => {
fetchData()
}, { immediate: true })
async function handleInvite(email: string) {
await collaborationStore.addCollaborationUser({
goalId: projectId.value,
email,
})
}
async function handleUpdateMemberRoles(data: { userId: number; roles: number[] }) {
await collaborationStore.toggleUserRole({
userId: data.userId,
goalId: projectId.value,
roles: data.roles,
})
}
async function handleRemoveMember(userId: number) {
await collaborationStore.deleteUserFromCollaboration({
id: userId,
goalId: projectId.value,
})
}
async function handleCreateRole(roleName: string) {
await rolesStore.addCollaborationRole({
goalId: projectId.value,
roleName,
})
}
async function handleTogglePermission(data: { roleId: number; permissionId: number }) {
await rolesStore.togglePermissionForRole({
roleId: data.roleId,
permissionId: data.permissionId,
})
}
async function handleDeleteRole(roleId: number) {
await rolesStore.deleteCollaborationRole({
id: roleId,
goalId: projectId.value,
})
}
</script>
@@ -0,0 +1,63 @@
<template>
<UInput
v-model="email"
type="email"
:placeholder="t('collaboration.members.emailPlaceholder')"
size="xl"
variant="soft"
class="w-full"
:ui="{
base: 'bg-tv-ui-bg-elevated',
}"
@keydown.enter="addMember"
>
<template #trailing>
<UButton
v-if="isValid"
icon="i-lucide-corner-down-left"
color="primary"
variant="ghost"
size="xs"
:aria-label="t('collaboration.members.add')"
@click="addMember"
/>
<UIcon
v-else
name="i-lucide-keyboard"
class="size-4 text-dimmed"
/>
</template>
</UInput>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { type } from 'arktype'
defineProps<{
loading?: boolean
}>()
const emit = defineEmits<{
add: [email: string]
}>()
const { t } = useI18n()
const emailSchema = type('string.email')
const email = ref('')
const isValid = computed(() => {
const result = emailSchema(email.value)
return !(result instanceof type.errors)
})
function addMember() {
if (isValid.value) {
emit('add', email.value)
email.value = ''
}
}
</script>
@@ -0,0 +1,111 @@
<template>
<UModal
v-model:open="isOpen"
:title="t('collaboration.members.assignRoles')"
>
<template #body>
<div class="space-y-4">
<div class="flex items-center gap-3 p-3 bg-elevated rounded-lg">
<UAvatar
:alt="member?.email"
size="sm"
>
{{ member?.email?.slice(0, 2).toUpperCase() }}
</UAvatar>
<div>
<p class="text-sm font-medium">
{{ member?.email }}
</p>
</div>
</div>
<UFormField :label="t('collaboration.members.selectRoles')">
<div class="space-y-2">
<label
v-for="role in roles"
:key="role.id"
class="flex items-center gap-3 p-2 rounded hover:bg-elevated cursor-pointer"
>
<UCheckbox
:model-value="selectedRoleIds.includes(role.id)"
@update:model-value="toggleRole(role.id, $event)"
/>
<div class="flex-1">
<p class="text-sm font-medium">
{{ role.name }}
</p>
</div>
</label>
</div>
</UFormField>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="isOpen = false"
/>
<UButton
:label="t('common.save')"
color="primary"
variant="outline"
@click="handleSave"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CollaborationResponseFetchAllUsers } from 'taskview-api'
import type { CollaborationRole } from '@/types/collaboration-roles.types'
const props = defineProps<{
member: CollaborationResponseFetchAllUsers | null
roles: CollaborationRole[]
}>()
const emit = defineEmits<{
save: [data: { userId: number; roles: number[] }]
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const { t } = useI18n()
const selectedRoleIds = ref<number[]>([])
watch(
() => props.member,
(member) => {
if (member) {
selectedRoleIds.value = [...member.roles]
}
},
{ immediate: true },
)
function toggleRole(roleId: number, enabled: boolean | string) {
if (enabled) {
selectedRoleIds.value = [...selectedRoleIds.value, roleId]
} else {
selectedRoleIds.value = selectedRoleIds.value.filter(id => id !== roleId)
}
}
function handleSave() {
if (props.member) {
emit('save', {
userId: props.member.id,
roles: selectedRoleIds.value,
})
isOpen.value = false
}
}
</script>
@@ -0,0 +1,78 @@
<template>
<div class="flex items-center justify-between p-3 rounded-lg border border-default hover:bg-elevated transition-colors">
<div class="flex items-center gap-3">
<UAvatar
:alt="member.email"
size="sm"
>
{{ member.email.slice(0, 2).toUpperCase() }}
</UAvatar>
<div>
<p class="text-sm font-medium">
{{ member.email }}
</p>
<p class="text-xs text-muted">
{{ t('collaboration.members.invitedAt') }}: {{ formatDate(member.invitation_date) }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<div
v-if="roleNames.length > 0"
class="flex flex-wrap gap-1"
>
<UBadge
v-for="roleName in roleNames"
:key="roleName"
:label="roleName"
color="primary"
variant="outline"
size="xs"
/>
</div>
<span
v-else
class="text-xs text-muted"
>
{{ t('collaboration.members.noRoles') }}
</span>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
size="xs"
@click.stop="$emit('menu', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CollaborationResponseFetchAllUsers } from 'taskview-api'
import type { CollaborationRole } from '@/types/collaboration-roles.types'
const props = defineProps<{
member: CollaborationResponseFetchAllUsers
roles: CollaborationRole[]
}>()
defineEmits<{
menu: [event: MouseEvent]
}>()
const { t } = useI18n()
const roleNames = computed(() => {
return props.member.roles
.map(roleId => props.roles.find(r => r.id === roleId)?.name)
.filter(Boolean) as string[]
})
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString()
}
</script>
@@ -0,0 +1,131 @@
<template>
<div class="space-y-4">
<h3 class="text-lg font-semibold">
{{ t('collaboration.members.title') }}
</h3>
<MemberAddInput
:loading="loading"
@add="$emit('invite', $event)"
/>
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<UIcon
name="i-lucide-loader-2"
class="size-8 animate-spin text-muted"
/>
</div>
<div
v-else-if="invitedMembers.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted"
>
<UIcon
name="i-lucide-users"
class="size-12 mb-4"
/>
<p>{{ t('collaboration.members.empty') }}</p>
</div>
<div
v-else
class="space-y-2"
>
<MemberItem
v-for="member in invitedMembers"
:key="member.id"
:member="member"
:roles="roles"
@menu="openContextMenu($event, member)"
/>
</div>
<TvContextMenu ref="contextMenu">
<div class="p-1 flex flex-col gap-1">
<UButton
:label="t('collaboration.members.assignRoles')"
icon="i-lucide-user-cog"
variant="ghost"
color="neutral"
class="w-full justify-start"
@click="openEditModal"
/>
<USeparator class="my-1" />
<UButton
:label="t('collaboration.members.remove')"
icon="i-lucide-user-minus"
variant="ghost"
color="error"
class="w-full justify-start"
:disabled="selectedMember?.goalOwner"
@click="handleRemove"
/>
</div>
</TvContextMenu>
<MemberEditModal
v-model:open="isEditModalOpen"
:member="selectedMember"
:roles="roles"
@save="handleUpdateRoles"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CollaborationResponseFetchAllUsers } from 'taskview-api'
import type { CollaborationRole } from '@/types/collaboration-roles.types'
import TvContextMenu from '@/components/features/base/TvContextMenu.vue'
import MemberItem from './MemberItem.vue'
import MemberAddInput from './MemberAddInput.vue'
import MemberEditModal from './MemberEditModal.vue'
const props = defineProps<{
members: CollaborationResponseFetchAllUsers[]
roles: CollaborationRole[]
goalId: number
loading?: boolean
}>()
const invitedMembers = computed(() =>
props.members.filter(member => !member.goalOwner),
)
const emit = defineEmits<{
invite: [email: string]
updateRoles: [data: { userId: number; roles: number[] }]
remove: [userId: number]
}>()
const { t } = useI18n()
const contextMenu = ref<InstanceType<typeof TvContextMenu> | null>(null)
const selectedMember = ref<CollaborationResponseFetchAllUsers | null>(null)
const isEditModalOpen = ref(false)
function openContextMenu(event: MouseEvent, member: CollaborationResponseFetchAllUsers) {
selectedMember.value = member
contextMenu.value?.openAt(event)
}
function openEditModal() {
contextMenu.value?.close()
isEditModalOpen.value = true
}
function handleUpdateRoles(data: { userId: number; roles: number[] }) {
emit('updateRoles', data)
}
function handleRemove() {
if (selectedMember.value) {
emit('remove', selectedMember.value.id)
}
contextMenu.value?.close()
}
</script>
@@ -0,0 +1,128 @@
<template>
<div class="space-y-4">
<UInput
v-model="searchQuery"
:placeholder="t('collaboration.permissions.searchPlaceholder')"
size="xl"
variant="soft"
class="w-full"
:ui="{
base: 'bg-tv-ui-bg-elevated',
}"
>
<template #trailing>
<UButton
v-if="searchQuery"
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="xs"
@click="searchQuery = ''"
/>
<UIcon
v-else
name="i-lucide-search"
class="size-4 text-dimmed"
/>
</template>
</UInput>
<div
v-for="group in filteredPermissionGroups"
:key="group.groupId"
class="space-y-2"
>
<h4 class="text-sm font-medium">
{{ getGroupName(group.groupId) }}
</h4>
<div class="space-y-1">
<label
v-for="permission in group.permissions"
:key="permission.id"
class="flex items-start gap-3 p-2 rounded hover:bg-elevated cursor-pointer"
>
<UCheckbox
:model-value="!!selectedPermissions[permission.id]"
class="mt-0.5"
@update:model-value="$emit('toggle', permission.id)"
/>
<div class="flex-1">
<p class="text-base ">
{{ getLocalizedDescription(permission) }}
</p>
<p class="text-xs text-muted">
{{ permission.name }}
</p>
</div>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CollaborationPermission } from 'taskview-api'
import { PermissionGroups, type PermissioinGroupsIds } from '@/types/collaboration-permissions.types'
const props = defineProps<{
permissions: CollaborationPermission[]
selectedPermissions: Record<number, true>
}>()
defineEmits<{
toggle: [permissionId: number]
}>()
const { t, locale } = useI18n()
const searchQuery = ref('')
const permissionGroups = computed(() => {
const groups: Record<number, CollaborationPermission[]> = {}
for (const permission of props.permissions) {
const groupId = permission.permission_group
if (!groups[groupId]) {
groups[groupId] = []
}
groups[groupId].push(permission)
}
return Object.entries(groups).map(([groupId, permissions]) => ({
groupId: Number(groupId) as PermissioinGroupsIds,
permissions,
}))
})
function getLocalizedDescription(permission: CollaborationPermission): string {
try {
const locales = JSON.parse(permission.descriptionLocales || '{}')
return locales[locale.value] || locales.en || permission.description
} catch {
return permission.description
}
}
const filteredPermissionGroups = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
if (!query) return permissionGroups.value
return permissionGroups.value
.map(group => ({
...group,
permissions: group.permissions.filter(permission => {
const localizedDesc = getLocalizedDescription(permission).toLowerCase()
return permission.name.toLowerCase().includes(query) || localizedDesc.includes(query)
}),
}))
.filter(group => group.permissions.length > 0)
})
function getGroupName(groupId: PermissioinGroupsIds): string {
const group = PermissionGroups[groupId]
if (!group) return ''
return group[locale.value as 'en' | 'ru'] || group.en
}
</script>
@@ -0,0 +1,55 @@
<template>
<UInput
v-model="name"
:placeholder="t('collaboration.roles.namePlaceholder')"
size="xl"
variant="soft"
class="w-full"
:ui="{
base: 'bg-tv-ui-bg-elevated',
}"
@keydown.enter="addRole"
>
<template #trailing>
<UButton
v-if="name.trim()"
icon="i-lucide-corner-down-left"
color="primary"
variant="ghost"
size="xs"
:aria-label="t('collaboration.roles.create')"
@click="addRole"
/>
<UIcon
v-else
name="i-lucide-keyboard"
class="size-4 text-dimmed"
/>
</template>
</UInput>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
loading?: boolean
}>()
const emit = defineEmits<{
add: [name: string]
}>()
const { t } = useI18n()
const name = ref('')
function addRole() {
const trimmed = name.value.trim()
if (trimmed) {
emit('add', trimmed)
name.value = ''
}
}
</script>
@@ -0,0 +1,53 @@
<template>
<UModal
v-model:open="isOpen"
:title="t('collaboration.roles.deleteConfirm')"
>
<template #body>
<p class="text-muted">
{{ t('collaboration.roles.deleteMessage', { name: role?.name }) }}
</p>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="isOpen = false"
/>
<UButton
:label="t('contextMenu.delete')"
color="error"
variant="outline"
@click="handleDelete"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { CollaborationRole } from '@/types/collaboration-roles.types'
const props = defineProps<{
role: CollaborationRole | null
}>()
const emit = defineEmits<{
confirm: []
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const { t } = useI18n()
function handleDelete() {
if (props.role) {
emit('confirm')
isOpen.value = false
}
}
</script>
@@ -0,0 +1,71 @@
<template>
<UModal
v-model:open="isOpen"
:fullscreen="isMobile"
>
<template #header="{close}">
<div class="flex items-center justify-between w-full">
<h3 class="text-lg font-semibold">
{{ t('collaboration.roles.assignPermissions') }}
</h3>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
@click="close"
/>
</div>
</template>
<template #body>
<div class="space-y-4">
<div class="flex items-center gap-2 p-3 bg-elevated rounded-lg">
<UIcon
name="i-lucide-shield"
class="size-5"
/>
<span class="font-medium">{{ role?.name }}</span>
</div>
<PermissionsEditor
:permissions="permissions"
:selected-permissions="rolePermissions"
@toggle="$emit('togglePermission', $event)"
/>
</div>
</template>
<template #footer>
<div class="flex justify-end w-full">
<UButton
:label="t('common.done')"
color="primary"
variant="outline"
@click="isOpen = false"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { CollaborationPermission } from 'taskview-api'
import type { CollaborationRole } from '@/types/collaboration-roles.types'
import PermissionsEditor from './PermissionsEditor.vue'
import { useTaskView } from '@/composables/useTaskView'
defineProps<{
role: CollaborationRole | null
permissions: CollaborationPermission[]
rolePermissions: Record<number, true>
}>()
defineEmits<{
togglePermission: [permissionId: number]
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const { t } = useI18n()
const { isMobile } = useTaskView()
</script>
@@ -0,0 +1,40 @@
<template>
<div class="flex items-center justify-between p-3 rounded-lg border border-default hover:bg-elevated transition-colors">
<div class="flex items-center gap-2">
<UIcon
name="i-lucide-shield"
class="size-4 text-muted"
/>
<p class="text-sm font-medium">
{{ role.name }}
</p>
<span class="text-xs text-muted">
{{ t('collaboration.roles.permissionsCount', { count: permissionsCount }) }}
</span>
</div>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
size="xs"
@click.stop="$emit('menu', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { CollaborationRole } from '@/types/collaboration-roles.types'
defineProps<{
role: CollaborationRole
permissionsCount: number
}>()
defineEmits<{
menu: [event: MouseEvent]
}>()
const { t } = useI18n()
</script>
@@ -0,0 +1,156 @@
<template>
<div class="space-y-4">
<h3 class="text-lg font-semibold">
{{ t('collaboration.roles.title') }}
</h3>
<RoleAddInput
:loading="loading"
@add="$emit('create', $event)"
/>
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<UIcon
name="i-lucide-loader-2"
class="size-8 animate-spin text-muted"
/>
</div>
<div
v-else-if="roles.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted"
>
<UIcon
name="i-lucide-shield"
class="size-12 mb-4"
/>
<p>{{ t('collaboration.roles.empty') }}</p>
</div>
<div
v-else
class="space-y-2"
>
<RoleItem
v-for="role in roles"
:key="role.id"
:role="role"
:permissions-count="getPermissionsCount(role.id)"
@menu="openContextMenu($event, role)"
/>
</div>
<TvContextMenu ref="contextMenu">
<div class="p-1 flex flex-col gap-1">
<UButton
:label="t('collaboration.roles.assignPermissions')"
icon="i-lucide-shield-check"
variant="ghost"
color="neutral"
class="w-full justify-start"
@click="openEditModal"
/>
<USeparator class="my-1" />
<UButton
:label="t('contextMenu.delete')"
icon="i-lucide-trash-2"
variant="ghost"
color="error"
class="w-full justify-start"
@click="openDeleteDialog"
/>
</div>
</TvContextMenu>
<RoleEditModal
v-model:open="isEditModalOpen"
:role="selectedRole"
:permissions="permissions"
:role-permissions="getRolePermissions(selectedRole?.id)"
@toggle-permission="handleTogglePermission"
/>
<RoleDeleteDialog
v-model:open="isDeleteDialogOpen"
:role="selectedRole"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CollaborationPermission } from 'taskview-api'
import type { CollaborationRole, CollaborationRolesStore } from '@/types/collaboration-roles.types'
import TvContextMenu from '@/components/features/base/TvContextMenu.vue'
import RoleItem from './RoleItem.vue'
import RoleAddInput from './RoleAddInput.vue'
import RoleEditModal from './RoleEditModal.vue'
import RoleDeleteDialog from './RoleDeleteDialog.vue'
const props = defineProps<{
roles: CollaborationRole[]
permissions: CollaborationPermission[]
rolesPermissions: CollaborationRolesStore['rolesPermissions']
goalId: number
loading?: boolean
}>()
const emit = defineEmits<{
create: [name: string]
togglePermission: [data: { roleId: number; permissionId: number }]
delete: [roleId: number]
}>()
const { t } = useI18n()
const contextMenu = ref<InstanceType<typeof TvContextMenu> | null>(null)
const selectedRole = ref<CollaborationRole | null>(null)
const isEditModalOpen = ref(false)
const isDeleteDialogOpen = ref(false)
function getPermissionsCount(roleId: number): number {
const perms = props.rolesPermissions[roleId]
return perms ? Object.keys(perms).length : 0
}
function getRolePermissions(roleId?: number): Record<number, true> {
if (!roleId) return {}
return props.rolesPermissions[roleId] || {}
}
function openContextMenu(event: MouseEvent, role: CollaborationRole) {
selectedRole.value = role
contextMenu.value?.openAt(event)
}
function openEditModal() {
contextMenu.value?.close()
isEditModalOpen.value = true
}
function openDeleteDialog() {
contextMenu.value?.close()
isDeleteDialogOpen.value = true
}
function handleTogglePermission(permissionId: number) {
if (selectedRole.value) {
emit('togglePermission', {
roleId: selectedRole.value.id,
permissionId,
})
}
}
function handleDelete() {
if (selectedRole.value) {
emit('delete', selectedRole.value.id)
isDeleteDialogOpen.value = false
}
}
</script>
@@ -0,0 +1,68 @@
export interface Permission {
id: string
key: string
name: string
description: string
group: 'tasks' | 'lists' | 'members' | 'roles'
}
export interface Role {
id: string
name: string
permissions: string[]
isSystem?: boolean
}
export interface Member {
id: string
email: string
name?: string
avatar?: string
roleIds: string[]
status: 'pending' | 'active'
invitedAt: Date
}
export const PERMISSIONS: Permission[] = [
// Tasks
{ id: 'view_tasks', key: 'view_tasks', name: 'View Tasks', description: 'Can view tasks in the project', group: 'tasks' },
{ id: 'create_tasks', key: 'create_tasks', name: 'Create Tasks', description: 'Can create new tasks', group: 'tasks' },
{ id: 'edit_tasks', key: 'edit_tasks', name: 'Edit Tasks', description: 'Can edit existing tasks', group: 'tasks' },
{ id: 'delete_tasks', key: 'delete_tasks', name: 'Delete Tasks', description: 'Can delete tasks', group: 'tasks' },
// Lists
{ id: 'view_lists', key: 'view_lists', name: 'View Lists', description: 'Can view lists in the project', group: 'lists' },
{ id: 'create_lists', key: 'create_lists', name: 'Create Lists', description: 'Can create new lists', group: 'lists' },
{ id: 'edit_lists', key: 'edit_lists', name: 'Edit Lists', description: 'Can edit existing lists', group: 'lists' },
{ id: 'delete_lists', key: 'delete_lists', name: 'Delete Lists', description: 'Can delete lists', group: 'lists' },
// Members
{ id: 'view_members', key: 'view_members', name: 'View Members', description: 'Can view project members', group: 'members' },
{ id: 'invite_members', key: 'invite_members', name: 'Invite Members', description: 'Can invite new members', group: 'members' },
{ id: 'remove_members', key: 'remove_members', name: 'Remove Members', description: 'Can remove members from project', group: 'members' },
// Roles
{ id: 'view_roles', key: 'view_roles', name: 'View Roles', description: 'Can view roles', group: 'roles' },
{ id: 'manage_roles', key: 'manage_roles', name: 'Manage Roles', description: 'Can create, edit and delete roles', group: 'roles' },
]
export const DEFAULT_ROLES: Role[] = [
{
id: 'admin',
name: 'Admin',
permissions: PERMISSIONS.map(p => p.id),
isSystem: true,
},
{
id: 'member',
name: 'Member',
permissions: ['view_tasks', 'create_tasks', 'edit_tasks', 'view_lists', 'create_lists', 'edit_lists', 'view_members'],
isSystem: true,
},
{
id: 'viewer',
name: 'Viewer',
permissions: ['view_tasks', 'view_lists', 'view_members'],
isSystem: true,
},
]
@@ -0,0 +1,94 @@
<template>
<UModal v-model:open="model">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">
{{ t('graph.addTask') }}
</h3>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
@click="close"
/>
</div>
</template>
<UFormField :label="t('tasks.description')">
<UInput
v-model="taskName"
:placeholder="t('tasks.addPlaceholder')"
class="w-full"
@keyup.enter="addTask"
/>
</UFormField>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
@click="close"
>
{{ t('common.cancel') }}
</UButton>
<UButton
:disabled="!taskName.trim()"
variant="outline"
@click="addTask"
>
{{ t('tasks.add') }}
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTasksStore } from '@/stores/tasks.store'
import type { TaskItem } from '@/types/tasks.types'
const emit = defineEmits<{
addTask: [task: TaskItem]
close: []
}>()
const model = defineModel<boolean>({ required: true })
const props = defineProps<{
goalId: number
nodePosition: { x: number; y: number } | undefined
}>()
const { t } = useI18n()
const taskName = ref('')
const store = useTasksStore()
const addTask = async () => {
if (!props.goalId) return
if (!taskName.value.trim()) return
const task = await store.addTask({
description: taskName.value,
goalId: props.goalId,
nodeGraphPosition: props.nodePosition,
})
if (!task) {
return
}
emit('addTask', task)
taskName.value = ''
}
const close = () => {
taskName.value = ''
emit('close')
}
</script>
@@ -0,0 +1,274 @@
<template>
<VueFlow
v-if="canViewGraph"
v-model:nodes="store.nodes"
v-model:edges="store.edges"
:min-zoom="-2"
fit-view-on-init
elevate-edges-on-select
elevate-nodes-on-select
:pan-on-scroll-mode="PanOnScrollMode.Free"
:pan-on-scroll="true"
:default-edge-options="defaultEdgeOptions"
:connection-mode="ConnectionMode.Loose"
:nodes-connectable="canManageGraph"
:nodes-draggable="canManageGraph"
class="h-full w-full"
@connect-start="onConnectStart"
@connect-end="onConnectEnd"
>
<AddNewTaskToGraph
v-model="addNewTaskToGraph"
:goal-id="+projectId"
:node-position="nodePosition"
@close="addNewTaskToGraph = false"
@add-task="addTaskToGraph"
/>
<Controls />
<Background />
<template #node-task="taskNodeProps">
<TaskNode :data="{ ...taskNodeProps.data, direction: layoutDirection }" />
</template>
<!-- Selected Edge Panel -->
<div
v-if="selectedEdge"
class="w-80 max-w-[calc(100%-16px)] absolute bottom-0 left-1/2 -translate-x-1/2 mb-3 z-50 p-3 rounded-lg bg-elevated shadow-lg"
>
<p class="text-sm text-muted mb-3">
{{ t('graph.selectedEdge') }}
</p>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="soft"
class="w-full"
@click="deleteSelectedEdge(+selectedEdge?.id)"
>
{{ t('common.delete') }}
</UButton>
</div>
<!-- Controls Panel -->
<div class="absolute bottom-4 right-4 z-40 flex gap-2">
<UButton
icon="i-lucide-git-graph"
:variant="layoutDirection === 'LR' ? 'solid' : 'outline'"
color="neutral"
class="rotate-90"
@click="layoutGraph('LR')"
/>
<UButton
icon="i-lucide-git-graph"
:variant="layoutDirection === 'TB' ? 'solid' : 'outline'"
color="neutral"
@click="layoutGraph('TB')"
/>
<UButton
v-if="canManageGraph"
icon="i-lucide-plus"
@click="addNewTaskToGraph = true"
/>
</div>
</VueFlow>
</template>
<script setup lang="ts">
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import {
ConnectionMode,
type DefaultEdgeOptions,
type Edge,
MarkerType,
type OnConnectStartParams,
PanOnScrollMode,
useVueFlow,
VueFlow,
} from '@vue-flow/core'
import { nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGraphStore } from '@/stores/graph.store'
import type { TaskItem } from '@/types/tasks.types'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import { useAppRouteInfo } from '@/composables/useAppRouteInfo'
import AddNewTaskToGraph from './AddNewTaskToGraph.vue'
import { useLayout } from './composables/useLayout'
import TaskNode from './TaskNode.vue'
const {
fitView,
onConnect,
addEdges,
onEdgesChange,
onEdgeClick,
onNodeDragStop,
getEdges,
updateEdgeData,
removeEdges,
screenToFlowCoordinate,
} = useVueFlow()
const { projectId } = useAppRouteInfo()
const { layout } = useLayout()
const store = useGraphStore()
const { t } = useI18n()
const { canManageGraph, canViewGraph } = useGoalPermissions()
const addNewTaskToGraph = ref(false)
const currentSession = ref<number | null>(null)
const successfulSession = ref<number | null>(null)
const targetNodeId = ref<{ event?: MouseEvent } & OnConnectStartParams>()
const nodePosition = ref<{ x: number; y: number } | undefined>(undefined)
const defaultEdgeOptions: DefaultEdgeOptions = {
type: 'smoothstep',
animated: true,
style: {
strokeWidth: 3,
},
markerEnd: {
type: MarkerType.ArrowClosed,
width: 22,
height: 22,
},
}
const selectedEdge = ref<Edge | null>(null)
const layoutDirection = ref<'LR' | 'TB'>('TB')
watch(
projectId,
(id) => {
if (!id) return
store.fetchAllTasksAndLists(id).then(() => {
layoutGraph(layoutDirection.value)
})
},
{ immediate: true },
)
onNodeDragStop((params) => {
store.updateTaskPosition({
nodeGraphPosition: { x: params.node.position.x, y: params.node.position.y },
id: +params.node.id,
})
})
onConnect(async (params) => {
if (currentSession.value != null) {
successfulSession.value = currentSession.value
}
const newEdge = await store.addEdge({
source: +params.source,
target: +params.target,
})
if (!newEdge) return
addEdges([newEdge])
})
onEdgesChange((params) => {
params.forEach((param) => {
if (param.type === 'select' && !param.selected) {
selectedEdge.value = null
}
if (param.type === 'remove') {
deleteSelectedEdge(+param.id)
selectedEdge.value = null
}
})
})
onEdgeClick((params) => {
selectedEdge.value = params.edge
})
const newToken = () => {
return Date.now() + Math.random()
}
const onConnectStart = (payload: { event?: MouseEvent } & OnConnectStartParams) => {
const token = newToken()
currentSession.value = token
successfulSession.value = null
targetNodeId.value = payload
}
const onConnectEnd = (event?: MouseEvent | TouchEvent) => {
if (!event) return
const { clientX, clientY } = 'changedTouches' in event ? (event as TouchEvent).changedTouches[0] : event
const el = document.querySelector('[data-id]')
const position = el?.getBoundingClientRect()
nodePosition.value = screenToFlowCoordinate({ x: clientX - (position?.width || 2) / 2, y: clientY })
const isSuccess = currentSession.value != null && successfulSession.value === currentSession.value
if (!isSuccess) {
handleCancelledConnection()
}
currentSession.value = null
successfulSession.value = null
}
const handleCancelledConnection = () => {
addNewTaskToGraph.value = true
}
const addTaskToGraph = async (task: TaskItem) => {
addNewTaskToGraph.value = false
await store.addNode(task)
if (targetNodeId.value && targetNodeId.value.nodeId) {
const edge = {
source: +task.id,
target: +task.id,
}
edge[targetNodeId.value.handleType as 'source' | 'target'] = +targetNodeId.value.nodeId
await store.addEdge(edge)
}
nodePosition.value = { x: 0, y: 0 }
targetNodeId.value = undefined
}
const deleteSelectedEdge = async (id: number) => {
if (!Number.isFinite(id)) return
await store.deleteEdge(+id)
if (selectedEdge.value?.id === id.toString()) {
selectedEdge.value = null
}
removeEdges([id.toString()])
}
const layoutGraph = async (direction: 'LR' | 'TB') => {
layoutDirection.value = direction
store.nodes = layout(store.nodes, store.edges, direction)
nextTick(() => {
fitView()
getEdges.value.forEach((edge) => {
updateEdgeData(edge.id, edge)
})
})
}
</script>
<style>
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/node-resizer/dist/style.css';
@import '@vue-flow/controls/dist/style.css';
@import '@vue-flow/minimap/dist/style.css';
</style>
@@ -0,0 +1,78 @@
<template>
<div class="bg-elevated rounded-lg w-72 shadow-lg">
<Handle
v-if="props.data.direction !== 'LR'"
type="target"
:position="Position.Bottom"
:style="targetHandleStyle"
/>
<Handle
v-if="props.data.direction !== 'LR'"
type="source"
:position="Position.Top"
:style="sourceHandleStyle"
/>
<Handle
v-if="props.data.direction === 'LR'"
type="target"
:position="Position.Left"
:style="targetHandleStyle"
/>
<Handle
v-if="props.data.direction === 'LR'"
type="source"
:position="Position.Right"
:style="sourceHandleStyle"
/>
<TaskItem
:task="props.data.task"
class="w-full"
@toggle="toggleComplete($event)"
/>
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue'
import type { TaskItem } from '@/types/tasks.types'
import { useTasksStore } from '@/stores/tasks.store'
import { Task } from 'taskview-api'
type TaskNodeProps = {
toolbarVisible: boolean
toolbarPosition: Position
label: string
task: TaskItem
direction?: 'LR' | 'TB'
}
const props = defineProps<{ data: TaskNodeProps }>()
const tasksStore = useTasksStore()
async function toggleComplete(task: Task) {
await tasksStore.updateTaskCompleteStatus({
id: task.id,
complete: !task.complete,
})
}
const handleStyle = computed(() => ({
width: '16px',
height: '16px',
borderRadius: '50%',
}))
const targetHandleStyle = computed(() => ({
...handleStyle.value,
background: '#f43f5e',
}))
const sourceHandleStyle = computed(() => ({
...handleStyle.value,
background: '#22c55e',
}))
</script>
@@ -0,0 +1,120 @@
import dagre from '@dagrejs/dagre'
import { type Edge, type Node, Position, useVueFlow } from '@vue-flow/core'
import { ref } from 'vue'
/**
* Composable to run the layout algorithm on the graph.
* It uses the `dagre` library to calculate the layout of the nodes and edges.
*/
export function useLayout() {
const { findNode } = useVueFlow()
const graph = ref(new dagre.graphlib.Graph())
const previousDirection = ref('LR')
function layout(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
// we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
const dagreGraph = new dagre.graphlib.Graph()
graph.value = dagreGraph
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = direction === 'LR'
dagreGraph.setGraph({
rankdir: direction,
// align: 'UL', // Align to upper left
nodesep: 50, // Minimum space between nodes
ranksep: 100, // Minimum space between ranks
marginx: 20,
marginy: 20,
})
previousDirection.value = direction
for (const node of nodes) {
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
const graphNode = findNode(node.id)
dagreGraph.setNode(node.id, {
width: graphNode?.dimensions.width || 150,
height: graphNode?.dimensions.height || 50,
})
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target)
}
dagre.layout(dagreGraph)
// set nodes with updated positions
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
}
})
// For TB mode, invert Y coordinates to put root at top
if (!isHorizontal) {
const maxY = Math.max(...layoutedNodes.map((node) => node.position.y))
layoutedNodes.forEach((node) => {
const graphNode = findNode(node.id)
const nodeHeight = graphNode?.dimensions.height || 50
// Invert Y coordinate and adjust for node height to keep center aligned
node.position.y = maxY - node.position.y + nodeHeight
})
}
return layoutedNodes
}
// function layout(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
// // we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
// const dagreGraph = new dagre.graphlib.Graph()
// graph.value = dagreGraph
// dagreGraph.setDefaultEdgeLabel(() => ({}))
// const isHorizontal = direction === 'LR'
// dagreGraph.setGraph({ rankdir: direction })
// previousDirection.value = direction
// for (const node of nodes) {
// // if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
// const graphNode = findNode(node.id)
// dagreGraph.setNode(node.id, { width: graphNode.dimensions.width || 150, height: graphNode.dimensions.height || 50 })
// }
// for (const edge of edges) {
// dagreGraph.setEdge(edge.source, edge.target)
// }
// dagre.layout(dagreGraph)
// // set nodes with updated positions
// return nodes.map((node) => {
// const nodeWithPosition = dagreGraph.node(node.id)
// return {
// ...node,
// targetPosition: isHorizontal ? Position.Left : Position.Top,
// sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
// position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
// }
// })
// }
return { graph, layout, previousDirection }
}
@@ -0,0 +1,309 @@
<template>
<div
v-if="canViewKanban"
ref="kanbanContainer"
class="flex gap-2 h-full"
@pointermove="handlePointerMove"
@pointerleave="boardStopScrolling"
@pointerdown="pointerDownHandler"
@touchend="boardStopScrolling"
>
<div
v-for="status in kanbanStore.statuses"
:key="status.id"
class="h-full max-w-[340px] min-w-[272px] shadow-lg gap-2 flex flex-col w-[91.666667%] rounded-lg"
>
<div class="bg-elevated rounded-lg p-2 px-3 flex items-center text-base h-10">
<span class="grow truncate">
{{ t(status.name) }}
</span>
<KanbanTitleMenu
v-if="status.id !== DEFAULT_ID"
:status="status"
/>
</div>
<KanbanAddTask
v-if="canAddTask"
:goal-id="kanbanStore.goalId"
:status-id="status.id"
/>
<draggable
v-bind="sortableOptions"
:list="kanbanStore.tasksData[status.id]?.tasks"
:data-column-id="status.id"
:animation="150"
class="flex gap-2 p-2 h-full flex-col overflow-auto overflow-x-hidden"
item-key="id"
group="kanban-tasks"
@start="startHandler"
@end="onDragEnd"
@clone="onClone"
>
<template #item="{ element }">
<div
:data-order="element.kanbanOrder"
:data-task-id="element.id"
>
<TaskItem
:task="element"
:ui="{ additionalInfo: 'flex-col items-stretch flex-nowrap' }"
@toggle="updateTaskChecked"
/>
</div>
</template>
<template #footer>
<UButton
v-if="kanbanStore.tasksData[status.id]?.nextCursor !== null"
icon="i-lucide-circle-arrow-down"
variant="soft"
color="info"
class="w-full mt-2"
@click="loadMoreTasks(status.id)"
>
{{ t('kanban.loadMore') }}
</UButton>
</template>
</draggable>
</div>
<KanbanAddStatus :goal-id="kanbanStore.goalId" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import draggable from 'vuedraggable'
import type { Task } from 'taskview-api'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import KanbanAddStatus from '@/components/features/kanban/parts/KanbanAddStatus.vue'
import KanbanAddTask from '@/components/features/kanban/parts/KanbanAddTask.vue'
import KanbanTitleMenu from '@/components/features/kanban/parts/KanbanTitleMenu.vue'
import TaskItem from '@/components/features/tasks/parts/TaskItem.vue'
import { useKanbanStore } from '@/stores/kanban.store'
import { useTasksStore } from '@/stores/tasks.store'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
import { DEFAULT_ID } from '@/types/app.types'
const kanbanStore = useKanbanStore()
const tasksStore = useTasksStore()
const route = useRoute()
const { t } = useI18n()
const { canViewKanban, canAddTask, canManageKanban } = useGoalPermissions()
const kanbanContainer = ref<HTMLElement>()
const goalId = computed(() => route.params.projectId as string)
let boardScrollDirection: string | null = null
let boardScrollSpeed = 3
let boardAnimationFrameId: number | null = null
let cloneEl: HTMLElement | null = null
let dragStartPositionX: number | null = null
let speedDelta = 0
const sortableOptions = computed(() => ({
delay: 100,
delayOnTouchOnly: false,
forceFallback: true,
swapThreshold: 1,
scroll: true,
scrollSensitivity: 50,
scrollSpeed: 10,
disabled: !canManageKanban.value,
}))
const initLoadTasksForEachColumn = async () => {
await Promise.all(kanbanStore.statuses.map((status) => {
kanbanStore.fetchTasksForColumn(kanbanStore.goalId, status.id === DEFAULT_ID ? null : status.id, null)
}))
}
watch(goalId, (lGoalId) => {
kanbanStore.$reset()
if (lGoalId) {
kanbanStore.goalId = +lGoalId
}
}, { immediate: true })
watch(
() => kanbanStore.statuses,
(statuses) => {
if (statuses.length > 0) {
initLoadTasksForEachColumn()
}
},
)
const updateTaskChecked = async (task: Task) => {
const newStatus = !task.complete
await tasksStore.updateTaskCompleteStatus({
id: task.id,
complete: newStatus,
})
for (const [, value] of Object.entries(kanbanStore.tasksData)) {
if (!value) continue
for (const t of value.tasks) {
if (t.id === task.id) {
t.complete = newStatus
return
}
}
}
}
const loadMoreTasks = (columnId: number) => {
kanbanStore.fetchTasksForColumn(
kanbanStore.goalId,
columnId === DEFAULT_ID ? null : columnId,
kanbanStore.tasksData[columnId]?.tasks[kanbanStore.tasksData[columnId]?.tasks.length - 1]?.kanbanOrder || null,
)
}
const startHandler = (event: MouseEvent) => {
event.preventDefault()
}
const onClone = (evt: { clone: HTMLElement }) => {
cloneEl = evt.clone as HTMLElement
}
const pointerDownHandler = (event: PointerEvent) => {
speedDelta = 0
dragStartPositionX = event.clientX
}
const detectScrollDirectionFromStart = (clientX: number): 'right' | 'left' | undefined => {
if (!dragStartPositionX) return
const threshold = window.innerWidth < 1024 ? 80 : 240
const delta = clientX - dragStartPositionX
if (Math.abs(delta) < threshold) return
const percentage = (100 / threshold) * (Math.abs(delta) - threshold)
speedDelta = ((boardScrollSpeed / 100) * Math.min(percentage, 250)) / 20
if (delta < 0) {
return 'left'
}
return 'right'
}
const boardStopScrolling = () => {
boardScrollDirection = null
if (boardAnimationFrameId) {
cancelAnimationFrame(boardAnimationFrameId)
boardAnimationFrameId = null
}
}
const handlePointerMove = (event: MouseEvent) => {
if (!kanbanContainer.value || !cloneEl) return
detectScrollDirectionFromStart(event.clientX)
if (detectScrollDirectionFromStart(event.clientX) === 'right') {
boardStartScrolling('right')
} else if (detectScrollDirectionFromStart(event.clientX) === 'left') {
boardStartScrolling('left')
} else {
boardStopScrolling()
}
}
const boardStartScrolling = (direction: 'left' | 'right') => {
boardScrollDirection = direction
if (!boardAnimationFrameId) {
boardScrollStep()
}
}
const boardScrollStep = () => {
if (!kanbanContainer.value || !boardScrollDirection) return
if (boardScrollDirection === 'right') {
kanbanContainer.value.scrollLeft += boardScrollSpeed + speedDelta
} else if (boardScrollDirection === 'left') {
kanbanContainer.value.scrollLeft -= boardScrollSpeed + speedDelta
}
boardAnimationFrameId = requestAnimationFrame(boardScrollStep)
}
function scrollToColumn(targetColumn: HTMLElement) {
const board = kanbanContainer.value
if (!board) return
if (targetColumn) {
const boardRect = board.getBoundingClientRect()
const columnRect = targetColumn.getBoundingClientRect()
const scrollLeft =
board.scrollLeft + (columnRect.left - boardRect.left) - board.clientWidth / 2 + columnRect.width / 2
board.scrollTo({
left: scrollLeft,
behavior: 'smooth',
})
}
}
async function onDragEnd(evt: {
to: HTMLElement
item: HTMLElement
newDraggableIndex: number
oldDraggableIndex: number
}) {
cloneEl = null
boardStopScrolling()
const targetColumn = evt.to as HTMLElement
const newColumnId = targetColumn.dataset.columnId
if (newColumnId === undefined) {
console.error('Can not find columns id in dataset for element')
return
}
let taskId: string | number | undefined = (evt.item as HTMLElement).dataset.taskId
if (taskId === undefined) {
return
}
taskId = +taskId
const newColumnIdNum = +newColumnId
const columnTasks = kanbanStore.tasksData[newColumnIdNum]?.tasks ?? []
const activeTasks = columnTasks.filter((task: Task) => task.id !== taskId)
const nextItemInColumn = activeTasks[evt.newDraggableIndex] || null
const prevItemInColumn = activeTasks[evt.newDraggableIndex - 1] || null
await kanbanStore.updateTasksOrderAndColumn({
taskId,
prevTaskId: prevItemInColumn?.id ?? null,
nextTaskId: nextItemInColumn?.id ?? null,
columnId: newColumnIdNum === DEFAULT_ID ? null : newColumnIdNum,
goalId: kanbanStore.goalId,
})
if (targetColumn) {
scrollToColumn(targetColumn)
}
}
onMounted(() => {
boardScrollSpeed = window.innerWidth < 1024 ? 7 : 5
})
onUnmounted(() => {
kanbanStore.$reset()
boardStopScrolling()
})
</script>
@@ -0,0 +1,38 @@
<template>
<div class="tv-kanban__col">
<div class="w-full flex flex-col gap-3">
<UInput
v-model="statusName"
:placeholder="t('kanban.addColumn')"
icon="i-lucide-plus"
size="xl"
class="h-full max-w-[340px] min-w-[272px] bg-neutral-800 flex flex-col w-[91.666667%] rounded-lg"
/>
<UButton
v-if="statusName.trim()"
@click="addStatus"
>
{{ t('kanban.addColumn') }}
</UButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GoalItem } from 'taskview-api'
import { useKanbanStore } from '@/stores/kanban.store'
const props = defineProps<{ goalId: GoalItem['id'] }>()
const { t } = useI18n()
const kanbanStore = useKanbanStore()
const statusName = ref('')
async function addStatus() {
await kanbanStore.addStatus({ goalId: props.goalId, name: statusName.value })
statusName.value = ''
}
</script>
@@ -0,0 +1,53 @@
<template>
<UInput
v-model="taskName"
:loading="loading"
:placeholder="t('kanban.addTask')"
:trailing-icon="inputIcon"
icon="i-lucide-plus"
size="xl"
class="mt-1 rounded-lg"
enterkeyhint="go"
spellcheck="false"
@keyup.enter="addTask"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GoalItem } from 'taskview-api'
import { useKanbanStore } from '@/stores/kanban.store'
import { DEFAULT_ID } from '@/types/app.types'
import type { TaskItem } from '@/types/tasks.types'
const props = defineProps<{
goalId: GoalItem['id']
statusId: TaskItem['statusId']
}>()
const { t } = useI18n()
const kanbanStore = useKanbanStore()
const taskName = ref('')
const loading = ref(false)
const inputIcon = computed(() => taskName.value.trim() ? 'i-lucide-corner-down-left' : 'i-lucide-keyboard')
const addTask = async () => {
if (!taskName.value.trim()) {
return
}
loading.value = true
await kanbanStore.addTask({
goalId: props.goalId,
goalListId: null,
description: taskName.value,
statusId: props.statusId === DEFAULT_ID ? null : props.statusId,
})
taskName.value = ''
loading.value = false
}
</script>
@@ -0,0 +1,61 @@
<template>
<UModal v-model:open="model">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">
{{ t('kanban.deleteColumn') }}
</h3>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
@click="model = false"
/>
</div>
</template>
<p class="text-muted">
{{ t('kanban.deleteColumnConfirm') }}
</p>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
@click="model = false"
>
{{ t('common.cancel') }}
</UButton>
<UButton
color="error"
@click="confirmDelete"
>
{{ t('common.delete') }}
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { KanbanColumnItem } from 'taskview-api'
import { useKanbanStore } from '@/stores/kanban.store'
const props = defineProps<{ status: KanbanColumnItem }>()
const model = defineModel<boolean>({ required: true })
const { t } = useI18n()
const store = useKanbanStore()
async function confirmDelete() {
await store.deleteStatus({ id: props.status.id })
model.value = false
}
</script>
@@ -0,0 +1,73 @@
<template>
<UModal v-model:open="model">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">
{{ t('kanban.editColumn') }}
</h3>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
@click="model = false"
/>
</div>
</template>
<UFormField :label="t('kanban.columnName')">
<UInput
v-model="name"
:placeholder="t('kanban.columnName')"
class="w-full"
/>
</UFormField>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
@click="model = false"
>
{{ t('common.cancel') }}
</UButton>
<UButton
variant="outline"
@click="save"
>
{{ t('common.save') }}
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { KanbanColumnItem } from 'taskview-api'
import { useKanbanStore } from '@/stores/kanban.store'
const props = defineProps<{ status: KanbanColumnItem }>()
const model = defineModel<boolean>({ required: true })
const { t } = useI18n()
const store = useKanbanStore()
const name = ref(props.status.name)
watch(model, (open) => {
if (open) {
name.value = props.status.name
}
})
async function save() {
await store.updateStatus({ id: props.status.id, name: name.value })
model.value = false
}
</script>
@@ -0,0 +1,67 @@
<template>
<UDropdownMenu
:items="menuItems"
:ui="{ content: 'z-50' }"
size="lg"
>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
size="sm"
/>
</UDropdownMenu>
<KanbanEditModal
v-model="editOpen"
:status="props.status"
/>
<KanbanDeleteModal
v-model="deleteOpen"
:status="props.status"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { KanbanColumnItem } from 'taskview-api'
import type { DropdownMenuItem } from '@nuxt/ui'
import KanbanEditModal from './KanbanEditModal.vue'
import KanbanDeleteModal from './KanbanDeleteModal.vue'
const props = defineProps<{ status: KanbanColumnItem }>()
const { t } = useI18n()
const editOpen = ref(false)
const deleteOpen = ref(false)
const ui = {
itemLeadingIcon: 'size-4',
item: 'items-center',
}
const menuItems = computed<DropdownMenuItem[][]>(() => [
[
{
label: t('kanban.edit'),
icon: 'i-lucide-pencil',
ui,
// size: 'lg',
onSelect: () => {
editOpen.value = true
},
},
{
label: t('kanban.delete'),
icon: 'i-lucide-trash-2',
color: 'error' as const,
ui,
onSelect: () => {
deleteOpen.value = true
},
},
],
])
</script>
@@ -0,0 +1,43 @@
<template>
<ListsListBase
v-model:open="open"
:title="t('archive.title')"
:lists="lists"
:is-archive="true"
@save="handleSave"
@delete="handleDelete"
@restore="handleRestore"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import ListsListBase from '@/components/features/lists/parts/ListsListBase.vue'
import type { List } from '@/components/features/lists/types'
defineProps<{
lists: List[]
}>()
const emit = defineEmits<{
save: [list: List, data: { name: string }]
delete: [list: List]
restore: [list: List]
}>()
const { t } = useI18n()
const open = defineModel<boolean>('open', { required: false, default: false })
function handleSave(list: List, data: { name: string }) {
emit('save', list, data)
}
function handleDelete(list: List) {
emit('delete', list)
}
function handleRestore(list: List) {
emit('restore', list)
}
</script>
@@ -0,0 +1,112 @@
<template>
<UDashboardPanel
id="lists"
collapsible
:resizable="false"
:default-size="25"
:min-size="15"
:max-size="35"
:class="[
'lg:flex',
hasListSelected || hasTaskSelected ? 'hidden' : 'flex'
]"
>
<template #header>
<UDashboardNavbar :ui="{ right: 'flex-1', root: 'px-2!' }">
<template #leading>
<TvCollapseSidebarDesktop />
</template>
<template #right>
<ListAddInput
v-if="canAddTaskList"
@add="handleAdd"
/>
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-col gap-2">
<ListsList
:lists="activeLists"
@save="handleSave"
@delete="handleDelete"
@archive="handleArchive"
/>
<ListsArchive
v-if="archivedLists.length > 0"
:lists="archivedLists"
@save="handleSave"
@delete="handleDelete"
@restore="handleRestore"
/>
</div>
</template>
</UDashboardPanel>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { useGoalListsStore } from '@/stores/goal-lists.store'
import ListAddInput from './parts/ListAddInput.vue'
import ListsList from './ListsList.vue'
import ListsArchive from './ListsArchive.vue'
import type { List } from './types'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
const { canAddTaskList } = useGoalPermissions()
const route = useRoute()
const router = useRouter()
const listsStore = useGoalListsStore()
const { lists } = storeToRefs(listsStore)
const projectId = computed(() => Number(route.params.projectId) || null)
const hasListSelected = computed(() => !!route.params.listId)
const hasTaskSelected = computed(() => !!route.params.taskId)
const activeLists = computed(() => lists.value.filter(l => l.archive === 0))
const archivedLists = computed(() => lists.value.filter(l => l.archive === 1))
watch(projectId, async (newProjectId) => {
if (newProjectId) {
await listsStore.fetchLists(newProjectId)
}
}, { immediate: true })
async function handleAdd(name: string) {
if (!projectId.value) return
const newList = await listsStore.addList({ name, goalId: projectId.value })
if (newList) {
router.push({ name: 'user', params: { projectId: projectId.value, listId: newList.id } })
}
}
async function handleSave(list: List, data: { name: string }) {
await listsStore.updateList({
id: list.id,
name: data.name,
})
}
async function handleDelete(list: List) {
await listsStore.deleteList(list.id)
}
async function handleArchive(list: List) {
await listsStore.updateList({
id: list.id,
archive: 1,
})
}
async function handleRestore(list: List) {
await listsStore.updateList({
id: list.id,
archive: 0,
})
}
</script>
@@ -0,0 +1,146 @@
<template>
<div class="flex flex-col gap-2 w-full">
<template v-if="canViewLists">
<TvGoalLikeItem
v-for="list in lists"
:key="list.id"
:to="{ name: 'user', params: { projectId, listId: list.id } }"
:active="currentListId === list.id"
variant="taskview"
>
<div class="flex items-center justify-between w-full">
<span>{{ list.name }}</span>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
size="xs"
class="z-10 cursor-pointer"
@click.prevent.stop="openContextMenu($event, list)"
/>
</div>
</TvGoalLikeItem>
</template>
</div>
<TvContextMenu ref="contextMenu">
<div class="p-1 flex flex-col gap-1">
<UButton
v-if="canEditList"
:label="t('contextMenu.edit')"
icon="i-lucide-pencil"
variant="ghost"
color="neutral"
class="w-full justify-start"
@click="openEditModal"
/>
<UButton
v-if="canEditList"
:label="t('contextMenu.moveToArchive')"
icon="i-lucide-archive"
variant="ghost"
color="neutral"
class="w-full justify-start"
@click="handleArchive"
/>
<USeparator class="my-1" />
<UButton
v-if="canDeleteList"
:label="t('contextMenu.delete')"
icon="i-lucide-trash-2"
variant="ghost"
color="error"
class="w-full justify-start"
@click="openDeleteDialog"
/>
</div>
</TvContextMenu>
<ListEditModal
v-model:open="isEditModalOpen"
:list="selectedList"
@save="handleSave"
/>
<ListDeleteDialog
v-model:open="isDeleteDialogOpen"
:list="selectedList"
@confirm="handleDelete"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import TvContextMenu from '@/components/features/base/TvContextMenu.vue'
import ListEditModal from '@/components/features/lists/parts/ListEditModal.vue'
import ListDeleteDialog from '@/components/features/lists/parts/ListDeleteDialog.vue'
import type { List } from '@/components/features/lists/types'
import { useGoalPermissions } from '@/composables/useGoalPermissions'
const {
canViewLists,
canDeleteList,
canEditList,
} = useGoalPermissions()
defineProps<{
lists: List[]
}>()
const emit = defineEmits<{
save: [list: List, data: { name: string }]
delete: [list: List]
archive: [list: List]
}>()
const { t } = useI18n()
const route = useRoute()
const projectId = computed(() => route.params.projectId)
const currentListId = computed(() => Number(route.params.listId) || null)
const contextMenu = ref<InstanceType<typeof TvContextMenu> | null>(null)
const selectedList = ref<List | null>(null)
const isEditModalOpen = ref(false)
const isDeleteDialogOpen = ref(false)
function openContextMenu(event: MouseEvent, list: List) {
selectedList.value = list
contextMenu.value?.openAt(event)
}
function openEditModal() {
contextMenu.value?.close()
isEditModalOpen.value = true
}
function handleSave(data: { name: string }) {
if (selectedList.value) {
emit('save', selectedList.value, data)
}
}
function handleArchive() {
if (selectedList.value) {
emit('archive', selectedList.value)
}
contextMenu.value?.close()
}
function openDeleteDialog() {
contextMenu.value?.close()
isDeleteDialogOpen.value = true
}
function handleDelete() {
if (selectedList.value) {
emit('delete', selectedList.value)
}
}
</script>
@@ -0,0 +1,51 @@
<template>
<UInput
v-model="listName"
:placeholder="t('lists.addPlaceholder')"
size="xl"
variant="soft"
class="w-full"
:ui="{
base: 'bg-tv-ui-bg-elevated',
}"
@keydown.enter="addList"
>
<template #trailing>
<UButton
v-if="listName.trim()"
icon="i-lucide-corner-down-left"
color="primary"
variant="ghost"
size="xs"
:aria-label="t('lists.add')"
@click="addList"
/>
<UIcon
v-else
name="i-lucide-keyboard"
class="size-4 text-dimmed"
/>
</template>
</UInput>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const emit = defineEmits<{
add: [name: string]
}>()
const { t } = useI18n()
const listName = ref('')
function addList() {
const name = listName.value.trim()
if (name) {
emit('add', name)
listName.value = ''
}
}
</script>
@@ -0,0 +1,51 @@
<template>
<UModal
v-model:open="isOpen"
:title="t('lists.deleteConfirm')"
:ui="{ footer: 'justify-end!' }"
>
<template #body>
<p class="text-muted">
{{ t('lists.deleteMessage', { name: list?.name }) }}
</p>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="isOpen = false"
/>
<UButton
:label="t('contextMenu.delete')"
color="error"
@click="confirmDelete"
/>
</div>
</template>
</UModal>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { List } from '@/components/features/lists/types'
defineProps<{
list: List | null
}>()
const emit = defineEmits<{
confirm: []
}>()
const { t } = useI18n()
const isOpen = defineModel<boolean>('open', { required: true })
function confirmDelete() {
emit('confirm')
isOpen.value = false
}
</script>
@@ -0,0 +1,71 @@
<template>
<UModal
v-model:open="isOpen"
:title="t('lists.edit')"
:ui="{ footer: 'justify-end!' }"
>
<template #body>
<div class="space-y-4">
<UFormField :label="t('lists.name')">
<UInput
v-model="formData.name"
:placeholder="t('lists.namePlaceholder')"
class="w-full"
/>
</UFormField>
</div>
</template>
<template #footer>
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="isOpen = false"
/>
<UButton
:label="t('common.save')"
color="primary"
variant="outline"
@click="save"
/>
</template>
</UModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { List } from '@/components/features/lists/types'
const props = defineProps<{
list: List | null
}>()
const emit = defineEmits<{
save: [data: { name: string }]
}>()
const { t } = useI18n()
const isOpen = defineModel<boolean>('open', { required: true })
const formData = ref({
name: '',
})
watch(
() => props.list,
(list) => {
if (list) {
formData.value.name = list.name
}
},
{ immediate: true },
)
function save() {
emit('save', { name: formData.value.name })
isOpen.value = false
}
</script>

Some files were not shown because too many files have changed in this diff Show More