Merge pull request #18 from Gimanh/feat/nuxt-ui-client
feat: web and mobile client in with nuxt-ui lib
@@ -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"
|
||||
@@ -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,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": [
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"context7@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
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>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
CAPACITOR_DEBUG = true
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": [
|
||||
"github>nuxt/renovate-config-nuxt"
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [{
|
||||
"matchDepTypes": ["resolutions"],
|
||||
"enabled": false
|
||||
}],
|
||||
"postUpdateOptions": ["pnpmDedupe"]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 236 KiB |
@@ -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>
|
||||