Compare commits
19 Commits
v1.2.34
...
backup-in-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c832e6af6c | ||
|
|
fc7f46618f | ||
|
|
b2949b2dd9 | ||
|
|
9478f7d0f5 | ||
|
|
2e0ff5a10c | ||
|
|
85826d8068 | ||
|
|
9b0af72d74 | ||
|
|
eb088a259a | ||
|
|
dcd157274a | ||
|
|
19ed0ba3ab | ||
|
|
b8fe2833b6 | ||
|
|
c39bbe5984 | ||
|
|
9950d8ff6c | ||
|
|
99b467488f | ||
|
|
8dfaa4d8c6 | ||
|
|
000e4d0d61 | ||
|
|
0132408eec | ||
|
|
b2d4d90c9c | ||
|
|
f5da57d7f7 |
@@ -1,36 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:tailwindcss/recommended',
|
||||
],
|
||||
ignorePatterns: [
|
||||
'dist',
|
||||
'.eslintrc.cjs',
|
||||
'tailwind.config.js',
|
||||
'postcss.config.js',
|
||||
],
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
settings: { react: { version: '18.2' } },
|
||||
plugins: [
|
||||
'react-refresh',
|
||||
'simple-import-sort',
|
||||
'sort-destructure-keys',
|
||||
'sort-keys-fix',
|
||||
'prettier',
|
||||
|
||||
'tailwindcss',
|
||||
],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
}
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -2,7 +2,6 @@ name: Build validation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -19,3 +18,4 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm i
|
||||
- run: npm run build
|
||||
- run: npm run lint:ci
|
||||
|
||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
LICENSE.md
|
||||
dist/
|
||||
13
.prettierrc
13
.prettierrc
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto",
|
||||
"jsxBracketSameLine": false,
|
||||
"printWidth": 80,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-in-app-review')
|
||||
implementation project(':capacitor-community-speech-recognition')
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation project(':capacitor-app')
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-community-in-app-review'
|
||||
project(':capacitor-community-in-app-review').projectDir = new File('../node_modules/@capacitor-community/in-app-review/android')
|
||||
|
||||
include ':capacitor-community-speech-recognition'
|
||||
project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android')
|
||||
|
||||
|
||||
187
eslint.config.mjs
Normal file
187
eslint.config.mjs
Normal file
@@ -0,0 +1,187 @@
|
||||
import js from '@eslint/js'
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import queryPlugin from '@tanstack/eslint-plugin-query'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier/flat'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
import sortDestructureKeys from 'eslint-plugin-sort-destructure-keys'
|
||||
import tailwind from 'eslint-plugin-tailwindcss'
|
||||
import globals from 'globals'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
react.configs.flat.recommended,
|
||||
{
|
||||
...react.configs.flat['jsx-runtime'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
'simple-import-sort': simpleImportSort,
|
||||
},
|
||||
rules: {
|
||||
'simple-import-sort/exports': 'error',
|
||||
'simple-import-sort/imports': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'sort-destructure-keys': sortDestructureKeys,
|
||||
},
|
||||
rules: {
|
||||
'sort-destructure-keys/sort-destructure-keys': 'error',
|
||||
},
|
||||
},
|
||||
...tailwind.configs['flat/recommended'],
|
||||
...queryPlugin.configs['flat/recommended'],
|
||||
eslintConfigPrettier, // Disable any rules that conflict with prettier
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
files: ['*.{js,ts}'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.config.{mjs,ts}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
eqeqeq: 'error',
|
||||
indent: ['error', 2],
|
||||
'space-infix-ops': ['error', { int32Hint: false }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
globals: globals.browser,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: {
|
||||
'@stylistic': stylistic,
|
||||
},
|
||||
rules: {
|
||||
'@stylistic/brace-style': 'error',
|
||||
'@stylistic/comma-dangle': ['error', 'always-multiline'],
|
||||
'@stylistic/keyword-spacing': 'error',
|
||||
'@stylistic/no-multiple-empty-lines': ['error', { max: 1 }],
|
||||
'@stylistic/no-trailing-spaces': 'error',
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'@stylistic/space-in-parens': 'error',
|
||||
'@stylistic/space-infix-ops': ['error', { int32Hint: false }],
|
||||
'@stylistic/type-annotation-spacing': [
|
||||
'error',
|
||||
{
|
||||
after: true,
|
||||
before: true,
|
||||
overrides: {
|
||||
colon: {
|
||||
before: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
eqeqeq: 'warn',
|
||||
'max-depth': ['error', { max: 5 }],
|
||||
'max-len': ['warn', { code: 120 }],
|
||||
'max-lines': [
|
||||
'error',
|
||||
{ max: 2000, skipBlankLines: true, skipComments: true },
|
||||
],
|
||||
'max-lines-per-function': ['warn', { max: 500 }],
|
||||
'no-constant-condition': 'warn',
|
||||
'no-loss-of-precision': 'error',
|
||||
'no-undef': 'warn',
|
||||
'no-unused-vars': 'off', // @typescript-eslint/no-unused-vars
|
||||
'space-infix-ops': 'off', // @stylistic/space-infix-ops
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||
'@typescript-eslint/no-duplicate-enum-values': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-inferrable-types': 'error',
|
||||
'@typescript-eslint/no-mixed-enums': 'error',
|
||||
'@typescript-eslint/no-require-imports': 'error',
|
||||
'@typescript-eslint/no-type-alias': [
|
||||
'error',
|
||||
{
|
||||
allowAliases: 'always',
|
||||
allowCallbacks: 'always',
|
||||
allowConditionalTypes: 'never',
|
||||
allowConstructors: 'never',
|
||||
allowGenerics: 'always',
|
||||
allowLiterals: 'always',
|
||||
allowMappedTypes: 'never',
|
||||
allowTupleTypes: 'always',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/prefer-as-const': 'error',
|
||||
'@typescript-eslint/prefer-for-of': 'warn',
|
||||
'@typescript-eslint/prefer-function-type': 'error',
|
||||
'@typescript-eslint/prefer-includes': 'error',
|
||||
'@typescript-eslint/prefer-literal-enum-member': 'error',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Reduce existing errors to warnings
|
||||
plugins: {
|
||||
'@tanstack/query': queryPlugin,
|
||||
},
|
||||
rules: {
|
||||
'@tanstack/query/exhaustive-deps': 'warn',
|
||||
'no-case-declarations': 'warn',
|
||||
'no-empty': 'warn',
|
||||
'no-redeclare': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-hooks/preserve-manual-memoization': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-hooks/refs': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/static-components': 'warn',
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
'react/jsx-no-undef': 'warn',
|
||||
'react/no-unescaped-entities': 'warn',
|
||||
'react/prop-types': 'warn',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCommunityInAppReview', :path => '../../node_modules/@capacitor-community/in-app-review'
|
||||
pod 'CapacitorCommunitySpeechRecognition', :path => '../../node_modules/@capacitor-community/speech-recognition'
|
||||
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
|
||||
@@ -18,6 +18,8 @@ PODS:
|
||||
- Capacitor
|
||||
- CapacitorBrowser (8.0.3):
|
||||
- Capacitor
|
||||
- CapacitorCommunityInAppReview (8.0.0):
|
||||
- Capacitor
|
||||
- CapacitorCommunitySpeechRecognition (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorCommunitySqlite (8.1.0):
|
||||
@@ -143,6 +145,7 @@ DEPENDENCIES:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||
- "CapacitorCommunityInAppReview (from `../../node_modules/@capacitor-community/in-app-review`)"
|
||||
- "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)"
|
||||
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
|
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||
@@ -195,6 +198,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/app"
|
||||
CapacitorBrowser:
|
||||
:path: "../../node_modules/@capacitor/browser"
|
||||
CapacitorCommunityInAppReview:
|
||||
:path: "../../node_modules/@capacitor-community/in-app-review"
|
||||
CapacitorCommunitySpeechRecognition:
|
||||
:path: "../../node_modules/@capacitor-community/speech-recognition"
|
||||
CapacitorCommunitySqlite:
|
||||
@@ -239,6 +244,7 @@ SPEC CHECKSUMS:
|
||||
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
|
||||
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
|
||||
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
|
||||
CapacitorCommunityInAppReview: 4492bdd34aad4d27ed87949376022cc93b294ea1
|
||||
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
|
||||
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
|
||||
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
|
||||
@@ -277,6 +283,6 @@ SPEC CHECKSUMS:
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 1099083fe561f8852fcef1bfcff4051f45db9770
|
||||
PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
4068
package-lock.json
generated
4068
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -20,7 +20,9 @@
|
||||
"build-cf": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build",
|
||||
"build-selfhosted": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build --mode selfhosted",
|
||||
"build-win": "del package-lock.json && npm install && npm install --force @rollup/rollup-win32-x64-msvc @swc/core-win32-x64-msvc && vite build --mode selfhosted",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint && prettier -c .",
|
||||
"lint:ci": "true # TODO: eslint -f gha && prettier -c .",
|
||||
"lint:fix": "eslint --fix && prettier -w .",
|
||||
"preview": "vite preview",
|
||||
"setup-m1": "rm -rf node_modules package-lock.json && npm install && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
|
||||
"setup-apple-silicon": "npm install --no-optional && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
|
||||
@@ -39,6 +41,7 @@
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/in-app-review": "^8.0.0",
|
||||
"@capacitor-community/speech-recognition": "^7.0.1",
|
||||
"@capacitor-community/sqlite": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.0",
|
||||
@@ -71,8 +74,11 @@
|
||||
"@openreplay/tracker": "^14.0.4",
|
||||
"@revenuecat/purchases-capacitor": "^12.0.0",
|
||||
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@swc/core": "^1.12.5",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"aos": "^2.3.4",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"caniuse-lite": "^1.0.30001769",
|
||||
@@ -116,20 +122,20 @@
|
||||
"autoprefixer": "^10.4.16",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"capacitor-set-version": "^2.2.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.2",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"eslint-plugin-sort-destructure-keys": "^1.5.0",
|
||||
"eslint-plugin-sort-keys-fix": "^1.1.2",
|
||||
"eslint-plugin-tailwindcss": "^3.13.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-formatter-gha": "^2.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||
"eslint-plugin-sort-destructure-keys": "^3.0.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||
"husky": "^8.0.3",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.10",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
tailwindcss: {},
|
||||
},
|
||||
}
|
||||
13
prettier.config.mjs
Normal file
13
prettier.config.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
arrowParens: 'avoid',
|
||||
bracketSpacing: true,
|
||||
endOfLine: 'auto',
|
||||
jsxBracketSameLine: false,
|
||||
jsxSingleQuote: true,
|
||||
plugins: ['prettier-plugin-tailwindcss'],
|
||||
printWidth: 80,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'all',
|
||||
}
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "Activities",
|
||||
"points": "Points",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
"issues": "Having issues"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Bugs",
|
||||
"missingFeature": "Missing feature",
|
||||
"tooComplicated": "Too complicated",
|
||||
"slow": "Slow",
|
||||
"notifications": "Notifications",
|
||||
"ai": "AI",
|
||||
"other": "Other"
|
||||
},
|
||||
"details": {
|
||||
"title": "What could we improve?",
|
||||
"messageLabel": "Tell us more",
|
||||
"messagePlaceholder": "What happened, or what would make this better?",
|
||||
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
|
||||
"submit": "Send feedback",
|
||||
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
|
||||
"submitSelfHosted": "Continue to GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Glad you're enjoying it!",
|
||||
"subtitle": "A rating or a star helps other people find Donetick.",
|
||||
"github": "Star on GitHub",
|
||||
"appStore": "Rate on the App Store",
|
||||
"playStore": "Rate on Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Thanks for the feedback",
|
||||
"subtitle": "We read every response and it shapes what we work on next."
|
||||
},
|
||||
"github": {
|
||||
"title": "Report it on GitHub",
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "Developer Settings",
|
||||
"description": "View technical information about authentication tokens, SSE connections, and debug data."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-env node */
|
||||
export const API_URL =
|
||||
import.meta.env.VITE_APP_API_URL === 'AUTO'
|
||||
? `${window.location.hostname}/api`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import LogoSVG from '@/assets/logo.svg'
|
||||
const Logo = () => {
|
||||
const Logo = ({ size = '128px' }) => {
|
||||
return (
|
||||
<div className='logo'>
|
||||
<img src={LogoSVG} alt='logo' width='128px' height='128px' />
|
||||
<img src={LogoSVG} alt='logo' width={size} height={size} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { Check, Star } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Divider,
|
||||
Modal,
|
||||
ModalDialog,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import AppModal from './common/AppModal'
|
||||
import ModalActions from './common/ModalActions'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||
|
||||
@@ -76,183 +68,158 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<ModalDialog
|
||||
layout='center'
|
||||
sx={{
|
||||
width: 600,
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
overflow: 'auto',
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 4 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography level='h3' sx={{ mb: 1 }}>
|
||||
Upgrade to Plus
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Features List */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography level='title-lg' sx={{ mb: 2 }}>
|
||||
What's included:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{features.map((feature, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||
>
|
||||
<Check color='success' sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-md'>{feature}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
<AppModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title='Upgrade to Plus'
|
||||
description='Unlock reminders, rich task details, and advanced automation.'
|
||||
size='lg'
|
||||
closeOnBackdrop={!isLoading}
|
||||
closeOnEscape={!isLoading}
|
||||
footer={
|
||||
<ModalActions
|
||||
stackOnMobile
|
||||
secondary={{ label: 'Cancel', onClick: onClose, disabled: isLoading }}
|
||||
primary={{
|
||||
label: 'Subscribe',
|
||||
onClick: handleSubscribe,
|
||||
loading: isLoading,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Features List */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography level='title-lg' sx={{ mb: 2 }}>
|
||||
What's included:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{features.map((feature, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
|
||||
>
|
||||
<Check color='success' sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-md'>{feature}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Plan Selection */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
|
||||
>
|
||||
{Object.entries(plans).map(([key, plan]) => (
|
||||
<Card
|
||||
key={key}
|
||||
color={selectedPlan === key ? 'primary' : 'neutral'}
|
||||
onClick={() => setSelectedPlan(key)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: 48,
|
||||
maxHeight: 64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
mb: 0.2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 1.2,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
checked={selectedPlan === key}
|
||||
onChange={() => setSelectedPlan(key)}
|
||||
value={key}
|
||||
name='subscription-plan'
|
||||
color='primary'
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Typography level='body-md' sx={{ fontWeight: 600 }}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
|
||||
{plan.price}
|
||||
<span style={{ color: '#888', fontWeight: 400 }}>
|
||||
{' '}
|
||||
/ {plan.period}
|
||||
</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: -18,
|
||||
}}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Chip
|
||||
variant='solid'
|
||||
color='warning'
|
||||
size='sm'
|
||||
startDecorator={<Star />}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
Most Popular
|
||||
</Chip>
|
||||
)}
|
||||
{plan.savings && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='success'
|
||||
size='sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
{plan.savings}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleSubscribe}
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 3 }}
|
||||
>
|
||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Plan Selection */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}>
|
||||
{Object.entries(plans).map(([key, plan]) => (
|
||||
<Card
|
||||
component='label'
|
||||
htmlFor={`subscription-plan-${key}`}
|
||||
key={key}
|
||||
color={selectedPlan === key ? 'primary' : 'neutral'}
|
||||
onClick={() => setSelectedPlan(key)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: 48,
|
||||
maxHeight: 64,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
mb: 0.2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2.5,
|
||||
py: 1.2,
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
id={`subscription-plan-${key}`}
|
||||
checked={selectedPlan === key}
|
||||
onChange={() => setSelectedPlan(key)}
|
||||
value={key}
|
||||
name='subscription-plan'
|
||||
color='primary'
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Typography level='body-md' sx={{ fontWeight: 600 }}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
|
||||
{plan.price}
|
||||
<span style={{ color: '#888', fontWeight: 400 }}>
|
||||
{' '}
|
||||
/ {plan.period}
|
||||
</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
top: -18,
|
||||
}}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Chip
|
||||
variant='solid'
|
||||
color='warning'
|
||||
size='sm'
|
||||
startDecorator={<Star />}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
Most Popular
|
||||
</Chip>
|
||||
)}
|
||||
{plan.savings && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='success'
|
||||
size='sm'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
px: 1,
|
||||
py: 0.1,
|
||||
boxShadow: 2,
|
||||
mt: 0.8,
|
||||
}}
|
||||
>
|
||||
{plan.savings}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ textAlign: 'center', mt: 3 }}
|
||||
>
|
||||
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
|
||||
</Typography>
|
||||
</AppModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
230
src/components/common/AppModal.jsx
Normal file
230
src/components/common/AppModal.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { forwardRef, useId } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const WIDTH_BY_SIZE = {
|
||||
sm: 400,
|
||||
md: 520,
|
||||
lg: 680,
|
||||
xl: 840,
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's modal primitive. It owns modal layout, spacing, accessibility,
|
||||
* responsive presentation, and motion so feature components only own content.
|
||||
*/
|
||||
const AppModal = forwardRef(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
isMobile: isMobileProp,
|
||||
mobilePresentation = 'sheet',
|
||||
role = 'dialog',
|
||||
showCloseButton = true,
|
||||
showHandle = false,
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
backdropBlur = true,
|
||||
maxHeight = '90dvh',
|
||||
contentSx,
|
||||
footerSx,
|
||||
sx,
|
||||
unmountDelay = 180,
|
||||
...modalProps
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const generatedId = useId()
|
||||
const detectedMobile = useMediaQuery('(max-width:768px)')
|
||||
const isMobile = isMobileProp ?? detectedMobile
|
||||
const titleId = title ? `${generatedId}-title` : undefined
|
||||
const descriptionId = description ? `${generatedId}-description` : undefined
|
||||
const isSheet = isMobile && mobilePresentation === 'sheet'
|
||||
const isFullscreen = isMobile && mobilePresentation === 'fullscreen'
|
||||
|
||||
const handleClose = (event, reason) => {
|
||||
if (reason === 'backdropClick' && !closeOnBackdrop) return
|
||||
if (reason === 'escapeKeyDown' && !closeOnEscape) return
|
||||
onClose?.(event, reason)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
keepMounted
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_BACKDROP,
|
||||
display: 'flex',
|
||||
alignItems: isSheet ? 'flex-end' : 'center',
|
||||
justifyContent: 'center',
|
||||
p: isMobile ? 0 : 2,
|
||||
'& .MuiModal-backdrop': {
|
||||
backgroundColor: 'rgba(8, 15, 24, 0.52)',
|
||||
backdropFilter: backdropBlur ? 'blur(4px)' : 'none',
|
||||
},
|
||||
}}
|
||||
{...modalProps}
|
||||
>
|
||||
<Sheet
|
||||
ref={ref}
|
||||
role={role}
|
||||
aria-modal='true'
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
variant='outlined'
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: isFullscreen
|
||||
? '100%'
|
||||
: isSheet
|
||||
? '100%'
|
||||
: fullWidth
|
||||
? `min(calc(100vw - 32px), ${WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md}px)`
|
||||
: 'auto',
|
||||
maxWidth: isMobile
|
||||
? 'none'
|
||||
: WIDTH_BY_SIZE[size] || WIDTH_BY_SIZE.md,
|
||||
height: isFullscreen ? '100dvh' : 'auto',
|
||||
maxHeight: isFullscreen ? '100dvh' : maxHeight,
|
||||
overflow: 'hidden',
|
||||
borderRadius: isFullscreen ? 0 : isSheet ? '16px 16px 0 0' : '16px',
|
||||
borderBottom: isSheet ? 0 : undefined,
|
||||
boxShadow: 'lg',
|
||||
outline: 0,
|
||||
pb: isMobile ? 'env(safe-area-inset-bottom)' : 0,
|
||||
animation: open
|
||||
? `${isSheet ? 'appSheetEnter' : 'appModalEnter'} ${Math.min(unmountDelay, 300)}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||
: undefined,
|
||||
'@keyframes appModalEnter': {
|
||||
from: { opacity: 0, transform: 'translateY(8px) scale(0.99)' },
|
||||
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
|
||||
},
|
||||
'@keyframes appSheetEnter': {
|
||||
from: { transform: 'translateY(24px)' },
|
||||
to: { transform: 'translateY(0)' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{isSheet && showHandle && (
|
||||
<Box
|
||||
aria-hidden='true'
|
||||
sx={{ display: 'flex', justifyContent: 'center', pt: 1.25 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 999,
|
||||
bgcolor: 'neutral.300',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(title || description || showCloseButton) && (
|
||||
<Box
|
||||
component='header'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
px: { xs: 2, sm: 3 },
|
||||
pt: isSheet && showHandle ? 1.25 : { xs: 2, sm: 2.5 },
|
||||
pb: description ? 1.5 : 2,
|
||||
pr: showCloseButton ? { xs: 7, sm: 8 } : { xs: 2, sm: 3 },
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<Typography
|
||||
id={titleId}
|
||||
level='title-lg'
|
||||
sx={{ fontWeight: 650 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{description && (
|
||||
<Typography
|
||||
id={descriptionId}
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.tertiary', mt: 0.5 }}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<IconButton
|
||||
aria-label='Close dialog'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: isSheet && showHandle ? 6 : 12,
|
||||
right: { xs: 10, sm: 16 },
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
px: { xs: 2, sm: 3 },
|
||||
pb: { xs: 2.5, sm: 3 },
|
||||
...contentSx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box
|
||||
component='footer'
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
px: { xs: 2, sm: 3 },
|
||||
py: 2,
|
||||
bgcolor: 'background.surface',
|
||||
...footerSx,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</Modal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
AppModal.displayName = 'AppModal'
|
||||
|
||||
export default AppModal
|
||||
@@ -1,254 +0,0 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
|
||||
import { forwardRef, useEffect, useState } from 'react'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const BottomSheetModal = forwardRef(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
title,
|
||||
footer,
|
||||
height = 'auto',
|
||||
maxHeight = '90vh',
|
||||
expandedHeight = '95vh',
|
||||
backdropBlur = true,
|
||||
showHandle = true,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(open)
|
||||
|
||||
// Handle opening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInternalOpen(true)
|
||||
setIsClosing(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Handle closing with animation
|
||||
useEffect(() => {
|
||||
if (!open && internalOpen) {
|
||||
setIsClosing(true)
|
||||
// Wait for animation to complete before hiding modal
|
||||
const timer = setTimeout(() => {
|
||||
setInternalOpen(false)
|
||||
setIsClosing(false)
|
||||
setIsExpanded(false)
|
||||
}, 250) // Match transition duration
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, internalOpen])
|
||||
|
||||
// Handle toggle expansion
|
||||
const handleToggleExpansion = () => {
|
||||
setIsExpanded(prev => !prev)
|
||||
}
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = event => {
|
||||
if (event.key === 'Escape' && internalOpen) {
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
if (internalOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Prevent body scroll when modal is open
|
||||
// document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
// Restore scroll immediately when modal starts closing
|
||||
// document.body.style.overflow = 'unset'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [internalOpen, onClose])
|
||||
|
||||
// Calculate current height
|
||||
const currentHeight = isExpanded ? expandedHeight : height
|
||||
|
||||
// Filter out DOM props that shouldn't be passed to Modal
|
||||
const {
|
||||
fullWidth: _fullWidth,
|
||||
unmountDelay: _unmountDelay,
|
||||
...modalProps
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={internalOpen}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'& .MuiModal-backdrop': {
|
||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
keepMounted
|
||||
{...modalProps}
|
||||
>
|
||||
<Sheet
|
||||
ref={ref}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
minHeight: '20%',
|
||||
width: '100%',
|
||||
height: currentHeight,
|
||||
maxHeight: isExpanded ? expandedHeight : maxHeight,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition:
|
||||
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
transform:
|
||||
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
|
||||
// Handle safe area on mobile devices
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
}}
|
||||
>
|
||||
{/* Header Section with drag handle, title, and close button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'inherit',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Close button positioned absolutely in top-right */}
|
||||
{showCloseButton && (
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 16,
|
||||
zIndex: 1,
|
||||
borderRadius: '50%',
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: 'neutral.softBg',
|
||||
color: 'neutral.softColor',
|
||||
'&:hover': {
|
||||
backgroundColor: 'neutral.softHoverBg',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Close fontSize='small' />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Drag Handle */}
|
||||
{showHandle && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '12px 0 8px 0',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
onClick={handleToggleExpansion}
|
||||
title={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'var(--joy-palette-neutral-300)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Row */}
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: showHandle
|
||||
? '0 20px 16px 20px'
|
||||
: '16px 20px 16px 20px',
|
||||
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
|
||||
minHeight: 24,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Content area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '0 20px 20px 20px',
|
||||
minHeight: 0, // Important for flex child with overflow
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<>
|
||||
<Divider />
|
||||
<footer
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
// borderTop: '1px solid var(--joy-palette-divider)',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</Modal>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
BottomSheetModal.displayName = 'BottomSheetModal'
|
||||
|
||||
export default BottomSheetModal
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
/**
|
||||
* FadeModal component with consistent fade-in/out animations
|
||||
* Can be used as a drop-in replacement for Joy UI's Modal component
|
||||
*/
|
||||
const FadeModal = ({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
size = 'md',
|
||||
fullWidth = true,
|
||||
backdropBlur = true,
|
||||
title,
|
||||
footer,
|
||||
...props
|
||||
}) => {
|
||||
// Filter out props that shouldn't be passed to Modal
|
||||
const { unmountDelay: _unmountDelay, ...modalProps } = props
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'& .MuiModal-backdrop': {
|
||||
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
|
||||
},
|
||||
}}
|
||||
keepMounted
|
||||
// These transition properties create a smooth fade + slide effect
|
||||
transition={{
|
||||
mount: { opacity: 1, transform: 'translateY(0px)' },
|
||||
unmount: { opacity: 0, transform: 'translateY(20px)' },
|
||||
duration: 250, // Animation duration in ms
|
||||
easing: {
|
||||
enter: 'cubic-bezier(0.34, 1.56, 0.64, 1)', // Slight overshoot for natural feel
|
||||
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
|
||||
},
|
||||
}}
|
||||
{...modalProps}
|
||||
>
|
||||
<ModalOverflow>
|
||||
<ModalDialog
|
||||
size={size}
|
||||
sx={{
|
||||
zIndex: Z_INDEX.MODAL_CONTENT,
|
||||
minWidth: fullWidth ? '90%' : 'auto',
|
||||
animation: open
|
||||
? 'modalFadeIn 0.35s forwards'
|
||||
: 'modalFadeOut 0.25s forwards',
|
||||
'@keyframes modalFadeIn': {
|
||||
from: { opacity: 0, transform: 'translateY(8px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
'@keyframes modalFadeOut': {
|
||||
from: { opacity: 1, transform: 'translateY(0)' },
|
||||
to: { opacity: 0, transform: 'translateY(8px)' },
|
||||
},
|
||||
// Add staggered animation for child elements
|
||||
'& > *': {
|
||||
opacity: 0,
|
||||
animation: open
|
||||
? 'contentFadeIn 0.35s forwards'
|
||||
: 'contentFadeOut 0.2s forwards',
|
||||
},
|
||||
// Stagger child animations
|
||||
'& > *:nth-of-type(1)': { animationDelay: '0.05s' },
|
||||
'& > *:nth-of-type(2)': { animationDelay: '0.1s' },
|
||||
'& > *:nth-of-type(3)': { animationDelay: '0.15s' },
|
||||
'& > *:nth-of-type(4)': { animationDelay: '0.2s' },
|
||||
'& > *:nth-of-type(5)': { animationDelay: '0.25s' },
|
||||
'@keyframes contentFadeIn': {
|
||||
to: { opacity: 1 },
|
||||
},
|
||||
'@keyframes contentFadeOut': {
|
||||
to: { opacity: 0 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ModalClose />
|
||||
{title && (
|
||||
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||
{footer && <div style={{ marginTop: 16 }}>{footer}</div>}
|
||||
</ModalDialog>
|
||||
</ModalOverflow>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FadeModal
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import BottomSheetModal from './BottomSheetModal'
|
||||
import AppModal from './AppModal'
|
||||
import ModalActions from './ModalActions'
|
||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||
|
||||
/**
|
||||
@@ -41,7 +42,10 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Today',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
return {
|
||||
from: t.toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -49,8 +53,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Yesterday',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const y = new Date(t); y.setDate(t.getDate() - 1)
|
||||
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
|
||||
const y = new Date(t)
|
||||
y.setDate(t.getDate() - 1)
|
||||
return {
|
||||
from: d(y).toISOString(),
|
||||
to: d(y, 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -58,9 +66,14 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'This Week',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
|
||||
const end = new Date(start); end.setDate(start.getDate() + 6)
|
||||
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setDate(t.getDate() - t.getDay())
|
||||
const end = new Date(start)
|
||||
end.setDate(start.getDate() + 6)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(end, 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,8 +81,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Last 7 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 6)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setDate(t.getDate() - 6)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -79,7 +96,10 @@ const DATE_RANGE_PRESETS = [
|
||||
const n = new Date()
|
||||
const start = new Date(n.getFullYear(), n.getMonth(), 1)
|
||||
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
|
||||
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
|
||||
return {
|
||||
from: start.toISOString(),
|
||||
to: d(end, 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -87,8 +107,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Last 30 Days',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setDate(t.getDate() - 29)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setDate(t.getDate() - 29)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -96,8 +120,12 @@ const DATE_RANGE_PRESETS = [
|
||||
label: 'Last 3 Months',
|
||||
getRange: () => {
|
||||
const t = d(new Date())
|
||||
const start = new Date(t); start.setMonth(t.getMonth() - 3)
|
||||
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
|
||||
const start = new Date(t)
|
||||
start.setMonth(t.getMonth() - 3)
|
||||
return {
|
||||
from: d(start).toISOString(),
|
||||
to: d(new Date(), 23, 59, 59, 999).toISOString(),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -127,7 +155,8 @@ const FilterBar = ({
|
||||
const activeFilterCount = filterDefs.filter(def => {
|
||||
const value = activeFilters[def.id]
|
||||
if (value === undefined || value === null) return false
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return false
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue)
|
||||
return false
|
||||
if (Array.isArray(value) && value.length === 0) return false
|
||||
if (def.type === 'date-range') return !!(value?.from || value?.to)
|
||||
return true
|
||||
@@ -183,13 +212,18 @@ const FilterBar = ({
|
||||
if (value === undefined || value === null) return null
|
||||
|
||||
if (def.type === 'single-select') {
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue) return null
|
||||
if (def.defaultValue !== undefined && value === def.defaultValue)
|
||||
return null
|
||||
return def.options?.find(o => o.value === value)?.label ?? def.label
|
||||
}
|
||||
|
||||
if (def.type === 'boolean') return def.label
|
||||
|
||||
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
|
||||
if (
|
||||
def.type === 'multi-select' &&
|
||||
Array.isArray(value) &&
|
||||
value.length > 0
|
||||
) {
|
||||
if (value.length === 1) {
|
||||
return def.options?.find(o => o.value === value[0])?.label ?? def.label
|
||||
}
|
||||
@@ -199,7 +233,10 @@ const FilterBar = ({
|
||||
if (def.type === 'date-range') {
|
||||
if (!value?.from && !value?.to) return null
|
||||
if (value.preset) {
|
||||
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
|
||||
return (
|
||||
DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ??
|
||||
'Date Range'
|
||||
)
|
||||
}
|
||||
const from = fmtDisplayDate(value.from)
|
||||
const to = fmtDisplayDate(value.to)
|
||||
@@ -259,7 +296,15 @@ const FilterBar = ({
|
||||
return (
|
||||
<>
|
||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
@@ -309,37 +354,42 @@ const FilterBar = ({
|
||||
</Box>
|
||||
|
||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
<AppModal
|
||||
open={isOpen}
|
||||
isMobile
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tune sx={{ fontSize: 20 }} />
|
||||
Filters
|
||||
{hasActive && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={modalCountChipSx}
|
||||
>
|
||||
{activeFilterCount}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
disabled={!hasActive}
|
||||
onClick={onClearAll}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
|
||||
{resultCount !== undefined
|
||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||
: 'Done'}
|
||||
</Button>
|
||||
</Box>
|
||||
<ModalActions
|
||||
tertiary={{
|
||||
label: 'Clear all',
|
||||
color: 'danger',
|
||||
disabled: !hasActive,
|
||||
onClick: onClearAll,
|
||||
}}
|
||||
primary={{
|
||||
label:
|
||||
resultCount !== undefined
|
||||
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
|
||||
: 'Done',
|
||||
onClick: () => setIsOpen(false),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
@@ -348,9 +398,18 @@ const FilterBar = ({
|
||||
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
|
||||
|
||||
{/* Section header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}
|
||||
>
|
||||
{def.icon && (
|
||||
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'& svg': { fontSize: 18 },
|
||||
}}
|
||||
>
|
||||
{def.icon}
|
||||
</Box>
|
||||
)}
|
||||
@@ -359,21 +418,41 @@ const FilterBar = ({
|
||||
</Typography>
|
||||
|
||||
{/* active badge in header */}
|
||||
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{activeFilters[def.id].length} selected
|
||||
</Chip>
|
||||
)}
|
||||
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
|
||||
const opt = def.options?.find(o => o.value === activeFilters[def.id])
|
||||
return opt ? (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
{opt.label}
|
||||
{def.type === 'multi-select' &&
|
||||
(activeFilters[def.id]?.length ?? 0) > 0 && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={sectionBadgeChipSx}
|
||||
>
|
||||
{activeFilters[def.id].length} selected
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
)}
|
||||
{def.type === 'single-select' &&
|
||||
activeFilters[def.id] != null &&
|
||||
(() => {
|
||||
const opt = def.options?.find(
|
||||
o => o.value === activeFilters[def.id],
|
||||
)
|
||||
return opt ? (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={sectionBadgeChipSx}
|
||||
>
|
||||
{opt.label}
|
||||
</Chip>
|
||||
) : null
|
||||
})()}
|
||||
{def.type === 'date-range' && getActiveChipLabel(def) && (
|
||||
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
sx={sectionBadgeChipSx}
|
||||
>
|
||||
{getActiveChipLabel(def)}
|
||||
</Chip>
|
||||
)}
|
||||
@@ -383,18 +462,28 @@ const FilterBar = ({
|
||||
{def.type === 'multi-select' && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{def.options?.map(opt => {
|
||||
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
|
||||
const isSelected = (activeFilters[def.id] || []).includes(
|
||||
opt.value,
|
||||
)
|
||||
return (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
color={
|
||||
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||
}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
<Avatar
|
||||
src={opt.avatar}
|
||||
alt={opt.label}
|
||||
sx={{ '--Avatar-size': '20px' }}
|
||||
/>
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
) : (
|
||||
(opt.icon ?? null)
|
||||
)
|
||||
}
|
||||
onClick={() => handleMultiToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
@@ -415,13 +504,21 @@ const FilterBar = ({
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
color={
|
||||
isSelected ? (opt.color ?? 'primary') : 'neutral'
|
||||
}
|
||||
startDecorator={
|
||||
opt.avatar ? (
|
||||
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
|
||||
<Avatar
|
||||
src={opt.avatar}
|
||||
alt={opt.label}
|
||||
sx={{ '--Avatar-size': '20px' }}
|
||||
/>
|
||||
) : isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (opt.icon ?? null)
|
||||
) : (
|
||||
(opt.icon ?? null)
|
||||
)
|
||||
}
|
||||
onClick={() => handleSingleToggle(def.id, opt.value)}
|
||||
sx={selectableChipSx}
|
||||
@@ -438,7 +535,11 @@ const FilterBar = ({
|
||||
<Chip
|
||||
variant={activeFilters[def.id] ? 'solid' : 'soft'}
|
||||
color={activeFilters[def.id] ? 'primary' : 'neutral'}
|
||||
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
startDecorator={
|
||||
activeFilters[def.id] ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : null
|
||||
}
|
||||
onClick={() => handleBoolToggle(def.id)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
@@ -447,58 +548,84 @@ const FilterBar = ({
|
||||
)}
|
||||
|
||||
{/* date-range */}
|
||||
{def.type === 'date-range' && (() => {
|
||||
const val = activeFilters[def.id] || {}
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{/* Preset chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DATE_RANGE_PRESETS.map(preset => {
|
||||
const isSelected = val.preset === preset.value
|
||||
return (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
||||
onClick={() => handleDateRangePreset(def.id, preset.value)}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{preset.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
{def.type === 'date-range' &&
|
||||
(() => {
|
||||
const val = activeFilters[def.id] || {}
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{/* Preset chips */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{DATE_RANGE_PRESETS.map(preset => {
|
||||
const isSelected = val.preset === preset.value
|
||||
return (
|
||||
<Chip
|
||||
key={preset.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : null
|
||||
}
|
||||
onClick={() =>
|
||||
handleDateRangePreset(def.id, preset.value)
|
||||
}
|
||||
sx={selectableChipSx}
|
||||
>
|
||||
{preset.label}
|
||||
</Chip>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Custom date inputs */}
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.from)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
|
||||
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
|
||||
–
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.to)}
|
||||
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
|
||||
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
{/* Custom date inputs */}
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.from)}
|
||||
onChange={e =>
|
||||
handleDateRangeInput(def.id, 'from', e.target.value)
|
||||
}
|
||||
slotProps={{
|
||||
input: { max: toInputDate(val.to) || undefined },
|
||||
}}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary', flexShrink: 0 }}
|
||||
>
|
||||
–
|
||||
</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
size='sm'
|
||||
value={toInputDate(val.to)}
|
||||
onChange={e =>
|
||||
handleDateRangeInput(def.id, 'to', e.target.value)
|
||||
}
|
||||
slotProps={{
|
||||
input: { min: toInputDate(val.from) || undefined },
|
||||
}}
|
||||
sx={{ flex: 1, fontSize: '0.8rem' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})()}
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</AppModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
61
src/components/common/ModalActions.jsx
Normal file
61
src/components/common/ModalActions.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Box, Button } from '@mui/joy'
|
||||
|
||||
const ActionButton = ({ action, defaults, sx }) => {
|
||||
if (!action) return null
|
||||
|
||||
const { label, sx: actionSx, ...props } = action
|
||||
return (
|
||||
<Button {...defaults} {...props} sx={{ ...sx, ...actionSx }}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistent modal action row. Secondary actions are rendered first and the
|
||||
* primary action is always the final, highest-emphasis control.
|
||||
*/
|
||||
const ModalActions = ({
|
||||
primary,
|
||||
secondary,
|
||||
tertiary,
|
||||
children,
|
||||
stackOnMobile = false,
|
||||
sx,
|
||||
}) => {
|
||||
const responsiveButtonStyles = stackOnMobile
|
||||
? { '& > button': { width: { xs: '100%', sm: 'auto' } } }
|
||||
: undefined
|
||||
|
||||
const layoutSx = {
|
||||
display: 'flex',
|
||||
flexDirection: stackOnMobile ? { xs: 'column-reverse', sm: 'row' } : 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
...responsiveButtonStyles,
|
||||
...sx,
|
||||
}
|
||||
|
||||
if (children) return <Box sx={layoutSx}>{children}</Box>
|
||||
|
||||
return (
|
||||
<Box sx={layoutSx}>
|
||||
<ActionButton
|
||||
action={tertiary}
|
||||
defaults={{ color: 'neutral', variant: 'plain' }}
|
||||
sx={{ mr: { sm: 'auto' } }}
|
||||
/>
|
||||
<ActionButton
|
||||
action={secondary}
|
||||
defaults={{ color: 'neutral', variant: 'outlined' }}
|
||||
/>
|
||||
<ActionButton
|
||||
action={primary}
|
||||
defaults={{ color: 'primary', variant: 'solid' }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalActions
|
||||
52
src/components/common/README.md
Normal file
52
src/components/common/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# UI foundations
|
||||
|
||||
## Modals
|
||||
|
||||
Use `AppModal` for new work. Existing features may continue using
|
||||
`useResponsiveModal`; it now renders the same primitive.
|
||||
|
||||
### Presentations
|
||||
|
||||
- Desktop: centered, constrained dialog (`sm` 400, `md` 520, `lg` 680,
|
||||
`xl` 840 pixels).
|
||||
- Mobile default: bottom sheet.
|
||||
- Long mobile workflows: `mobilePresentation='fullscreen'`.
|
||||
- Destructive confirmation: `size='sm'`, `role='alertdialog'`, and
|
||||
`closeOnBackdrop={false}`.
|
||||
|
||||
`AppModal` owns the header, close control, content scrolling, safe-area spacing,
|
||||
and footer. Do not add another close button or duplicate the title inside the
|
||||
content.
|
||||
|
||||
```jsx
|
||||
<AppModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title='Create item'
|
||||
description='Add a recognizable name.'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: 'Create', onClick: onCreate, loading: isSaving }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</AppModal>
|
||||
```
|
||||
|
||||
## Buttons
|
||||
|
||||
- Primary: `solid primary`; one primary action per surface.
|
||||
- Secondary: `outlined neutral`.
|
||||
- Tertiary: `plain neutral`.
|
||||
- Destructive confirmation: `solid danger`.
|
||||
- Destructive trigger: usually `outlined danger` or `plain danger`.
|
||||
- Modal order: secondary first, primary last.
|
||||
- Every icon-only button requires an `aria-label`.
|
||||
- Use the built-in `loading` state to prevent repeated submission.
|
||||
|
||||
Button heights, radii, focus states, and reduced-motion behavior are defined in
|
||||
`src/contexts/ThemeContext.jsx`. Avoid local overrides for those properties.
|
||||
|
||||
The live reference is available at `/test`.
|
||||
@@ -1,5 +1,6 @@
|
||||
import resolveConfig from 'tailwindcss/resolveConfig'
|
||||
import tailwindConfig from '/tailwind.config.js'
|
||||
|
||||
import tailwindConfig from '/tailwind.config.mjs'
|
||||
|
||||
export const { theme: THEME } = resolveConfig(tailwindConfig)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
|
||||
]
|
||||
|
||||
export const LocalizationProvider = ({ children }) => {
|
||||
|
||||
@@ -4,22 +4,33 @@ import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
|
||||
import PropType from 'prop-types'
|
||||
|
||||
const primaryColor = 'cyan'
|
||||
|
||||
const shades = [
|
||||
'50',
|
||||
...Array.from({ length: 9 }, (_, i) => String((i + 1) * 100)),
|
||||
]
|
||||
|
||||
const getPallete = (key = primaryColor) => {
|
||||
return shades.reduce((acc, shade) => {
|
||||
acc[shade] = COLORS[key][shade]
|
||||
return acc
|
||||
const getPalette = (key = primaryColor) =>
|
||||
shades.reduce((palette, shade) => {
|
||||
palette[shade] = COLORS[key][shade]
|
||||
return palette
|
||||
}, {})
|
||||
}
|
||||
|
||||
const primaryPalette = getPallete(primaryColor)
|
||||
const primaryPalette = getPalette(primaryColor)
|
||||
|
||||
// Fallbacks only. A parent that owns the radius (ButtonGroup, Input/Select
|
||||
// decorator slots, CardActions) sets --Button-radius / --IconButton-radius and
|
||||
// takes precedence, which is what keeps connected groups looking connected.
|
||||
const CONTROL_RADIUS = '12px'
|
||||
const ICON_BUTTON_RADIUS = '10px'
|
||||
|
||||
const theme = extendTheme({
|
||||
radius: {
|
||||
xs: '6px',
|
||||
sm: '8px',
|
||||
md: '10px',
|
||||
lg: '12px',
|
||||
xl: '16px',
|
||||
},
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
@@ -42,42 +53,100 @@ const theme = extendTheme({
|
||||
200: '#fbd5d5',
|
||||
300: '#f9c1c1',
|
||||
400: '#f6a8a8',
|
||||
500: '',
|
||||
600: '#f47272',
|
||||
700: '#e33434',
|
||||
800: '#cc1f1a',
|
||||
900: '#b91c1c',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffdf7',
|
||||
100: '#fef8e1',
|
||||
200: '#fdecb2',
|
||||
300: '#fcd982',
|
||||
400: '#fbcf52',
|
||||
500: '#f9c222',
|
||||
600: '#f6b81e',
|
||||
700: '#f3ae1a',
|
||||
800: '#f0a416',
|
||||
900: '#e99b0e',
|
||||
},
|
||||
},
|
||||
warning: {
|
||||
50: '#fffdf7',
|
||||
100: '#fef8e1',
|
||||
200: '#fdecb2',
|
||||
300: '#fcd982',
|
||||
400: '#fbcf52',
|
||||
500: '#f9c222',
|
||||
600: '#f6b81e',
|
||||
700: '#f3ae1a',
|
||||
800: '#f0a416',
|
||||
900: '#e99b0e',
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: primaryPalette,
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: primaryPalette,
|
||||
components: {
|
||||
JoyButton: {
|
||||
styleOverrides: {
|
||||
root: ({ ownerState }) => ({
|
||||
minHeight:
|
||||
ownerState.size === 'lg'
|
||||
? '44px'
|
||||
: ownerState.size === 'sm'
|
||||
? '36px'
|
||||
: '40px',
|
||||
// Read through the CSS variable so parents that own the radius
|
||||
// (ButtonGroup, Input/Select decorators, Card actions) still win.
|
||||
borderRadius: `var(--Button-radius, ${CONTROL_RADIUS})`,
|
||||
fontWeight: 600,
|
||||
transition:
|
||||
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, box-shadow 140ms ease, transform 100ms ease',
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:active:not(:disabled)': { transform: 'none' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
JoyIconButton: {
|
||||
styleOverrides: {
|
||||
root: ({ ownerState }) => ({
|
||||
borderRadius: `var(--IconButton-radius, ${ICON_BUTTON_RADIUS})`,
|
||||
transition:
|
||||
'background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 100ms ease',
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'scale(0.96)',
|
||||
},
|
||||
// Touch target only for the default size. `sm`/`lg` are explicit
|
||||
// choices by the call site and keep Joy's own sizing.
|
||||
...(ownerState.size === 'md' && {
|
||||
minWidth: '40px',
|
||||
minHeight: '40px',
|
||||
'@media (max-width: 768px)': {
|
||||
minWidth: '44px',
|
||||
minHeight: '44px',
|
||||
},
|
||||
}),
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:active:not(:disabled)': { transform: 'none' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
JoyButtonGroup: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'--ButtonGroup-radius': CONTROL_RADIUS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const ThemeContext = ({ children }) => {
|
||||
return (
|
||||
<CssVarsProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</CssVarsProvider>
|
||||
)
|
||||
}
|
||||
const ThemeContext = ({ children }) => (
|
||||
<CssVarsProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</CssVarsProvider>
|
||||
)
|
||||
|
||||
ThemeContext.propTypes = {
|
||||
children: PropType.node,
|
||||
|
||||
120
src/hooks/useLongPress.js
Normal file
120
src/hooks/useLongPress.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
const DEFAULT_DELAY_MS = 450
|
||||
// Deliberately smaller than the swipe list's swipeStartThreshold (10px) so the
|
||||
// hold is abandoned before a swipe is even recognized.
|
||||
const MOVE_TOLERANCE_PX = 6
|
||||
|
||||
const haptic = async () => {
|
||||
try {
|
||||
const { Haptics, ImpactStyle } = await import('@capacitor/haptics')
|
||||
await Haptics.impact({ style: ImpactStyle.Medium })
|
||||
} catch {
|
||||
// no haptics on this platform
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Press-and-hold gesture that works for both touch and mouse.
|
||||
*
|
||||
* Returns `handlers` to spread on the element and a `cancel` function so the
|
||||
* owner can abandon a pending hold when another gesture wins (e.g. the swipe
|
||||
* list reports a swipe start). A press that drifts more than
|
||||
* MOVE_TOLERANCE_PX, scrolls, or gets cancelled by the browser never fires,
|
||||
* and the click that follows a successful hold is swallowed so the element's
|
||||
* normal click action doesn't also run.
|
||||
*/
|
||||
export const useLongPress = (
|
||||
onLongPress,
|
||||
{ delay = DEFAULT_DELAY_MS, enabled = true } = {},
|
||||
) => {
|
||||
const timerRef = useRef(null)
|
||||
const originRef = useRef(null)
|
||||
const firedRef = useRef(false)
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
originRef.current = null
|
||||
}, [])
|
||||
|
||||
// Watch movement on the window rather than only on the element: while the
|
||||
// swipe list drags the row it translates under the finger, and the pointer
|
||||
// can end up over a different element than the one we started on.
|
||||
useEffect(() => {
|
||||
const handleWindowMove = event => {
|
||||
if (!timerRef.current || !originRef.current) return
|
||||
const point = event.touches?.[0] ?? event
|
||||
if (point.clientX === undefined) return
|
||||
const dx = Math.abs(point.clientX - originRef.current.x)
|
||||
const dy = Math.abs(point.clientY - originRef.current.y)
|
||||
if (dx > MOVE_TOLERANCE_PX || dy > MOVE_TOLERANCE_PX) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleWindowMove, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
})
|
||||
window.addEventListener('touchmove', handleWindowMove, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
})
|
||||
window.addEventListener('scroll', clear, { capture: true, passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleWindowMove, true)
|
||||
window.removeEventListener('touchmove', handleWindowMove, true)
|
||||
window.removeEventListener('scroll', clear, true)
|
||||
clear()
|
||||
}
|
||||
}, [clear])
|
||||
|
||||
const start = useCallback(
|
||||
event => {
|
||||
if (!enabled || !onLongPress) return
|
||||
// Ignore right/middle mouse buttons
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return
|
||||
|
||||
clear()
|
||||
firedRef.current = false
|
||||
originRef.current = { x: event.clientX, y: event.clientY }
|
||||
timerRef.current = setTimeout(() => {
|
||||
firedRef.current = true
|
||||
timerRef.current = null
|
||||
haptic()
|
||||
onLongPress(event)
|
||||
}, delay)
|
||||
},
|
||||
[enabled, onLongPress, delay, clear],
|
||||
)
|
||||
|
||||
const handlers = {
|
||||
onPointerDown: start,
|
||||
onPointerUp: clear,
|
||||
onPointerCancel: clear,
|
||||
onPointerLeave: clear,
|
||||
onDragStart: clear,
|
||||
// Swallow the click that the browser fires after the finger lifts
|
||||
onClickCapture: event => {
|
||||
if (firedRef.current) {
|
||||
firedRef.current = false
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
onContextMenu: event => {
|
||||
// A touch long-press otherwise pops the native context menu on top
|
||||
if (firedRef.current) {
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return { handlers, cancel: clear }
|
||||
}
|
||||
|
||||
export default useLongPress
|
||||
@@ -1,18 +1,23 @@
|
||||
import BottomSheetModal from '../components/common/BottomSheetModal'
|
||||
import FadeModal from '../components/common/FadeModal'
|
||||
import useWindowWidth from './useWindowWidth'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { createElement } from 'react'
|
||||
import AppModal from '../components/common/AppModal'
|
||||
|
||||
const MobileAppModal = props =>
|
||||
createElement(AppModal, { ...props, isMobile: true })
|
||||
const DesktopAppModal = props =>
|
||||
createElement(AppModal, { ...props, isMobile: false })
|
||||
|
||||
/**
|
||||
* Hook that returns the appropriate modal component based on screen size
|
||||
* @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px)
|
||||
* @returns {Object} - { Modal: Component, isMobile: boolean }
|
||||
* Backwards-compatible access to the app modal system.
|
||||
*
|
||||
* New code may render AppModal directly when it already knows the desired
|
||||
* presentation. Existing callers can continue using ResponsiveModal.
|
||||
*/
|
||||
export const useResponsiveModal = (breakpoint = 768) => {
|
||||
const windowWidth = useWindowWidth()
|
||||
const isMobile = windowWidth <= breakpoint
|
||||
const isMobile = useMediaQuery(`(max-width:${breakpoint}px)`)
|
||||
|
||||
return {
|
||||
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal,
|
||||
ResponsiveModal: isMobile ? MobileAppModal : DesktopAppModal,
|
||||
isMobile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,24 @@ const isNetworkError = error =>
|
||||
((error instanceof TypeError && error.message === 'Failed to fetch') ||
|
||||
error?.name === 'AbortError')
|
||||
|
||||
// The backend returns { error: "..." } on failures. Surface that message when
|
||||
// it is there, flagged so callers can tell it apart from our generic fallback.
|
||||
const errorFromResponse = async (resp, fallbackMessage) => {
|
||||
if (!resp) return new Error(fallbackMessage)
|
||||
let serverMessage = null
|
||||
try {
|
||||
const body = await resp.json()
|
||||
if (typeof body?.error === 'string' && body.error.trim() !== '') {
|
||||
serverMessage = body.error
|
||||
}
|
||||
} catch {
|
||||
// body was empty or not JSON, keep the fallback
|
||||
}
|
||||
const error = new Error(serverMessage || fallbackMessage)
|
||||
error.isServerMessage = Boolean(serverMessage)
|
||||
return error
|
||||
}
|
||||
|
||||
const buildOfflineChore = task => ({
|
||||
...task,
|
||||
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
||||
@@ -201,7 +219,7 @@ export const useCreateChore = () => {
|
||||
try {
|
||||
const resp = await CreateChore(newTask)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to create chore')
|
||||
throw await errorFromResponse(resp, 'Failed to create chore')
|
||||
}
|
||||
const createdChore = await resp.json()
|
||||
if (!createdChore) {
|
||||
@@ -254,7 +272,7 @@ export const useUpdateChore = () => {
|
||||
try {
|
||||
const resp = await SaveChore(updatedChore)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to save chore')
|
||||
throw await errorFromResponse(resp, 'Failed to save chore')
|
||||
}
|
||||
const updatedChoreRes = await resp.json()
|
||||
if (!updatedChoreRes) {
|
||||
|
||||
475
src/service/FeedbackService.js
Normal file
475
src/service/FeedbackService.js
Normal file
@@ -0,0 +1,475 @@
|
||||
import { InAppReview } from '@capacitor-community/in-app-review'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Device } from '@capacitor/device'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { isOfficialDonetickInstance } from '../utils/FeatureToggle'
|
||||
|
||||
const STATE_KEY = 'feedbackState'
|
||||
|
||||
// Eligibility thresholds for the automatic sentiment prompt.
|
||||
const MIN_COMPLETIONS = 10
|
||||
const MIN_DAYS_SINCE_SIGNUP = 7
|
||||
const COOLDOWN_DAYS = 120
|
||||
// A recent crash/API failure poisons the sentiment reading, so hold off.
|
||||
const ERROR_QUIET_PERIOD_MS = 10 * 60 * 1000
|
||||
|
||||
const APP_STORE_URL =
|
||||
'https://apps.apple.com/app/apple-store/id6742807441?pt=127258663&ct=website&mt=8'
|
||||
const PLAY_STORE_URL =
|
||||
'https://play.google.com/store/apps/details?id=com.donetick.app'
|
||||
const GITHUB_URL = 'https://github.com/donetick/donetick'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const defaultState = {
|
||||
completions: 0,
|
||||
// null until the first prompt is shown/snoozed.
|
||||
lastPromptedAt: null,
|
||||
lastPromptedVersion: null,
|
||||
dismissCount: 0,
|
||||
reviewRequestedAt: null,
|
||||
lastSentiment: null,
|
||||
optedOut: false,
|
||||
// Developer Settings escape hatch; never set in normal use.
|
||||
devForced: false,
|
||||
}
|
||||
|
||||
let cachedState = null
|
||||
|
||||
const readState = async () => {
|
||||
if (cachedState) return cachedState
|
||||
try {
|
||||
const { value } = await Preferences.get({ key: STATE_KEY })
|
||||
cachedState = { ...defaultState, ...(value ? JSON.parse(value) : {}) }
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: unable to read state', error)
|
||||
cachedState = { ...defaultState }
|
||||
}
|
||||
return cachedState
|
||||
}
|
||||
|
||||
const writeState = async patch => {
|
||||
const current = await readState()
|
||||
cachedState = { ...current, ...patch }
|
||||
try {
|
||||
await Preferences.set({
|
||||
key: STATE_KEY,
|
||||
value: JSON.stringify(cachedState),
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: unable to persist state', error)
|
||||
}
|
||||
return cachedState
|
||||
}
|
||||
|
||||
export const getFeedbackState = () => readState()
|
||||
|
||||
/**
|
||||
* Counts a completed task towards prompt eligibility. Called from the single
|
||||
* network choke point for completions so offline completions are counted when
|
||||
* they sync rather than twice.
|
||||
*/
|
||||
export const recordTaskCompleted = async () => {
|
||||
const state = await readState()
|
||||
// Stop writing once we are well past the threshold; nothing reads the exact
|
||||
// number beyond reporting it as context.
|
||||
if (state.completions > MIN_COMPLETIONS * 100) return
|
||||
await writeState({ completions: state.completions + 1 })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent error breadcrumbs (in memory only, never persisted)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const recentErrors = []
|
||||
const MAX_ERRORS = 5
|
||||
|
||||
export const recordFeedbackError = message => {
|
||||
if (!message) return
|
||||
recentErrors.push({ at: Date.now(), message: String(message).slice(0, 300) })
|
||||
if (recentErrors.length > MAX_ERRORS) recentErrors.shift()
|
||||
}
|
||||
|
||||
let errorListenersInstalled = false
|
||||
|
||||
export const installFeedbackErrorListeners = () => {
|
||||
if (errorListenersInstalled || typeof window === 'undefined') return
|
||||
errorListenersInstalled = true
|
||||
window.addEventListener('error', event => {
|
||||
recordFeedbackError(event?.message)
|
||||
})
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
recordFeedbackError(event?.reason?.message || event?.reason)
|
||||
})
|
||||
}
|
||||
|
||||
const hasRecentError = () =>
|
||||
recentErrors.some(error => Date.now() - error.at < ERROR_QUIET_PERIOD_MS)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getAppVersion = async () => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const { App } = await import('@capacitor/app')
|
||||
const info = await App.getInfo()
|
||||
return `${info.version} (${info.build})`
|
||||
} catch {
|
||||
// fall through to the web bundle version
|
||||
}
|
||||
}
|
||||
return import.meta.env.VITE_APP_VERSION || 'web'
|
||||
}
|
||||
|
||||
const getDeviceContext = async () => {
|
||||
try {
|
||||
const info = await Device.getInfo()
|
||||
return {
|
||||
deviceModel: [info.manufacturer, info.model].filter(Boolean).join(' '),
|
||||
osVersion: `${info.operatingSystem} ${info.osVersion}`,
|
||||
}
|
||||
} catch {
|
||||
return { deviceModel: 'unknown', osVersion: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything we attach to a submission without asking the user for it.
|
||||
*/
|
||||
export const collectFeedbackContext = async ({ feature, userProfile } = {}) => {
|
||||
const [version, device, state, isCloud] = await Promise.all([
|
||||
getAppVersion(),
|
||||
getDeviceContext(),
|
||||
readState(),
|
||||
isOfficialDonetickInstance().catch(() => false),
|
||||
])
|
||||
|
||||
const signupDate = userProfile?.created_at
|
||||
const daysSinceSignup = signupDate
|
||||
? Math.floor((Date.now() - new Date(signupDate).getTime()) / DAY_MS)
|
||||
: null
|
||||
|
||||
return {
|
||||
appVersion: version,
|
||||
platform: Capacitor.getPlatform(),
|
||||
isNative: Capacitor.isNativePlatform(),
|
||||
deviceModel: device.deviceModel,
|
||||
osVersion: device.osVersion,
|
||||
locale:
|
||||
localStorage.getItem('i18nextLng') || navigator.language || 'unknown',
|
||||
hosting: isCloud ? 'cloud' : 'self-hosted',
|
||||
feature: feature || 'unknown',
|
||||
tasksCompleted: state.completions,
|
||||
daysSinceSignup,
|
||||
userId: userProfile?.id ?? null,
|
||||
subscription: userProfile?.subscription ?? null,
|
||||
recentErrors: recentErrors.map(error => error.message),
|
||||
submittedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Eligibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Runs every gate and reports which ones failed, so Developer Settings can
|
||||
* explain why the prompt is or isn't showing rather than just saying "no".
|
||||
*/
|
||||
export const evaluatePromptEligibility = async ({ userProfile } = {}) => {
|
||||
const state = await readState()
|
||||
const version = await getAppVersion()
|
||||
|
||||
if (state.devForced) {
|
||||
return { eligible: true, forced: true, blockers: [], state, version }
|
||||
}
|
||||
|
||||
const blockers = []
|
||||
|
||||
if (state.optedOut) {
|
||||
blockers.push('User opted out (chose a sentiment, or dismissed 3 times)')
|
||||
}
|
||||
if (state.completions < MIN_COMPLETIONS) {
|
||||
blockers.push(
|
||||
`Only ${state.completions} completions, needs ${MIN_COMPLETIONS}`,
|
||||
)
|
||||
}
|
||||
if (hasRecentError()) {
|
||||
blockers.push('An error occurred in the last 10 minutes')
|
||||
}
|
||||
|
||||
const signupDate = userProfile?.createdAt
|
||||
if (signupDate) {
|
||||
const days = (Date.now() - new Date(signupDate).getTime()) / DAY_MS
|
||||
if (days < MIN_DAYS_SINCE_SIGNUP) {
|
||||
blockers.push(
|
||||
`Account is ${Math.floor(days)} days old, needs ${MIN_DAYS_SINCE_SIGNUP}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.lastPromptedAt) {
|
||||
const daysSincePrompt = (Date.now() - state.lastPromptedAt) / DAY_MS
|
||||
if (daysSincePrompt < COOLDOWN_DAYS) {
|
||||
blockers.push(
|
||||
`Cooldown: ${Math.ceil(COOLDOWN_DAYS - daysSincePrompt)} days remaining`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Never ask twice on the same build, even after the cooldown expires.
|
||||
if (state.lastPromptedVersion && state.lastPromptedVersion === version) {
|
||||
blockers.push(`Already prompted on this version (${version})`)
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: blockers.length === 0,
|
||||
forced: false,
|
||||
blockers,
|
||||
state,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
export const shouldShowSentimentPrompt = async options =>
|
||||
(await evaluatePromptEligibility(options)).eligible
|
||||
|
||||
/** Developer Settings: bypass every gate on the next eligibility check. */
|
||||
export const setDevForcedPrompt = forced => writeState({ devForced: !!forced })
|
||||
|
||||
/** Developer Settings: back to a never-prompted, zero-completions user. */
|
||||
export const resetFeedbackState = async () => {
|
||||
cachedState = { ...defaultState }
|
||||
try {
|
||||
await Preferences.remove({ key: STATE_KEY })
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: unable to clear state', error)
|
||||
}
|
||||
return cachedState
|
||||
}
|
||||
|
||||
export const markPromptShown = async () => {
|
||||
const version = await getAppVersion()
|
||||
return writeState({
|
||||
lastPromptedAt: Date.now(),
|
||||
lastPromptedVersion: version,
|
||||
// A forced prompt is spent once shown, otherwise it would fire on every
|
||||
// visit to My Chores.
|
||||
devForced: false,
|
||||
})
|
||||
}
|
||||
|
||||
export const markPromptDismissed = async () => {
|
||||
const state = await readState()
|
||||
const dismissCount = state.dismissCount + 1
|
||||
// Three dismissals in a row is an answer: stop asking automatically.
|
||||
return writeState({ dismissCount, optedOut: dismissCount >= 3 })
|
||||
}
|
||||
|
||||
export const markSentiment = async sentiment =>
|
||||
writeState({ lastSentiment: sentiment, dismissCount: 0 })
|
||||
|
||||
export const optOutOfFeedbackPrompts = () => writeState({ optedOut: true })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store review + submission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getStoreUrl = () => {
|
||||
const platform = Capacitor.getPlatform()
|
||||
if (platform === 'ios') return APP_STORE_URL
|
||||
if (platform === 'android') return PLAY_STORE_URL
|
||||
return GITHUB_URL
|
||||
}
|
||||
|
||||
export const storeLinks = {
|
||||
appStore: APP_STORE_URL,
|
||||
playStore: PLAY_STORE_URL,
|
||||
github: GITHUB_URL,
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the OS to show its native review dialog. The OS decides whether to
|
||||
* actually display it and gives no feedback either way, so this resolves true
|
||||
* only to mean "the request went through".
|
||||
*/
|
||||
export const requestStoreReview = async () => {
|
||||
if (!Capacitor.isNativePlatform()) return false
|
||||
try {
|
||||
await InAppReview.requestReview()
|
||||
await writeState({ reviewRequestedAt: Date.now(), optedOut: true })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: review request failed', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const WEBHOOK_URL = import.meta.env.VITE_FEEDBACK_WEBHOOK_URL
|
||||
|
||||
/**
|
||||
* A chat-provider webhook pasted straight into the env var. That can't work —
|
||||
* the app posts its own schema, which Discord rejects with "Cannot send an
|
||||
* empty message" (50006) — and it would publish the webhook to every user,
|
||||
* since VITE_ vars are baked into the bundle. Relay through workers/feedback.
|
||||
*/
|
||||
const isRawChatWebhook = url =>
|
||||
/^https:\/\/(discord(app)?\.com\/api\/webhooks|hooks\.slack\.com)/i.test(
|
||||
url || '',
|
||||
)
|
||||
|
||||
export const isFeedbackSubmissionConfigured = () =>
|
||||
Boolean(WEBHOOK_URL) && !isRawChatWebhook(WEBHOOK_URL)
|
||||
|
||||
/** Developer Settings: flag the misconfiguration in the UI, not just the log. */
|
||||
export const isRawChatWebhookConfigured = () => isRawChatWebhook(WEBHOOK_URL)
|
||||
|
||||
/**
|
||||
* Whether this app is talking to the hosted donetick.com service. Self-hosted
|
||||
* instances route feedback to GitHub instead of the webhook, so their data
|
||||
* never leaves infrastructure they control.
|
||||
*/
|
||||
export const isCloudInstance = () =>
|
||||
isOfficialDonetickInstance().catch(() => false)
|
||||
|
||||
export const SUBMIT_RESULT = {
|
||||
SENT: 'sent',
|
||||
FAILED: 'failed',
|
||||
UNCONFIGURED: 'unconfigured',
|
||||
MISCONFIGURED: 'misconfigured',
|
||||
SELF_HOSTED: 'self-hosted',
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a pre-filled GitHub issue for self-hosted users. Everything the
|
||||
* webhook would have collected goes into the issue body, where the user can
|
||||
* see and edit it before anything is published.
|
||||
*/
|
||||
export const buildGithubIssueUrl = ({
|
||||
sentiment,
|
||||
category,
|
||||
message,
|
||||
context,
|
||||
}) => {
|
||||
const labelFor = {
|
||||
bugs: 'bug',
|
||||
missingFeature: 'feature request',
|
||||
tooComplicated: 'usability',
|
||||
slow: 'performance',
|
||||
notifications: 'notifications',
|
||||
ai: 'ai',
|
||||
other: 'feedback',
|
||||
}
|
||||
const title = `[${labelFor[category] || 'feedback'}] ${
|
||||
message?.split('\n')[0]?.slice(0, 80) || 'App feedback'
|
||||
}`
|
||||
|
||||
const body = [
|
||||
message?.trim() || '_no description_',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'<details><summary>Environment</summary>',
|
||||
'',
|
||||
`- App version: ${context.appVersion}`,
|
||||
`- Platform: ${context.platform}${context.isNative ? ' (native)' : ''}`,
|
||||
`- Device: ${context.deviceModel}`,
|
||||
`- OS: ${context.osVersion}`,
|
||||
`- Locale: ${context.locale}`,
|
||||
`- Hosting: ${context.hosting}`,
|
||||
`- Screen: ${context.feature}`,
|
||||
`- Sentiment: ${sentiment}`,
|
||||
...(context.recentErrors.length
|
||||
? ['', 'Recent errors:', '```', ...context.recentErrors, '```']
|
||||
: []),
|
||||
'',
|
||||
'</details>',
|
||||
].join('\n')
|
||||
|
||||
return `${GITHUB_URL}/issues/new?title=${encodeURIComponent(
|
||||
title,
|
||||
)}&body=${encodeURIComponent(body)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the structured feedback plus the auto-collected context to the
|
||||
* configured webhook. Never throws, so the UI can show a soft failure without
|
||||
* losing what the user typed.
|
||||
*
|
||||
* Self-hosted instances are never relayed: the caller gets SELF_HOSTED plus a
|
||||
* pre-filled GitHub issue URL to send the user to instead.
|
||||
*/
|
||||
export const submitFeedback = async ({
|
||||
sentiment,
|
||||
category,
|
||||
message,
|
||||
feature,
|
||||
userProfile,
|
||||
}) => {
|
||||
const context = await collectFeedbackContext({ feature, userProfile })
|
||||
const payload = {
|
||||
source: 'donetick-app',
|
||||
sentiment,
|
||||
category: category || null,
|
||||
message: message?.trim() || null,
|
||||
context,
|
||||
}
|
||||
|
||||
// Enforced here rather than only in the UI so no future caller can leak a
|
||||
// self-hosted user's feedback to the hosted relay.
|
||||
if (context.hosting !== 'cloud') {
|
||||
return {
|
||||
result: SUBMIT_RESULT.SELF_HOSTED,
|
||||
githubUrl: buildGithubIssueUrl({ sentiment, category, message, context }),
|
||||
}
|
||||
}
|
||||
|
||||
if (!WEBHOOK_URL) {
|
||||
console.info('FeedbackService: no webhook configured, feedback:', payload)
|
||||
return { result: SUBMIT_RESULT.UNCONFIGURED }
|
||||
}
|
||||
|
||||
if (isRawChatWebhook(WEBHOOK_URL)) {
|
||||
console.error(
|
||||
'FeedbackService: VITE_FEEDBACK_WEBHOOK_URL points directly at a ' +
|
||||
'Discord/Slack webhook. Discord will reject this with 50006 ' +
|
||||
'("Cannot send an empty message") because the app posts its own ' +
|
||||
'schema, and the URL would ship inside the public bundle. Deploy ' +
|
||||
'workers/feedback and point the variable at the Worker instead.',
|
||||
payload,
|
||||
)
|
||||
return { result: SUBMIT_RESULT.MISCONFIGURED }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return {
|
||||
result: response.ok ? SUBMIT_RESULT.SENT : SUBMIT_RESULT.FAILED,
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('FeedbackService: submission failed', error)
|
||||
return { result: SUBMIT_RESULT.FAILED }
|
||||
}
|
||||
}
|
||||
|
||||
export const FEEDBACK_CATEGORIES = [
|
||||
'bugs',
|
||||
'missingFeature',
|
||||
'tooComplicated',
|
||||
'slow',
|
||||
'notifications',
|
||||
'ai',
|
||||
'other',
|
||||
]
|
||||
|
||||
export const SENTIMENTS = {
|
||||
LOVE: 'love',
|
||||
OKAY: 'okay',
|
||||
ISSUES: 'issues',
|
||||
}
|
||||
@@ -166,6 +166,15 @@ const MarkChoreComplete = (id, body, completedDate, performer) => {
|
||||
method: 'POST',
|
||||
headers: HEADERS(),
|
||||
body: JSON.stringify(body),
|
||||
}).then(response => {
|
||||
if (response?.ok) {
|
||||
// Single choke point for completions, so queued offline completions are
|
||||
// counted once, when they sync.
|
||||
import('../service/FeedbackService')
|
||||
.then(({ recordTaskCompleted }) => recordTaskCompleted())
|
||||
.catch(() => {})
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
160
src/views/Authorization/AuthFields.jsx
Normal file
160
src/views/Authorization/AuthFields.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined'
|
||||
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Link,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { authButtonSx, authInputSx } from './authStyles'
|
||||
|
||||
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
|
||||
|
||||
export const AuthField = ({ label, error, helper, children, ...formProps }) => (
|
||||
<FormControl error={Boolean(error)} {...formProps}>
|
||||
<FormLabel sx={labelSx}>{label}</FormLabel>
|
||||
{children}
|
||||
{(error || helper) && (
|
||||
<FormHelperText
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
color: error ? 'danger.plainColor' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{error || helper}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
|
||||
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
|
||||
<AuthField label={label} error={error} helper={helper}>
|
||||
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
|
||||
</AuthField>
|
||||
)
|
||||
|
||||
export const AuthPasswordField = ({
|
||||
label = 'Password',
|
||||
error,
|
||||
helper,
|
||||
sx,
|
||||
...inputProps
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<AuthField label={label} error={error} helper={helper}>
|
||||
<Input
|
||||
size='lg'
|
||||
type={visible ? 'text' : 'password'}
|
||||
sx={{ ...authInputSx, ...sx }}
|
||||
endDecorator={
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
tabIndex={-1}
|
||||
aria-label={visible ? 'Hide password' : 'Show password'}
|
||||
onClick={() => setVisible(v => !v)}
|
||||
sx={{ borderRadius: '8px' }}
|
||||
>
|
||||
{visible ? (
|
||||
<VisibilityOffOutlined fontSize='small' />
|
||||
) : (
|
||||
<VisibilityOutlined fontSize='small' />
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
{...inputProps}
|
||||
/>
|
||||
</AuthField>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthSubmitButton = ({ children, sx, ...props }) => (
|
||||
<Button
|
||||
type='submit'
|
||||
size='lg'
|
||||
variant='solid'
|
||||
fullWidth
|
||||
sx={{ ...authButtonSx, ...sx }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const SocialButton = ({ icon, children, sx, ...props }) => (
|
||||
<Button
|
||||
type='button'
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
startDecorator={icon}
|
||||
sx={{
|
||||
...authButtonSx,
|
||||
fontWeight: 500,
|
||||
justifyContent: 'center',
|
||||
...sx,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const AuthDivider = ({ children = 'or' }) => (
|
||||
<Box
|
||||
role='separator'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
my: 2.5,
|
||||
'&::before, &::after': {
|
||||
content: '""',
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
bgcolor: 'divider',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const LegalLinks = () => (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
<Link
|
||||
href='https://donetick.com/privacy'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{' · '}
|
||||
<Link
|
||||
href='https://donetick.com/terms'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
>
|
||||
Terms of Use
|
||||
</Link>
|
||||
</Typography>
|
||||
)
|
||||
117
src/views/Authorization/AuthShell.jsx
Normal file
117
src/views/Authorization/AuthShell.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Box, Sheet, Typography } from '@mui/joy'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
/**
|
||||
* Full-height auth layout: edge-to-edge on phones, a centered surface card from
|
||||
* the `sm` breakpoint up. The route renders without a navbar, so the shell owns
|
||||
* its own safe-area padding (the top inset is already reserved by NavBar).
|
||||
*/
|
||||
const AuthShell = ({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
footer,
|
||||
logoSize = 48,
|
||||
// In the app the user already came through the app icon and the Get Started
|
||||
// mark, so repeating it here is noise. On the web these routes are the first
|
||||
// thing a visitor sees — often on a self-hosted domain, and with no navbar —
|
||||
// so the mark is the only thing identifying the app. Views reached from an
|
||||
// emailed link override this to always show it.
|
||||
showLogo = !Capacitor.isNativePlatform(),
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
pt: { xs: 3, sm: 5 },
|
||||
pb: 'calc(var(--safe-area-inset-bottom, 0px) + 24px)',
|
||||
bgcolor: 'background.body',
|
||||
}}
|
||||
>
|
||||
{/* my:auto centers the column without the top-clipping that
|
||||
justify-content:center causes once the form outgrows the viewport. */}
|
||||
<Box sx={{ width: '100%', maxWidth: 420, my: 'auto' }}>
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: { xs: 0, sm: '20px' },
|
||||
bgcolor: { xs: 'transparent', sm: 'background.surface' },
|
||||
border: { xs: 'none', sm: '1px solid' },
|
||||
borderColor: { sm: 'divider' },
|
||||
boxShadow: { xs: 'none', sm: 'sm' },
|
||||
p: { xs: 0, sm: 3.5 },
|
||||
animation: 'authPanelIn 240ms cubic-bezier(0.22, 1, 0.36, 1) both',
|
||||
'@keyframes authPanelIn': {
|
||||
from: { opacity: 0, transform: 'translateY(8px)' },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{action && (
|
||||
<Box sx={{ position: 'absolute', top: 0, right: 0 }}>{action}</Box>
|
||||
)}
|
||||
|
||||
{/* Mark only: the wordmark sat at nearly the same size and weight as
|
||||
the title below it, so the two competed instead of forming a
|
||||
hierarchy. */}
|
||||
{showLogo && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Logo size={`${logoSize}px`} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{title && (
|
||||
<Typography
|
||||
level='h2'
|
||||
sx={{
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
textAlign: 'center',
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
mt: 0.75,
|
||||
textAlign: 'center',
|
||||
color: 'text.secondary',
|
||||
textWrap: 'pretty',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: title || subtitle ? 3 : 0 }}>{children}</Box>
|
||||
</Sheet>
|
||||
|
||||
{footer && <Box sx={{ mt: 2.5 }}>{footer}</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthShell
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
|
||||
import { Box, Button, LinearProgress } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import Cookies from 'js-cookie'
|
||||
@@ -11,14 +10,16 @@ import { apiClient } from '../../utils/ApiClient'
|
||||
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
|
||||
import { GetUserProfile } from '../../utils/Fetcher'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const AuthenticationLoading = () => {
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
const { refetch: refetchUserProfile } = useUserProfile()
|
||||
const Navigate = useNavigate()
|
||||
const hasCalledHandleOAuth2 = useRef(false)
|
||||
const [message, setMessage] = useState('Authenticating')
|
||||
const [subMessage, setSubMessage] = useState('Please wait')
|
||||
const [message, setMessage] = useState('Signing you in')
|
||||
const [subMessage, setSubMessage] = useState('This will only take a moment.')
|
||||
const [status, setStatus] = useState('pending')
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
@@ -30,14 +31,15 @@ const AuthenticationLoading = () => {
|
||||
// suppress a genuine session expiry later on.
|
||||
handleOAuth2().finally(endOAuthExchange)
|
||||
} else if (provider !== 'oauth2') {
|
||||
setMessage('Unknown Authentication Provider')
|
||||
setSubMessage('Please contact support')
|
||||
setMessage('Unknown sign-in provider')
|
||||
setSubMessage('Please contact support.')
|
||||
setStatus('error')
|
||||
}
|
||||
return endOAuthExchange
|
||||
}, [provider])
|
||||
const getUserProfileAndNavigateToHome = () => {
|
||||
GetUserProfile().then(data => {
|
||||
data.json().then(data => {
|
||||
GetUserProfile().then(response => {
|
||||
response.json().then(() => {
|
||||
refetchUserProfile().then(() => {
|
||||
// check if redirect url is set in cookie:
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
@@ -69,8 +71,8 @@ const AuthenticationLoading = () => {
|
||||
const handleMFAClose = () => {
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Two-factor authentication was cancelled')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Two-factor authentication was cancelled.')
|
||||
setStatus('error')
|
||||
}
|
||||
|
||||
@@ -83,8 +85,8 @@ const AuthenticationLoading = () => {
|
||||
const storedState = localStorage.getItem('authState')
|
||||
|
||||
if (returnedState !== storedState) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('State does not match')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('The sign-in request could not be verified.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
@@ -110,8 +112,8 @@ const AuthenticationLoading = () => {
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Authentication failed')
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Please try again')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Please try again.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
@@ -120,22 +122,22 @@ const AuthenticationLoading = () => {
|
||||
|
||||
if (data.mfaRequired) {
|
||||
if (!data.sessionToken) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('MFA session is missing. Please try again')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('The MFA session is missing. Please try again.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
setMfaSessionToken(data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
setMessage('Two-Factor Authentication Required')
|
||||
setSubMessage('Please verify your login to continue')
|
||||
setMessage('Two-factor authentication')
|
||||
setSubMessage('Verify your login to continue.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.token && !data.access_token) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('No valid authentication token returned')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('No valid authentication token was returned.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
@@ -156,66 +158,52 @@ const AuthenticationLoading = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication request failed', error)
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Please try again')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Please try again.')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className='flex h-full items-center justify-center'>
|
||||
<AuthShell title={message} subtitle={subMessage}>
|
||||
<Box
|
||||
className='flex flex-col items-center justify-center'
|
||||
sx={{
|
||||
minHeight: '80vh',
|
||||
}}
|
||||
role='status'
|
||||
aria-live='polite'
|
||||
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||
>
|
||||
<CircularProgress
|
||||
determinate={status === 'error'}
|
||||
color={status === 'pending' ? 'primary' : 'danger'}
|
||||
sx={{ '--CircularProgress-size': '200px' }}
|
||||
>
|
||||
<Logo />
|
||||
</CircularProgress>
|
||||
<Box
|
||||
className='flex items-center gap-2'
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Box>
|
||||
<Typography level='body-md' fontWeight={500} textAlign={'center'}>
|
||||
{subMessage}
|
||||
</Typography>
|
||||
{status === 'pending' && (
|
||||
<LinearProgress
|
||||
sx={{ width: '60%', '--LinearProgress-radius': '999px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Button
|
||||
component={Link}
|
||||
to='/login'
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
mt: 4,
|
||||
}}
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
sx={authButtonSx}
|
||||
>
|
||||
<Link to='/login'>Go back Login</Link>
|
||||
Back to sign in
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
onClose={handleMFAClose}
|
||||
sessionToken={mfaSessionToken}
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={() => {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Two-factor authentication failed. Please try again')
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
onClose={handleMFAClose}
|
||||
sessionToken={mfaSessionToken}
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={() => {
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Two-factor authentication failed. Please try again.')
|
||||
}}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,38 @@
|
||||
// create boilerplate for ResetPasswordView:
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import MarkEmailReadOutlined from '@mui/icons-material/MarkEmailReadOutlined'
|
||||
import { Box, Button, Link, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ResetPassword } from '../../utils/Fetcher'
|
||||
import { AuthSubmitButton, AuthTextField, LegalLinks } from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
|
||||
const isInvalidEmail = email =>
|
||||
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
|
||||
|
||||
const ForgotPasswordView = () => {
|
||||
const navigate = useNavigate()
|
||||
const [resetStatusOk, setResetStatusOk] = useState(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { showError, showNotification } = useNotification()
|
||||
|
||||
const validateEmail = email => {
|
||||
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
|
||||
}
|
||||
const handleSubmit = async e => {
|
||||
e?.preventDefault()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!email) {
|
||||
return setEmailError('Email is required')
|
||||
setEmailError('Email is required')
|
||||
return
|
||||
}
|
||||
|
||||
// validate email:
|
||||
if (validateEmail(email)) {
|
||||
if (isInvalidEmail(email)) {
|
||||
setEmailError('Please enter a valid email address')
|
||||
return
|
||||
}
|
||||
|
||||
if (emailError) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const response = await ResetPassword(email)
|
||||
|
||||
@@ -64,146 +56,106 @@ const ForgotPasswordView = () => {
|
||||
title: 'Reset Failed',
|
||||
message: 'Failed to send reset email, please try again later',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate on blur/submit only; flagging a half-typed address as invalid on
|
||||
// every keystroke reads as the form yelling at you mid-word.
|
||||
const handleEmailChange = e => {
|
||||
setEmail(e.target.value)
|
||||
if (validateEmail(e.target.value)) {
|
||||
setEmailError('Please enter a valid email address')
|
||||
} else {
|
||||
if (emailError) {
|
||||
setEmailError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
const handleEmailBlur = () => {
|
||||
if (email && isInvalidEmail(email)) {
|
||||
setEmailError('Please enter a valid email address')
|
||||
}
|
||||
}
|
||||
|
||||
if (resetStatusOk !== null) {
|
||||
return (
|
||||
<AuthShell
|
||||
title='Check your email'
|
||||
subtitle={`If an account exists for ${email}, we've sent instructions for resetting your password.`}
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
<MarkEmailReadOutlined
|
||||
sx={{ fontSize: 40, color: 'primary.plainColor', mb: 2 }}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to sign in
|
||||
</Button>
|
||||
</Box>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
{resetStatusOk === null && (
|
||||
<>
|
||||
<Typography level='body2' sx={{ mb: 3 }}>
|
||||
Enter your email, and we'll send you a link to get into your
|
||||
account.
|
||||
</Typography>
|
||||
return (
|
||||
<AuthShell
|
||||
title='Reset your password'
|
||||
subtitle="Enter your email and we'll send you a link to get back into your account."
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
>
|
||||
<Box
|
||||
component='form'
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<AuthTextField
|
||||
label='Email address'
|
||||
id='email'
|
||||
name='email'
|
||||
type='email'
|
||||
autoComplete='email'
|
||||
placeholder='you@example.com'
|
||||
autoFocus
|
||||
value={email}
|
||||
error={emailError}
|
||||
onChange={handleEmailChange}
|
||||
onBlur={handleEmailBlur}
|
||||
/>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Email Address
|
||||
</Typography>
|
||||
<FormControl
|
||||
error={emailError !== null}
|
||||
sx={{ width: '100%', mb: 2 }}
|
||||
>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='email'
|
||||
placeholder='Enter your email address'
|
||||
type='email'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
error={emailError !== null}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>{emailError}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
color='neutral'
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{resetStatusOk != null && (
|
||||
<>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{ textAlign: 'center', mt: 2, mb: 3 }}
|
||||
>
|
||||
If there is an account associated with the email you entered,
|
||||
you will receive an email with instructions on how to reset your
|
||||
password.
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant='solid'
|
||||
size='lg'
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
|
||||
Send reset link
|
||||
</AuthSubmitButton>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Remembered it?{' '}
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
fontWeight={600}
|
||||
underline='hover'
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</Typography>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,16 @@ import { Preferences } from '@capacitor/preferences'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||
import WifiIcon from '@mui/icons-material/Wifi'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { AuthSubmitButton, AuthTextField } from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 8000
|
||||
|
||||
@@ -138,7 +131,8 @@ const LoginSettings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async e => {
|
||||
e.preventDefault()
|
||||
const trimmedURL = serverURL.trim()
|
||||
|
||||
if (trimmedURL === '') {
|
||||
@@ -192,138 +186,113 @@ const LoginSettings = () => {
|
||||
const isTesting = status === 'testing'
|
||||
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<AuthShell
|
||||
title='Server settings'
|
||||
subtitle='Point the app at your own self-hosted Donetick server.'
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
component='form'
|
||||
onSubmit={handleSave}
|
||||
sx={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
<AuthTextField
|
||||
label='Server URL'
|
||||
id='serverURL'
|
||||
name='serverURL'
|
||||
inputMode='url'
|
||||
autoCapitalize='none'
|
||||
autoCorrect='off'
|
||||
spellCheck='false'
|
||||
placeholder='https://your-server:2021'
|
||||
autoFocus
|
||||
value={serverURL}
|
||||
onChange={handleURLChange}
|
||||
disabled={isTesting}
|
||||
color={
|
||||
status === 'success'
|
||||
? 'success'
|
||||
: status === 'error'
|
||||
? 'danger'
|
||||
: 'neutral'
|
||||
}
|
||||
endDecorator={
|
||||
status === 'success' ? (
|
||||
<CheckCircleOutlineIcon color='success' fontSize='small' />
|
||||
) : status === 'error' ? (
|
||||
<ErrorOutlineIcon color='error' fontSize='small' />
|
||||
) : null
|
||||
}
|
||||
helper='Include the protocol (http:// or https://) and the port if needed. Donetick defaults to port 2021.'
|
||||
/>
|
||||
|
||||
{status === 'error' && (
|
||||
<Alert
|
||||
color='danger'
|
||||
variant='soft'
|
||||
startDecorator={<ErrorOutlineIcon />}
|
||||
sx={{ mt: 2, borderRadius: '12px', alignItems: 'flex-start' }}
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert
|
||||
color='success'
|
||||
variant='soft'
|
||||
startDecorator={<CheckCircleOutlineIcon />}
|
||||
sx={{ mt: 2, borderRadius: '12px' }}
|
||||
>
|
||||
Connected. Taking you to sign in...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'testing' && (
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
startDecorator={<WifiIcon />}
|
||||
sx={{ mt: 2, borderRadius: '12px' }}
|
||||
>
|
||||
Testing connection to server...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AuthSubmitButton
|
||||
loading={isTesting}
|
||||
disabled={status === 'success'}
|
||||
startDecorator={isTesting ? <CircularProgress size='sm' /> : null}
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
{isTesting ? 'Testing connection' : 'Save & connect'}
|
||||
</AuthSubmitButton>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
disabled={isTesting}
|
||||
sx={{ ...authButtonSx, mt: 1 }}
|
||||
onClick={async () => {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mt={4}>
|
||||
Server URL
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='serverURL'
|
||||
name='serverURL'
|
||||
autoFocus
|
||||
value={serverURL}
|
||||
onChange={handleURLChange}
|
||||
disabled={isTesting}
|
||||
color={
|
||||
status === 'success'
|
||||
? 'success'
|
||||
: status === 'error'
|
||||
? 'danger'
|
||||
: 'neutral'
|
||||
}
|
||||
endDecorator={
|
||||
status === 'success' ? (
|
||||
<CheckCircleOutlineIcon color='success' fontSize='small' />
|
||||
) : status === 'error' ? (
|
||||
<ErrorOutlineIcon color='error' fontSize='small' />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Typography mt={1} level='body-xs'>
|
||||
Change the server URL to connect to a different server, such as your
|
||||
own self-hosted Donetick server.
|
||||
</Typography>
|
||||
<Typography mt={1} level='body-xs'>
|
||||
Include the protocol (http:// or https://) and port if necessary
|
||||
(default Donetick port is 2021).
|
||||
</Typography>
|
||||
|
||||
{status === 'error' && (
|
||||
<Alert
|
||||
color='danger'
|
||||
variant='soft'
|
||||
startDecorator={<ErrorOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert
|
||||
color='success'
|
||||
variant='soft'
|
||||
startDecorator={<CheckCircleOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Connected! Redirecting to login...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'testing' && (
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
startDecorator={<WifiIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Testing connection to server...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
disabled={isTesting || status === 'success'}
|
||||
sx={{ width: '100%', mt: 2, mb: 2, borderRadius: '8px' }}
|
||||
onClick={handleSave}
|
||||
startDecorator={
|
||||
isTesting ? <CircularProgress size='sm' /> : undefined
|
||||
}
|
||||
>
|
||||
{isTesting ? 'Testing...' : 'Save & Connect'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
disabled={isTesting}
|
||||
sx={{ width: '100%', mb: 2, borderRadius: '8px' }}
|
||||
onClick={async () => {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
}}
|
||||
>
|
||||
Cancel and Reset
|
||||
</Button>
|
||||
</Sheet>
|
||||
Reset to default server
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mt: 2.5, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Changing the server clears locally cached data on this device.
|
||||
</Typography>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,24 +3,10 @@ import { Capacitor } from '@capacitor/core'
|
||||
import { Device } from '@capacitor/device'
|
||||
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
|
||||
import { SocialLogin } from '@capgo/capacitor-social-login'
|
||||
import { Settings } from '@mui/icons-material'
|
||||
import { SettingsOutlined } from '@mui/icons-material'
|
||||
import AppleIcon from '@mui/icons-material/Apple'
|
||||
import GoogleIcon from '@mui/icons-material/Google'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
Sheet,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Avatar, Box, Button, IconButton, Link, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -28,25 +14,83 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
||||
import {
|
||||
AuthDivider,
|
||||
AuthPasswordField,
|
||||
AuthSubmitButton,
|
||||
AuthTextField,
|
||||
LegalLinks,
|
||||
SocialButton,
|
||||
} from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const SegmentedControl = ({ value, onChange, options }) => (
|
||||
<Box
|
||||
role='tablist'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
p: 0.5,
|
||||
gap: 0.5,
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
mb: 2.5,
|
||||
}}
|
||||
>
|
||||
{options.map(option => {
|
||||
const selected = option.value === value
|
||||
return (
|
||||
<Box
|
||||
key={option.value}
|
||||
component='button'
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={selected}
|
||||
onClick={() => onChange(option.value)}
|
||||
sx={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '9px',
|
||||
py: 1,
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 600,
|
||||
color: selected ? 'text.primary' : 'text.secondary',
|
||||
bgcolor: selected ? 'background.surface' : 'transparent',
|
||||
boxShadow: selected ? 'xs' : 'none',
|
||||
transition: 'background-color 180ms ease, color 180ms ease',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'primary.500',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const LoginView = () => {
|
||||
// Use React Query client directly to invalidate the user profile query
|
||||
const queryClient = useQueryClient()
|
||||
// const [userProfile, setUserProfile] = useState(null)
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Child login state
|
||||
const [loginType, setLoginType] = useState('primary')
|
||||
@@ -54,7 +98,7 @@ const LoginView = () => {
|
||||
const [childName, setChildName] = useState('')
|
||||
|
||||
// Clear fields when switching login modes
|
||||
const handleLoginModeChange = (event, newValue) => {
|
||||
const handleLoginModeChange = newValue => {
|
||||
setLoginType(newValue)
|
||||
setUsername('')
|
||||
setParentUsername('')
|
||||
@@ -94,7 +138,6 @@ const LoginView = () => {
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
setUserProfile(user)
|
||||
Navigate('/chores')
|
||||
}
|
||||
}, [isAuthenticated, user, Navigate])
|
||||
@@ -141,7 +184,19 @@ const LoginView = () => {
|
||||
? buildChildUsername(parentUsername, childName)
|
||||
: username
|
||||
|
||||
const result = await authLogin({ username: actualUsername, password })
|
||||
setIsSubmitting(true)
|
||||
let result
|
||||
try {
|
||||
result = await authLogin({ username: actualUsername, password })
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Login Failed',
|
||||
message: error?.message || 'An error occurred, please try again',
|
||||
})
|
||||
return
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
if (result.data?.mfaRequired) {
|
||||
@@ -352,473 +407,262 @@ const LoginView = () => {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
component='main'
|
||||
maxWidth='xs'
|
||||
const displayName = userProfile?.displayName || userProfile?.username
|
||||
const showSocialLogin = import.meta.env.VITE_IS_SELF_HOSTED !== 'true'
|
||||
const hasSocialOptions =
|
||||
showSocialLogin || Boolean(resource?.identity_provider?.client_id)
|
||||
|
||||
// make content center in the middle of the page:
|
||||
return (
|
||||
<AuthShell
|
||||
title={userProfile ? 'Welcome back' : 'Sign in'}
|
||||
subtitle={
|
||||
userProfile
|
||||
? 'Pick up right where you left off.'
|
||||
: 'Sign in to your account to continue.'
|
||||
}
|
||||
logoSize={0}
|
||||
footer={<LegalLinks />}
|
||||
action={
|
||||
Capacitor.isNativePlatform() ? (
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
aria-label='Server settings'
|
||||
onClick={() => Navigate('/login/settings')}
|
||||
>
|
||||
<SettingsOutlined />
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
{userProfile ? (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{Capacitor.isNativePlatform() && (
|
||||
<IconButton
|
||||
// on top right of the screen:
|
||||
sx={{ position: 'absolute', top: 2, right: 2, color: 'black' }}
|
||||
onClick={() => {
|
||||
Navigate('/login/settings')
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
<Settings />
|
||||
</IconButton>
|
||||
)}
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
|
||||
{userProfile && (
|
||||
<>
|
||||
<Avatar
|
||||
src={userProfile?.image}
|
||||
alt={userProfile?.username}
|
||||
size='lg'
|
||||
sx={{ mt: 2, width: '96px', height: '96px', mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
Welcome back,{' '}
|
||||
{userProfile?.displayName || userProfile?.username}
|
||||
{getUserDisplayInfo(userProfile).userType === 'child' && (
|
||||
<Typography
|
||||
component='span'
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
(Sub Account)
|
||||
</Typography>
|
||||
)}
|
||||
<Avatar
|
||||
src={userProfile?.image}
|
||||
alt={displayName}
|
||||
sx={{ width: 88, height: 88 }}
|
||||
/>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography level='title-md'>{displayName}</Typography>
|
||||
{getUserDisplayInfo(userProfile).userType === 'child' && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
Sub Account
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={() => {
|
||||
getUserProfileAndNavigateToHome()
|
||||
}}
|
||||
>
|
||||
Continue as {userProfile.displayName || userProfile.username}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
apiClient.handleLogout()
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!userProfile && (
|
||||
<>
|
||||
<Typography level='body2' sx={{ mb: 3 }}>
|
||||
Sign in to your account to continue
|
||||
</Typography>
|
||||
|
||||
{/* Login Type Tabs */}
|
||||
<Tabs
|
||||
value={loginType}
|
||||
onChange={handleLoginModeChange}
|
||||
sx={{ width: '100%', mb: 3 }}
|
||||
>
|
||||
<TabList
|
||||
sx={{
|
||||
width: '100%',
|
||||
p: 0.5,
|
||||
borderBottom: 'none',
|
||||
boxShadow: 'none',
|
||||
'&::after': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value='primary'
|
||||
variant='plain'
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Primary Account
|
||||
</Tab>
|
||||
<Tab
|
||||
value='sub'
|
||||
variant='plain'
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Sub Account
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value='primary' sx={{ p: 0, mt: 2 }}>
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='email'
|
||||
label='Email Address'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value='sub' sx={{ p: 0, mt: 2 }}>
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Primary Account Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='parentUsername'
|
||||
name='parentUsername'
|
||||
placeholder='Enter primary account username'
|
||||
autoFocus
|
||||
value={parentUsername}
|
||||
onChange={e => {
|
||||
setParentUsername(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Typography level='body2' alignSelf={'start'} mt={1} mb={1}>
|
||||
Sub Account Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='childName'
|
||||
name='childName'
|
||||
placeholder='Enter sub account name'
|
||||
value={childName}
|
||||
onChange={e => {
|
||||
setChildName(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Password:
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
name='password'
|
||||
label='Password'
|
||||
type='password'
|
||||
id='password'
|
||||
autoComplete='password'
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loginType === 'sub' ? 'Sign In as Sub Account' : 'Sign In'}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={handleForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Divider> or </Divider>
|
||||
{import.meta.env.VITE_IS_SELF_HOSTED !== 'true' && (
|
||||
<>
|
||||
{!Capacitor.isNativePlatform() && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LoginSocialGoogle
|
||||
client_id={GOOGLE_CLIENT_ID}
|
||||
redirect_uri={REDIRECT_URL}
|
||||
scope='openid profile email'
|
||||
discoveryDocs='claims_supported'
|
||||
access_type='online'
|
||||
isOnlyGetToken={true}
|
||||
onResolve={({ provider, data }) => {
|
||||
loggedWithProvider(provider, data)
|
||||
}}
|
||||
onReject={() => {
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Google, please try again",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
fullWidth
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 1,
|
||||
mb: 1,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</div>
|
||||
</Button>
|
||||
</LoginSocialGoogle>
|
||||
|
||||
{/* <Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
sx={{
|
||||
mt: 1,
|
||||
mb: 1,
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: '#333',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<AppleIcon />
|
||||
Continue with Apple
|
||||
</div>
|
||||
</Button> */}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Capacitor.isNativePlatform() && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
||||
const user = await SocialLogin.login({
|
||||
provider: 'google',
|
||||
options: { scopes: ['profile', 'email', 'openid'] },
|
||||
})
|
||||
console.log('Google user', user)
|
||||
loggedWithProvider('google', user.result)
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error)
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message: `Couldn't log in with Google, please try again${
|
||||
error?.message ? `: ${error.message}` : ''
|
||||
}`,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Apple Sign In Button for Native Platforms */}
|
||||
{isAppleSignInSupported && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
sx={{
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
state: 'random_string',
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<AppleIcon />
|
||||
Continue with Apple
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{resource?.identity_provider?.client_id && (
|
||||
<Button
|
||||
fullWidth
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={handleAuthentikLogin}
|
||||
>
|
||||
Continue with {resource?.identity_provider?.name}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!resource?.is_user_creation_disabled && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Navigate('/signup')
|
||||
}}
|
||||
fullWidth
|
||||
variant='soft'
|
||||
size='lg'
|
||||
// sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Create new account
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/privacy', '_blank')
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/terms', '_blank')
|
||||
}}
|
||||
>
|
||||
Terms of Use
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Sheet>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ ...authButtonSx, mt: 1 }}
|
||||
onClick={getUserProfileAndNavigateToHome}
|
||||
>
|
||||
Continue as {displayName}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => apiClient.handleLogout()}
|
||||
>
|
||||
Use a different account
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
component='form'
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={loginType}
|
||||
onChange={handleLoginModeChange}
|
||||
options={[
|
||||
{ value: 'primary', label: 'Primary Account' },
|
||||
{ value: 'sub', label: 'Sub Account' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{loginType === 'primary' ? (
|
||||
<AuthTextField
|
||||
label='Username'
|
||||
id='username'
|
||||
name='username'
|
||||
autoComplete='username'
|
||||
placeholder='Your username'
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AuthTextField
|
||||
label='Primary account username'
|
||||
id='parentUsername'
|
||||
name='parentUsername'
|
||||
autoComplete='username'
|
||||
placeholder='Enter primary account username'
|
||||
autoFocus
|
||||
value={parentUsername}
|
||||
onChange={e => setParentUsername(e.target.value)}
|
||||
/>
|
||||
<AuthTextField
|
||||
label='Sub account name'
|
||||
id='childName'
|
||||
name='childName'
|
||||
placeholder='Enter sub account name'
|
||||
value={childName}
|
||||
onChange={e => setChildName(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<AuthPasswordField
|
||||
id='password'
|
||||
name='password'
|
||||
autoComplete='current-password'
|
||||
placeholder='Enter your password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
underline='hover'
|
||||
onClick={handleForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 3 }}>
|
||||
{loginType === 'sub' ? 'Sign in as sub account' : 'Sign in'}
|
||||
</AuthSubmitButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasSocialOptions && <AuthDivider>or continue with</AuthDivider>}
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{showSocialLogin && !Capacitor.isNativePlatform() && (
|
||||
<LoginSocialGoogle
|
||||
client_id={GOOGLE_CLIENT_ID}
|
||||
redirect_uri={REDIRECT_URL}
|
||||
scope='openid profile email'
|
||||
discoveryDocs='claims_supported'
|
||||
access_type='online'
|
||||
isOnlyGetToken={true}
|
||||
onResolve={({ provider, data }) => {
|
||||
loggedWithProvider(provider, data)
|
||||
}}
|
||||
onReject={() => {
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message: "Couldn't log in with Google, please try again",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SocialButton icon={<GoogleIcon />}>Google</SocialButton>
|
||||
</LoginSocialGoogle>
|
||||
)}
|
||||
|
||||
{showSocialLogin && Capacitor.isNativePlatform() && (
|
||||
<>
|
||||
<SocialButton
|
||||
icon={<GoogleIcon />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const user = await SocialLogin.login({
|
||||
provider: 'google',
|
||||
options: { scopes: ['profile', 'email', 'openid'] },
|
||||
})
|
||||
console.log('Google user', user)
|
||||
loggedWithProvider('google', user.result)
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error)
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message: `Couldn't log in with Google, please try again${
|
||||
error?.message ? `: ${error.message}` : ''
|
||||
}`,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Google
|
||||
</SocialButton>
|
||||
|
||||
{isAppleSignInSupported && (
|
||||
<SocialButton
|
||||
icon={<AppleIcon />}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
state: 'random_string',
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message: "Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
Apple
|
||||
</SocialButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{resource?.identity_provider?.client_id && (
|
||||
<SocialButton onClick={handleAuthentikLogin}>
|
||||
{resource?.identity_provider?.name}
|
||||
</SocialButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!userProfile && !resource?.is_user_creation_disabled && (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
fontWeight={600}
|
||||
underline='hover'
|
||||
onClick={() => Navigate('/signup')}
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
onClose={handleMFAClose}
|
||||
@@ -826,7 +670,7 @@ const LoginView = () => {
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={handleMFAError}
|
||||
/>
|
||||
</Container>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { Security, Smartphone } from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Input,
|
||||
Link,
|
||||
ModalClose,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { VerifyMFA } from '../../utils/Fetcher'
|
||||
import { authInputSx } from './authStyles'
|
||||
|
||||
const MFAVerificationModal = ({
|
||||
open,
|
||||
@@ -26,6 +18,7 @@ const MFAVerificationModal = ({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode.trim()) {
|
||||
setError('Please enter a verification code')
|
||||
@@ -43,12 +36,17 @@ const MFAVerificationModal = ({
|
||||
onSuccess(data)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(
|
||||
errorData.message || 'Invalid verification code. Please try again.',
|
||||
)
|
||||
const message =
|
||||
errorData.message || 'Invalid verification code. Please try again.'
|
||||
setError(message)
|
||||
onError?.(message)
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to verify code. Please try again.')
|
||||
// A wrong code is shown inline; a failed request is escalated to the
|
||||
// caller so it can surface a toast instead of looking like a bad code.
|
||||
const message = 'Failed to verify code. Please try again.'
|
||||
setError(message)
|
||||
onError?.(message)
|
||||
console.error('MFA verification error:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -63,8 +61,9 @@ const MFAVerificationModal = ({
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleKeyPress = e => {
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
e.preventDefault()
|
||||
handleVerify()
|
||||
}
|
||||
}
|
||||
@@ -73,86 +72,104 @@ const MFAVerificationModal = ({
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title='Two-Factor Authentication'
|
||||
size='md'
|
||||
title='Two-factor authentication'
|
||||
description={
|
||||
isBackupCode
|
||||
? 'Enter one of the backup codes you saved when setting up two-factor authentication.'
|
||||
: 'Enter the 6-digit code from your authenticator app.'
|
||||
}
|
||||
closeOnBackdrop={!loading}
|
||||
closeOnEscape={!loading}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
onClick: handleClose,
|
||||
disabled: loading,
|
||||
}}
|
||||
primary={{
|
||||
label: 'Verify & Sign In',
|
||||
onClick: handleVerify,
|
||||
loading,
|
||||
disabled: !verificationCode.trim(),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ModalClose />
|
||||
|
||||
<Box className='mb-4 text-center'>
|
||||
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
||||
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
|
||||
Enter the verification code from your authenticator app
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
{isBackupCode ? 'Backup Code' : 'Verification Code'}
|
||||
<Typography
|
||||
component='label'
|
||||
htmlFor='mfa-code'
|
||||
level='body-sm'
|
||||
sx={{ display: 'block', fontWeight: 600, mb: 0.75 }}
|
||||
>
|
||||
{isBackupCode ? 'Backup code' : 'Verification code'}
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder={
|
||||
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
|
||||
}
|
||||
id='mfa-code'
|
||||
size='lg'
|
||||
placeholder={isBackupCode ? 'Enter backup code' : '000000'}
|
||||
value={verificationCode}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
onKeyDown={handleKeyDown}
|
||||
error={Boolean(error)}
|
||||
autoFocus
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.1em',
|
||||
letterSpacing: isBackupCode ? 'normal' : '0.1em',
|
||||
...authInputSx,
|
||||
// Targets the inner <input>; styling the root leaves the text
|
||||
// itself unaligned.
|
||||
'& input': {
|
||||
textAlign: 'center',
|
||||
letterSpacing: isBackupCode ? 'normal' : '0.4em',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontSize: '1.125rem',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: isBackupCode ? 50 : 6,
|
||||
inputMode: isBackupCode ? 'text' : 'numeric',
|
||||
pattern: isBackupCode ? undefined : '[0-9]*',
|
||||
autoComplete: isBackupCode ? 'off' : 'one-time-code',
|
||||
},
|
||||
}}
|
||||
startDecorator={<Smartphone />}
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert color='danger' size='sm'>
|
||||
<Alert color='danger' variant='soft' sx={{ borderRadius: '12px' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
loading={loading}
|
||||
onClick={handleVerify}
|
||||
disabled={!verificationCode.trim()}
|
||||
size='lg'
|
||||
>
|
||||
Verify & Sign In
|
||||
</Button>
|
||||
|
||||
<Box className='text-center'>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
underline='hover'
|
||||
onClick={() => {
|
||||
setIsBackupCode(!isBackupCode)
|
||||
setVerificationCode('')
|
||||
setError('')
|
||||
}}
|
||||
sx={{ fontSize: 'sm' }}
|
||||
>
|
||||
{isBackupCode
|
||||
? 'Use authenticator app instead'
|
||||
: "Can't access your authenticator? Use a backup code"}
|
||||
: 'Use a backup code instead'}
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Alert color='neutral' size='sm'>
|
||||
<Typography level='body-xs'>
|
||||
Having trouble? Make sure your authenticator app is synced and try
|
||||
again. Each backup code can only be used once.
|
||||
{isBackupCode && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Each backup code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Link, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { login, signUp } from '../../utils/Fetcher'
|
||||
import {
|
||||
AuthPasswordField,
|
||||
AuthSubmitButton,
|
||||
AuthTextField,
|
||||
LegalLinks,
|
||||
} from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
|
||||
const SignupView = () => {
|
||||
const [username, setUsername] = React.useState('')
|
||||
@@ -27,6 +23,7 @@ const SignupView = () => {
|
||||
const [passwordError, setPasswordError] = React.useState('')
|
||||
const [emailError, setEmailError] = React.useState('')
|
||||
const [displayNameError, setDisplayNameError] = React.useState('')
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const { showError } = useNotification()
|
||||
const handleLogin = (username, password) => {
|
||||
login(username, password).then(response => {
|
||||
@@ -90,10 +87,10 @@ const SignupView = () => {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// username should only contain lowercase letters, dot and dash:
|
||||
if (!/^[a-z.-]+$/.test(username)) {
|
||||
// username should only contain lowercase letters, numbers, dot and dash:
|
||||
if (!/^[a-z0-9.-]+$/.test(username)) {
|
||||
setUsernameError(
|
||||
'Username can only contain lowercase letters, dot and dash',
|
||||
'Username can only contain lowercase letters, numbers, dot and dash',
|
||||
)
|
||||
isValid = false
|
||||
}
|
||||
@@ -105,208 +102,129 @@ const SignupView = () => {
|
||||
if (!handleSignUpValidation()) {
|
||||
return
|
||||
}
|
||||
signUp(username, password, displayName, email).then(response => {
|
||||
if (response.status === 201) {
|
||||
handleLogin(username, password)
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
message: 'Signup disabled, please contact admin',
|
||||
})
|
||||
} else {
|
||||
console.log('Signup failed')
|
||||
response.json().then(res => {
|
||||
setIsSubmitting(true)
|
||||
signUp(username, password, displayName, email)
|
||||
.then(response => {
|
||||
if (response.status === 201) {
|
||||
handleLogin(username, password)
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
message: res.error || 'An error occurred during signup',
|
||||
message: 'Signup disabled, please contact admin',
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log('Signup failed')
|
||||
response.json().then(res => {
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
message: res.error || 'An error occurred during signup',
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
.finally(() => setIsSubmitting(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<AuthShell
|
||||
title='Create your account'
|
||||
subtitle='Track chores and tasks together, in one shared place.'
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
}}
|
||||
component='form'
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
<AuthTextField
|
||||
label='Display name'
|
||||
id='displayName'
|
||||
name='displayName'
|
||||
autoComplete='name'
|
||||
placeholder='How others see your name'
|
||||
autoFocus
|
||||
value={displayName}
|
||||
error={displayNameError}
|
||||
onChange={e => {
|
||||
setDisplayNameError(null)
|
||||
setDisplayName(e.target.value)
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
</Typography>
|
||||
<Typography level='body2'>
|
||||
Create an account to get started!
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography level='body2' alignSelf={'start'} mt={4}>
|
||||
Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='username'
|
||||
label='Username'
|
||||
name='username'
|
||||
autoComplete='username'
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsernameError(null)
|
||||
setUsername(e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
<FormControl error={usernameError}>
|
||||
<FormHelperText c>{usernameError}</FormHelperText>
|
||||
</FormControl>
|
||||
{/* Error message display */}
|
||||
<Typography level='body2' alignSelf={'start'}>
|
||||
Email
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='email'
|
||||
label='email'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
value={email}
|
||||
onChange={e => {
|
||||
setEmailError(null)
|
||||
setEmail(e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
<FormControl error={emailError}>
|
||||
<FormHelperText c>{emailError}</FormHelperText>
|
||||
</FormControl>
|
||||
<Typography level='body2' alignSelf={'start'}>
|
||||
Password:
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
name='password'
|
||||
label='Password'
|
||||
type='password'
|
||||
id='password'
|
||||
placeholder='Enter password (8-64 characters)'
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPasswordError(null)
|
||||
setPassword(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<FormControl error={passwordError}>
|
||||
<FormHelperText>{passwordError}</FormHelperText>
|
||||
</FormControl>
|
||||
<Typography level='body2' alignSelf={'start'}>
|
||||
Display Name:
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
name='displayName'
|
||||
label='Display Name'
|
||||
id='displayName'
|
||||
placeholder='How others see your name'
|
||||
value={displayName}
|
||||
onChange={e => {
|
||||
setDisplayNameError(null)
|
||||
setDisplayName(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<FormControl error={displayNameError}>
|
||||
<FormHelperText>{displayNameError}</FormHelperText>
|
||||
</FormControl>
|
||||
<Typography
|
||||
level='body2'
|
||||
sx={{ mt: 2, mb: 1, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||
</Typography>
|
||||
<Button
|
||||
// type='submit'
|
||||
size='lg'
|
||||
fullWidth
|
||||
variant='solid'
|
||||
sx={{ mt: 1, mb: 1 }}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
<Divider> or </Divider>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
Navigate('/login')
|
||||
}}
|
||||
fullWidth
|
||||
variant='soft'
|
||||
// sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/privacy-policy', '_blank')
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/terms', '_blank')
|
||||
}}
|
||||
>
|
||||
Terms of Use
|
||||
</Button>
|
||||
</Box>
|
||||
</Sheet>
|
||||
<AuthTextField
|
||||
label='Username'
|
||||
id='username'
|
||||
name='username'
|
||||
autoComplete='username'
|
||||
placeholder='lowercase letters, numbers, dot and dash'
|
||||
value={username}
|
||||
error={usernameError}
|
||||
onChange={e => {
|
||||
setUsernameError(null)
|
||||
setUsername(e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthTextField
|
||||
label='Email'
|
||||
id='email'
|
||||
name='email'
|
||||
type='email'
|
||||
autoComplete='email'
|
||||
placeholder='you@example.com'
|
||||
value={email}
|
||||
error={emailError}
|
||||
onChange={e => {
|
||||
setEmailError(null)
|
||||
setEmail(e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthPasswordField
|
||||
id='password'
|
||||
name='password'
|
||||
autoComplete='new-password'
|
||||
placeholder='At least 8 characters'
|
||||
value={password}
|
||||
error={passwordError}
|
||||
helper='Use 8 to 64 characters.'
|
||||
onChange={e => {
|
||||
setPasswordError(null)
|
||||
setPassword(e.target.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
|
||||
Create account
|
||||
</AuthSubmitButton>
|
||||
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
By creating an account you agree to our Terms of Service and Privacy
|
||||
Policy.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
fontWeight={600}
|
||||
underline='hover'
|
||||
onClick={() => Navigate('/login')}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</Typography>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +1,68 @@
|
||||
// create boilerplate for ResetPasswordView:
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Button } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChangePassword } from '../../utils/Fetcher'
|
||||
import { AuthPasswordField, AuthSubmitButton, LegalLinks } from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
|
||||
const UpdatePasswordView = () => {
|
||||
const navigate = useNavigate()
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
const [passwordError, setPasswordError] = useState(null)
|
||||
const [passworConfirmationError, setPasswordConfirmationError] =
|
||||
const [passwordConfirmationError, setPasswordConfirmationError] =
|
||||
useState(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [searchParams] = useSearchParams()
|
||||
const { showError, showNotification } = useNotification()
|
||||
|
||||
const verifiticationCode = searchParams.get('c')
|
||||
const verificationCode = searchParams.get('c')
|
||||
|
||||
const handlePasswordChange = e => {
|
||||
const password = e.target.value
|
||||
setPassword(password)
|
||||
if (password.length < 8 || password.length > 64) {
|
||||
setPasswordError('Password must be between 8 and 64 characters')
|
||||
} else {
|
||||
setPassword(e.target.value)
|
||||
if (passwordError) {
|
||||
setPasswordError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordConfirmChange = e => {
|
||||
setPasswordConfirm(e.target.value)
|
||||
if (e.target.value !== password) {
|
||||
setPasswordConfirmationError('Passwords do not match')
|
||||
} else {
|
||||
if (passwordConfirmationError) {
|
||||
setPasswordConfirmationError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (passwordError != null || passworConfirmationError != null) {
|
||||
const validate = () => {
|
||||
let isValid = true
|
||||
|
||||
if (password.length < 8 || password.length > 64) {
|
||||
setPasswordError('Password must be between 8 and 64 characters')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if (passwordConfirm !== password) {
|
||||
setPasswordConfirmationError('Passwords do not match')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
const handleSubmit = async e => {
|
||||
e?.preventDefault()
|
||||
|
||||
// The old version only bailed when an error was already set, so an
|
||||
// untouched form submitted an empty password.
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const response = await ChangePassword(verifiticationCode, password)
|
||||
const response = await ChangePassword(verificationCode, password)
|
||||
|
||||
if (response.ok) {
|
||||
showNotification({
|
||||
@@ -60,7 +71,6 @@ const UpdatePasswordView = () => {
|
||||
message:
|
||||
'Your password has been updated successfully. Redirecting to login...',
|
||||
})
|
||||
// wait 3 seconds and then redirect to login:
|
||||
setTimeout(() => {
|
||||
navigate('/login')
|
||||
}, 3000)
|
||||
@@ -75,111 +85,99 @@ const UpdatePasswordView = () => {
|
||||
title: 'Password Update Failed',
|
||||
message: 'Failed to update password, please try again later',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
}}
|
||||
|
||||
if (!verificationCode) {
|
||||
return (
|
||||
<AuthShell
|
||||
title='This link is not valid'
|
||||
subtitle='The password reset link is incomplete or has already been used. Request a new one to continue.'
|
||||
footer={<LegalLinks />}
|
||||
showLogo
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/forgot-password')}
|
||||
>
|
||||
Request a new link
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to sign in
|
||||
</Button>
|
||||
</Box>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title='Set a new password'
|
||||
subtitle='Choose a password you have not used on this account before.'
|
||||
footer={<LegalLinks />}
|
||||
// Reached from an emailed link, usually in a browser: an unbranded page
|
||||
// asking for a new password is the exact shape of a phishing screen.
|
||||
showLogo
|
||||
>
|
||||
<Box
|
||||
component='form'
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<AuthPasswordField
|
||||
label='New password'
|
||||
id='password'
|
||||
name='password'
|
||||
autoComplete='new-password'
|
||||
placeholder='At least 8 characters'
|
||||
autoFocus
|
||||
value={password}
|
||||
error={passwordError}
|
||||
helper='Use 8 to 64 characters.'
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
|
||||
<AuthPasswordField
|
||||
label='Confirm new password'
|
||||
id='passwordConfirm'
|
||||
name='passwordConfirm'
|
||||
autoComplete='new-password'
|
||||
placeholder='Re-enter your password'
|
||||
value={passwordConfirm}
|
||||
error={passwordConfirmationError}
|
||||
onChange={handlePasswordConfirmChange}
|
||||
/>
|
||||
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
|
||||
Save password
|
||||
</AuthSubmitButton>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
</Typography>
|
||||
<Typography level='body2' mb={4}>
|
||||
Please enter your new password below
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControl error>
|
||||
<Input
|
||||
placeholder='Password'
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
error={passwordError !== null}
|
||||
// onKeyDown={e => {
|
||||
// if (e.key === 'Enter' && validateForm(validateFormInput)) {
|
||||
// handleSubmit(e)
|
||||
// }
|
||||
// }}
|
||||
/>
|
||||
<FormHelperText>{passwordError}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl error>
|
||||
<Input
|
||||
placeholder='Confirm Password'
|
||||
type='password'
|
||||
value={passwordConfirm}
|
||||
onChange={handlePasswordConfirmChange}
|
||||
error={passworConfirmationError !== null}
|
||||
// onKeyDown={e => {
|
||||
// if (e.key === 'Enter' && validateForm(validateFormInput)) {
|
||||
// handleSubmit(e)
|
||||
// }
|
||||
// }}
|
||||
/>
|
||||
<FormHelperText>{passworConfirmationError}</FormHelperText>
|
||||
</FormControl>
|
||||
{/* helper to show password not matching : */}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{
|
||||
mt: 5,
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Save Password
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Sheet>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
13
src/views/Authorization/authStyles.js
Normal file
13
src/views/Authorization/authStyles.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Shared shape for the auth screens so all four stay in one control vocabulary.
|
||||
export const authInputSx = {
|
||||
'--Input-radius': '12px',
|
||||
'--Input-minHeight': '48px',
|
||||
'--Input-focusedThickness': '2px',
|
||||
fontSize: '1rem',
|
||||
}
|
||||
|
||||
export const authButtonSx = {
|
||||
'--Button-radius': '12px',
|
||||
minHeight: 48,
|
||||
fontWeight: 600,
|
||||
}
|
||||
@@ -415,7 +415,9 @@ const ChoreEdit = () => {
|
||||
console.error('Failed to save chore:', error)
|
||||
showError({
|
||||
title: 'Save Failed',
|
||||
message: 'Failed to save chore, please try again.',
|
||||
message: error?.isServerMessage
|
||||
? error.message
|
||||
: 'Failed to save chore, please try again.',
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -631,18 +633,19 @@ const ChoreEdit = () => {
|
||||
}, [frequencyType])
|
||||
|
||||
useEffect(() => {
|
||||
if (assignees.length === 0) {
|
||||
if (anyone || assignableTo.length === 0) {
|
||||
setAssignStrategy('no_assignee')
|
||||
setAssignedTo(null)
|
||||
} else {
|
||||
if (!assignees.some(a => a.userId === assignedTo)) {
|
||||
setAssignedTo(assignees[0].userId)
|
||||
}
|
||||
if (assignStrategy === 'no_assignee') {
|
||||
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
|
||||
} else if (assignStrategy === 'no_assignee') {
|
||||
// user explicitly picked no_assignee while having assignees, keep it
|
||||
// but there is nobody currently assigned
|
||||
if (assignedTo !== null) {
|
||||
setAssignedTo(null)
|
||||
}
|
||||
} else if (!assignableTo.some(a => a.userId === assignedTo)) {
|
||||
setAssignedTo(assignableTo[0].userId)
|
||||
}
|
||||
}, [assignStrategy, assignedTo, assignees])
|
||||
}, [assignStrategy, assignedTo, assignableTo, anyone])
|
||||
|
||||
// useEffect(() => {
|
||||
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
|
||||
@@ -1257,9 +1260,14 @@ const ChoreEdit = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{assignees.length > 1 && (
|
||||
{!anyone && assignableTo.length > 1 && (
|
||||
<>
|
||||
<Box mb={3}>
|
||||
<Box
|
||||
mb={3}
|
||||
sx={{
|
||||
display: assignStrategy === 'no_assignee' ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<Typography level='h4'>Currently Assigned To</Typography>
|
||||
<Typography level='body-md'>
|
||||
Who is assigned the next due?
|
||||
@@ -1795,13 +1803,13 @@ const ChoreEdit = () => {
|
||||
<FormControl>
|
||||
<Radio
|
||||
overlay
|
||||
disabled={assignees.length === 0}
|
||||
disabled={anyone || assignableTo.length === 0}
|
||||
value={true}
|
||||
label='Limited'
|
||||
/>
|
||||
<FormHelperText>
|
||||
You and others that are assigned to the task
|
||||
{assignees.length === 0
|
||||
{anyone || assignableTo.length === 0
|
||||
? ' (No assignees selected, Limited option is disabled)'
|
||||
: ''}
|
||||
</FormHelperText>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Pause,
|
||||
PlayArrow,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
|
||||
import { Box, Button, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const TimerSplitButton = ({
|
||||
@@ -95,36 +95,25 @@ const TimerSplitButton = ({
|
||||
disabled={disabled}
|
||||
>
|
||||
{/* Main action button */}
|
||||
<IconButton
|
||||
<Button
|
||||
onClick={handleMainAction}
|
||||
disabled={disabled}
|
||||
size='md'
|
||||
startDecorator={chore.status === 1 ? <Pause /> : <PlayArrow />}
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 1,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
minWidth: fullWidth ? 'auto' : 120,
|
||||
flex: fullWidth ? 1 : 'none',
|
||||
}}
|
||||
>
|
||||
{chore.status === 1 ? <Pause /> : <PlayArrow />}
|
||||
{chore.status === 1 ? 'Pause' : 'Resume'}
|
||||
</IconButton>
|
||||
</Button>
|
||||
|
||||
{/* Dropdown arrow button */}
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
disabled={disabled}
|
||||
size='lg'
|
||||
sx={{
|
||||
px: 1,
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderLeft: '1px solid',
|
||||
borderLeftColor: 'divider',
|
||||
minWidth: 'auto',
|
||||
}}
|
||||
size='md'
|
||||
sx={{ px: 1, minWidth: 'auto' }}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
</IconButton>
|
||||
|
||||
@@ -432,6 +432,16 @@ const ArchivedTasks = () => {
|
||||
setSelectedChores(newSelection)
|
||||
}
|
||||
|
||||
// Press-and-hold on a task card enters multi-select with that task picked
|
||||
const enterMultiSelectWithChore = choreId => {
|
||||
if (!isMultiSelectMode) {
|
||||
setIsMultiSelectMode(true)
|
||||
setSelectedChores(new Set([choreId]))
|
||||
return
|
||||
}
|
||||
toggleChoreSelection(choreId)
|
||||
}
|
||||
|
||||
const selectAllVisibleChores = () => {
|
||||
if (finalChores.length > 0) {
|
||||
setSelectedChores(new Set(finalChores.map(c => c.id)))
|
||||
@@ -706,7 +716,7 @@ const ArchivedTasks = () => {
|
||||
}}
|
||||
onChange={handleSearchChange}
|
||||
startDecorator={
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
showKeyboardShortcuts ? <KeyboardShortcutHint shortcut='F' /> : null
|
||||
}
|
||||
endDecorator={
|
||||
searchTerm && (
|
||||
@@ -1051,6 +1061,7 @@ const ArchivedTasks = () => {
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
@@ -108,27 +108,29 @@ const ChoreCard = ({
|
||||
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
|
||||
</Chip>
|
||||
|
||||
<Chip
|
||||
variant='soft'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
top: 10,
|
||||
zIndex: 3,
|
||||
ml: 0.4,
|
||||
left: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
{!['once', 'no_repeat'].includes(chore.frequencyType) && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
top: 10,
|
||||
zIndex: 3,
|
||||
ml: 0.4,
|
||||
left: 10,
|
||||
}}
|
||||
>
|
||||
{getFrequencyIcon(chore)}
|
||||
{getRecurrentChipText(chore)}
|
||||
</div>
|
||||
</Chip>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{getFrequencyIcon(chore)}
|
||||
{getRecurrentChipText(chore)}
|
||||
</div>
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
|
||||
<PendingBadge commands={pendingCmds} />
|
||||
|
||||
@@ -18,9 +18,60 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Typography } from '@mui/joy'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLongPress } from '../../hooks/useLongPress'
|
||||
import ChoreCard from './ChoreCard'
|
||||
import CompactChoreCard from './CompactChoreCard'
|
||||
|
||||
/**
|
||||
* One swipeable row. Owns the press-and-hold gesture (multi-select), which
|
||||
* can't live in the render loop because it needs a hook.
|
||||
*/
|
||||
const ChoreSwipeableItem = ({
|
||||
trailingActions,
|
||||
onClick,
|
||||
onLongPress,
|
||||
longPressEnabled,
|
||||
children,
|
||||
// SwipeableList clones its children to inject list-level config
|
||||
// (listType, fullSwipe, thresholds…), so it has to be passed through.
|
||||
...listProps
|
||||
}) => {
|
||||
const { handlers: longPressHandlers, cancel: cancelLongPress } = useLongPress(
|
||||
onLongPress,
|
||||
{ enabled: longPressEnabled },
|
||||
)
|
||||
|
||||
// The swipe list owns the gesture the moment it recognizes a drag — a hold
|
||||
// that turned into a swipe must not also open multi-select.
|
||||
const handleSwipeStart = () => {
|
||||
cancelLongPress()
|
||||
}
|
||||
|
||||
return (
|
||||
<SwipeableListItem
|
||||
{...listProps}
|
||||
trailingActions={trailingActions}
|
||||
onClick={onClick}
|
||||
onSwipeStart={handleSwipeStart}
|
||||
onSwipeProgress={cancelLongPress}
|
||||
>
|
||||
<Box
|
||||
{...longPressHandlers}
|
||||
sx={{
|
||||
width: '100%',
|
||||
// Keep a long press from selecting the task text / popping the
|
||||
// native callout on mobile
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</SwipeableListItem>
|
||||
)
|
||||
}
|
||||
|
||||
const ChoreListView = ({
|
||||
chores,
|
||||
viewMode,
|
||||
@@ -34,6 +85,7 @@ const ChoreListView = ({
|
||||
userProfile,
|
||||
isOfficialInstance,
|
||||
toggleMultiSelectMode,
|
||||
onLongPressChore,
|
||||
showActions = true,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
@@ -248,7 +300,7 @@ const ChoreListView = ({
|
||||
return (
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{chores.map(chore => (
|
||||
<SwipeableListItem
|
||||
<ChoreSwipeableItem
|
||||
key={chore.id}
|
||||
trailingActions={getTrailingActions(chore)}
|
||||
onClick={() => {
|
||||
@@ -258,9 +310,11 @@ const ChoreListView = ({
|
||||
navigate(`/chores/${chore.id}`)
|
||||
}
|
||||
}}
|
||||
longPressEnabled={Boolean(onLongPressChore)}
|
||||
onLongPress={() => onLongPressChore?.(chore.id)}
|
||||
>
|
||||
{renderChoreCard(chore)}
|
||||
</SwipeableListItem>
|
||||
</ChoreSwipeableItem>
|
||||
))}
|
||||
</SwipeableList>
|
||||
)
|
||||
|
||||
@@ -84,7 +84,9 @@ const CompactChoreCard = ({
|
||||
const parts = []
|
||||
|
||||
// Frequency
|
||||
parts.push(getRecurrentChipText(chore))
|
||||
if (!['once', 'no_repeat'].includes(chore.frequencyType)) {
|
||||
parts.push(getRecurrentChipText(chore))
|
||||
}
|
||||
|
||||
// Assignee
|
||||
if (chore.assignedTo) {
|
||||
@@ -408,7 +410,8 @@ const CompactChoreCard = ({
|
||||
|
||||
{/* Line 2: Metadata */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{getFrequencyIcon(chore)}
|
||||
{!['once', 'no_repeat'].includes(chore.frequencyType) &&
|
||||
getFrequencyIcon(chore)}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
color='text.secondary'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Close, HelpOutline, Keyboard } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy'
|
||||
import { HelpOutline } from '@mui/icons-material'
|
||||
import { Box, Card, IconButton, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
@@ -28,37 +29,24 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
borderRadius: '50%',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
aria-label='Show keyboard shortcuts'
|
||||
title='Show keyboard shortcuts'
|
||||
>
|
||||
<HelpOutline />
|
||||
</IconButton>
|
||||
|
||||
{/* Help Modal */}
|
||||
<ResponsiveModal open={isHelpOpen} onClose={() => setIsHelpOpen(false)}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Keyboard color='primary' />
|
||||
<Typography level='title-lg'>Multi-select Mode</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => setIsHelpOpen(false)}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography level='body-md' sx={{ mb: 3, color: 'text.secondary' }}>
|
||||
Use these keyboard shortcuts to work more efficiently with multiple
|
||||
tasks:
|
||||
</Typography>
|
||||
<ResponsiveModal
|
||||
open={isHelpOpen}
|
||||
onClose={() => setIsHelpOpen(false)}
|
||||
title='Multi-select Mode'
|
||||
description='Use these keyboard shortcuts to work more efficiently.'
|
||||
footer={
|
||||
<ModalActions
|
||||
primary={{ label: 'Got it', onClick: () => setIsHelpOpen(false) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Selection shortcuts */}
|
||||
<Card variant='soft' sx={{ p: 2 }}>
|
||||
@@ -107,16 +95,6 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => setIsHelpOpen(false)}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
Got it!
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -47,6 +47,7 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import CalendarDual from '../components/CalendarDual'
|
||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
|
||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||
import ChoreListView from './ChoreListView.jsx'
|
||||
@@ -121,7 +122,7 @@ const MyChores = () => {
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
||||
useProjectFilter(projects)
|
||||
useProjectFilter(projects, !projectsLoading)
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
@@ -143,6 +144,7 @@ const MyChores = () => {
|
||||
selectedChores,
|
||||
toggleMultiSelectMode,
|
||||
toggleChoreSelection,
|
||||
enterMultiSelectWithChore,
|
||||
selectAllVisibleChores,
|
||||
clearSelection,
|
||||
getSelectedChoresData,
|
||||
@@ -366,6 +368,7 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
processEffectAsync()
|
||||
// throw new Error('Fake Error to test posthog')
|
||||
}
|
||||
}, [
|
||||
membersLoading,
|
||||
@@ -570,6 +573,7 @@ const MyChores = () => {
|
||||
handleBulkArchive,
|
||||
handleBulkDelete,
|
||||
handleBulkSkip,
|
||||
handleBulkMoveToProject,
|
||||
} = useChoreActions({
|
||||
chores,
|
||||
filteredChores,
|
||||
@@ -865,8 +869,8 @@ const MyChores = () => {
|
||||
[getFilteredChores],
|
||||
)
|
||||
|
||||
const updateChores = newChore => {
|
||||
let newChores = [...chores, newChore]
|
||||
const appendChore = (prev, newChore) => {
|
||||
let newChores = [...prev, newChore]
|
||||
|
||||
if (impersonatedUser) {
|
||||
newChores = newChores.filter(
|
||||
@@ -874,8 +878,15 @@ const MyChores = () => {
|
||||
)
|
||||
}
|
||||
|
||||
setChores(newChores)
|
||||
setFilteredChores(newChores)
|
||||
return newChores
|
||||
}
|
||||
|
||||
// Uses functional setState so back-to-back calls (e.g. creating several
|
||||
// voice-captured tasks in a row) each build on the latest state instead of
|
||||
// a closure snapshot taken before earlier calls landed.
|
||||
const updateChores = newChore => {
|
||||
setChores(prev => appendChore(prev, newChore))
|
||||
setFilteredChores(prev => appendChore(prev, newChore))
|
||||
clearQuickFilters()
|
||||
}
|
||||
|
||||
@@ -1046,12 +1057,22 @@ const MyChores = () => {
|
||||
<MultiSelectToolbar
|
||||
isVisible={isMultiSelectMode}
|
||||
selectedCount={selectedChores.size}
|
||||
onSelectAll={selectAllVisibleChores}
|
||||
onSelectAll={() =>
|
||||
selectAllVisibleChores(
|
||||
searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
||||
? getFilteredChores
|
||||
: null,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
)
|
||||
}
|
||||
onClear={clearSelection}
|
||||
onComplete={handleBulkComplete}
|
||||
onSkip={handleBulkSkip}
|
||||
onArchive={handleBulkArchive}
|
||||
onDelete={handleBulkDelete}
|
||||
onMoveToProject={handleBulkMoveToProject}
|
||||
projects={projects}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
selectAllDisabled={
|
||||
searchTerm?.length > 0 || hasQuickFilters
|
||||
@@ -1116,6 +1137,7 @@ const MyChores = () => {
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'calendar' && (
|
||||
@@ -1293,6 +1315,7 @@ const MyChores = () => {
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1373,6 +1396,7 @@ const MyChores = () => {
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
@@ -1456,6 +1480,7 @@ const MyChores = () => {
|
||||
/>
|
||||
</Box>
|
||||
<NotificationAccessSnackbar />
|
||||
<FeedbackPrompt />
|
||||
{addTaskModalOpen && (
|
||||
<TaskInput
|
||||
autoFocus={taskInputFocus}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import DateModal from '../../Modals/Inputs/DateModal'
|
||||
import DueDatePickerModal, {
|
||||
combineDueDate,
|
||||
splitDueDate,
|
||||
} from '../../components/DueDatePickerModal'
|
||||
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
||||
import SelectModal from '../../Modals/Inputs/SelectModal'
|
||||
import TextModal from '../../Modals/Inputs/TextModal'
|
||||
@@ -24,13 +28,16 @@ const ChoreModals = ({
|
||||
return (
|
||||
<>
|
||||
{activeModal === 'changeDueDate' && modalChore && (
|
||||
<DateModal
|
||||
isOpen={true}
|
||||
<DueDatePickerModal
|
||||
open={true}
|
||||
key={'changeDueDate' + modalChore.id}
|
||||
current={modalChore.nextDueDate}
|
||||
title='Change due date'
|
||||
{...splitDueDate(modalChore.nextDueDate)}
|
||||
onClose={onClose}
|
||||
onSave={onChangeDueDate}
|
||||
onApply={parts =>
|
||||
onChangeDueDate(combineDueDate(parts)?.toISOString() ?? null)
|
||||
}
|
||||
onRemove={() => onChangeDueDate(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import AppModal from '../../../components/common/AppModal'
|
||||
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||
import { Z_INDEX } from '../../../constants/zIndex'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
@@ -105,13 +105,15 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
|
||||
<Chip
|
||||
key={opt.value}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
|
||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||
startDecorator={
|
||||
opt.icon != null
|
||||
? isSelected
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
: undefined
|
||||
opt.icon != null ? (
|
||||
isSelected ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
opt.icon
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => onToggle(opt.value)}
|
||||
sx={{
|
||||
@@ -287,7 +289,9 @@ const ChoreToolbar = ({
|
||||
return member?.displayName || member?.username || String(value)
|
||||
}
|
||||
if (condition.type === 'status') {
|
||||
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
||||
return (
|
||||
CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
|
||||
)
|
||||
}
|
||||
if (condition.type === 'priority') {
|
||||
return Priorities.find(p => p.value === value)?.name || String(value)
|
||||
@@ -363,8 +367,7 @@ const ChoreToolbar = ({
|
||||
setLocalSelections(conditionsToSelections(tempFilter.conditions))
|
||||
if (tempFilterMeta?.sourceFilterId) {
|
||||
const sourceFilter =
|
||||
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
|
||||
null
|
||||
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) || null
|
||||
setEditingSavedFilter(
|
||||
sourceFilter ||
|
||||
(tempFilterMeta.sourceFilterId
|
||||
@@ -444,7 +447,13 @@ const ChoreToolbar = ({
|
||||
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
|
||||
FILTER_COLORS[0].value
|
||||
|
||||
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
|
||||
saveFilter?.({
|
||||
name,
|
||||
description: '',
|
||||
color,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
})?.then?.(() => {
|
||||
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
|
||||
onFilterSaved?.(name)
|
||||
})
|
||||
@@ -460,16 +469,13 @@ const ChoreToolbar = ({
|
||||
const conditions = selectionsToConditions(localSelections)
|
||||
if (conditions.length === 0) return
|
||||
|
||||
updateFilter(
|
||||
editingSavedFilter.id,
|
||||
{
|
||||
name: editingSavedFilter.name,
|
||||
description: editingSavedFilter.description || '',
|
||||
color: editingSavedFilter.color,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
},
|
||||
)?.then?.(() => {
|
||||
updateFilter(editingSavedFilter.id, {
|
||||
name: editingSavedFilter.name,
|
||||
description: editingSavedFilter.description || '',
|
||||
color: editingSavedFilter.color,
|
||||
conditions,
|
||||
operator: 'AND',
|
||||
})?.then?.(() => {
|
||||
clearTempFilter?.()
|
||||
onSavedFilterClick?.(editingSavedFilter.id)
|
||||
onFilterSaved?.(editingSavedFilter.name)
|
||||
@@ -498,9 +504,21 @@ const ChoreToolbar = ({
|
||||
]
|
||||
|
||||
const viewOptions = [
|
||||
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
|
||||
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Cards',
|
||||
icon: <ViewAgenda sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
value: 'compact',
|
||||
label: 'Compact',
|
||||
icon: <ViewComfy sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
{
|
||||
value: 'calendar',
|
||||
label: 'Calendar',
|
||||
icon: <CalendarMonth sx={{ fontSize: 16 }} />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -536,6 +554,7 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={openFilterSheet}
|
||||
aria-label='Filters'
|
||||
title='Filters'
|
||||
>
|
||||
<FilterList />
|
||||
@@ -543,13 +562,14 @@ const ChoreToolbar = ({
|
||||
</Badge>
|
||||
|
||||
{/* Project selector */}
|
||||
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
|
||||
<ProjectSelector
|
||||
selectedProject={selectedProject?.name || 'Default Project'}
|
||||
onProjectSelect={onProjectSelect}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
)}
|
||||
{!filterActive &&
|
||||
projects.filter(p => p.id !== 'default').length > 0 && (
|
||||
<ProjectSelector
|
||||
selectedProject={selectedProject?.name || 'Default Project'}
|
||||
onProjectSelect={onProjectSelect}
|
||||
showKeyboardShortcuts={showKeyboardShortcuts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display button — View + Group combined */}
|
||||
<IconButton
|
||||
@@ -558,6 +578,7 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={() => setDisplaySheetOpen(true)}
|
||||
aria-label='View and group options'
|
||||
title='View & Group'
|
||||
>
|
||||
{viewMode === 'calendar' ? (
|
||||
@@ -577,6 +598,9 @@ const ChoreToolbar = ({
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%' }}
|
||||
onClick={onToggleMultiSelect}
|
||||
aria-label={
|
||||
isMultiSelectMode ? 'Exit multi-select' : 'Enter multi-select'
|
||||
}
|
||||
title={
|
||||
isMultiSelectMode
|
||||
? 'Exit multi-select (Ctrl+S)'
|
||||
@@ -628,8 +652,9 @@ const ChoreToolbar = ({
|
||||
)}
|
||||
|
||||
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
|
||||
<BottomSheetModal
|
||||
<AppModal
|
||||
open={filterSheetOpen}
|
||||
isMobile
|
||||
onClose={() => {
|
||||
setSaveMenuAnchorEl(null)
|
||||
setFilterSheetOpen(false)
|
||||
@@ -649,7 +674,12 @@ const ChoreToolbar = ({
|
||||
footer={
|
||||
savingFilter ? (
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
size='sm'
|
||||
@@ -710,12 +740,11 @@ const ChoreToolbar = ({
|
||||
}}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
{resultCount != null
|
||||
? `Show ${resultCount}`
|
||||
: 'Done'}
|
||||
{resultCount != null ? `Show ${resultCount}` : 'Done'}
|
||||
</Button>
|
||||
<IconButton
|
||||
ref={saveMenuRef}
|
||||
aria-label='More save options'
|
||||
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<ArrowDropDown />
|
||||
@@ -825,11 +854,12 @@ const ChoreToolbar = ({
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</AppModal>
|
||||
|
||||
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
|
||||
<BottomSheetModal
|
||||
<AppModal
|
||||
open={displaySheetOpen}
|
||||
isMobile
|
||||
onClose={() => setDisplaySheetOpen(false)}
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
@@ -838,7 +868,10 @@ const ChoreToolbar = ({
|
||||
</Box>
|
||||
}
|
||||
footer={
|
||||
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
|
||||
<Button
|
||||
onClick={() => setDisplaySheetOpen(false)}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
@@ -853,9 +886,11 @@ const ChoreToolbar = ({
|
||||
variant={viewMode === opt.value ? 'solid' : 'soft'}
|
||||
color={viewMode === opt.value ? 'primary' : 'neutral'}
|
||||
startDecorator={
|
||||
viewMode === opt.value
|
||||
? <Check sx={{ fontSize: 14 }} />
|
||||
: opt.icon
|
||||
viewMode === opt.value ? (
|
||||
<Check sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
opt.icon
|
||||
)
|
||||
}
|
||||
onClick={() => onToggleViewMode?.(opt.value)}
|
||||
sx={{
|
||||
@@ -910,7 +945,8 @@ const ChoreToolbar = ({
|
||||
label='Show tasks for'
|
||||
badge={
|
||||
selectedAssigneeFilter !== 'anyone'
|
||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
|
||||
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)
|
||||
?.label
|
||||
: null
|
||||
}
|
||||
/>
|
||||
@@ -920,9 +956,8 @@ const ChoreToolbar = ({
|
||||
multi={false}
|
||||
onToggle={v => onAssigneeFilterChange?.(v)}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</AppModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,39 @@ import {
|
||||
Close,
|
||||
Delete,
|
||||
Done,
|
||||
DriveFileMove,
|
||||
SelectAll,
|
||||
SkipNext,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, Divider, Typography } from '@mui/joy'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useRef, useState } from 'react'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import LABEL_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../../utils/Colors'
|
||||
import { getIconComponent } from '../../../utils/ProjectIcons'
|
||||
|
||||
const renderProjectAvatar = (color, icon) => {
|
||||
const bg = color || LABEL_COLORS[0].value
|
||||
const IconComponent = getIconComponent(icon || 'FolderOpen')
|
||||
return (
|
||||
<Avatar size='sm' sx={{ width: 22, height: 22, backgroundColor: bg }}>
|
||||
<IconComponent
|
||||
sx={{ fontSize: 13, color: getTextColorFromBackgroundColor(bg) }}
|
||||
/>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
const MultiSelectToolbar = ({
|
||||
isVisible,
|
||||
@@ -20,9 +48,21 @@ const MultiSelectToolbar = ({
|
||||
onSkip,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onMoveToProject,
|
||||
projects = [],
|
||||
showKeyboardShortcuts,
|
||||
selectAllDisabled,
|
||||
}) => {
|
||||
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
|
||||
const projectMenuRef = useRef(null)
|
||||
|
||||
const closeProjectMenu = () => setProjectMenuAnchor(null)
|
||||
|
||||
const handleMoveToProject = project => {
|
||||
closeProjectMenu()
|
||||
onMoveToProject?.(project)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -216,6 +256,63 @@ const MultiSelectToolbar = ({
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{onMoveToProject && (
|
||||
<>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
ref={projectMenuRef}
|
||||
onClick={() =>
|
||||
setProjectMenuAnchor(prev =>
|
||||
prev ? null : projectMenuRef.current,
|
||||
)
|
||||
}
|
||||
startDecorator={<DriveFileMove />}
|
||||
disabled={selectedCount === 0}
|
||||
sx={{
|
||||
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
|
||||
}}
|
||||
title='Move selected tasks to a project'
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
<Menu
|
||||
size='md'
|
||||
anchorEl={projectMenuAnchor}
|
||||
open={Boolean(projectMenuAnchor)}
|
||||
onClose={closeProjectMenu}
|
||||
placement='bottom-end'
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>Default Project</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
{projects.map(project => (
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleMoveToProject(project)}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>{project.name}</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
|
||||
@@ -1155,6 +1155,61 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkMoveToProject = useCallback(
|
||||
async project => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
if (selectedData.length === 0) return
|
||||
|
||||
const projectId = project?.id ?? null
|
||||
const movedTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
const response = await SaveChore({ ...chore, projectId })
|
||||
if (response.ok) {
|
||||
movedTasks.push(chore)
|
||||
} else {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
} catch (error) {
|
||||
failedTasks.push(chore)
|
||||
}
|
||||
}
|
||||
|
||||
if (movedTasks.length > 0) {
|
||||
const movedIds = new Set(movedTasks.map(c => c.id))
|
||||
const applyMove = list =>
|
||||
list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c))
|
||||
setChores(applyMove)
|
||||
setFilteredChores(applyMove)
|
||||
showSuccess({
|
||||
title: 'Tasks Moved',
|
||||
message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`,
|
||||
})
|
||||
}
|
||||
if (failedTasks.length > 0) {
|
||||
showError({
|
||||
title: 'Some Tasks Failed',
|
||||
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`,
|
||||
})
|
||||
}
|
||||
|
||||
refetchChores()
|
||||
clearSelection()
|
||||
},
|
||||
[
|
||||
chores,
|
||||
getSelectedChoresData,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
],
|
||||
)
|
||||
|
||||
return {
|
||||
handleChoreAction,
|
||||
handleChangeDueDate,
|
||||
@@ -1166,5 +1221,6 @@ export const useChoreActions = ({
|
||||
handleBulkArchive,
|
||||
handleBulkDelete,
|
||||
handleBulkSkip,
|
||||
handleBulkMoveToProject,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,20 @@ export const useMultiSelect = () => {
|
||||
[selectedChores],
|
||||
)
|
||||
|
||||
// Entry point for press-and-hold on a task card: turn multi-select on (if it
|
||||
// isn't already) with that task selected.
|
||||
const enterMultiSelectWithChore = useCallback(
|
||||
choreId => {
|
||||
if (!isMultiSelectMode) {
|
||||
setIsMultiSelectMode(true)
|
||||
setSelectedChores(new Set([choreId]))
|
||||
return
|
||||
}
|
||||
toggleChoreSelection(choreId)
|
||||
},
|
||||
[isMultiSelectMode, toggleChoreSelection],
|
||||
)
|
||||
|
||||
const selectAllVisibleChores = useCallback(
|
||||
(visibleChores, choreSections = [], openChoreSections = {}) => {
|
||||
let choresToSelect = []
|
||||
@@ -42,7 +56,9 @@ export const useMultiSelect = () => {
|
||||
expandedChores.every(chore => selectedChores.has(chore.id))
|
||||
|
||||
if (allExpandedSelected) {
|
||||
choresToSelect = choreSections.flatMap(section => section.content || [])
|
||||
choresToSelect = choreSections.flatMap(
|
||||
section => section.content || [],
|
||||
)
|
||||
} else {
|
||||
choresToSelect = expandedChores
|
||||
}
|
||||
@@ -80,6 +96,7 @@ export const useMultiSelect = () => {
|
||||
selectedChores,
|
||||
toggleMultiSelectMode,
|
||||
toggleChoreSelection,
|
||||
enterMultiSelectWithChore,
|
||||
selectAllVisibleChores,
|
||||
clearSelection,
|
||||
getSelectedChoresData,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export const useProjectFilter = projects => {
|
||||
export const useProjectFilter = (projects, projectsLoaded = false) => {
|
||||
const [selectedProject, setSelectedProject] = useState(() => {
|
||||
const saved = localStorage.getItem('selectedProject')
|
||||
return saved ? JSON.parse(saved) : null
|
||||
@@ -37,6 +37,20 @@ export const useProjectFilter = projects => {
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
}, [])
|
||||
|
||||
// The cached selection is a whole project object, so a stale one keeps
|
||||
// rendering its old name/color even though the project no longer belongs to
|
||||
// this account (deleted project, or a different user signing in on a device
|
||||
// where logout didn't get to clear storage). Once the real list has loaded,
|
||||
// drop any selection that isn't in it.
|
||||
useEffect(() => {
|
||||
if (!projectsLoaded) return
|
||||
if (!selectedProject || selectedProject.id === 'default') return
|
||||
|
||||
if (!projects.some(p => p.id === selectedProject.id)) {
|
||||
setSelectedProjectWithCache(null)
|
||||
}
|
||||
}, [projectsLoaded, projects, selectedProject, setSelectedProjectWithCache])
|
||||
|
||||
return {
|
||||
selectedProject,
|
||||
projectsWithDefault,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Button, FormLabel, Input } from '@mui/joy'
|
||||
import { FormLabel, Input } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import ConfirmationModal from './Inputs/ConfirmationModal'
|
||||
|
||||
@@ -41,31 +42,19 @@ function EditHistoryModal({ config, historyRecord }) {
|
||||
// fullWidth={true}
|
||||
title='Edit History'
|
||||
footer={
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() =>
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: config.onClose }}
|
||||
primary={{
|
||||
label: 'Save',
|
||||
onClick: () =>
|
||||
config.onSave({
|
||||
id: historyRecord.id,
|
||||
performedAt: moment(completedDate).toISOString(),
|
||||
dueDate: moment(dueDate).toISOString(),
|
||||
notes,
|
||||
})
|
||||
}
|
||||
fullWidth
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={config.onClose}
|
||||
variant='outlined'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormLabel>Due Date</FormLabel>
|
||||
@@ -119,6 +108,7 @@ function EditHistoryModal({ config, historyRecord }) {
|
||||
message: 'Are you sure you want to delete this history?',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
color: 'danger',
|
||||
}}
|
||||
/>
|
||||
</ResponsiveModal>
|
||||
|
||||
324
src/views/Modals/FeedbackModal.jsx
Normal file
324
src/views/Modals/FeedbackModal.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { Android, Apple, Favorite, GitHub } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Stack,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
FEEDBACK_CATEGORIES,
|
||||
isCloudInstance,
|
||||
markSentiment,
|
||||
requestStoreReview,
|
||||
SENTIMENTS,
|
||||
storeLinks,
|
||||
submitFeedback,
|
||||
SUBMIT_RESULT,
|
||||
} from '../../service/FeedbackService'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
const STEP = {
|
||||
SENTIMENT: 'sentiment',
|
||||
DETAILS: 'details',
|
||||
THANKS: 'thanks',
|
||||
WEB_REVIEW: 'webReview',
|
||||
GITHUB: 'github',
|
||||
}
|
||||
|
||||
// Native webviews swallow target="_blank"; route through the system browser.
|
||||
const openUrl = async url => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
await Browser.open({ url })
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
const SENTIMENT_OPTIONS = [
|
||||
{ value: SENTIMENTS.LOVE, emoji: '😍' },
|
||||
{ value: SENTIMENTS.OKAY, emoji: '🙂' },
|
||||
{ value: SENTIMENTS.ISSUES, emoji: '😕' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Sentiment-first feedback flow. "Love it" routes to the native store review
|
||||
* dialog (or star links on web); anything else collects structured feedback
|
||||
* and never asks for a review.
|
||||
*/
|
||||
const FeedbackModal = ({ open, onClose, onDismiss }) => {
|
||||
const { t } = useTranslation()
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const location = useLocation()
|
||||
|
||||
const [step, setStep] = useState(STEP.SENTIMENT)
|
||||
const [sentiment, setSentiment] = useState(null)
|
||||
const [category, setCategory] = useState(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [isCloud, setIsCloud] = useState(true)
|
||||
const [githubUrl, setGithubUrl] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep(STEP.SENTIMENT)
|
||||
setSentiment(null)
|
||||
setCategory(null)
|
||||
setMessage('')
|
||||
setSubmitting(false)
|
||||
setGithubUrl(null)
|
||||
isCloudInstance().then(setIsCloud)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleClose = () => {
|
||||
// Backing out before answering counts as a dismissal for the cooldown.
|
||||
if (step === STEP.SENTIMENT) onDismiss?.()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSentiment = async value => {
|
||||
setSentiment(value)
|
||||
await markSentiment(value)
|
||||
|
||||
if (value !== SENTIMENTS.LOVE) {
|
||||
setStep(STEP.DETAILS)
|
||||
return
|
||||
}
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const requested = await requestStoreReview()
|
||||
if (requested) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
setStep(STEP.WEB_REVIEW)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true)
|
||||
const { result, githubUrl: url } = await submitFeedback({
|
||||
sentiment,
|
||||
category,
|
||||
message,
|
||||
feature: location.pathname,
|
||||
userProfile,
|
||||
})
|
||||
setSubmitting(false)
|
||||
|
||||
// Self-hosted feedback is never relayed; hand the user a pre-filled issue
|
||||
// instead so they choose what gets published.
|
||||
if (result === SUBMIT_RESULT.SELF_HOSTED) {
|
||||
setGithubUrl(url)
|
||||
setStep(STEP.GITHUB)
|
||||
return
|
||||
}
|
||||
setStep(STEP.THANKS)
|
||||
}
|
||||
|
||||
const canSubmit = Boolean(category) || message.trim().length > 0
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={open} onClose={handleClose} size='md'>
|
||||
<Stack spacing={2}>
|
||||
{step === STEP.SENTIMENT && (
|
||||
<>
|
||||
<Typography level='h4' sx={{ fontWeight: 600 }}>
|
||||
{t('feedback.sentiment.title')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{t('feedback.sentiment.subtitle')}
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
{SENTIMENT_OPTIONS.map(option => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => handleSentiment(option.value)}
|
||||
sx={{
|
||||
justifyContent: 'flex-start',
|
||||
fontWeight: 500,
|
||||
py: 1.5,
|
||||
}}
|
||||
startDecorator={
|
||||
<Box component='span' sx={{ fontSize: '1.4rem' }}>
|
||||
{option.emoji}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{t(`feedback.sentiment.options.${option.value}`)}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
<Button variant='plain' color='neutral' onClick={handleClose}>
|
||||
{t('feedback.later')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === STEP.DETAILS && (
|
||||
<>
|
||||
<Typography level='h4' sx={{ fontWeight: 600 }}>
|
||||
{t('feedback.details.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
|
||||
{FEEDBACK_CATEGORIES.map(item => (
|
||||
<Chip
|
||||
key={item}
|
||||
variant={category === item ? 'solid' : 'outlined'}
|
||||
color={category === item ? 'primary' : 'neutral'}
|
||||
size='lg'
|
||||
onClick={() => setCategory(category === item ? null : item)}
|
||||
>
|
||||
{t(`feedback.categories.${item}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel sx={{ fontWeight: 600 }}>
|
||||
{t('feedback.details.messageLabel')}
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder={t('feedback.details.messagePlaceholder')}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{isCloud
|
||||
? t('feedback.details.contextNote')
|
||||
: t('feedback.details.contextNoteSelfHosted')}
|
||||
</Typography>
|
||||
|
||||
<Divider />
|
||||
<Stack direction='row' spacing={1} justifyContent='flex-end'>
|
||||
<Button variant='plain' color='neutral' onClick={handleClose}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!canSubmit || submitting}
|
||||
startDecorator={
|
||||
submitting ? <CircularProgress size='sm' /> : null
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isCloud
|
||||
? t('feedback.details.submit')
|
||||
: t('feedback.details.submitSelfHosted')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === STEP.WEB_REVIEW && (
|
||||
<>
|
||||
<Typography
|
||||
level='h4'
|
||||
sx={{ fontWeight: 600 }}
|
||||
startDecorator={<Favorite color='error' />}
|
||||
>
|
||||
{t('feedback.review.title')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{t('feedback.review.subtitle')}
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<GitHub />}
|
||||
onClick={() => openUrl(storeLinks.github)}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
{t('feedback.review.github')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Apple />}
|
||||
onClick={() => openUrl(storeLinks.appStore)}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
{t('feedback.review.appStore')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Android />}
|
||||
onClick={() => openUrl(storeLinks.playStore)}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
{t('feedback.review.playStore')}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button variant='plain' color='neutral' onClick={onClose}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === STEP.GITHUB && (
|
||||
<>
|
||||
<Typography level='h4' sx={{ fontWeight: 600 }}>
|
||||
{t('feedback.github.title')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{t('feedback.github.subtitle')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<GitHub />}
|
||||
onClick={() => {
|
||||
openUrl(githubUrl)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{t('feedback.github.open')}
|
||||
</Button>
|
||||
<Button variant='plain' color='neutral' onClick={onClose}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === STEP.THANKS && (
|
||||
<>
|
||||
<Typography level='h4' sx={{ fontWeight: 600 }}>
|
||||
{t('feedback.thanks.title')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{t('feedback.thanks.subtitle')}
|
||||
</Typography>
|
||||
<Button variant='solid' color='primary' onClick={onClose}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeedbackModal
|
||||
@@ -15,28 +15,40 @@ import {
|
||||
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
|
||||
1: { label: 'Completed', color: 'success', icon: <Check /> },
|
||||
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
|
||||
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
|
||||
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
|
||||
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
|
||||
}
|
||||
|
||||
const DetailRow = ({ icon, label, value, children }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
|
||||
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
|
||||
<Box
|
||||
sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{children ?? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.primary', fontWeight: 'md' }}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -52,15 +64,41 @@ const TimingBadge = ({ historyEntry }) => {
|
||||
const gracePeriod = 6 * 60 * 60 * 1000
|
||||
|
||||
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
|
||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }} startDecorator={<Check />}>On Time</Chip>
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
On Time
|
||||
</Chip>
|
||||
)
|
||||
} else if (performedAt.isBefore(dueDate)) {
|
||||
const abs = Math.abs(diffHours)
|
||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
|
||||
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
|
||||
startDecorator={<Check />}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
)
|
||||
} else {
|
||||
const abs = Math.abs(diffHours)
|
||||
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
|
||||
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
|
||||
return (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='solid'
|
||||
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +117,8 @@ function HistoryDetailModal({ config }) {
|
||||
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
|
||||
const performer = performers.find(p => p.userId === entry.completedBy)
|
||||
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
|
||||
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
|
||||
const isDifferentAssignee =
|
||||
entry.assignedTo && entry.completedBy !== entry.assignedTo
|
||||
|
||||
// updatedAt is only meaningful if it differs from performedAt by more than a minute
|
||||
const showUpdatedAt =
|
||||
@@ -100,14 +139,50 @@ function HistoryDetailModal({ config }) {
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
title='Activity Detail'
|
||||
footer={
|
||||
<ModalActions>
|
||||
{entry.choreId && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
|
||||
onClick={() => {
|
||||
config?.onClose?.()
|
||||
navigate(`/chores/${entry.choreId}`)
|
||||
}}
|
||||
>
|
||||
Open Task
|
||||
</Button>
|
||||
)}
|
||||
{config?.onEdit && (
|
||||
<Button
|
||||
startDecorator={<Edit sx={{ fontSize: 16 }} />}
|
||||
onClick={() => config.onEdit(entry)}
|
||||
>
|
||||
Edit Entry
|
||||
</Button>
|
||||
)}
|
||||
</ModalActions>
|
||||
}
|
||||
>
|
||||
{/* Status header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar size='sm' color={statusCfg.color} variant='soft'>
|
||||
{statusCfg.icon}
|
||||
</Avatar>
|
||||
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
|
||||
<Typography
|
||||
level='title-md'
|
||||
fontWeight='lg'
|
||||
sx={{ color: `${statusCfg.color}.plainColor` }}
|
||||
>
|
||||
{statusLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -119,17 +194,31 @@ function HistoryDetailModal({ config }) {
|
||||
<Stack spacing={0}>
|
||||
{/* Who performed it */}
|
||||
{performer && (
|
||||
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
|
||||
<DetailRow
|
||||
icon={<Check sx={{ fontSize: 16 }} />}
|
||||
label='Performed by'
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
|
||||
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
|
||||
<Avatar
|
||||
src={performer.image}
|
||||
alt={performer.displayName}
|
||||
size='sm'
|
||||
sx={{ width: 20, height: 20 }}
|
||||
/>
|
||||
<Typography level='body-sm' fontWeight='md'>
|
||||
{performer.displayName}
|
||||
</Typography>
|
||||
</Box>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{/* Assigned to (only if different) */}
|
||||
{isDifferentAssignee && assignedTo && (
|
||||
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
|
||||
<DetailRow
|
||||
icon={<Person sx={{ fontSize: 16 }} />}
|
||||
label='Assigned to'
|
||||
value={assignedTo.displayName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
@@ -138,7 +227,15 @@ function HistoryDetailModal({ config }) {
|
||||
{entry.performedAt && (
|
||||
<DetailRow
|
||||
icon={<AccessTime sx={{ fontSize: 16 }} />}
|
||||
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
|
||||
label={
|
||||
isFirstSchedule
|
||||
? 'Scheduled on'
|
||||
: entry.status === 6
|
||||
? 'Rescheduled on'
|
||||
: entry.status === 2
|
||||
? 'Skipped on'
|
||||
: 'Completed on'
|
||||
}
|
||||
value={fmt.dateTime(entry.performedAt)}
|
||||
/>
|
||||
)}
|
||||
@@ -147,7 +244,13 @@ function HistoryDetailModal({ config }) {
|
||||
{entry.dueDate && (
|
||||
<DetailRow
|
||||
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
|
||||
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
|
||||
label={
|
||||
entry.status === 6
|
||||
? 'Previous due date'
|
||||
: entry.status === 5
|
||||
? 'Was due'
|
||||
: 'Due date'
|
||||
}
|
||||
value={fmt.dateTime(entry.dueDate)}
|
||||
/>
|
||||
)}
|
||||
@@ -184,45 +287,19 @@ function HistoryDetailModal({ config }) {
|
||||
<>
|
||||
<Divider />
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary', mb: 0.5 }}
|
||||
>
|
||||
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
|
||||
</Typography>
|
||||
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
|
||||
<RichTextEditor value={entry.notes || ''} isEditable={false} />
|
||||
</Box>
|
||||
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
|
||||
<RichTextEditor value={entry.notes || ''} isEditable={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Action buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
|
||||
{entry.choreId && (
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
|
||||
onClick={() => {
|
||||
config?.onClose?.()
|
||||
navigate(`/chores/${entry.choreId}`)
|
||||
}}
|
||||
>
|
||||
Open Task
|
||||
</Button>
|
||||
)}
|
||||
{config?.onEdit && (
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='md'
|
||||
startDecorator={<Edit sx={{ fontSize: 16 }} />}
|
||||
onClick={() => config.onEdit(entry)}
|
||||
>
|
||||
Edit Entry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { Typography } from '@mui/joy'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
function AcknowledgmentModal({ config }) {
|
||||
@@ -11,42 +12,24 @@ function AcknowledgmentModal({ config }) {
|
||||
config.onClose()
|
||||
}, [config])
|
||||
|
||||
// Keyboard shortcuts for acknowledgment modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
if (!config?.isOpen) return
|
||||
|
||||
// Show keyboard shortcuts when Ctrl/Cmd is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey) setShowKeyboardShortcuts(true)
|
||||
|
||||
// Ctrl/Cmd + Y for acknowledge
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
|
||||
if (
|
||||
((event.ctrlKey || event.metaKey) && event.key === 'y') ||
|
||||
event.key === 'Escape' ||
|
||||
event.key === 'Enter'
|
||||
) {
|
||||
event.preventDefault()
|
||||
handleAction()
|
||||
return
|
||||
}
|
||||
|
||||
// Escape key for acknowledge
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handleAction()
|
||||
return
|
||||
}
|
||||
|
||||
// Enter key for acknowledge
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleAction()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = event => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
|
||||
}
|
||||
|
||||
if (config?.isOpen) {
|
||||
@@ -63,43 +46,33 @@ function AcknowledgmentModal({ config }) {
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
unmountDelay={250}
|
||||
onClose={handleAction}
|
||||
size='sm'
|
||||
title={config?.title}
|
||||
>
|
||||
<Box
|
||||
sx={{ p: 2, minWidth: { xs: '100%', sm: '400px' }, maxWidth: '500px' }}
|
||||
>
|
||||
|
||||
<Typography
|
||||
level='body-md'
|
||||
mb={3}
|
||||
sx={{
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
showCloseButton={false}
|
||||
footer={
|
||||
<ModalActions
|
||||
primary={{
|
||||
label: config?.acknowledgeText,
|
||||
color: config?.color || 'primary',
|
||||
onClick: handleAction,
|
||||
endDecorator: showKeyboardShortcuts ? (
|
||||
<KeyboardShortcutHint shortcut='Y' />
|
||||
) : undefined,
|
||||
}}
|
||||
>
|
||||
{config?.message}
|
||||
</Typography>
|
||||
|
||||
<Box display={'flex'} justifyContent={'center'} mt={2}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={handleAction}
|
||||
color={config?.color || 'primary'}
|
||||
fullWidth
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
|
||||
}
|
||||
sx={{ minWidth: '120px' }}
|
||||
>
|
||||
{config?.acknowledgeText}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{config?.message}
|
||||
</Typography>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { Save } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Button, Chip, Divider, Input, Typography } from '@mui/joy'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import BottomSheetModal from '../../../components/common/BottomSheetModal'
|
||||
import AppModal from '../../../components/common/AppModal'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import FilterBuilderContent, {
|
||||
conditionsToSelections,
|
||||
defaultSelections,
|
||||
@@ -18,6 +12,8 @@ import { FILTER_COLORS } from '../../../utils/Colors'
|
||||
import { applyFilter } from '../../../utils/FilterEngine'
|
||||
import { useFilters } from '../../Filters/FilterQueries'
|
||||
|
||||
const EMPTY_FILTERS = []
|
||||
|
||||
const AdvancedFilterBuilder = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -33,7 +29,7 @@ const AdvancedFilterBuilder = ({
|
||||
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
|
||||
const [selections, setSelections] = useState(defaultSelections())
|
||||
const [error, setError] = useState('')
|
||||
const { data: existedFilters = [] } = useFilters()
|
||||
const { data: existedFilters = EMPTY_FILTERS } = useFilters()
|
||||
|
||||
const filterNameExists = (name, excludeId = null) =>
|
||||
existedFilters.some(
|
||||
@@ -55,9 +51,12 @@ const AdvancedFilterBuilder = ({
|
||||
setSelections(defaultSelections())
|
||||
}
|
||||
setError('')
|
||||
}, [editingFilter, isOpen])
|
||||
}, [editingFilter, existedFilters, isOpen])
|
||||
|
||||
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
|
||||
const conditions = useMemo(
|
||||
() => selectionsToConditions(selections),
|
||||
[selections],
|
||||
)
|
||||
|
||||
const previewChores = useMemo(() => {
|
||||
if (conditions.length === 0) return []
|
||||
@@ -100,8 +99,9 @@ const AdvancedFilterBuilder = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
<AppModal
|
||||
open={isOpen}
|
||||
isMobile
|
||||
onClose={onClose}
|
||||
maxHeight='92vh'
|
||||
title={
|
||||
@@ -109,7 +109,8 @@ const AdvancedFilterBuilder = ({
|
||||
{editingFilter ? 'Edit Filter' : 'New Filter'}
|
||||
{activeConditionCount > 0 && (
|
||||
<Chip size='sm' variant='solid' color='primary'>
|
||||
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
|
||||
{activeConditionCount} condition
|
||||
{activeConditionCount !== 1 ? 's' : ''}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
@@ -144,20 +145,19 @@ const AdvancedFilterBuilder = ({
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
|
||||
<ModalActions>
|
||||
<Button variant='outlined' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='sm'
|
||||
startDecorator={<Save sx={{ fontSize: 16 }} />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Filter
|
||||
</Button>
|
||||
</Box>
|
||||
</ModalActions>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
@@ -231,7 +231,7 @@ const AdvancedFilterBuilder = ({
|
||||
projects={projects}
|
||||
/>
|
||||
</Box>
|
||||
</BottomSheetModal>
|
||||
</AppModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AttachFile, Close, Image } from '@mui/icons-material'
|
||||
import { AttachFile, Image } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { GetChoreAttachments } from '../../../utils/Fetcher'
|
||||
import { resolvePhotoURL } from '../../../utils/Helpers'
|
||||
@@ -87,16 +87,7 @@ function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
|
||||
onClose={handleClose}
|
||||
title='Attachments'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Close, Download } from '@mui/icons-material'
|
||||
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
|
||||
import { Download } from '@mui/icons-material'
|
||||
import { Box, CircularProgress, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
const openUrl = async url => {
|
||||
@@ -47,25 +48,15 @@ function AttachmentViewerModal({ config }) {
|
||||
title={fileName || 'Attachment'}
|
||||
maxHeight='92vh'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<Close />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
startDecorator={<Download />}
|
||||
onClick={() => downloadUrl(url, fileName)}
|
||||
disabled={!url}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Box>
|
||||
<ModalActions
|
||||
secondary={{ label: 'Close', onClick: handleClose }}
|
||||
primary={{
|
||||
label: 'Download',
|
||||
startDecorator: <Download />,
|
||||
onClick: () => downloadUrl(url, fileName),
|
||||
disabled: !url,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
@@ -78,10 +69,7 @@ function AttachmentViewerModal({ config }) {
|
||||
}}
|
||||
>
|
||||
{!imgLoaded && !imgError && (
|
||||
<CircularProgress
|
||||
sx={{ position: 'absolute' }}
|
||||
size='md'
|
||||
/>
|
||||
<CircularProgress sx={{ position: 'absolute' }} size='md' />
|
||||
)}
|
||||
{imgError ? (
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
|
||||
|
||||
@@ -140,7 +140,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
const response = await RestoreBackup(restoreEncryptionKey, backupData)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Backup restored successfully. Please refresh the page.',
|
||||
@@ -212,7 +211,7 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
placeholder='Enter a strong encryption key'
|
||||
/>
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Keep this key safe - you'll need it to restore your backup
|
||||
Keep this key safe—you'll need it to restore your backup
|
||||
</Typography>
|
||||
</FormControl>
|
||||
|
||||
@@ -238,22 +237,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box display='flex' justifyContent='space-between' gap={2}>
|
||||
<Button size='lg' variant='outlined' onClick={handleClose} fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
color='primary'
|
||||
onClick={handleCreateBackup}
|
||||
loading={loading}
|
||||
disabled={!encryptionKey.trim()}
|
||||
fullWidth
|
||||
>
|
||||
Create Backup
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -294,22 +277,6 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box display='flex' justifyContent='space-between' gap={2}>
|
||||
<Button size='lg' variant='outlined' onClick={handleClose} fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
color='warning'
|
||||
onClick={handleRestore}
|
||||
loading={loading}
|
||||
disabled={!restoreEncryptionKey.trim() || !backupFile}
|
||||
fullWidth
|
||||
>
|
||||
Restore Backup
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -320,7 +287,28 @@ function BackupRestoreModal({ isOpen, onClose, showNotification }) {
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
unmountDelay={250}
|
||||
title='🔄 Backup & Restore'
|
||||
title='Backup & Restore'
|
||||
closeOnBackdrop={!loading}
|
||||
closeOnEscape={!loading}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
onClick: handleClose,
|
||||
disabled: loading,
|
||||
}}
|
||||
primary={{
|
||||
label: activeTab === 0 ? 'Create Backup' : 'Restore Backup',
|
||||
color: activeTab === 0 ? 'primary' : 'warning',
|
||||
onClick: activeTab === 0 ? handleCreateBackup : handleRestore,
|
||||
loading,
|
||||
disabled:
|
||||
activeTab === 0
|
||||
? !encryptionKey.trim()
|
||||
: !restoreEncryptionKey.trim() || !backupFile,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Box
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { Typography } from '@mui/joy'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
function ConfirmationModal({ config }) {
|
||||
@@ -14,49 +15,29 @@ function ConfirmationModal({ config }) {
|
||||
[config],
|
||||
)
|
||||
|
||||
// Keyboard shortcuts for confirmation modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
if (!config?.isOpen) return
|
||||
|
||||
// Show keyboard shortcuts when Ctrl/Cmd is pressed
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey) setShowKeyboardShortcuts(true)
|
||||
|
||||
// Ctrl/Cmd + Y for confirm
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'y') {
|
||||
event.preventDefault()
|
||||
handleAction(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + X for cancel
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'x') {
|
||||
event.preventDefault()
|
||||
handleAction(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Escape key for cancel
|
||||
if (event.key === 'Escape') {
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handleAction(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Enter key for confirm
|
||||
if (event.key === 'Enter') {
|
||||
} else if (event.key === 'Enter' && config?.color !== 'danger') {
|
||||
event.preventDefault()
|
||||
handleAction(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = event => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
if (!event.ctrlKey && !event.metaKey) setShowKeyboardShortcuts(false)
|
||||
}
|
||||
|
||||
if (config?.isOpen) {
|
||||
@@ -68,51 +49,45 @@ function ConfirmationModal({ config }) {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [config?.isOpen, handleAction])
|
||||
}, [config?.isOpen, config?.color, handleAction])
|
||||
|
||||
const isDestructive = config?.color === 'danger'
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={() => handleAction(false)}
|
||||
size='sm'
|
||||
unmountDelay={250}
|
||||
role={isDestructive ? 'alertdialog' : 'dialog'}
|
||||
title={config?.title}
|
||||
showCloseButton={false}
|
||||
closeOnBackdrop={!isDestructive}
|
||||
footer={
|
||||
<ModalActions
|
||||
stackOnMobile
|
||||
secondary={{
|
||||
label: config?.cancelText,
|
||||
onClick: () => handleAction(false),
|
||||
endDecorator: showKeyboardShortcuts ? (
|
||||
<KeyboardShortcutHint shortcut='X' />
|
||||
) : undefined,
|
||||
}}
|
||||
primary={{
|
||||
label: config?.confirmText,
|
||||
color: config?.color || 'primary',
|
||||
onClick: () => handleAction(true),
|
||||
endDecorator: showKeyboardShortcuts ? (
|
||||
<KeyboardShortcutHint shortcut='Y' />
|
||||
) : undefined,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Typography level='h4' mb={1}>
|
||||
{config?.title}
|
||||
</Typography>
|
||||
<Typography level='body-md' gutterBottom>
|
||||
<Typography level='body-md' sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{config?.message}
|
||||
</Typography>
|
||||
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1} gap={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
handleAction(true)
|
||||
}}
|
||||
fullWidth
|
||||
color={config?.color || 'primary'}
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
|
||||
}
|
||||
>
|
||||
{config?.confirmText}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
handleAction(false)
|
||||
}}
|
||||
variant='outlined'
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
|
||||
}
|
||||
>
|
||||
{config?.cancelText}
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmationModal
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
@@ -104,16 +98,30 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
password === confirmPassword
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={isOpen} onClose={handleClose}>
|
||||
<Typography level='h4' mb={2}>
|
||||
Create Sub Account
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-md' mb={3}>
|
||||
Create a new sub account. The user will be able to log in using their
|
||||
combined username and complete tasks assigned to them.
|
||||
</Typography>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
title='Create Sub Account'
|
||||
description='Create a login that can complete tasks assigned to this account.'
|
||||
size='md'
|
||||
closeOnBackdrop={!isSubmitting}
|
||||
closeOnEscape={!isSubmitting}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
onClick: handleClose,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
primary={{
|
||||
label: 'Create Account',
|
||||
onClick: handleSubmit,
|
||||
disabled: !isValid || isSubmitting,
|
||||
loading: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
|
||||
<Typography level='body2' mb={1}>
|
||||
Sub Account Name *
|
||||
@@ -196,27 +204,6 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
|
||||
<FormHelperText>{errors.confirmPassword}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<Box display='flex' justifyContent='space-between' gap={2}>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Textarea,
|
||||
Typography,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
@@ -29,7 +28,7 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
setState(0)
|
||||
}
|
||||
}
|
||||
}, [type])
|
||||
}, [type, state])
|
||||
|
||||
const isValid = () => {
|
||||
const newErrors = {}
|
||||
@@ -63,9 +62,20 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
size='md'
|
||||
title={`${currentThing?.id ? 'Edit' : 'Create'} Thing`}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
onClick: onClose,
|
||||
}}
|
||||
primary={{
|
||||
label: currentThing?.id ? 'Update' : 'Create',
|
||||
onClick: handleSave,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Typography>Name</Typography>
|
||||
@@ -79,9 +89,9 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<Typography>Type</Typography>
|
||||
<Select value={type} sx={{ minWidth: 300 }}>
|
||||
<Select value={type} onChange={(_, value) => setType(value)}>
|
||||
{['text', 'number', 'boolean'].map(type => (
|
||||
<Option value={type} key={type} onClick={() => setType(type)}>
|
||||
<Option value={type} key={type}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</Option>
|
||||
))}
|
||||
@@ -118,24 +128,15 @@ function CreateThingModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
{type === 'boolean' && (
|
||||
<FormControl>
|
||||
<Typography>Value</Typography>
|
||||
<Select sx={{ minWidth: 300 }} value={state}>
|
||||
<Select value={state} onChange={(_, value) => setState(value)}>
|
||||
{['true', 'false'].map(value => (
|
||||
<Option value={value} key={value} onClick={() => setState(value)}>
|
||||
<Option value={value} key={value}>
|
||||
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
||||
{currentThing?.id ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
||||
{currentThing?.id ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Box, Button, Input } from '@mui/joy'
|
||||
import { Input } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
function DateModal({ isOpen, onClose, onSave, current, title }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [date, setDate] = useState(
|
||||
current ? new Date(current).toISOString().split('T')[0] : '',
|
||||
)
|
||||
@@ -18,74 +18,23 @@ function DateModal({ isOpen, onClose, onSave, current, title }) {
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
size='sm'
|
||||
title={title}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: 'Save', onClick: handleSave, disabled: !date }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
sx={{ mt: 3 }}
|
||||
autoFocus
|
||||
type='date'
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
onChange={event => setDate(event.target.value)}
|
||||
/>
|
||||
|
||||
{/* <Box sx={{ mt: 3 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 1.5, fontWeight: 500 }}>
|
||||
Quick select:
|
||||
</Typography>
|
||||
<Stack direction='row' spacing={1} flexWrap='wrap' useFlexGap>
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<Today />}
|
||||
size='lg'
|
||||
onClick={() => handleQuickSchedule('today')}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Today
|
||||
</Chip>
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<WbSunny />}
|
||||
size='lg'
|
||||
onClick={() => handleQuickSchedule('tomorrow')}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Tomorrow
|
||||
</Chip>
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<Weekend />}
|
||||
size='lg'
|
||||
onClick={() => handleQuickSchedule('weekend')}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Weekend
|
||||
</Chip>
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='primary'
|
||||
startDecorator={<NextWeek />}
|
||||
size='lg'
|
||||
onClick={() => handleQuickSchedule('next-week')}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
Next week
|
||||
</Chip>
|
||||
</Stack>
|
||||
</Box> */}
|
||||
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={4}>
|
||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateModal
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
@@ -31,7 +25,7 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
return
|
||||
}
|
||||
onSave({
|
||||
name,
|
||||
name: currentThing?.name,
|
||||
type: currentThing?.type,
|
||||
id: currentThing?.id,
|
||||
state: state || null,
|
||||
@@ -43,9 +37,14 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
size='sm'
|
||||
title='Update state'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: 'Update', onClick: handleSave }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Typography>Value</Typography>
|
||||
@@ -57,15 +56,6 @@ function EditThingStateModal({ isOpen, onClose, onSave, currentThing }) {
|
||||
/>
|
||||
<FormHelperText color='danger'>{errors.state}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
||||
{currentThing?.id ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
||||
{currentThing?.id ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Grid,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Avatar, Box, FormControl, FormLabel, Grid, Typography } from '@mui/joy'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
|
||||
import PROJECT_ICONS from '../../../utils/ProjectIcons'
|
||||
@@ -33,8 +26,10 @@ const IconPickerModal = ({
|
||||
fullWidth={true}
|
||||
unmountDelay={250}
|
||||
title='Choose Project Icon'
|
||||
footer={
|
||||
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
|
||||
}
|
||||
>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Available Icons</FormLabel>
|
||||
<Grid
|
||||
@@ -58,7 +53,9 @@ const IconPickerModal = ({
|
||||
border: '2px solid',
|
||||
borderColor: isCurrentIcon ? 'primary.500' : 'transparent',
|
||||
'&:hover': {
|
||||
borderColor: isCurrentIcon ? 'primary.600' : 'neutral.300',
|
||||
borderColor: isCurrentIcon
|
||||
? 'primary.600'
|
||||
: 'neutral.300',
|
||||
},
|
||||
transition: 'border-color 0.2s',
|
||||
}}
|
||||
@@ -96,12 +93,6 @@ const IconPickerModal = ({
|
||||
})}
|
||||
</Grid>
|
||||
</FormControl>
|
||||
|
||||
<Box display='flex' justifyContent='center' mt={3}>
|
||||
<Button variant='outlined' onClick={onClose} fullWidth size='lg'>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Button, FormControl, Input, Typography } from '@mui/joy'
|
||||
import { Box, FormControl, Input, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal.js'
|
||||
import { useNotification } from '../../../service/NotificationProvider.jsx'
|
||||
import LABEL_COLORS from '../../../utils/Colors.jsx'
|
||||
@@ -90,14 +91,13 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
fullWidth={true}
|
||||
title={label ? 'Edit Label' : 'Add Label'}
|
||||
footer={
|
||||
<Box display='flex' justifyContent='space-around' mt={1}>
|
||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
||||
{label ? 'Save Changes' : 'Add Label'}
|
||||
</Button>
|
||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{
|
||||
label: label ? 'Save Changes' : 'Add Label',
|
||||
onClick: handleSave,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
@@ -120,12 +120,18 @@ function LabelModal({ isOpen, onClose, label }) {
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{LABEL_COLORS.map(colorOption => (
|
||||
<Box
|
||||
component='button'
|
||||
type='button'
|
||||
key={colorOption.value}
|
||||
aria-label={`Select ${colorOption.name}`}
|
||||
aria-pressed={color === colorOption.value}
|
||||
title={colorOption.name}
|
||||
onClick={() => setColor(colorOption.value)}
|
||||
sx={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: 0,
|
||||
p: 0,
|
||||
borderRadius: '50%',
|
||||
background: colorOption.value,
|
||||
cursor: 'pointer',
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { Box, Typography } from '@mui/joy'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={isOpen} onClose={onClose} size='md' fullWidth>
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Cancel Subscription
|
||||
</Typography>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
title='Cancel Subscription'
|
||||
footer={
|
||||
<ModalActions
|
||||
stackOnMobile
|
||||
tertiary={{ label: 'Dismiss', onClick: onClose }}
|
||||
secondary={{
|
||||
label: "I'll cancel from my app store",
|
||||
onClick: onClose,
|
||||
}}
|
||||
primary={{
|
||||
label: 'Cancel desktop subscription',
|
||||
color: 'danger',
|
||||
onClick: () => onClose('desktop'),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<Typography level='body-md' mb={3}>
|
||||
To cancel your subscription, please follow the instructions for your
|
||||
platform (you should cancel through the same platform you used to
|
||||
@@ -84,8 +102,8 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
||||
<strong>Important:</strong> You must cancel your subscription
|
||||
through the same platform where you originally subscribed. If you
|
||||
subscribed through the iOS App Store or Google Play Store (even if
|
||||
you're now using the web/desktop version), you must cancel through
|
||||
that original platform using the instructions above.
|
||||
you're now using the web/desktop version), you must cancel
|
||||
through that original platform using the instructions above.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -93,24 +111,6 @@ const NativeCancelSubscriptionModal = ({ isOpen, onClose }) => {
|
||||
Your subscription will remain active until the end of your current
|
||||
billing period.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Button size='lg' onClick={onClose} variant='outlined' fullWidth>
|
||||
I'll cancel from my app store
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => onClose('desktop')}
|
||||
variant='solid'
|
||||
color='danger'
|
||||
fullWidth
|
||||
>
|
||||
I subscribed via desktop - Cancel now
|
||||
</Button>
|
||||
<Button size='lg' onClick={onClose} fullWidth>
|
||||
Dismiss
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
Textarea,
|
||||
Typography,
|
||||
Alert,
|
||||
Box,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { isOfficialDonetickInstanceSync } from '../../../utils/FeatureToggle'
|
||||
|
||||
@@ -108,19 +108,34 @@ function NudgeModal({ config }) {
|
||||
fullWidth={true}
|
||||
unmountDelay={250}
|
||||
title='Send Nudge'
|
||||
description='Send a gentle reminder to the people assigned to this task.'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
onClick: () => handleAction(false),
|
||||
endDecorator: showKeyboardShortcuts ? (
|
||||
<KeyboardShortcutHint shortcut='X' />
|
||||
) : undefined,
|
||||
}}
|
||||
primary={{
|
||||
label: 'Send Nudge',
|
||||
onClick: () => handleAction(true),
|
||||
disabled: !isOfficialInstance,
|
||||
endDecorator: showKeyboardShortcuts ? (
|
||||
<KeyboardShortcutHint shortcut='Y' />
|
||||
) : undefined,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Typography level='body-md' mb={2}>
|
||||
Send a gentle reminder to the assignee about this task. You can
|
||||
customize the message and choose who gets notified.
|
||||
</Typography>
|
||||
|
||||
{!isOfficialInstance && (
|
||||
<Alert color='warning' sx={{ mb: 2 }}>
|
||||
<Typography level='body-sm'>
|
||||
<strong>Heads up!</strong>This feature avaiable on Donetick Cloud!
|
||||
Since you're using a self-hosted instance, nudges will requires you
|
||||
to setup Google cloud account and Firebase Cloud Messaging (FCM).
|
||||
and build the Android or the iOS app by yourself.
|
||||
Since you're using a self-hosted instance, nudges will requires
|
||||
you to setup Google cloud account and Firebase Cloud Messaging
|
||||
(FCM). and build the Android or the iOS app by yourself.
|
||||
<br />
|
||||
Will update if we come up with a solution to make this easier for to
|
||||
configure. for selfhosters
|
||||
@@ -152,33 +167,6 @@ function NudgeModal({ config }) {
|
||||
onChange={e => setNotifyAllAssignees(e.target.checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Box display={'flex'} justifyContent={'space-around'} gap={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => handleAction(true)}
|
||||
disabled={!isOfficialInstance}
|
||||
fullWidth
|
||||
color='primary'
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint shortcut='Y' show={showKeyboardShortcuts} />
|
||||
}
|
||||
>
|
||||
Send Nudge
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => handleAction(false)}
|
||||
variant='outlined'
|
||||
fullWidth
|
||||
endDecorator={
|
||||
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React, { useEffect } from 'react'
|
||||
import { FormControl, FormHelperText, Input, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
function PassowrdChangeModal({ isOpen, onClose }) {
|
||||
function PasswordChangeModal({ isOpen, onClose }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordError, setPasswordError] = useState(null)
|
||||
const [passwordTouched, setPasswordTouched] = useState(false)
|
||||
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false)
|
||||
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [confirmPassword, setConfirmPassword] = React.useState('')
|
||||
const [passwordError, setPasswordError] = React.useState(false)
|
||||
const [passwordTouched, setPasswordTouched] = React.useState(false)
|
||||
const [confirmPasswordTouched, setConfirmPasswordTouched] =
|
||||
React.useState(false)
|
||||
useEffect(() => {
|
||||
if (!passwordTouched || !confirmPasswordTouched) {
|
||||
return
|
||||
} else if (password !== confirmPassword) {
|
||||
if (!passwordTouched || !confirmPasswordTouched) return
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setPasswordError('Passwords do not match')
|
||||
} else if (password.length < 8) {
|
||||
setPasswordError('Password must be at least 8 characters')
|
||||
@@ -32,90 +25,66 @@ function PassowrdChangeModal({ isOpen, onClose }) {
|
||||
}
|
||||
}, [password, confirmPassword, passwordTouched, confirmPasswordTouched])
|
||||
|
||||
const handleAction = isConfirmed => {
|
||||
if (!isConfirmed) {
|
||||
onClose(null)
|
||||
return
|
||||
}
|
||||
onClose(password)
|
||||
}
|
||||
const handleAction = isConfirmed => onClose(isConfirmed ? password : null)
|
||||
const canSubmit =
|
||||
passwordTouched &&
|
||||
confirmPasswordTouched &&
|
||||
password.length >= 8 &&
|
||||
password === confirmPassword &&
|
||||
passwordError == null
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
onClose={() => handleAction(false)}
|
||||
size='sm'
|
||||
title='Change Password'
|
||||
description='Choose a password between 8 and 64 characters.'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: () => handleAction(false) }}
|
||||
primary={{
|
||||
label: 'Change Password',
|
||||
disabled: !canSubmit,
|
||||
onClick: () => handleAction(true),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Typography level='body-md' gutterBottom>
|
||||
Please enter your new password.
|
||||
</Typography>
|
||||
<FormControl>
|
||||
<Typography level='body2' alignSelf={'start'}>
|
||||
New Password
|
||||
</Typography>
|
||||
<FormControl sx={{ mb: 2 }}>
|
||||
<Typography level='body-sm'>New password</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
name='password'
|
||||
label='Password'
|
||||
type='password'
|
||||
id='password'
|
||||
placeholder='Enter password (8-64 characters)'
|
||||
autoComplete='new-password'
|
||||
placeholder='Enter password'
|
||||
value={password}
|
||||
onChange={e => {
|
||||
onChange={event => {
|
||||
setPasswordTouched(true)
|
||||
setPassword(e.target.value)
|
||||
setPassword(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<Typography level='body2' alignSelf={'start'}>
|
||||
Confirm Password
|
||||
</Typography>
|
||||
<FormControl error={Boolean(passwordError)}>
|
||||
<Typography level='body-sm'>Confirm password</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
name='confirmPassword'
|
||||
label='confirmPassword'
|
||||
type='password'
|
||||
id='confirmPassword'
|
||||
autoComplete='new-password'
|
||||
placeholder='Repeat password'
|
||||
value={confirmPassword}
|
||||
onChange={e => {
|
||||
onChange={event => {
|
||||
setConfirmPasswordTouched(true)
|
||||
setConfirmPassword(e.target.value)
|
||||
setConfirmPassword(event.target.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormHelperText>{passwordError}</FormHelperText>
|
||||
{passwordError && <FormHelperText>{passwordError}</FormHelperText>}
|
||||
</FormControl>
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
||||
<Button
|
||||
size='lg'
|
||||
disabled={passwordError != null}
|
||||
onClick={() => {
|
||||
handleAction(true)
|
||||
}}
|
||||
fullWidth
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
handleAction(false)
|
||||
}}
|
||||
variant='outlined'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
export default PassowrdChangeModal
|
||||
|
||||
export default PasswordChangeModal
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import PROJECT_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
@@ -124,28 +125,23 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
|
||||
unmountDelay={250}
|
||||
fullWidth={true}
|
||||
title={project ? 'Edit Project' : 'Create New Project'}
|
||||
closeOnBackdrop={!isSubmitting}
|
||||
closeOnEscape={!isSubmitting}
|
||||
footer={
|
||||
<Box display='flex' justifyContent='space-around' gap={1}>
|
||||
<Button
|
||||
type='submit'
|
||||
form='project-form'
|
||||
loading={isSubmitting}
|
||||
disabled={!projectName.trim() || isSubmitting}
|
||||
fullWidth
|
||||
size='lg'
|
||||
>
|
||||
{project ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
size='lg'
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
onClick: handleClose,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
primary={{
|
||||
label: project ? 'Update' : 'Create',
|
||||
type: 'submit',
|
||||
form: 'project-form',
|
||||
loading: isSubmitting,
|
||||
disabled: !projectName.trim() || isSubmitting,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit} id='project-form'>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Button, Option, Select } from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { Option, Select } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
function SelectModal({
|
||||
@@ -12,8 +13,8 @@ function SelectModal({
|
||||
placeholder,
|
||||
}) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const [selected, setSelected] = useState(null)
|
||||
|
||||
const [selected, setSelected] = React.useState(null)
|
||||
const handleSave = () => {
|
||||
onSave(options.find(item => item.id === selected))
|
||||
onClose()
|
||||
@@ -23,33 +24,33 @@ function SelectModal({
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
size='sm'
|
||||
title={title}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{
|
||||
label: 'Save',
|
||||
onClick: handleSave,
|
||||
disabled: selected == null,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Select placeholder={placeholder}>
|
||||
{options.map((item, index) => (
|
||||
<Option
|
||||
value={item.id}
|
||||
key={item[displayKey]}
|
||||
onClick={() => {
|
||||
setSelected(item.id)
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
autoFocus
|
||||
placeholder={placeholder}
|
||||
value={selected}
|
||||
onChange={(_, value) => setSelected(value)}
|
||||
>
|
||||
{options.map(item => (
|
||||
<Option value={item.id} key={item[displayKey]}>
|
||||
{item[displayKey]}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectModal
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Button, Textarea } from '@mui/joy'
|
||||
import { Textarea } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
function TextModal({
|
||||
@@ -12,7 +13,6 @@ function TextModal({
|
||||
cancelText,
|
||||
}) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const [text, setText] = useState(current)
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -24,28 +24,25 @@ function TextModal({
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
size='md'
|
||||
title={title}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: cancelText || 'Cancel', onClick: onClose }}
|
||||
primary={{ label: okText || 'Save', onClick: handleSave }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
autoFocus
|
||||
placeholder='Type in here…'
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
sx={{ minWidth: 300 }}
|
||||
onChange={event => setText(event.target.value)}
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
/>
|
||||
|
||||
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
|
||||
<Button size='lg' onClick={handleSave} fullWidth sx={{ mr: 1 }}>
|
||||
{okText ? okText : 'Save'}
|
||||
</Button>
|
||||
<Button size='lg' onClick={onClose} variant='outlined'>
|
||||
{cancelText ? cancelText : 'Cancel'}
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextModal
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocalization } from '../../../contexts/LocalizationContext'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { useNotification } from '../../../service/NotificationProvider'
|
||||
import {
|
||||
@@ -59,7 +60,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
}
|
||||
}, [isOpen, timerData])
|
||||
|
||||
|
||||
const formatTime = seconds => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
@@ -304,10 +304,37 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title='Timer Details'
|
||||
footer={
|
||||
<ModalActions
|
||||
tertiary={
|
||||
!loading && timerData && !editingSessions[timerData.id]
|
||||
? {
|
||||
label: 'Delete',
|
||||
color: 'danger',
|
||||
onClick: () => confirmDeleteSession(timerData.id),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Close', onClick: handleClose }}
|
||||
primary={
|
||||
!loading && timerData
|
||||
? editingSessions[timerData.id]
|
||||
? {
|
||||
label: 'Save',
|
||||
onClick: () => saveSession(timerData.id),
|
||||
loading,
|
||||
}
|
||||
: {
|
||||
label: 'Edit',
|
||||
startDecorator: <Edit />,
|
||||
onClick: () => startEditingSession(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Typography level='h4'>Timer Details</Typography>
|
||||
|
||||
{loading && (
|
||||
<Alert color='neutral' sx={{ mb: 2 }}>
|
||||
Loading timer data...
|
||||
@@ -919,56 +946,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => {
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant='outlined' onClick={handleClose} color='neutral'>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{/* Action buttons on the right */}
|
||||
{!loading && timerData && !editingSessions[timerData.id] && (
|
||||
<>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => confirmDeleteSession(timerData.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
startDecorator={<Edit />}
|
||||
onClick={() => startEditingSession()}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save button when editing */}
|
||||
{!loading && timerData && editingSessions[timerData.id] && (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => saveSession(timerData.id)}
|
||||
loading={loading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
|
||||
<ConfirmationModal config={confirmDeleteConfig} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
@@ -10,13 +9,13 @@ import {
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { data } from 'autoprefixer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { CheckUserDeletion, DeleteUser } from '../../../utils/Fetcher'
|
||||
|
||||
function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
function UserDeletionModal({ isOpen, onClose }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const Navigate = useNavigate()
|
||||
const [step, setStep] = useState(1) // 1: Warning, 2: Transfer, 3: Confirm
|
||||
@@ -70,7 +69,8 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
setError(data.error || 'Failed to check deletion requirements')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(data.error || 'Failed to check deletion requirements')
|
||||
console.error('Failed to check deletion requirements:', err)
|
||||
setError('Failed to check deletion requirements')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -119,6 +119,7 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
setError(data.message || 'Failed to delete account')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete account:', err)
|
||||
setError('Failed to delete account')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -148,10 +149,6 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
|
||||
const renderWarningStep = () => (
|
||||
<>
|
||||
<Typography level='h4' mb={2} color='danger'>
|
||||
Delete Account
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-md' mb={2}>
|
||||
<strong>This action cannot be undone.</strong> Deleting your account
|
||||
will permanently remove:
|
||||
@@ -193,30 +190,11 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
|
||||
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={checkDeletionRequirements}
|
||||
loading={loading}
|
||||
disabled={!password}
|
||||
fullWidth
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderTransferStep = () => (
|
||||
<>
|
||||
<Typography level='h4' mb={2} color='warning'>
|
||||
Circle Ownership Transfer Required
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-md' mb={3}>
|
||||
You own circles that require ownership transfer before deletion. Please
|
||||
select new owners:
|
||||
@@ -253,29 +231,11 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
</FormControl>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Box display='flex' justifyContent='space-between' mt={3} gap={2}>
|
||||
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={proceedToConfirmation}
|
||||
disabled={circlesRequiringTransfer.length !== transferOptions.length}
|
||||
fullWidth
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderConfirmationStep = () => (
|
||||
<>
|
||||
<Typography level='h4' mb={2} color='danger'>
|
||||
Final Confirmation
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-md' mb={3}>
|
||||
Please enter your password and type <strong>DELETE</strong> to confirm
|
||||
account deletion.
|
||||
@@ -296,7 +256,7 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ mb: 3 }}>
|
||||
<FormLabel>Type "DELETE" to confirm</FormLabel>
|
||||
<FormLabel>Type "DELETE" to confirm</FormLabel>
|
||||
<Input
|
||||
value={confirmation}
|
||||
onChange={e => setConfirmation(e.target.value)}
|
||||
@@ -309,21 +269,6 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box display='flex' justifyContent='space-between' gap={2}>
|
||||
<Button variant='outlined' onClick={() => handleClose(false)} fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={executeUserDeletion}
|
||||
loading={loading}
|
||||
disabled={!password || confirmation !== 'DELETE'}
|
||||
fullWidth
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -345,8 +290,42 @@ function UserDeletionModal({ isOpen, onClose, userProfile }) {
|
||||
open={isOpen}
|
||||
onClose={() => handleClose(false)}
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title='Delete Account'
|
||||
title={
|
||||
step === 1
|
||||
? 'Delete Account'
|
||||
: step === 2
|
||||
? 'Transfer Circle Ownership'
|
||||
: 'Final Confirmation'
|
||||
}
|
||||
role={step === 3 ? 'alertdialog' : 'dialog'}
|
||||
closeOnBackdrop={false}
|
||||
footer={
|
||||
!loading && (
|
||||
<ModalActions
|
||||
stackOnMobile
|
||||
secondary={{
|
||||
label: 'Cancel',
|
||||
onClick: () => handleClose(false),
|
||||
}}
|
||||
primary={{
|
||||
label: step === 3 ? 'Delete Account' : 'Continue',
|
||||
color: step === 3 ? 'danger' : 'primary',
|
||||
onClick:
|
||||
step === 1
|
||||
? checkDeletionRequirements
|
||||
: step === 2
|
||||
? proceedToConfirmation
|
||||
: executeUserDeletion,
|
||||
disabled:
|
||||
step === 1
|
||||
? !password
|
||||
: step === 2
|
||||
? circlesRequiringTransfer.length !== transferOptions.length
|
||||
: !password || confirmation !== 'DELETE',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{loading && step === 1 ? (
|
||||
<Box
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Avatar, Box, Button, List, ListItem, Typography } from '@mui/joy'
|
||||
import { Avatar, Box, List, ListItem, Typography } from '@mui/joy'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
|
||||
const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
||||
@@ -11,6 +12,9 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
||||
size='lg'
|
||||
fullWidth={true}
|
||||
title='Select User'
|
||||
footer={
|
||||
<ModalActions secondary={{ label: 'Cancel', onClick: onClose }} />
|
||||
}
|
||||
>
|
||||
<List sx={{ mb: 2 }}>
|
||||
{performers.map(user => (
|
||||
@@ -38,11 +42,6 @@ const UserModal = ({ isOpen, performers = [], onSelect, onClose }) => {
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button size='lg' variant='outlined' color='neutral' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,16 +5,9 @@ import {
|
||||
ErrorOutline,
|
||||
Nfc,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Input,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, IconButton, Input, Switch, Typography } from '@mui/joy'
|
||||
import { useRef, useState } from 'react'
|
||||
import ModalActions from '../../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
|
||||
import { startNativeNFCWrite } from '../../../service/NFCWriter'
|
||||
|
||||
@@ -29,9 +22,6 @@ const pulseKeyframes = `
|
||||
70% { transform: scale(2.1); opacity: 0; }
|
||||
100% { transform: scale(2.1); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nfc-pulse-ring { animation: none !important; }
|
||||
}
|
||||
`
|
||||
|
||||
function NFCIcon({ status }) {
|
||||
@@ -55,7 +45,6 @@ function NFCIcon({ status }) {
|
||||
{isWaiting && (
|
||||
<>
|
||||
<Box
|
||||
className='nfc-pulse-ring'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -63,10 +52,10 @@ function NFCIcon({ status }) {
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.400',
|
||||
animation: 'nfc-pulse 1.8s ease-out infinite',
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
className='nfc-pulse-ring'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -74,6 +63,7 @@ function NFCIcon({ status }) {
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.300',
|
||||
animation: 'nfc-pulse-2 1.8s ease-out infinite 0.4s',
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
@@ -215,27 +205,37 @@ function WriteNFCModal({ config }) {
|
||||
return (
|
||||
<>
|
||||
<style>{pulseKeyframes}</style>
|
||||
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={handleClose}
|
||||
title={title}
|
||||
description={subtitle}
|
||||
closeOnBackdrop={!isWaiting}
|
||||
closeOnEscape={!isWaiting}
|
||||
footer={
|
||||
isSuccess ? (
|
||||
<ModalActions primary={{ label: 'Done', onClick: handleClose }} />
|
||||
) : isWaiting ? (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: handleCancel }}
|
||||
/>
|
||||
) : (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: handleClose }}
|
||||
primary={{
|
||||
label: nfcStatus === 'writing' ? 'Starting…' : 'Write tag',
|
||||
onClick: writeToNFC,
|
||||
disabled: nfcStatus === 'writing',
|
||||
startDecorator: <Nfc />,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Box sx={{ px: 0.5, pb: 1 }}>
|
||||
{/* Icon */}
|
||||
<NFCIcon status={nfcStatus} />
|
||||
|
||||
{/* Heading */}
|
||||
<Typography
|
||||
level='title-lg'
|
||||
textAlign='center'
|
||||
sx={{ mb: 0.75, fontWeight: 600 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
textAlign='center'
|
||||
sx={{ color: 'text.secondary', mb: 3, px: 2 }}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
|
||||
{/* Idle / Error: URL + toggle + CTA */}
|
||||
{!isWaiting && !isSuccess && (
|
||||
<>
|
||||
@@ -264,6 +264,7 @@ function WriteNFCModal({ config }) {
|
||||
}}
|
||||
endDecorator={
|
||||
<IconButton
|
||||
aria-label='Copy tag URL'
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color={copied ? 'success' : 'neutral'}
|
||||
@@ -310,55 +311,8 @@ function WriteNFCModal({ config }) {
|
||||
size='sm'
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
onClick={isError ? handleClose : handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
sx={{ flex: 1 }}
|
||||
onClick={writeToNFC}
|
||||
disabled={nfcStatus === 'writing'}
|
||||
startDecorator={
|
||||
nfcStatus === 'writing' ? (
|
||||
<CircularProgress size='sm' />
|
||||
) : (
|
||||
<Nfc />
|
||||
)
|
||||
}
|
||||
>
|
||||
{nfcStatus === 'writing' ? 'Starting…' : 'Write tag'}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Waiting state */}
|
||||
{isWaiting && (
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Success state */}
|
||||
{isSuccess && (
|
||||
<Button size='lg' fullWidth onClick={handleClose}>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
|
||||
@@ -2,10 +2,8 @@ import { CreditCard, Person, Toll } from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
@@ -15,6 +13,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import ModalActions from '../../components/common/ModalActions.jsx'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
|
||||
|
||||
@@ -53,22 +52,28 @@ function RedeemPointsModal({ config }) {
|
||||
const canRedeem = points > 0 && points <= config.available
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={config?.isOpen} onClose={config?.onClose} size='md'>
|
||||
{/* Header Section */}
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
size='md'
|
||||
title='Redeem Points'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: config?.onClose }}
|
||||
primary={{
|
||||
label: 'Redeem',
|
||||
startDecorator: <CreditCard />,
|
||||
disabled: !canRedeem,
|
||||
onClick: () =>
|
||||
config?.onSave({
|
||||
points: Number(points),
|
||||
userId: config?.user?.userId,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<CreditCard
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
}}
|
||||
/>
|
||||
<Typography level='h4' sx={{ fontWeight: 600 }}>
|
||||
Redeem Points
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* User Info Card */}
|
||||
<Card
|
||||
variant='soft'
|
||||
@@ -155,6 +160,7 @@ function RedeemPointsModal({ config }) {
|
||||
{predefinedPoints.map(point => (
|
||||
<IconButton
|
||||
key={point}
|
||||
aria-label={`Add ${point} points`}
|
||||
variant='outlined'
|
||||
disabled={points + point > config?.available}
|
||||
onClick={() => addPredefinedPoints(point)}
|
||||
@@ -209,43 +215,6 @@ function RedeemPointsModal({ config }) {
|
||||
</Typography>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Stack direction='row' spacing={2}>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={config?.onClose}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'neutral.50',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() =>
|
||||
config?.onSave({
|
||||
points: Number(points),
|
||||
userId: config?.user?.userId,
|
||||
})
|
||||
}
|
||||
disabled={!canRedeem}
|
||||
fullWidth
|
||||
startDecorator={<CreditCard />}
|
||||
sx={{
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Redeem
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
|
||||
@@ -222,7 +222,10 @@ const ProjectView = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
|
||||
const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
|
||||
const { setSelectedProjectWithCache } = useProjectFilter(projectsData)
|
||||
const { setSelectedProjectWithCache } = useProjectFilter(
|
||||
projectsData,
|
||||
!projectsLoading,
|
||||
)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [userProjects, setUserProjects] = useState([])
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Refresh, Token } from '@mui/icons-material'
|
||||
import { Refresh, Star, Token } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { useSSEContext } from '../../hooks/useSSEContext'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
evaluatePromptEligibility,
|
||||
isFeedbackSubmissionConfigured,
|
||||
isRawChatWebhookConfigured,
|
||||
requestStoreReview,
|
||||
resetFeedbackState,
|
||||
setDevForcedPrompt,
|
||||
} from '../../service/FeedbackService'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
@@ -13,11 +22,13 @@ import { RefreshToken } from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
|
||||
import FeedbackModal from '../Modals/FeedbackModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
|
||||
const DeveloperSettings = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
@@ -41,6 +52,8 @@ const DeveloperSettings = () => {
|
||||
const [scheduledNotifications, setScheduledNotifications] = useState([])
|
||||
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
|
||||
const [isResettingSync, setIsResettingSync] = useState(false)
|
||||
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
|
||||
const [feedbackEligibility, setFeedbackEligibility] = useState(null)
|
||||
const [syncDiagnostics, setSyncDiagnostics] = useState({
|
||||
cursor: null,
|
||||
lastSync: null,
|
||||
@@ -362,6 +375,45 @@ const DeveloperSettings = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const refreshFeedbackEligibility = useCallback(async () => {
|
||||
const result = await evaluatePromptEligibility({ userProfile })
|
||||
setFeedbackEligibility(result)
|
||||
return result
|
||||
}, [userProfile])
|
||||
|
||||
useEffect(() => {
|
||||
refreshFeedbackEligibility()
|
||||
}, [refreshFeedbackEligibility])
|
||||
|
||||
const handleForceFeedbackPrompt = async () => {
|
||||
await setDevForcedPrompt(true)
|
||||
await refreshFeedbackEligibility()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Next visit to My Chores will show the prompt after ~4s',
|
||||
})
|
||||
}
|
||||
|
||||
const handleResetFeedbackState = async () => {
|
||||
await resetFeedbackState()
|
||||
await refreshFeedbackEligibility()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Feedback state cleared (completions, cooldown, opt-out)',
|
||||
})
|
||||
}
|
||||
|
||||
const handleRequestStoreReview = async () => {
|
||||
const requested = await requestStoreReview()
|
||||
await refreshFeedbackEligibility()
|
||||
showNotification({
|
||||
type: requested ? 'success' : 'warning',
|
||||
message: requested
|
||||
? 'Review requested. The OS decides whether to actually show it.'
|
||||
: 'Not available — native platform only.',
|
||||
})
|
||||
}
|
||||
|
||||
const getNotificationStatusColor = scheduleTime => {
|
||||
if (!scheduleTime) return 'neutral'
|
||||
|
||||
@@ -642,6 +694,166 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-lg'>Feedback & Review Prompt</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Refresh />}
|
||||
onClick={refreshFeedbackEligibility}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Prompt Eligibility</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Would auto-show:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={feedbackEligibility?.eligible ? 'success' : 'neutral'}
|
||||
>
|
||||
{feedbackEligibility?.eligible ? 'Yes' : 'No'}
|
||||
</Chip>
|
||||
{feedbackEligibility?.forced && (
|
||||
<Chip size='sm' variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Forced
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
{feedbackEligibility?.blockers?.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{feedbackEligibility.blockers.map(blocker => (
|
||||
<Typography key={blocker} level='body-xs' color='neutral'>
|
||||
• {blocker}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>State</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Completions counted:{' '}
|
||||
<Chip size='sm' variant='soft'>
|
||||
{feedbackEligibility?.state?.completions ?? 'N/A'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Last sentiment:{' '}
|
||||
<Chip size='sm' variant='soft'>
|
||||
{feedbackEligibility?.state?.lastSentiment ?? 'None'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Dismissals:{' '}
|
||||
<Chip size='sm' variant='soft'>
|
||||
{feedbackEligibility?.state?.dismissCount ?? 0}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Opted out:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={
|
||||
feedbackEligibility?.state?.optedOut ? 'danger' : 'success'
|
||||
}
|
||||
>
|
||||
{feedbackEligibility?.state?.optedOut ? 'Yes' : 'No'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Last prompted:{' '}
|
||||
{formatDateTime(feedbackEligibility?.state?.lastPromptedAt)}
|
||||
{feedbackEligibility?.state?.lastPromptedVersion
|
||||
? ` on ${feedbackEligibility.state.lastPromptedVersion}`
|
||||
: ''}
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Review requested:{' '}
|
||||
{formatDateTime(feedbackEligibility?.state?.reviewRequestedAt)}
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Current version: {feedbackEligibility?.version ?? 'N/A'} · Webhook{' '}
|
||||
{isFeedbackSubmissionConfigured()
|
||||
? 'configured'
|
||||
: 'NOT configured (submissions log to console)'}
|
||||
</Typography>
|
||||
{isRawChatWebhookConfigured() && (
|
||||
<Typography level='body-xs' color='danger'>
|
||||
VITE_FEEDBACK_WEBHOOK_URL points straight at a Discord/Slack
|
||||
webhook. Discord rejects that with 50006, and the URL ships
|
||||
inside the public bundle — deploy workers/feedback and point the
|
||||
variable at the Worker.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Actions</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
"Force next prompt" bypasses every gate, then open My
|
||||
Chores to see the automatic trigger.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
onClick={() => setFeedbackModalOpen(true)}
|
||||
>
|
||||
Open Flow
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleForceFeedbackPrompt}
|
||||
>
|
||||
Force Next Prompt
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Star />}
|
||||
onClick={handleRequestStoreReview}
|
||||
disabled={!isNativePlatform}
|
||||
>
|
||||
Request Store Review
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={handleResetFeedbackState}
|
||||
>
|
||||
Reset State
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{isNativePlatform && (
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -990,6 +1202,14 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<FeedbackModal
|
||||
open={feedbackModalOpen}
|
||||
onClose={() => {
|
||||
setFeedbackModalOpen(false)
|
||||
refreshFeedbackEligibility()
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalDialog,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Alert, Box, Button, Card, Input, Stack, Typography } from '@mui/joy'
|
||||
import QRCode from 'qrcode'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AppModal from '../../components/common/AppModal'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import {
|
||||
ConfirmMFA,
|
||||
DisableMFA,
|
||||
GetMFAStatus,
|
||||
RegenerateBackupCodes,
|
||||
SetupMFA,
|
||||
} from '../../utils/Fetcher'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
@@ -168,24 +158,6 @@ const MFASettings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
try {
|
||||
setError('')
|
||||
const response = await RegenerateBackupCodes()
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setBackupCodes(data.backupCodes)
|
||||
setBackupCodesModalOpen(true)
|
||||
setSuccess('New backup codes have been generated!')
|
||||
} else {
|
||||
setError('Failed to regenerate backup codes. Please try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to regenerate backup codes. Please try again.')
|
||||
console.error('Error regenerating backup codes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closeSetupModal = () => {
|
||||
setSetupModalOpen(false)
|
||||
setSetupStep(1)
|
||||
@@ -263,7 +235,7 @@ const MFASettings = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
{/*
|
||||
{/*
|
||||
{mfaEnabled && (
|
||||
<Card variant='outlined'>
|
||||
<Box className='flex items-center justify-between'>
|
||||
@@ -290,193 +262,105 @@ const MFASettings = () => {
|
||||
)} */}
|
||||
|
||||
{/* Setup MFA Modal */}
|
||||
<Modal open={setupModalOpen} onClose={closeSetupModal}>
|
||||
<ModalDialog size='md' sx={{ maxWidth: 500 }}>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Set up Multi-Factor Authentication
|
||||
</Typography>
|
||||
|
||||
{setupStep === 1 && setupData && (
|
||||
<Stack spacing={3}>
|
||||
<Typography level='body-md'>
|
||||
<strong>Step 1:</strong> Scan the QR code below with your
|
||||
authenticator app (Google Authenticator, Authy, etc.)
|
||||
</Typography>
|
||||
|
||||
<Box className='flex justify-center rounded bg-white p-4'>
|
||||
{qrCodeDataUrl || setupData.qrCode ? (
|
||||
<img
|
||||
src={
|
||||
qrCodeDataUrl ||
|
||||
`data:image/png;base64,${setupData.qrCode}`
|
||||
}
|
||||
alt='MFA QR Code'
|
||||
style={{ maxWidth: '200px', maxHeight: '200px' }}
|
||||
/>
|
||||
) : (
|
||||
<Alert color='danger'>
|
||||
QR code could not be generated. Please try again or use
|
||||
the manual entry key below.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm'>
|
||||
<strong>Manual entry key:</strong>
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{setupData.secret}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={() => setSetupStep(2)}
|
||||
startDecorator={<Smartphone />}
|
||||
>
|
||||
I've added the account to my app
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{setupStep === 2 && (
|
||||
<Stack spacing={3}>
|
||||
<Typography level='body-md'>
|
||||
<strong>Step 2:</strong> Enter the 6-digit verification code
|
||||
from your authenticator app
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
placeholder='Enter 6-digit code'
|
||||
value={verificationCode}
|
||||
size='lg'
|
||||
// send on enter:
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && verificationCode.length === 6) {
|
||||
handleConfirmMFA()
|
||||
}
|
||||
}}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.2em',
|
||||
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: 6,
|
||||
pattern: '[0-9]*',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <Alert color='danger'>{error}</Alert>}
|
||||
|
||||
<Box className='flex gap-2'>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={() => setSetupStep(1)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={handleConfirmMFA}
|
||||
disabled={verificationCode.length !== 6}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Verify & Enable
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{setupStep === 3 && (
|
||||
<Stack spacing={3}>
|
||||
<Box className='text-center'>
|
||||
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
|
||||
<Typography level='h4' color='success'>
|
||||
MFA Successfully Enabled!
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert color='warning'>
|
||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||
Save these backup codes in a safe place
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
You can use these codes to access your account if you lose
|
||||
your authenticator device. Each code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Card variant='outlined' sx={{ p: 2 }}>
|
||||
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
|
||||
{backupCodes?.map((code, index) => (
|
||||
<Typography
|
||||
key={index}
|
||||
level='body-sm'
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{code}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Button color='primary' onClick={closeSetupModal}>
|
||||
I've saved my backup codes
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
|
||||
{/* Disable MFA Modal */}
|
||||
<Modal open={disableModalOpen} onClose={closeDisableModal}>
|
||||
<ModalDialog size='sm'>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Disable Multi-Factor Authentication
|
||||
</Typography>
|
||||
|
||||
<AppModal
|
||||
open={setupModalOpen}
|
||||
onClose={closeSetupModal}
|
||||
title='Set up Multi-Factor Authentication'
|
||||
size='md'
|
||||
footer={
|
||||
setupStep === 1 ? (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: closeSetupModal }}
|
||||
primary={{
|
||||
label: "I've added the account",
|
||||
onClick: () => setSetupStep(2),
|
||||
startDecorator: <Smartphone />,
|
||||
}}
|
||||
/>
|
||||
) : setupStep === 2 ? (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Back', onClick: () => setSetupStep(1) }}
|
||||
primary={{
|
||||
label: 'Verify & Enable',
|
||||
onClick: handleConfirmMFA,
|
||||
disabled: verificationCode.length !== 6,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ModalActions
|
||||
primary={{
|
||||
label: "I've saved my backup codes",
|
||||
onClick: closeSetupModal,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{setupStep === 1 && setupData && (
|
||||
<Stack spacing={3}>
|
||||
<Alert color='warning'>
|
||||
<Typography level='body-sm'>
|
||||
Disabling MFA will make your account less secure. Are you sure
|
||||
you want to continue?
|
||||
<Typography level='body-md'>
|
||||
<strong>Step 1:</strong> Scan the QR code below with your
|
||||
authenticator app (Google Authenticator, Authy, etc.)
|
||||
</Typography>
|
||||
|
||||
<Box className='flex justify-center rounded bg-white p-4'>
|
||||
{qrCodeDataUrl || setupData.qrCode ? (
|
||||
<img
|
||||
src={
|
||||
qrCodeDataUrl ||
|
||||
`data:image/png;base64,${setupData.qrCode}`
|
||||
}
|
||||
alt='MFA QR Code'
|
||||
style={{ maxWidth: '200px', maxHeight: '200px' }}
|
||||
/>
|
||||
) : (
|
||||
<Alert color='danger'>
|
||||
QR code could not be generated. Please try again or use the
|
||||
manual entry key below.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm'>
|
||||
<strong>Manual entry key:</strong>
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{setupData.secret}
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{setupStep === 2 && (
|
||||
<Stack spacing={3}>
|
||||
<Typography level='body-md'>
|
||||
Enter a verification code from your authenticator app to
|
||||
confirm:
|
||||
<strong>Step 2:</strong> Enter the 6-digit verification code
|
||||
from your authenticator app
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
placeholder='Enter 6-digit code'
|
||||
value={disableCode}
|
||||
value={verificationCode}
|
||||
size='lg'
|
||||
// send on enter:
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && disableCode.length === 6) {
|
||||
handleDisableMFA()
|
||||
if (e.key === 'Enter' && verificationCode.length === 6) {
|
||||
handleConfirmMFA()
|
||||
}
|
||||
}}
|
||||
onChange={e => setDisableCode(e.target.value)}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.2em',
|
||||
@@ -491,44 +375,25 @@ const MFASettings = () => {
|
||||
/>
|
||||
|
||||
{error && <Alert color='danger'>{error}</Alert>}
|
||||
|
||||
<Box className='flex gap-2'>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={closeDisableModal}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={handleDisableMFA}
|
||||
disabled={disableCode.length !== 6}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
Disable MFA
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
|
||||
{/* Backup Codes Modal */}
|
||||
<Modal
|
||||
open={backupCodesModalOpen}
|
||||
onClose={() => setBackupCodesModalOpen(false)}
|
||||
>
|
||||
<ModalDialog size='sm'>
|
||||
<ModalClose />
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
New Backup Codes
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{setupStep === 3 && (
|
||||
<Stack spacing={3}>
|
||||
<Box className='text-center'>
|
||||
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
|
||||
<Typography level='h4' color='success'>
|
||||
MFA Successfully Enabled!
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert color='warning'>
|
||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||
Save these backup codes in a safe place
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Your previous backup codes are now invalid. Save these new
|
||||
codes in a safe place. Each code can only be used once.
|
||||
You can use these codes to access your account if you lose
|
||||
your authenticator device. Each code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
@@ -545,16 +410,107 @@ const MFASettings = () => {
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
onClick={() => setBackupCodesModalOpen(false)}
|
||||
>
|
||||
I've saved my backup codes
|
||||
</Button>
|
||||
</Stack>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
)}
|
||||
</AppModal>
|
||||
|
||||
{/* Disable MFA Modal */}
|
||||
<AppModal
|
||||
open={disableModalOpen}
|
||||
onClose={closeDisableModal}
|
||||
title='Disable Multi-Factor Authentication'
|
||||
size='sm'
|
||||
role='alertdialog'
|
||||
closeOnBackdrop={false}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: closeDisableModal }}
|
||||
primary={{
|
||||
label: 'Disable MFA',
|
||||
color: 'danger',
|
||||
onClick: handleDisableMFA,
|
||||
disabled: disableCode.length !== 6,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Alert color='warning'>
|
||||
<Typography level='body-sm'>
|
||||
Disabling MFA will make your account less secure. Are you sure
|
||||
you want to continue?
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Typography level='body-md'>
|
||||
Enter a verification code from your authenticator app to confirm:
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
placeholder='Enter 6-digit code'
|
||||
value={disableCode}
|
||||
size='lg'
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && disableCode.length === 6) {
|
||||
handleDisableMFA()
|
||||
}
|
||||
}}
|
||||
onChange={e => setDisableCode(e.target.value)}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.2em',
|
||||
letterSpacing: verificationCode.length === 0 ? '' : '0.4em',
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: 6,
|
||||
pattern: '[0-9]*',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <Alert color='danger'>{error}</Alert>}
|
||||
</Stack>
|
||||
</AppModal>
|
||||
|
||||
{/* Backup Codes Modal */}
|
||||
<AppModal
|
||||
open={backupCodesModalOpen}
|
||||
onClose={() => setBackupCodesModalOpen(false)}
|
||||
title='New Backup Codes'
|
||||
size='sm'
|
||||
footer={
|
||||
<ModalActions
|
||||
primary={{
|
||||
label: "I've saved my backup codes",
|
||||
onClick: () => setBackupCodesModalOpen(false),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Alert color='warning'>
|
||||
<Typography level='body-sm'>
|
||||
Your previous backup codes are now invalid. Save these new codes
|
||||
in a safe place. Each code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Card variant='outlined' sx={{ p: 2 }}>
|
||||
<Box className='grid grid-cols-2 gap-2 font-mono text-sm'>
|
||||
{backupCodes?.map((code, index) => (
|
||||
<Typography
|
||||
key={index}
|
||||
level='body-sm'
|
||||
sx={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{code}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
</Stack>
|
||||
</AppModal>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Modal from '@mui/joy/Modal'
|
||||
import ModalDialog from '@mui/joy/ModalDialog'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useRef, useState } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppModal from '../../components/common/AppModal'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
@@ -141,9 +141,7 @@ const ProfileSettings = () => {
|
||||
return (
|
||||
<SettingsLayout title={t('profile.title')}>
|
||||
<div className='grid gap-4 py-4' id='profile'>
|
||||
<Typography level='body-md'>
|
||||
{t('profile.description')}
|
||||
</Typography>
|
||||
<Typography level='body-md'>{t('profile.description')}</Typography>
|
||||
<Card
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -153,7 +151,10 @@ const ProfileSettings = () => {
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<Avatar src={resolvePhotoURL(userProfile?.image)} sx={{ width: 64, height: 64 }} />
|
||||
<Avatar
|
||||
src={resolvePhotoURL(userProfile?.image)}
|
||||
sx={{ width: 64, height: 64 }}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Button
|
||||
variant='soft'
|
||||
@@ -173,74 +174,56 @@ const ProfileSettings = () => {
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
<Modal
|
||||
<AppModal
|
||||
open={showCropper}
|
||||
onClose={() => {
|
||||
setShowCropper(false)
|
||||
setSelectedFile(null)
|
||||
}}
|
||||
>
|
||||
<ModalDialog
|
||||
layout='center'
|
||||
sx={{
|
||||
width: 360,
|
||||
maxWidth: '90vw',
|
||||
bgcolor: '#fff',
|
||||
borderRadius: 2,
|
||||
boxShadow: 24,
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 420,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 320, height: 320, position: 'relative', mt: 2 }}>
|
||||
<Cropper
|
||||
image={selectedFile}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape='round'
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
width: '100%',
|
||||
p: 2,
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleCropSave}
|
||||
loading={isUploading}
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='md'
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{t('profile.save')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
title={t('profile.editPhoto', { defaultValue: 'Edit profile photo' })}
|
||||
size='sm'
|
||||
closeOnBackdrop={!isUploading}
|
||||
closeOnEscape={!isUploading}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{
|
||||
label: t('profile.cancel'),
|
||||
disabled: isUploading,
|
||||
onClick: () => {
|
||||
setShowCropper(false)
|
||||
setSelectedFile(null)
|
||||
}}
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
>
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
</Box>
|
||||
</ModalDialog>
|
||||
</Modal>
|
||||
},
|
||||
}}
|
||||
primary={{
|
||||
label: t('profile.save'),
|
||||
loading: isUploading,
|
||||
onClick: handleCropSave,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 320,
|
||||
aspectRatio: '1',
|
||||
position: 'relative',
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
<Cropper
|
||||
image={selectedFile}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape='round'
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</Box>
|
||||
</AppModal>
|
||||
<Box sx={{ maxWidth: 400, mt: 3 }}>
|
||||
<Typography level='body-sm' sx={{ mb: 0.5 }}>
|
||||
{t('profile.displayName')}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Circle,
|
||||
Code,
|
||||
FamilyRestroom,
|
||||
Feedback,
|
||||
Language,
|
||||
Notifications,
|
||||
Palette,
|
||||
@@ -31,16 +32,19 @@ import {
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { isParentUser } from '../../utils/UserHelpers'
|
||||
import FeedbackModal from '../Modals/FeedbackModal'
|
||||
|
||||
const SettingsOverview = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const navigate = useNavigate()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
||||
|
||||
const settingsCards = [
|
||||
{
|
||||
@@ -122,10 +126,21 @@ const SettingsOverview = () => {
|
||||
description: t('overview.sections.developer.description'),
|
||||
icon: <Code />,
|
||||
},
|
||||
{
|
||||
id: 'feedback',
|
||||
title: t('overview.sections.feedback.title'),
|
||||
description: t('overview.sections.feedback.description'),
|
||||
icon: <Feedback />,
|
||||
onSelect: () => setFeedbackOpen(true),
|
||||
},
|
||||
]
|
||||
|
||||
const handleCardClick = settingId => {
|
||||
navigate(`/settings/${settingId}`)
|
||||
const handleCardClick = setting => {
|
||||
if (setting.onSelect) {
|
||||
setting.onSelect()
|
||||
return
|
||||
}
|
||||
navigate(`/settings/${setting.id}`)
|
||||
}
|
||||
|
||||
// Filter settings based on user type
|
||||
@@ -303,7 +318,7 @@ const SettingsOverview = () => {
|
||||
{getAvailableSettings().map((setting, index) => (
|
||||
<ListItem key={setting.id} sx={{ p: 0 }}>
|
||||
<ListItemButton
|
||||
onClick={() => handleCardClick(setting.id)}
|
||||
onClick={() => handleCardClick(setting)}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'background.level1',
|
||||
@@ -367,6 +382,11 @@ const SettingsOverview = () => {
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<FeedbackModal
|
||||
open={feedbackOpen}
|
||||
onClose={() => setFeedbackOpen(false)}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,8 +105,10 @@ const TermsView = () => {
|
||||
</ul>
|
||||
|
||||
<h2>5. Acceptable Use Policy</h2>
|
||||
<p><em>Applies to both Cloud and Self-Hosted Services</em></p>
|
||||
|
||||
<p>
|
||||
<em>Applies to both Cloud and Self-Hosted Services</em>
|
||||
</p>
|
||||
|
||||
<h3>You may not use our services to:</h3>
|
||||
<ul>
|
||||
<li>Violate any applicable laws or regulations</li>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMediaQuery } from '@mui/material'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
@@ -42,7 +43,7 @@ import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import ScanPanel from './ScanToTask/ScanPanel'
|
||||
import SubTasks from './SubTask'
|
||||
import { buildChorePayload } from './VoiceToTask/parseVoiceTask'
|
||||
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
|
||||
import VoicePanel from './VoiceToTask/VoicePanel'
|
||||
const getDefaultNotification = () => {
|
||||
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
|
||||
@@ -75,6 +76,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
// Stable identities for the voice panel: these queries are undefined while
|
||||
// loading, and a fresh [] each render would churn the panel's parse context
|
||||
const voiceLabels = useMemo(() => userLabels || [], [userLabels])
|
||||
const voiceMembers = useMemo(() => circleMembers?.res || [], [circleMembers])
|
||||
|
||||
const handleCreateLabel = useCallback(
|
||||
name => {
|
||||
const color =
|
||||
@@ -144,6 +150,19 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
const [llmAvailable, setLlmAvailable] = useState(false)
|
||||
const [showVoice, setShowVoice] = useState(false)
|
||||
const [voiceAvailable, setVoiceAvailable] = useState(false)
|
||||
// Voice capture state, reported up by VoicePanel so the modal footer owns
|
||||
// the confirm action instead of the panel having its own button row
|
||||
const [voiceState, setVoiceState] = useState({
|
||||
segments: [],
|
||||
isListening: false,
|
||||
})
|
||||
const [creatingVoiceTasks, setCreatingVoiceTasks] = useState(false)
|
||||
// Same arrangement for the scan panel: it reports the action for its
|
||||
// current phase and the modal footer renders it
|
||||
const [scanState, setScanState] = useState({
|
||||
phase: 'idle',
|
||||
primaryAction: null,
|
||||
})
|
||||
const { isNativeScanner } = useDocumentScanner()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -670,6 +689,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
// Multiple voice-captured tasks: they were reviewed as cards in the panel,
|
||||
// so create them all directly.
|
||||
const handleVoiceCreateMany = async parsedTasks => {
|
||||
setCreatingVoiceTasks(true)
|
||||
const notificationTemplates = getDefaultNotification()
|
||||
for (const parsed of parsedTasks) {
|
||||
const chore = buildChorePayload(parsed, {
|
||||
@@ -693,13 +713,40 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
console.error('Error creating voice task:', error)
|
||||
}
|
||||
}
|
||||
setCreatingVoiceTasks(false)
|
||||
handleCloseModal(false)
|
||||
}
|
||||
|
||||
// Footer confirm while the voice panel is open: one task lands in the smart
|
||||
// input for review, several are created straight away.
|
||||
const handleVoiceConfirm = () => {
|
||||
const { segments } = voiceState
|
||||
if (segments.length === 1) {
|
||||
handleVoiceSingle(segments[0].text, segments[0].overrides || {})
|
||||
} else if (segments.length > 1) {
|
||||
// Parse only at confirm time — the cards already parse for their own
|
||||
// display, so there's no need to keep a parsed copy in modal state
|
||||
const parseCtx = {
|
||||
userLabels: voiceLabels,
|
||||
members: voiceMembers,
|
||||
currentUserId: userProfile?.id,
|
||||
}
|
||||
handleVoiceCreateMany(
|
||||
segments.map(segment => ({
|
||||
...parseVoiceTask(segment.text, parseCtx),
|
||||
...(segment.overrides || {}),
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseModal = forceRefetch => {
|
||||
onClose(forceRefetch)
|
||||
setShowScan(false)
|
||||
setShowVoice(false)
|
||||
setVoiceState({ segments: [], isListening: false })
|
||||
setScanState({ phase: 'idle', primaryAction: null })
|
||||
setCreatingVoiceTasks(false)
|
||||
setTaskText('')
|
||||
setTaskTitle('')
|
||||
setDueDate(null)
|
||||
@@ -831,16 +878,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
fullWidth={true}
|
||||
title='Create new task'
|
||||
footer={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'end',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<ModalActions>
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleCloseModal}
|
||||
@@ -854,10 +893,40 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{/* Sub-panels (voice/scan) own their own confirm action */}
|
||||
{showVoice && (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
loading={creatingVoiceTasks}
|
||||
disabled={
|
||||
voiceState.segments.length === 0 || voiceState.isListening
|
||||
}
|
||||
onClick={handleVoiceConfirm}
|
||||
>
|
||||
{creatingVoiceTasks
|
||||
? 'Creating…'
|
||||
: voiceState.segments.length > 1
|
||||
? `Create ${voiceState.segments.length} Tasks`
|
||||
: 'Use Task'}
|
||||
</Button>
|
||||
)}
|
||||
{showScan && scanState.primaryAction && (
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={scanState.primaryAction.icon}
|
||||
onClick={scanState.primaryAction.onClick}
|
||||
>
|
||||
{scanState.primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{showScan && scanState.phase === 'processing' && (
|
||||
<Button variant='solid' color='primary' loading disabled>
|
||||
Processing
|
||||
</Button>
|
||||
)}
|
||||
{!showScan && !showVoice && (
|
||||
<Button
|
||||
size='lg'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!taskTitle.trim()}
|
||||
@@ -869,7 +938,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</ModalActions>
|
||||
}
|
||||
>
|
||||
{!showScan && !showVoice && (
|
||||
@@ -1219,12 +1288,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
|
||||
{showVoice && (
|
||||
<VoicePanel
|
||||
open
|
||||
userLabels={userLabels || []}
|
||||
members={circleMembers?.res || []}
|
||||
userLabels={voiceLabels}
|
||||
members={voiceMembers}
|
||||
userProfile={userProfile}
|
||||
onUseSingle={handleVoiceSingle}
|
||||
onCreateMany={handleVoiceCreateMany}
|
||||
onStateChange={setVoiceState}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1234,10 +1301,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
autoCapture={scanAutoCapture}
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
initialImageUrl={pendingPhotoUrl}
|
||||
onStateChange={setScanState}
|
||||
onClose={() => {
|
||||
setShowScan(false)
|
||||
setScanAutoCapture(false)
|
||||
setPendingPhotoUrl(null)
|
||||
setScanState({ phase: 'idle', primaryAction: null })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
Avatar,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
@@ -33,8 +36,10 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import AppModal from '../../components/common/AppModal'
|
||||
import LABEL_COLORS, {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../utils/Colors'
|
||||
@@ -64,6 +69,8 @@ const ChoreActionMenu = ({
|
||||
const menuRef = React.useRef(null)
|
||||
const navigate = useNavigate()
|
||||
const { data: projects = [] } = useProjects()
|
||||
// Phone-only condition (matches AddTaskModal.jsx) — tablets/desktop keep the Menu
|
||||
const isSmallScreen = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -75,11 +82,19 @@ const ChoreActionMenu = ({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
// AppModal owns its own backdrop/escape close behavior on small screens.
|
||||
if (anchorEl && onOpen) {
|
||||
onOpen()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const handleMenuOutsideClick = event => {
|
||||
if (
|
||||
anchorEl &&
|
||||
!anchorEl.contains(event.target) &&
|
||||
!menuRef.current.contains(event.target)
|
||||
!menuRef.current?.contains(event.target)
|
||||
) {
|
||||
handleMenuClose()
|
||||
}
|
||||
@@ -92,7 +107,7 @@ const ChoreActionMenu = ({
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
||||
}
|
||||
}, [anchorEl, onOpen])
|
||||
}, [anchorEl, onOpen, isSmallScreen])
|
||||
|
||||
const handleMenuOpen = event => {
|
||||
event.stopPropagation()
|
||||
@@ -229,6 +244,255 @@ const ChoreActionMenu = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Shared action list, rendered as MenuItems on large screens and as a
|
||||
// ListItemButton list inside an AppModal sheet on small screens.
|
||||
const actionItems = [
|
||||
{
|
||||
key: 'completeNote',
|
||||
icon: <NoteAdd />,
|
||||
label: 'Complete with note',
|
||||
onClick: () => {
|
||||
onCompleteWithNote?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'completePast',
|
||||
icon: <Update />,
|
||||
label: 'Complete in past',
|
||||
onClick: () => {
|
||||
onCompleteWithPastDate?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'skip',
|
||||
icon: <SwitchAccessShortcut />,
|
||||
label: 'Skip to next due date',
|
||||
onClick: handleSkip,
|
||||
},
|
||||
{
|
||||
key: 'delegate',
|
||||
icon: <RecordVoiceOver />,
|
||||
label: 'Delegate to someone else',
|
||||
onClick: () => {
|
||||
onChangeAssignee?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
isOfficialInstance && {
|
||||
key: 'nudge',
|
||||
icon: <Notifications />,
|
||||
label: 'Send nudge',
|
||||
onClick: () => {
|
||||
onNudge?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{ key: 'divider-1', type: 'divider' },
|
||||
{
|
||||
key: 'history',
|
||||
icon: <ManageSearch />,
|
||||
label: 'History',
|
||||
onClick: handleHistory,
|
||||
},
|
||||
{ key: 'divider-2', type: 'divider' },
|
||||
{ key: 'quickSchedule', type: 'quickSchedule' },
|
||||
{ key: 'divider-3', type: 'divider' },
|
||||
{
|
||||
key: 'changeDueDate',
|
||||
icon: <MoreTime />,
|
||||
label: 'Change due date',
|
||||
onClick: () => {
|
||||
onChangeDueDate?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'writeNfc',
|
||||
icon: <Nfc />,
|
||||
label: 'Write to NFC',
|
||||
onClick: () => {
|
||||
onWriteNFC?.()
|
||||
handleMenuClose()
|
||||
},
|
||||
},
|
||||
{ key: 'edit', icon: <Edit />, label: 'Edit', onClick: handleEdit },
|
||||
{ key: 'clone', icon: <CopyAll />, label: 'Clone', onClick: handleClone },
|
||||
{ key: 'view', icon: <ViewCarousel />, label: 'View', onClick: handleView },
|
||||
{
|
||||
key: 'archive',
|
||||
icon: chore.isActive ? <Archive /> : <Unarchive />,
|
||||
label: chore.isActive ? 'Archive' : 'Unarchive',
|
||||
onClick: handleArchive,
|
||||
color: 'neutral',
|
||||
},
|
||||
projects.length > 0 && {
|
||||
key: 'moveToProject',
|
||||
icon: <DriveFileMove />,
|
||||
label: 'Move to project',
|
||||
onClick: () => setShowProjectPicker(true),
|
||||
},
|
||||
{ key: 'divider-4', type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <Delete />,
|
||||
label: 'Delete',
|
||||
onClick: handleDelete,
|
||||
color: 'danger',
|
||||
},
|
||||
].filter(Boolean)
|
||||
|
||||
const quickScheduleButtons = (
|
||||
<>
|
||||
<Tooltip title='Today' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('today')
|
||||
}}
|
||||
>
|
||||
<Today />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Tomorrow' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('tomorrow')
|
||||
}}
|
||||
>
|
||||
<WbSunny />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Weekend' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('weekend')
|
||||
}}
|
||||
>
|
||||
<Weekend />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Next week' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('next-week')
|
||||
}}
|
||||
>
|
||||
<NextWeek />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Remove due date' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
color='neutral'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('remove')
|
||||
}}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
|
||||
const quickScheduleRowSx = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
}
|
||||
|
||||
const renderMenuActionItems = () =>
|
||||
actionItems.map(item => {
|
||||
if (item.type === 'divider') return <Divider key={item.key} />
|
||||
if (item.type === 'quickSchedule') {
|
||||
return (
|
||||
<MenuItem
|
||||
key={item.key}
|
||||
sx={{
|
||||
...quickScheduleRowSx,
|
||||
cursor: 'default',
|
||||
'&:hover': { backgroundColor: 'transparent' },
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{quickScheduleButtons}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
key={item.key}
|
||||
color={item.color}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
item.onClick()
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
)
|
||||
})
|
||||
|
||||
const renderModalActionItems = () =>
|
||||
actionItems.map(item => {
|
||||
if (item.type === 'divider') return <Divider key={item.key} />
|
||||
if (item.type === 'quickSchedule') {
|
||||
return (
|
||||
<ListItem key={item.key} sx={quickScheduleRowSx}>
|
||||
{quickScheduleButtons}
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ListItem key={item.key}>
|
||||
<ListItemButton color={item.color} onClick={() => item.onClick()}>
|
||||
<ListItemDecorator>{item.icon}</ListItemDecorator>
|
||||
<ListItemContent>{item.label}</ListItemContent>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)
|
||||
})
|
||||
|
||||
const renderModalProjectPicker = () => (
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>Default Project</ListItemContent>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{projects.map(project => (
|
||||
<ListItem key={project.id}>
|
||||
<ListItemButton onClick={() => handleMoveToProject(project)}>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>{project.name}</ListItemContent>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -249,280 +513,95 @@ const ChoreActionMenu = ({
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
size='md'
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
{showProjectPicker ? (
|
||||
<>
|
||||
{isSmallScreen ? (
|
||||
<AppModal
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
title={showProjectPicker ? 'Move to project' : chore?.name}
|
||||
mobilePresentation='sheet'
|
||||
showHandle
|
||||
contentSx={{ px: 0, pb: 1 }}
|
||||
>
|
||||
{showProjectPicker && (
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setShowProjectPicker(false)
|
||||
}}
|
||||
sx={{ gap: 1 }}
|
||||
onClick={() => setShowProjectPicker(false)}
|
||||
sx={{ gap: 1, mx: 2, mb: 1 }}
|
||||
>
|
||||
<ArrowBack fontSize='small' />
|
||||
<Typography level='body-sm' fontWeight={600}>
|
||||
Move to project
|
||||
Back
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>Default Project</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
{projects.map(project => (
|
||||
)}
|
||||
<List sx={{ '--ListItem-radius': '8px', px: 1 }}>
|
||||
{showProjectPicker
|
||||
? renderModalProjectPicker()
|
||||
: renderModalActionItems()}
|
||||
</List>
|
||||
</AppModal>
|
||||
) : (
|
||||
<Menu
|
||||
size='md'
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
}}
|
||||
>
|
||||
{showProjectPicker ? (
|
||||
<>
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleMoveToProject(project)
|
||||
setShowProjectPicker(false)
|
||||
}}
|
||||
sx={{ gap: 1 }}
|
||||
>
|
||||
<ArrowBack fontSize='small' />
|
||||
<Typography level='body-sm' fontWeight={600}>
|
||||
Move to project
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleMoveToProject({ id: null, name: 'Default Project' })
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>{project.name}</Typography>
|
||||
<Typography level='body-sm'>Default Project</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onCompleteWithNote?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<NoteAdd />
|
||||
Complete with note
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onCompleteWithPastDate?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Update />
|
||||
Complete in past
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleSkip()
|
||||
}}
|
||||
>
|
||||
<SwitchAccessShortcut />
|
||||
Skip to next due date
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onChangeAssignee?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<RecordVoiceOver />
|
||||
Delegate to someone else
|
||||
</MenuItem>
|
||||
{isOfficialInstance && (
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onNudge?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Notifications />
|
||||
Send nudge
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleHistory()
|
||||
}}
|
||||
>
|
||||
<ManageSearch />
|
||||
History
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
cursor: 'default',
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip title='Today' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
{projects.map(project => (
|
||||
<MenuItem
|
||||
key={project.id}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('today')
|
||||
handleMoveToProject(project)
|
||||
}}
|
||||
>
|
||||
<Today />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Tomorrow' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('tomorrow')
|
||||
}}
|
||||
>
|
||||
<WbSunny />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Weekend' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('weekend')
|
||||
}}
|
||||
>
|
||||
<Weekend />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Next week' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('next-week')
|
||||
}}
|
||||
>
|
||||
<NextWeek />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Remove due date' placement='top'>
|
||||
<IconButton
|
||||
size='sm'
|
||||
color='neutral'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleQuickSchedule('remove')
|
||||
}}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onChangeDueDate?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<MoreTime />
|
||||
Change due date
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onWriteNFC?.()
|
||||
handleMenuClose()
|
||||
}}
|
||||
>
|
||||
<Nfc />
|
||||
Write to NFC
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleEdit()
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
Edit
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleClone()
|
||||
}}
|
||||
>
|
||||
<CopyAll />
|
||||
Clone
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleView()
|
||||
}}
|
||||
>
|
||||
<ViewCarousel />
|
||||
View
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleArchive()
|
||||
}}
|
||||
color='neutral'
|
||||
>
|
||||
{chore.isActive ? <Archive /> : <Unarchive />}
|
||||
{chore.isActive ? 'Archive' : 'Unarchive'}
|
||||
</MenuItem>
|
||||
{projects.length > 0 && (
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setShowProjectPicker(true)
|
||||
}}
|
||||
>
|
||||
<DriveFileMove />
|
||||
Move to project
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleDelete()
|
||||
}}
|
||||
color='danger'
|
||||
>
|
||||
<Delete />
|
||||
Delete
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
<ListItemDecorator>
|
||||
{renderProjectAvatar(project.color, project.icon)}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>{project.name}</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
renderMenuActionItems()
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,8 @@
|
||||
import {
|
||||
Bedtime,
|
||||
CalendarMonth,
|
||||
Close,
|
||||
EventNote,
|
||||
LightMode,
|
||||
NextWeek,
|
||||
NightsStay,
|
||||
Today,
|
||||
WbSunny,
|
||||
WbTwilight,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { CalendarMonth, Close } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useMemo, useState } from 'react'
|
||||
import DueDatePickerModal from './DueDatePickerModal'
|
||||
|
||||
const DueDatePickerField = ({
|
||||
dueDateOnly,
|
||||
@@ -39,106 +16,17 @@ const DueDatePickerField = ({
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { firstDayOfWeek } = useLocalization()
|
||||
|
||||
// Local buffered state — only committed on Apply
|
||||
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
|
||||
const [localDueTime, setLocalDueTime] = useState(dueTime)
|
||||
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
|
||||
|
||||
// Sync local state from props whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalDueDateOnly(dueDateOnly)
|
||||
setLocalDueTime(dueTime)
|
||||
setLocalUseCustomTime(useCustomTime)
|
||||
}
|
||||
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
const calendarType =
|
||||
firstDayOfWeek === 1
|
||||
? 'iso8601'
|
||||
: firstDayOfWeek === 6
|
||||
? 'islamic'
|
||||
: 'gregory'
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
const getQuickScheduleDate = option => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
switch (option) {
|
||||
case 'today':
|
||||
return today
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
return tomorrow
|
||||
}
|
||||
case 'weekend': {
|
||||
const weekend = new Date(today)
|
||||
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
|
||||
weekend.setDate(today.getDate() + daysUntilSaturday)
|
||||
return weekend
|
||||
}
|
||||
case 'next-week': {
|
||||
const nextWeek = new Date(today)
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
|
||||
nextWeek.setDate(today.getDate() + daysUntilMonday)
|
||||
return nextWeek
|
||||
}
|
||||
case 'next-month': {
|
||||
const nextMonth = new Date(today)
|
||||
nextMonth.setMonth(today.getMonth() + 1)
|
||||
return nextMonth
|
||||
}
|
||||
default:
|
||||
return today
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickSchedule = option => {
|
||||
const date = getQuickScheduleDate(option)
|
||||
setLocalDueDateOnly(date.toISOString().split('T')[0])
|
||||
}
|
||||
|
||||
const handleQuickTime = timeStr => {
|
||||
// Tap the active chip again to deselect it
|
||||
if (localUseCustomTime && localDueTime === timeStr) {
|
||||
setLocalUseCustomTime(false)
|
||||
setLocalDueTime(null)
|
||||
return
|
||||
}
|
||||
if (!localDueDateOnly) {
|
||||
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
|
||||
}
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(timeStr)
|
||||
}
|
||||
|
||||
const handleCalendarChange = selected => {
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
const handleLocalTimeInputChange = e => {
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(e.target.value)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
|
||||
onUseCustomTimeChange?.(localUseCustomTime)
|
||||
if (localUseCustomTime && localDueTime) {
|
||||
onDueTimeChange?.({ target: { value: localDueTime } })
|
||||
} else {
|
||||
onDueTimeChange?.({ target: { value: '' } })
|
||||
}
|
||||
const handleSave = ({
|
||||
dueDateOnly: nextDate,
|
||||
dueTime: nextTime,
|
||||
useCustomTime: nextUseCustomTime,
|
||||
}) => {
|
||||
onDueDateChange?.({ target: { value: nextDate || '' } })
|
||||
onUseCustomTimeChange?.(nextUseCustomTime)
|
||||
onDueTimeChange?.({
|
||||
target: { value: nextUseCustomTime && nextTime ? nextTime : '' },
|
||||
})
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
@@ -202,6 +90,7 @@ const DueDatePickerField = ({
|
||||
</Button>
|
||||
{hasDueDate && onClear && (
|
||||
<IconButton
|
||||
aria-label='Clear due date'
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
@@ -211,11 +100,9 @@ const DueDatePickerField = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
top: -18,
|
||||
right: -18,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': {
|
||||
bgcolor: 'danger.softBg',
|
||||
@@ -227,401 +114,22 @@ const DueDatePickerField = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResponsiveModal
|
||||
<DueDatePickerModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Due Date'
|
||||
fullWidth={false}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasDueDate && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onApply={handleSave}
|
||||
onRemove={
|
||||
onClear
|
||||
? () => {
|
||||
onClear()
|
||||
setIsOpen(false)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
|
||||
{/* Date shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick date
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
icon: <Today sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'tomorrow',
|
||||
label: 'Tomorrow',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'weekend',
|
||||
label: 'Weekend',
|
||||
icon: <Weekend sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-week',
|
||||
label: 'Next week',
|
||||
icon: <NextWeek sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-month',
|
||||
label: 'Next month',
|
||||
icon: <EventNote sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => {
|
||||
const dateStr = getQuickScheduleDate(opt.key)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
return (
|
||||
<ListItem key={opt.key}>
|
||||
<Checkbox
|
||||
checked={localDueDateOnly === dateStr}
|
||||
onClick={() => handleQuickSchedule(opt.key)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
|
||||
{/* Time shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick time
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
time: '09:00',
|
||||
label: 'Morning',
|
||||
icon: <LightMode sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '12:00',
|
||||
label: 'Noon',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '15:00',
|
||||
label: 'Afternoon',
|
||||
icon: <WbTwilight sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '18:00',
|
||||
label: 'Evening',
|
||||
icon: <NightsStay sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '22:00',
|
||||
label: 'Night',
|
||||
icon: <Bedtime sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => (
|
||||
<ListItem key={opt.time}>
|
||||
<Checkbox
|
||||
checked={localUseCustomTime && localDueTime === opt.time}
|
||||
onClick={() => handleQuickTime(opt.time)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
// Fix the height so switching views (month/year/decade) doesn't
|
||||
// cause layout shift — month view with 6 rows is the tallest.
|
||||
minHeight: 300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& .react-calendar': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__viewContainer': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
|
||||
{
|
||||
flex: 1,
|
||||
},
|
||||
// Navigation row
|
||||
'& .react-calendar__navigation': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
mb: 1,
|
||||
},
|
||||
// All nav buttons — large tap targets
|
||||
'& .react-calendar__navigation button': {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
minHeight: '40px',
|
||||
minWidth: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.35,
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
// Label button (month/year text) takes remaining space
|
||||
'& .react-calendar__navigation__label': {
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.01em',
|
||||
},
|
||||
// Prev/next arrow buttons — slightly larger icon feel
|
||||
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
|
||||
{
|
||||
fontSize: '1.75rem',
|
||||
},
|
||||
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
|
||||
{
|
||||
fontSize: '1.4rem',
|
||||
},
|
||||
// Weekday headers
|
||||
'& .react-calendar__month-view__weekdays__weekday': {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
textAlign: 'center',
|
||||
padding: '4px 0',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays__weekday abbr': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
// All tiles — shared base
|
||||
'& .react-calendar__tile': {
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
background: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
},
|
||||
// Day tiles only — circular
|
||||
'& .react-calendar__month-view__days .react-calendar__tile': {
|
||||
aspectRatio: '1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
// Month tiles (year view) — pill shape, no huge circle
|
||||
'& .react-calendar__year-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Year tiles (decade view) — pill shape
|
||||
'& .react-calendar__decade-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Century tiles — pill shape
|
||||
'& .react-calendar__century-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
'& .react-calendar__tile--now': {
|
||||
border:
|
||||
'1.5px solid var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidBg) !important',
|
||||
fontWeight: 700,
|
||||
background: 'none !important',
|
||||
},
|
||||
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
|
||||
{
|
||||
background: 'var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidColor) !important',
|
||||
fontWeight: 700,
|
||||
},
|
||||
'& .react-calendar__month-view__days__day--neighboringMonth': {
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
},
|
||||
'& .react-calendar__month-view__days': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
value={
|
||||
localDueDateOnly
|
||||
? new Date(`${localDueDateOnly}T00:00:00`)
|
||||
: null
|
||||
}
|
||||
calendarType={calendarType}
|
||||
onChange={handleCalendarChange}
|
||||
formatShortWeekday={(locale, date) =>
|
||||
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
|
||||
}
|
||||
formatMonth={(locale, date) =>
|
||||
[
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
][date.getMonth()]
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
mt: 0.5,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Custom time
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={localUseCustomTime ? localDueTime || '' : ''}
|
||||
disabled={!localDueDateOnly}
|
||||
onChange={handleLocalTimeInputChange}
|
||||
sx={{ maxWidth: 200, mb: 1 }}
|
||||
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={!localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(false)}
|
||||
>
|
||||
Anytime
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(true)}
|
||||
>
|
||||
Specific time
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
544
src/views/components/DueDatePickerModal.jsx
Normal file
544
src/views/components/DueDatePickerModal.jsx
Normal file
@@ -0,0 +1,544 @@
|
||||
import {
|
||||
Bedtime,
|
||||
EventNote,
|
||||
LightMode,
|
||||
NextWeek,
|
||||
NightsStay,
|
||||
Today,
|
||||
WbSunny,
|
||||
WbTwilight,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
// Split a date-ish value (ISO string / Date) into the parts this picker edits.
|
||||
export const splitDueDate = value => {
|
||||
if (!value) {
|
||||
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
|
||||
}
|
||||
const m = moment(value)
|
||||
if (!m.isValid()) {
|
||||
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
|
||||
}
|
||||
const time = m.format('HH:mm')
|
||||
return {
|
||||
dueDateOnly: m.format('YYYY-MM-DD'),
|
||||
dueTime: time,
|
||||
// Midnight is how a date-only value round-trips, so treat it as "anytime"
|
||||
useCustomTime: time !== '00:00',
|
||||
}
|
||||
}
|
||||
|
||||
// Inverse of splitDueDate — returns a Date, or null when there is no due date.
|
||||
export const combineDueDate = ({ dueDateOnly, dueTime, useCustomTime }) => {
|
||||
if (!dueDateOnly) return null
|
||||
const time = useCustomTime && dueTime ? dueTime : '00:00'
|
||||
return moment(`${dueDateOnly} ${time}`, 'YYYY-MM-DD HH:mm').toDate()
|
||||
}
|
||||
|
||||
export const getQuickScheduleDate = option => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
switch (option) {
|
||||
case 'today':
|
||||
return today
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
return tomorrow
|
||||
}
|
||||
case 'weekend': {
|
||||
const weekend = new Date(today)
|
||||
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
|
||||
weekend.setDate(today.getDate() + daysUntilSaturday)
|
||||
return weekend
|
||||
}
|
||||
case 'next-week': {
|
||||
const nextWeek = new Date(today)
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
|
||||
nextWeek.setDate(today.getDate() + daysUntilMonday)
|
||||
return nextWeek
|
||||
}
|
||||
case 'next-month': {
|
||||
const nextMonth = new Date(today)
|
||||
nextMonth.setMonth(today.getMonth() + 1)
|
||||
return nextMonth
|
||||
}
|
||||
default:
|
||||
return today
|
||||
}
|
||||
}
|
||||
|
||||
const toDateKey = date => moment(date).format('YYYY-MM-DD')
|
||||
|
||||
/**
|
||||
* The shared due-date picker UI (quick dates, quick times, calendar, custom
|
||||
* time). Used both by DueDatePickerField and by anything that needs to
|
||||
* reschedule a task — task cards, swipe actions, action menus.
|
||||
*/
|
||||
const DueDatePickerModal = ({
|
||||
open,
|
||||
onClose,
|
||||
title = 'Due Date',
|
||||
dueDateOnly,
|
||||
dueTime,
|
||||
useCustomTime,
|
||||
onApply,
|
||||
onRemove,
|
||||
applyLabel = 'Apply',
|
||||
}) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { firstDayOfWeek } = useLocalization()
|
||||
|
||||
// Local buffered state — only committed on Apply
|
||||
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
|
||||
const [localDueTime, setLocalDueTime] = useState(dueTime)
|
||||
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
|
||||
|
||||
// Sync local state from props whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLocalDueDateOnly(dueDateOnly)
|
||||
setLocalDueTime(dueTime)
|
||||
setLocalUseCustomTime(useCustomTime)
|
||||
}
|
||||
}, [open, dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
const calendarType =
|
||||
firstDayOfWeek === 1
|
||||
? 'iso8601'
|
||||
: firstDayOfWeek === 6
|
||||
? 'islamic'
|
||||
: 'gregory'
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
const handleQuickSchedule = option => {
|
||||
setLocalDueDateOnly(toDateKey(getQuickScheduleDate(option)))
|
||||
}
|
||||
|
||||
const handleQuickTime = timeStr => {
|
||||
// Tap the active chip again to deselect it
|
||||
if (localUseCustomTime && localDueTime === timeStr) {
|
||||
setLocalUseCustomTime(false)
|
||||
setLocalDueTime(null)
|
||||
return
|
||||
}
|
||||
if (!localDueDateOnly) {
|
||||
setLocalDueDateOnly(toDateKey(new Date()))
|
||||
}
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(timeStr)
|
||||
}
|
||||
|
||||
const handleCalendarChange = selected => {
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
const handleLocalTimeInputChange = e => {
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(e.target.value)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onApply?.({
|
||||
dueDateOnly: localDueDateOnly || null,
|
||||
dueTime: localUseCustomTime ? localDueTime || null : null,
|
||||
useCustomTime: Boolean(localUseCustomTime && localDueTime),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
fullWidth={false}
|
||||
footer={
|
||||
<ModalActions
|
||||
tertiary={
|
||||
onRemove && dueDateOnly
|
||||
? {
|
||||
label: 'Remove',
|
||||
color: 'danger',
|
||||
onClick: onRemove,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: onClose }}
|
||||
primary={{ label: applyLabel, onClick: handleSave }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
|
||||
{/* Date shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick date
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
icon: <Today sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'tomorrow',
|
||||
label: 'Tomorrow',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'weekend',
|
||||
label: 'Weekend',
|
||||
icon: <Weekend sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-week',
|
||||
label: 'Next week',
|
||||
icon: <NextWeek sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-month',
|
||||
label: 'Next month',
|
||||
icon: <EventNote sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => {
|
||||
const dateStr = toDateKey(getQuickScheduleDate(opt.key))
|
||||
return (
|
||||
<ListItem key={opt.key}>
|
||||
<Checkbox
|
||||
checked={localDueDateOnly === dateStr}
|
||||
onClick={() => handleQuickSchedule(opt.key)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
|
||||
{/* Time shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick time
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
time: '09:00',
|
||||
label: 'Morning',
|
||||
icon: <LightMode sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '12:00',
|
||||
label: 'Noon',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '15:00',
|
||||
label: 'Afternoon',
|
||||
icon: <WbTwilight sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '18:00',
|
||||
label: 'Evening',
|
||||
icon: <NightsStay sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '22:00',
|
||||
label: 'Night',
|
||||
icon: <Bedtime sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => (
|
||||
<ListItem key={opt.time}>
|
||||
<Checkbox
|
||||
checked={localUseCustomTime && localDueTime === opt.time}
|
||||
onClick={() => handleQuickTime(opt.time)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
// Fix the height so switching views (month/year/decade) doesn't
|
||||
// cause layout shift — month view with 6 rows is the tallest.
|
||||
minHeight: 300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& .react-calendar': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__viewContainer': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
|
||||
{
|
||||
flex: 1,
|
||||
},
|
||||
// Navigation row
|
||||
'& .react-calendar__navigation': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
mb: 1,
|
||||
},
|
||||
// All nav buttons — large tap targets
|
||||
'& .react-calendar__navigation button': {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
minHeight: '40px',
|
||||
minWidth: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.35,
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
// Label button (month/year text) takes remaining space
|
||||
'& .react-calendar__navigation__label': {
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.01em',
|
||||
},
|
||||
// Prev/next arrow buttons — slightly larger icon feel
|
||||
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
|
||||
{
|
||||
fontSize: '1.75rem',
|
||||
},
|
||||
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
|
||||
{
|
||||
fontSize: '1.4rem',
|
||||
},
|
||||
// Weekday headers
|
||||
'& .react-calendar__month-view__weekdays__weekday': {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
textAlign: 'center',
|
||||
padding: '4px 0',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays__weekday abbr': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
// All tiles — shared base
|
||||
'& .react-calendar__tile': {
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
background: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
},
|
||||
// Day tiles only — circular
|
||||
'& .react-calendar__month-view__days .react-calendar__tile': {
|
||||
aspectRatio: '1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
// Month tiles (year view) — pill shape, no huge circle
|
||||
'& .react-calendar__year-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Year tiles (decade view) — pill shape
|
||||
'& .react-calendar__decade-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Century tiles — pill shape
|
||||
'& .react-calendar__century-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
'& .react-calendar__tile--now': {
|
||||
border:
|
||||
'1.5px solid var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidBg) !important',
|
||||
fontWeight: 700,
|
||||
background: 'none !important',
|
||||
},
|
||||
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
|
||||
{
|
||||
background: 'var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidColor) !important',
|
||||
fontWeight: 700,
|
||||
},
|
||||
'& .react-calendar__month-view__days__day--neighboringMonth': {
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
},
|
||||
'& .react-calendar__month-view__days': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
value={
|
||||
localDueDateOnly ? new Date(`${localDueDateOnly}T00:00:00`) : null
|
||||
}
|
||||
calendarType={calendarType}
|
||||
onChange={handleCalendarChange}
|
||||
formatShortWeekday={(locale, date) =>
|
||||
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
|
||||
}
|
||||
formatMonth={(locale, date) =>
|
||||
[
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
][date.getMonth()]
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
mt: 0.5,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Custom time
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={localUseCustomTime ? localDueTime || '' : ''}
|
||||
disabled={!localDueDateOnly}
|
||||
onChange={handleLocalTimeInputChange}
|
||||
sx={{ maxWidth: 200, mb: 1 }}
|
||||
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={!localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(false)}
|
||||
>
|
||||
Anytime
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(true)}
|
||||
>
|
||||
Specific time
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DueDatePickerModal
|
||||
58
src/views/components/FeedbackPrompt.jsx
Normal file
58
src/views/components/FeedbackPrompt.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
installFeedbackErrorListeners,
|
||||
markPromptDismissed,
|
||||
markPromptShown,
|
||||
shouldShowSentimentPrompt,
|
||||
} from '../../service/FeedbackService'
|
||||
import FeedbackModal from '../Modals/FeedbackModal'
|
||||
|
||||
// Let the screen settle before interrupting.
|
||||
const OPEN_DELAY_MS = 4000
|
||||
|
||||
/**
|
||||
* Decides whether to surface the sentiment prompt automatically. Mount once,
|
||||
* near the main task list.
|
||||
*/
|
||||
const FeedbackPrompt = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
useEffect(() => {
|
||||
installFeedbackErrorListeners()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile) return
|
||||
|
||||
let timer = null
|
||||
let cancelled = false
|
||||
|
||||
shouldShowSentimentPrompt({ userProfile }).then(eligible => {
|
||||
if (!eligible || cancelled) return
|
||||
timer = setTimeout(() => {
|
||||
if (cancelled) return
|
||||
markPromptShown()
|
||||
setOpen(true)
|
||||
}, OPEN_DELAY_MS)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [userProfile])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<FeedbackModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onDismiss={markPromptDismissed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeedbackPrompt
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { version } from '../../../package.json'
|
||||
@@ -33,7 +33,6 @@ import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import NavBarLink from './NavBarLink'
|
||||
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||
|
||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
@@ -100,19 +99,6 @@ const NavBar = () => {
|
||||
]
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
useEffect(() => {
|
||||
SafeArea.getSafeAreaInsets().then(data => {
|
||||
const { insets } = data
|
||||
const drawerContent = document.querySelector('.drawer-content')
|
||||
if (drawerContent) {
|
||||
drawerContent.style.paddingTop = `${insets.top}px`
|
||||
drawerContent.style.paddingRight = `${insets.right}px`
|
||||
drawerContent.style.paddingBottom = `${insets.bottom}px`
|
||||
drawerContent.style.paddingLeft = `${insets.left}px`
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const getMenuIcon = () => {
|
||||
const menuRounded = (
|
||||
<IconButton size='md' variant='plain' onClick={() => setDrawerOpen(true)}>
|
||||
@@ -223,7 +209,10 @@ const NavBar = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className='drawer-content'>
|
||||
{/* Safe-area padding comes from the --safe-area-inset-* variables that
|
||||
Capacitor's SystemBars keeps in sync with the live window insets.
|
||||
Top inset is left to the inner List so it isn't applied twice. */}
|
||||
<div className='drawer-content safe-area-x safe-area-bottom'>
|
||||
{/* <div className='align-center flex px-5 pt-4'>
|
||||
<ModalClose size='sm' sx={{ top: 'unset', right: 20 }} />
|
||||
</div> */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Close, NotificationsNone } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
@@ -11,8 +12,7 @@ const getDisplayLabel = templates => {
|
||||
const n = templates[0]
|
||||
const numericValue = Number(n.value)
|
||||
if (numericValue === 0) return 'On due date'
|
||||
const unitName =
|
||||
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
||||
const unitName = n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
||||
const absValue = Math.abs(numericValue)
|
||||
const plural = absValue !== 1 ? 's' : ''
|
||||
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
|
||||
@@ -48,33 +48,22 @@ const NotificationPickerField = ({
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasNotifications && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove all
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
<ModalActions
|
||||
tertiary={
|
||||
hasNotifications
|
||||
? {
|
||||
label: 'Remove all',
|
||||
color: 'danger',
|
||||
onClick: () => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: 'Apply', onClick: handleSave }}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -116,6 +105,7 @@ const NotificationPickerField = ({
|
||||
|
||||
{hasNotifications && onClear && (
|
||||
<IconButton
|
||||
aria-label='Remove reminders'
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
@@ -125,11 +115,9 @@ const NotificationPickerField = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
top: -18,
|
||||
right: -18,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Close, CloudSync } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -11,6 +9,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
|
||||
@@ -139,10 +138,24 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
{/* </Badge> */}
|
||||
</IconButton>
|
||||
|
||||
<ResponsiveModal open={isOpen} onClose={handleClose} size='sm'>
|
||||
<Typography level='title-lg' mb={0.5}>
|
||||
Pending actions
|
||||
</Typography>
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
size='sm'
|
||||
title='Pending actions'
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Close', onClick: handleClose }}
|
||||
primary={{
|
||||
label: 'Cancel all',
|
||||
color: 'danger',
|
||||
onClick: handleCancelAll,
|
||||
loading: isCancelingAll,
|
||||
disabled: commands.length === 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 1.5 }}>
|
||||
{commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
|
||||
synced.
|
||||
@@ -171,6 +184,7 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
</ListItemContent>
|
||||
|
||||
<IconButton
|
||||
aria-label={`Cancel ${formatCommandLabel(cmd.commandType)}`}
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
@@ -182,22 +196,6 @@ function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={handleCancelAll}
|
||||
loading={isCancelingAll}
|
||||
disabled={commands.length === 0}
|
||||
>
|
||||
Cancel all
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ArrowBack,
|
||||
CameraAlt,
|
||||
CheckCircle,
|
||||
Close,
|
||||
DocumentScanner,
|
||||
PhotoCamera,
|
||||
Replay,
|
||||
@@ -12,12 +11,12 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
|
||||
@@ -82,7 +81,10 @@ async function runNativeOCR(imageSource) {
|
||||
}
|
||||
|
||||
const result = await Ocr.process({ image })
|
||||
return result.results.map(r => r.text).join('\n').trim()
|
||||
return result.results
|
||||
.map(r => r.text)
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
async function runOCR(imageSource, onProgress) {
|
||||
@@ -214,7 +216,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
try {
|
||||
text = await runNativeOCR(capturedImage)
|
||||
} catch {
|
||||
throw new Error('Native OCR is only available on iOS and Android devices.')
|
||||
throw new Error(
|
||||
'Native OCR is only available on iOS and Android devices.',
|
||||
)
|
||||
}
|
||||
} else {
|
||||
text = await runOCR(capturedImage, pct => setOcrProgress(pct))
|
||||
@@ -231,7 +235,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
const task = await extractTaskFromOCR(text)
|
||||
|
||||
if (!task || !task.taskName) {
|
||||
setErrorMsg('Could not identify a task from this image. Please try a different photo.')
|
||||
setErrorMsg(
|
||||
'Could not identify a task from this image. Please try a different photo.',
|
||||
)
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
@@ -258,7 +264,9 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
const { image, cancelled, error } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan cancelled or failed.')
|
||||
setErrorMsg(
|
||||
error ? `Scanner error: ${error}` : 'Scan cancelled or failed.',
|
||||
)
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
@@ -377,7 +385,11 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
<Typography level='body-sm'>
|
||||
Reading text from image… {ocrProgress}%
|
||||
</Typography>
|
||||
<LinearProgress determinate value={ocrProgress} sx={{ width: '100%' }} />
|
||||
<LinearProgress
|
||||
determinate
|
||||
value={ocrProgress}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{phase === 'ocr' && ocrMethod === 'native' && (
|
||||
@@ -466,7 +478,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<ModalActions sx={{ mt: 1 }}>
|
||||
{phase === 'capture' && (
|
||||
<>
|
||||
<Button
|
||||
@@ -583,18 +595,7 @@ const PhotoTaskModal = ({ open, onClose, onTaskExtracted }) => {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isProcessing && (
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
onClick={handleClose}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</ModalActions>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
@@ -472,6 +473,7 @@ const RepeatPickerField = ({
|
||||
|
||||
{hasRepeat && onClear && (
|
||||
<IconButton
|
||||
aria-label='Clear repeat schedule'
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
@@ -481,11 +483,9 @@ const RepeatPickerField = ({
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -16,
|
||||
top: -18,
|
||||
right: -18,
|
||||
zIndex: 10,
|
||||
maxHeight: 18,
|
||||
maxWidth: 18,
|
||||
borderRadius: '50%',
|
||||
'&:hover': { bgcolor: 'danger.softBg' },
|
||||
}}
|
||||
@@ -500,38 +500,22 @@ const RepeatPickerField = ({
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Repeat Schedule'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasRepeat && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
<ModalActions
|
||||
tertiary={
|
||||
hasRepeat
|
||||
? {
|
||||
label: 'Remove',
|
||||
color: 'danger',
|
||||
onClick: () => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
|
||||
primary={{ label: 'Apply', onClick: handleSave }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Frequency type selector */}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useScanToTask } from './useScanToTask'
|
||||
|
||||
/**
|
||||
@@ -20,8 +20,20 @@ import { useScanToTask } from './useScanToTask'
|
||||
*
|
||||
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
|
||||
* → error [retake or cancel]
|
||||
*
|
||||
* The primary action (Capture / Scan Document / Retake) lives in the modal
|
||||
* footer alongside Cancel — the panel reports it up through onStateChange
|
||||
* rather than rendering its own button row. Upload stays inline because it
|
||||
* belongs to the capture surface and drives a hidden input in this subtree.
|
||||
*/
|
||||
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCapture }) => {
|
||||
const ScanPanel = ({
|
||||
open,
|
||||
onTaskExtracted,
|
||||
onClose,
|
||||
onStateChange,
|
||||
initialImageUrl,
|
||||
autoCapture,
|
||||
}) => {
|
||||
const {
|
||||
isNativeScanner,
|
||||
phase,
|
||||
@@ -76,6 +88,51 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase, taskResult])
|
||||
|
||||
const openFilePicker = useCallback(
|
||||
() => fileInputRef.current?.click(),
|
||||
[fileInputRef],
|
||||
)
|
||||
|
||||
// The one action the footer renders for the current phase; null while
|
||||
// processing (nothing to do but wait) and when done (the panel closes)
|
||||
const primaryAction = useMemo(() => {
|
||||
if (phase === 'capture') {
|
||||
if (isNativeScanner) {
|
||||
return {
|
||||
label: 'Scan Document',
|
||||
icon: <DocumentScanner />,
|
||||
onClick: handleNativeScan,
|
||||
}
|
||||
}
|
||||
if (cameraAvailable) {
|
||||
return { label: 'Capture', icon: <CameraAlt />, onClick: capture }
|
||||
}
|
||||
// No camera on this device — Upload is the only way forward, so it
|
||||
// graduates from the inline secondary to the footer's primary
|
||||
return {
|
||||
label: 'Upload Photo',
|
||||
icon: <PhotoCamera />,
|
||||
onClick: openFilePicker,
|
||||
}
|
||||
}
|
||||
if (phase === 'error') {
|
||||
return { label: 'Retake', icon: <Replay />, onClick: retake }
|
||||
}
|
||||
return null
|
||||
}, [
|
||||
phase,
|
||||
isNativeScanner,
|
||||
cameraAvailable,
|
||||
capture,
|
||||
handleNativeScan,
|
||||
retake,
|
||||
openFilePicker,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
onStateChange?.({ phase, primaryAction })
|
||||
}, [phase, primaryAction, onStateChange])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isProcessing = phase === 'processing'
|
||||
@@ -111,7 +168,10 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
||||
<DocumentScanner
|
||||
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'white', opacity: 0.6 }}
|
||||
>
|
||||
Tap "Scan Document" to open the scanner
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -137,65 +197,38 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
||||
<CameraAlt
|
||||
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'white', opacity: 0.6 }}
|
||||
>
|
||||
Camera not available — use Upload instead
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<PhotoCamera fontSize='small' />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
{/* Hidden when Upload is already the footer's primary action */}
|
||||
{(isNativeScanner || cameraAvailable) && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
{isNativeScanner ? (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<DocumentScanner fontSize='small' />}
|
||||
onClick={handleNativeScan}
|
||||
>
|
||||
Scan Document
|
||||
</Button>
|
||||
) : (
|
||||
cameraAvailable && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
startDecorator={<CameraAlt fontSize='small' />}
|
||||
onClick={capture}
|
||||
>
|
||||
Capture
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<PhotoCamera fontSize='small' />}
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -280,24 +313,22 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
|
||||
<Typography level='body-sm'>{errorMsg}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={<Replay fontSize='small' />}
|
||||
onClick={retake}
|
||||
>
|
||||
Retake
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Kept outside the phase branches so the footer's Upload action can
|
||||
reach it even when no capture surface is rendered */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -401,18 +401,18 @@ const TaskPreviewCard = ({
|
||||
/**
|
||||
* Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
|
||||
*
|
||||
* Opens straight into hands-free listening. Pauses and spoken separators
|
||||
* ("also") split the transcript into task cards; tapping a card opens inline
|
||||
* pickers whose edits override the parsed values. A single captured task
|
||||
* lands in the smart input for review; multiple are created directly.
|
||||
* Mounted only while voice capture is active, and opens straight into
|
||||
* hands-free listening. Pauses and spoken separators ("also") split the
|
||||
* transcript into task cards; tapping a card opens inline pickers whose edits
|
||||
* override the parsed values. The confirm action lives in the modal footer
|
||||
* alongside Cancel — this panel only reports its state up through
|
||||
* onStateChange so the modal can label and enable that button.
|
||||
*/
|
||||
const VoicePanel = ({
|
||||
open,
|
||||
userLabels = [],
|
||||
members = [],
|
||||
userProfile,
|
||||
onUseSingle,
|
||||
onCreateMany,
|
||||
onStateChange,
|
||||
}) => {
|
||||
const {
|
||||
phase,
|
||||
@@ -427,8 +427,6 @@ const VoicePanel = ({
|
||||
patchSegment,
|
||||
isNative,
|
||||
} = useVoiceToTask({ members, userLabels })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const autoStartedRef = useRef(false)
|
||||
const segmentsScrollRef = useRef(null)
|
||||
|
||||
const parseCtx = useMemo(
|
||||
@@ -441,14 +439,12 @@ const VoicePanel = ({
|
||||
[partialText, parseCtx],
|
||||
)
|
||||
|
||||
// Start capturing the moment the panel opens — the mic tap that opened it
|
||||
// is the only tap needed
|
||||
// Start capturing the moment the panel mounts — the mic tap that opened it
|
||||
// is the only tap needed. startHandsFree no-ops if already listening.
|
||||
useEffect(() => {
|
||||
if (open && !autoStartedRef.current) {
|
||||
autoStartedRef.current = true
|
||||
startHandsFree()
|
||||
}
|
||||
}, [open, startHandsFree])
|
||||
startHandsFree()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Keep the newest captured task visible as more are added
|
||||
useEffect(() => {
|
||||
@@ -456,24 +452,14 @@ const VoicePanel = ({
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [segments.length])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const isListening = phase === 'listening'
|
||||
const showActions = segments.length > 0 && !isListening && !creating
|
||||
|
||||
const mergedTask = segment => ({
|
||||
...parseVoiceTask(segment.text, parseCtx),
|
||||
...(segment.overrides || {}),
|
||||
})
|
||||
|
||||
const handleCreateAll = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
await onCreateMany(segments.map(mergedTask))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
// The confirm action lives in the modal footer, so report the raw segments
|
||||
// and whether the mic is live — that's all it needs to label and enable the
|
||||
// button. It parses the segments itself when the user confirms.
|
||||
useEffect(() => {
|
||||
onStateChange?.({ segments, isListening })
|
||||
}, [segments, isListening, onStateChange])
|
||||
|
||||
const micCaption = isListening
|
||||
? isLocked
|
||||
@@ -642,47 +628,6 @@ const VoicePanel = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Footer — dismissing is the modal's Cancel; this owns confirm only ── */}
|
||||
{(creating || showActions) && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 1,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
{creating ? (
|
||||
<Button size='sm' variant='solid' color='primary' loading>
|
||||
Creating…
|
||||
</Button>
|
||||
) : segments.length === 1 ? (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() =>
|
||||
onUseSingle(segments[0].text, segments[0].overrides || {})
|
||||
}
|
||||
>
|
||||
Use Task
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={handleCreateAll}
|
||||
>
|
||||
Create {segments.length} Tasks
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user