Compare commits
38 Commits
v1.2.46
...
631d273b02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
631d273b02 | ||
|
|
d4b21f8f4e | ||
|
|
a407606f1c | ||
|
|
4f21242e39 | ||
|
|
2a4f085e18 | ||
|
|
292597e7c6 | ||
|
|
e377fb5b06 | ||
|
|
84f86032c9 | ||
|
|
834db6b39d | ||
|
|
97453803d6 | ||
|
|
1c99bd1886 | ||
|
|
5359ae193b | ||
|
|
325d5e6df7 | ||
|
|
79d92ca8c9 | ||
|
|
2480049fd1 | ||
|
|
5c71e4ce58 | ||
|
|
77834aa3c0 | ||
|
|
76fb3500ec | ||
|
|
2d5dd81371 | ||
|
|
c6f7ce48d8 | ||
|
|
64aca507af | ||
|
|
d813971809 | ||
|
|
7729917611 | ||
|
|
96ba672b09 | ||
|
|
7d36090e46 | ||
|
|
f43369c960 | ||
|
|
64fa636413 | ||
|
|
c1f6431174 | ||
|
|
537ab0568b | ||
|
|
b095f26d61 | ||
|
|
2d776e973a | ||
|
|
ddb726ad1f | ||
|
|
67593ec284 | ||
|
|
6bc5982294 | ||
|
|
1dd01e0eca | ||
|
|
ede1a11099 | ||
|
|
6e97c70570 | ||
|
|
dfa07aa5d1 |
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -9,13 +9,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm i
|
||||
- run: npm run build
|
||||
- run: npm run lint:ci
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm i
|
||||
- run: npm run build
|
||||
- run: npm run lint:ci
|
||||
|
||||
@@ -12,15 +12,16 @@ Builds and (optionally) uploads signed Android and iOS release artifacts from th
|
||||
|
||||
Common flags on `release.sh`:
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--android` / `--ios` | Build only one platform |
|
||||
| `--bump minor\|major` | Bump type (default: `patch`) |
|
||||
| `--skip-bump` | Build with the current version, don't bump |
|
||||
| `--upload` | Upload after building |
|
||||
| `--track TRACK` | Play Store track for `--upload` (default: `internal`) |
|
||||
| Flag | Effect |
|
||||
| --------------------- | ----------------------------------------------------- |
|
||||
| `--android` / `--ios` | Build only one platform |
|
||||
| `--bump minor\|major` | Bump type (default: `patch`) |
|
||||
| `--skip-bump` | Build with the current version, don't bump |
|
||||
| `--upload` | Upload after building |
|
||||
| `--track TRACK` | Play Store track for `--upload` (default: `internal`) |
|
||||
|
||||
Outputs:
|
||||
|
||||
- Android: `android/app/build/outputs/bundle/release/app-release.aab`
|
||||
- iOS: `build/ios/Donetick.ipa`
|
||||
|
||||
@@ -36,15 +37,15 @@ Outputs:
|
||||
|
||||
All secrets live in Vaultwarden (`https://www.bitwarden.com`) as Secure Notes, named exactly as below:
|
||||
|
||||
| Vault item name | Written to | Encoding |
|
||||
|---|---|---|
|
||||
| Donetick Google Services Android | `android/app/google-services.json` | raw |
|
||||
| Donetick Android Keystore | `android/app/release/donetick.jks` | base64 |
|
||||
| Donetick Keystore Password | (used inline for `android/keystore.properties`) | raw |
|
||||
| Donetick Google Play Service Account | `android/play-service-account.json` | raw |
|
||||
| Donetick Google Services iOS | `ios/App/App/GoogleService-Info.plist` | raw |
|
||||
| Donetick App Store Connect Key | `ios/AuthKey_84F695CDQ3.p8` | base64 |
|
||||
| Donetick Env Production | `.env.production` | raw |
|
||||
| Vault item name | Written to | Encoding |
|
||||
| ------------------------------------ | ----------------------------------------------- | -------- |
|
||||
| Donetick Google Services Android | `android/app/google-services.json` | raw |
|
||||
| Donetick Android Keystore | `android/app/release/donetick.jks` | base64 |
|
||||
| Donetick Keystore Password | (used inline for `android/keystore.properties`) | raw |
|
||||
| Donetick Google Play Service Account | `android/play-service-account.json` | raw |
|
||||
| Donetick Google Services iOS | `ios/App/App/GoogleService-Info.plist` | raw |
|
||||
| Donetick App Store Connect Key | `ios/AuthKey_84F695CDQ3.p8` | base64 |
|
||||
| Donetick Env Production | `.env.production` | raw |
|
||||
|
||||
None of these files are committed to git — all covered by `.gitignore`.
|
||||
|
||||
@@ -57,6 +58,7 @@ The App target uses **manual signing for Release**, not Automatic. This was a de
|
||||
- `fastlane/Fastfile`'s `ios release` lane passes explicit `export_options: { signingStyle: "manual", provisioningProfiles: {...} }` to `build_app` — do **not** switch this back to `-allowProvisioningUpdates`/automatic without a good reason, it re-triggers the ambiguous cert selection.
|
||||
|
||||
**If you ever add/change an entitlement** (new Capability in Signing & Capabilities, e.g. a new permission), the existing provisioning profile becomes stale and exports will fail with an error like `"App.app" requires a provisioning profile with the X feature`. Fix:
|
||||
|
||||
1. developer.apple.com → Identifiers → `com.donetick.app` → confirm the new capability is checked
|
||||
2. developer.apple.com → Profiles → find `Donetick App Store(fastline)` → regenerate it → download
|
||||
3. Install it locally: get its UUID (`security cms -D -i <file>.mobileprovision | plutil -extract UUID xml1 -o - -`) and copy to `~/Library/MobileDevice/Provisioning Profiles/<uuid>.mobileprovision`
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.donetick.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 67
|
||||
versionName "1.2.46"
|
||||
versionCode 68
|
||||
versionName "1.2.47"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -21,17 +21,13 @@
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
"baselineProfiles": ["baselineProfiles/1/app-release.dm"]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
"baselineProfiles": ["baselineProfiles/0/app-release.dm"]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 28
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":1,"sessions":{}}
|
||||
{ "version": 1, "sessions": {} }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Crowdin configuration for Donetick
|
||||
# See: https://support.crowdin.com/configuration-file/
|
||||
|
||||
project_id: "donetick"
|
||||
project_id: 'donetick'
|
||||
api_token_env: CROWDIN_API_TOKEN
|
||||
preserve_hierarchy: true
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ const browser = await chromium.launch()
|
||||
const context = await browser.newContext({ storageState: state })
|
||||
const page = await context.newPage()
|
||||
page.on('console', msg => console.log('[console]', msg.type(), msg.text()))
|
||||
page.on('pageerror', err => console.log('[pageerror]', err.message, '\n', err.stack))
|
||||
page.on('pageerror', err =>
|
||||
console.log('[pageerror]', err.message, '\n', err.stack),
|
||||
)
|
||||
|
||||
await page.goto('http://localhost:5173/chores/create')
|
||||
await page.getByTestId('chore-name-input').fill('Debug Chore ' + Date.now())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { expect, test as base } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
@@ -9,7 +9,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
* After successful signup the app auto-logs in and walks through the
|
||||
* onboarding flow (/circle-setup, then /ready) before landing on /chores.
|
||||
*/
|
||||
export async function signUpViaUI(page, { username, email, password, displayName }) {
|
||||
export async function signUpViaUI(
|
||||
page,
|
||||
{ displayName, email, password, username },
|
||||
) {
|
||||
await page.goto('/signup')
|
||||
await page.locator('#username').fill(username)
|
||||
await page.locator('#email').fill(email)
|
||||
@@ -30,7 +33,7 @@ export async function signUpViaUI(page, { username, email, password, displayName
|
||||
* Fill and submit the login form through the UI.
|
||||
* After successful login the app redirects to /chores.
|
||||
*/
|
||||
export async function loginViaUI(page, { username, password }) {
|
||||
export async function loginViaUI(page, { password, username }) {
|
||||
await page.goto('/login')
|
||||
await page.locator('#username').fill(username)
|
||||
await page.locator('#password').fill(password)
|
||||
@@ -43,12 +46,12 @@ export async function loginViaUI(page, { username, password }) {
|
||||
* shared E2E user (via persisted storage state, no UI interaction required).
|
||||
*/
|
||||
export const test = base.extend({
|
||||
authenticatedPage: async ({ browser }, use) => {
|
||||
authenticatedPage: async ({ browser }, callback) => {
|
||||
const ctx = await browser.newContext({
|
||||
storageState: path.join(__dirname, '..', '.auth', 'state.json'),
|
||||
})
|
||||
const page = await ctx.newPage()
|
||||
await use(page)
|
||||
await callback(page)
|
||||
await ctx.close()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
@@ -56,7 +56,7 @@ export default async function globalSetup() {
|
||||
throw new Error(`Login failed (${loginRes.status}): ${body}`)
|
||||
}
|
||||
|
||||
const { token, expire } = await loginRes.json()
|
||||
const { expire, token } = await loginRes.json()
|
||||
|
||||
// Write a Playwright storage-state file containing the token in localStorage
|
||||
const stateDir = path.join(__dirname, '.auth')
|
||||
@@ -84,7 +84,11 @@ export default async function globalSetup() {
|
||||
async function waitForServer(url, retries = 20, delayMs = 1000) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(`${url}/api/v1/auth/login`, { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } })
|
||||
const res = await fetch(`${url}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.status < 500) return
|
||||
} catch {
|
||||
// server not up yet
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { signUpViaUI, loginViaUI } from '../fixtures/auth.js'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { loginViaUI, signUpViaUI } from '../fixtures/auth.js'
|
||||
|
||||
// Username must match /^[a-z.-]+$/ — no digits allowed.
|
||||
// Generate a random lowercase-only suffix for uniqueness across runs.
|
||||
function randomSuffix(len = 8) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz'
|
||||
return Array.from({ length: len }, () =>
|
||||
chars[Math.floor(Math.random() * 26)],
|
||||
return Array.from(
|
||||
{ length: len },
|
||||
() => chars[Math.floor(Math.random() * 26)],
|
||||
).join('')
|
||||
}
|
||||
|
||||
@@ -41,7 +43,9 @@ test.describe('Auth – Sign Up', () => {
|
||||
|
||||
test.describe('Auth – Login', () => {
|
||||
// Re-use the shared E2E user that global-setup already created
|
||||
test('logs in with valid credentials and lands on /chores', async ({ page }) => {
|
||||
test('logs in with valid credentials and lands on /chores', async ({
|
||||
page,
|
||||
}) => {
|
||||
await loginViaUI(page, {
|
||||
username: 'e2e.user',
|
||||
password: 'E2ePassword123!',
|
||||
|
||||
@@ -56,6 +56,15 @@ export default [
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['e2e/**/*.{js,mjs}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.config.{mjs,ts}'],
|
||||
languageOptions: {
|
||||
|
||||
@@ -31,8 +31,7 @@ Build a signed, App Store-ready .ipa
|
||||
|
||||
Upload the built .ipa to TestFlight
|
||||
|
||||
----
|
||||
|
||||
---
|
||||
|
||||
## Android
|
||||
|
||||
@@ -60,7 +59,7 @@ Build a signed release .apk (for sideloading/testing)
|
||||
|
||||
Upload the built .aab to the Play Store (internal testing track by default)
|
||||
|
||||
----
|
||||
---
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
"capacitor": {}
|
||||
},
|
||||
"type": "custom"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,12 +462,12 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 67;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.2.46;
|
||||
MARKETING_VERSION = 1.2.47;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -485,12 +485,12 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
|
||||
CURRENT_PROJECT_VERSION = 67;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.2.46;
|
||||
MARKETING_VERSION = 1.2.47;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -504,12 +504,12 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 67;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.46;
|
||||
MARKETING_VERSION = 1.2.47;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -527,12 +527,12 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
|
||||
CURRENT_PROJECT_VERSION = 67;
|
||||
CURRENT_PROJECT_VERSION = 68;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.46;
|
||||
MARKETING_VERSION = 1.2.47;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -11,4 +11,4 @@
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,4 @@
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
562
package-lock.json
generated
562
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"version": "1.2.45",
|
||||
"version": "1.2.47",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "donetick",
|
||||
"version": "1.2.45",
|
||||
"version": "1.2.47",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@capacitor-community/in-app-review": "^8.0.0",
|
||||
@@ -106,6 +106,7 @@
|
||||
"eslint-plugin-sort-destructure-keys": "^3.0.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^16.4.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.8.3",
|
||||
@@ -6268,6 +6269,22 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
"integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"environment": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"license": "MIT",
|
||||
@@ -7075,6 +7092,22 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"restore-cursor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-progress": {
|
||||
"version": "3.12.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
|
||||
@@ -7088,6 +7121,115 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
|
||||
"integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"slice-ansi": "^8.0.0",
|
||||
"string-width": "^8.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate/node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate/node_modules/slice-ansi": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
|
||||
"integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.3",
|
||||
"is-fullwidth-code-point": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate/node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"dev": true,
|
||||
@@ -8101,6 +8243,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"license": "MIT",
|
||||
@@ -9169,6 +9324,19 @@
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
@@ -10645,6 +10813,172 @@
|
||||
"version": "1.2.4",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lint-staged": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
|
||||
"integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^14.0.3",
|
||||
"listr2": "^9.0.5",
|
||||
"picomatch": "^4.0.3",
|
||||
"string-argv": "^0.3.2",
|
||||
"tinyexec": "^1.0.4",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"lint-staged": "bin/lint-staged.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/lint-staged"
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged/node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged/node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged/node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cli-truncate": "^5.0.0",
|
||||
"colorette": "^2.0.20",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"log-update": "^6.1.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/listr2/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2/node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/load-json-file": {
|
||||
"version": "4.0.0",
|
||||
"dev": true,
|
||||
@@ -10757,6 +11091,144 @@
|
||||
"version": "4.1.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-update": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"cli-cursor": "^5.0.0",
|
||||
"slice-ansi": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-update/node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/slice-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"license": "MIT",
|
||||
@@ -11014,6 +11486,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-function": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
||||
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"devOptional": true,
|
||||
@@ -11528,6 +12013,22 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-function": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "8.4.2",
|
||||
"dev": true,
|
||||
@@ -13376,6 +13877,36 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"onetime": "^7.0.0",
|
||||
"signal-exit": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor/node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
@@ -13384,6 +13915,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "6.0.1",
|
||||
"dev": true,
|
||||
@@ -13985,6 +14523,16 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-argv": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.19"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"license": "MIT",
|
||||
@@ -14732,6 +15280,16 @@
|
||||
"version": "1.3.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "1.2.46",
|
||||
"version": "1.2.47",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
@@ -21,7 +21,7 @@
|
||||
"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 && prettier -c .",
|
||||
"lint:ci": "true # TODO: eslint -f gha && prettier -c .",
|
||||
"lint:ci": "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",
|
||||
@@ -138,6 +138,7 @@
|
||||
"eslint-plugin-sort-destructure-keys": "^3.0.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^16.4.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"skip": "Skip",
|
||||
"cancel": "Cancel",
|
||||
"noPriority": "No Priority",
|
||||
"more": "More",
|
||||
"changeDueDate": "Change due date",
|
||||
"subtasks": "Subtasks",
|
||||
"noDescription": "No description available",
|
||||
"timer": {
|
||||
@@ -70,12 +72,338 @@
|
||||
"paused": "Timer Paused",
|
||||
"reset": "Reset Timer",
|
||||
"delete": "Delete Session"
|
||||
}
|
||||
},
|
||||
"unableUndo": "Failed to undo",
|
||||
"offlineComplete": "You're offline — completion will sync when back online",
|
||||
"unableComplete": "Unable to complete task",
|
||||
"offlineSkip": "You're offline — skip will sync when back online",
|
||||
"unableSkip": "Unable to skip task",
|
||||
"offlineStart": "You're offline — start will sync when back online",
|
||||
"unableStart": "Unable to start task",
|
||||
"offlinePause": "You're offline — pause will sync when back online",
|
||||
"unablePause": "Unable to pause task",
|
||||
"offlineRestore": "You're offline — restore will sync when back online",
|
||||
"restoreFailed": "Failed to restore",
|
||||
"unableRestore": "Unable to restore task"
|
||||
},
|
||||
"toolbar": {
|
||||
"defaultProject": "Default Project"
|
||||
"defaultProject": "Default Project",
|
||||
"everyone": "Everyone",
|
||||
"mine": "Mine",
|
||||
"availableToMe": "Available to me",
|
||||
"others": "Others",
|
||||
"cards": "Cards",
|
||||
"compact": "Compact",
|
||||
"calendar": "Calendar",
|
||||
"filters": "Filters",
|
||||
"viewGroup": "View & Group",
|
||||
"filterNamePlaceholder": "Filter name…",
|
||||
"clearAll": "Clear all",
|
||||
"saveFilter": "Save Filter",
|
||||
"saveAsNew": "Save as New Filter",
|
||||
"groupBy": "Group by",
|
||||
"showTasksFor": "Show tasks for",
|
||||
"type": {
|
||||
"assignee": "Assignee"
|
||||
},
|
||||
"display": "Display",
|
||||
"displayOptions": "Display options"
|
||||
},
|
||||
"sort": {
|
||||
"assignedToMe": "Assigned to me"
|
||||
"assignedToMe": "Assigned to me",
|
||||
"smart": "Smart",
|
||||
"createdDate": "Created Date",
|
||||
"updatedDate": "Updated Date",
|
||||
"sortAndGroup": "Sort and Group (Ctrl+G)",
|
||||
"groupBy": "Group By",
|
||||
"quickFilters": "Quick Filters",
|
||||
"assignedTo": "Assigned to:",
|
||||
"availableForMe": "Available for me",
|
||||
"assignedToOthers": "Assigned to others",
|
||||
"createFilter": "Create Filter",
|
||||
"createFilterHint": "Build advanced filter rules"
|
||||
},
|
||||
"thing": {
|
||||
"triggerHint": "Trigger a task when a thing state changes to a desired state",
|
||||
"selectThing": "Select a thing",
|
||||
"enterText": "Enter the text to trigger the task"
|
||||
},
|
||||
"activity": {
|
||||
"showMore": "Show more",
|
||||
"title": "Recent Activities",
|
||||
"loading": "Loading activities...",
|
||||
"status": {
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"overdue": "Overdue",
|
||||
"dueToday": "Due Today",
|
||||
"pendingApproval": "Pending Approval",
|
||||
"dueThisWeek": "Due This Week",
|
||||
"noDueDate": "No Due Date",
|
||||
"dueDate": "Due Date",
|
||||
"dueLater": "Due Later"
|
||||
},
|
||||
"insights": {
|
||||
"highPriority": "High Priority"
|
||||
},
|
||||
"labels": {
|
||||
"label": "Labels"
|
||||
},
|
||||
"actionMenu": {
|
||||
"view": "View",
|
||||
"archive": "Archive",
|
||||
"writeNFC": "Write to NFC",
|
||||
"completeWithNote": "Complete with note",
|
||||
"completeInPast": "Complete in past",
|
||||
"skipToNext": "Skip to next due date",
|
||||
"sendNudge": "Send nudge",
|
||||
"history": "History",
|
||||
"clone": "Clone",
|
||||
"moveToProject": "Move to project",
|
||||
"removeDueDate": "Remove due date"
|
||||
},
|
||||
"actions": {
|
||||
"rescheduledTitle": "Task Rescheduled",
|
||||
"rescheduledMessage": "The task due date has been updated successfully.",
|
||||
"unplannedTitle": "Task Unplanned",
|
||||
"unplannedMessage": "The task is now unplanned and has no due date.",
|
||||
"archivedTitle": "Task Archived",
|
||||
"startedTitle": "Task Started",
|
||||
"startedMessage": "The task has been marked as started.",
|
||||
"pausedTitle": "Task Paused",
|
||||
"pausedMessage": "The task has been paused.",
|
||||
"deletedMessage": "The task has been deleted.",
|
||||
"undoable": {
|
||||
"completed": "Task completed"
|
||||
},
|
||||
"completionPending": "Task completion pending",
|
||||
"failCompleteTitle": "Failed to complete",
|
||||
"failStartTitle": "Failed to start",
|
||||
"failPauseTitle": "Failed to pause",
|
||||
"failApproveTitle": "Failed to approve",
|
||||
"failRejectTitle": "Failed to reject",
|
||||
"deletedMessageLong": "The task has been deleted successfully.",
|
||||
"failDeleteTitle": "Failed to delete",
|
||||
"failArchiveTitle": "Failed to archive",
|
||||
"failSkipTitle": "Failed to skip",
|
||||
"movedTitle": "Task Moved",
|
||||
"failMoveTitle": "Failed to move task",
|
||||
"failRescheduleTitle": "Failed to reschedule",
|
||||
"nudgeSentTitle": "Nudge Sent!",
|
||||
"nudgeFailed": "Failed to send nudge",
|
||||
"failNudgeTitle": "Failed to Send Nudge",
|
||||
"bulk": {
|
||||
"completeTitle": "Complete Tasks",
|
||||
"completedTitle": "✅ Tasks Completed",
|
||||
"completeFailedTitle": "Bulk Complete Failed",
|
||||
"archiveTitle": "Archive Tasks",
|
||||
"archivedTitle": "📦 Tasks Archived",
|
||||
"archiveFailedTitle": "Bulk Archive Failed",
|
||||
"deleteTitle": "Delete Tasks",
|
||||
"skipTitle": "Skip Tasks",
|
||||
"skippedTitle": "⏭️ Tasks Skipped",
|
||||
"skipFailedTitle": "Bulk Skip Failed"
|
||||
}
|
||||
},
|
||||
"archived": {
|
||||
"restoredTitle": "Task Restored",
|
||||
"restoredMsg": "The task has been restored and is now active.",
|
||||
"deletedTitle": "Task Deleted",
|
||||
"delete": "Delete",
|
||||
"someFailedTitle": "Some Tasks Failed",
|
||||
"unexpectedError": "An unexpected error occurred. Please try again.",
|
||||
"deletedBulkTitle": "🗑️ Tasks Deleted",
|
||||
"bulkDeleteFailTitle": "Bulk Delete Failed",
|
||||
"archivedDate": "Archived Date",
|
||||
"loadFailTitle": "Failed to load archived tasks",
|
||||
"loadFailMsg": "Please try again later.",
|
||||
"deletedMsg": "The archived task has been permanently deleted.",
|
||||
"restoreTasksTitle": "Restore Tasks",
|
||||
"restore": "Restore",
|
||||
"restoreConfirm_one": "Restore {{count}} task to active list?",
|
||||
"restoreConfirm_other": "Restore {{count}} tasks to active list?",
|
||||
"restoredBulkTitle": "📤 Tasks Restored",
|
||||
"restoredCount_one": "Restored {{count}} task{{offline}}.",
|
||||
"restoredCount_other": "Restored {{count}} tasks{{offline}}.",
|
||||
"restoreFailCount_one": "{{count}} task could not be restored.",
|
||||
"restoreFailCount_other": "{{count}} tasks could not be restored.",
|
||||
"bulkRestoreFailTitle": "Bulk Restore Failed",
|
||||
"deleteTasksTitle": "Delete Archived Tasks",
|
||||
"deleteConfirm_one": "Permanently delete {{count}} archived task?\n\nThis action cannot be undone.",
|
||||
"deleteConfirm_other": "Permanently delete {{count}} archived tasks?\n\nThis action cannot be undone.",
|
||||
"deletedCount_one": "Successfully deleted {{count}} task.",
|
||||
"deletedCount_other": "Successfully deleted {{count}} tasks.",
|
||||
"deleteFailCount_one": "{{count}} task could not be deleted.",
|
||||
"deleteFailCount_other": "{{count}} tasks could not be deleted.",
|
||||
"title": "Archived Tasks",
|
||||
"subtitle": "View and manage tasks that have been archived or completed.",
|
||||
"heading": "Archived Tasks",
|
||||
"searchPlaceholder": "Search archived tasks",
|
||||
"switchToCompact": "Switch to Compact View",
|
||||
"switchToCard": "Switch to Card View",
|
||||
"exitMultiSelect": "Exit Multi-select Mode (Ctrl+S)",
|
||||
"enableMultiSelect": "Enable Multi-select Mode (Ctrl+S)",
|
||||
"selected_one": "{{count}} task selected",
|
||||
"selected_other": "{{count}} tasks selected",
|
||||
"selectAllTitle": "Select all visible tasks (Ctrl+A)",
|
||||
"all": "All",
|
||||
"closeMultiSelect": "Close multi-select (Esc)",
|
||||
"clearMultiSelect": "Clear multi-select (Esc)",
|
||||
"close": "Close",
|
||||
"clear": "Clear",
|
||||
"restoreSelectedTitle": "Restore selected tasks (R)",
|
||||
"deleteSelectedTitle": "Delete selected tasks (E)",
|
||||
"clearSearch": "Clear search",
|
||||
"clearFilters": "Clear filters",
|
||||
"count_one": "{{count}} archived task",
|
||||
"count_other": "{{count}} archived tasks",
|
||||
"matching": " matching \"{{term}}\""
|
||||
},
|
||||
"edit": {
|
||||
"deleteConfirm": "Are you sure you want to delete this chore?"
|
||||
},
|
||||
"list": {
|
||||
"complete": "Complete",
|
||||
"filterSaved": "Filter Saved",
|
||||
"filterSavedMsg": "\"{{name}}\" has been saved",
|
||||
"nothingScheduled": "Nothing scheduled",
|
||||
"createChore": "Create new chore (Cmd+C)",
|
||||
"filterUpdated": "Filter Updated",
|
||||
"filterUpdatedMsg": "\"{{name}}\" has been updated successfully",
|
||||
"advancedFilterCreated": "Advanced Filter Created",
|
||||
"advancedFilterCreatedMsg": "\"{{name}}\" has been created successfully"
|
||||
},
|
||||
"multiToolbar": {
|
||||
"skip": "Skip",
|
||||
"completeTitle": "Complete selected tasks (Enter)",
|
||||
"complete": "Complete",
|
||||
"skipTitle": "Skip selected tasks (/)",
|
||||
"archiveTitle": "Archive selected tasks (X)",
|
||||
"archive": "Archive",
|
||||
"deleteTitle": "Delete selected tasks (Shift+X)"
|
||||
},
|
||||
"remind": {
|
||||
"title": "Reminders"
|
||||
},
|
||||
"assignee": {
|
||||
"anyone": "Anyone"
|
||||
},
|
||||
"filterChip": {
|
||||
"cancelAll": "Cancel All Filters",
|
||||
"unpin": "Unpin filter",
|
||||
"pin": "Pin filter",
|
||||
"edit": "Edit filter",
|
||||
"delete": "Delete filter"
|
||||
},
|
||||
"multiSelect": {
|
||||
"showShortcuts": "Show keyboard shortcuts",
|
||||
"title": "Multi-select Mode",
|
||||
"selection": "Selection",
|
||||
"selectAll": "Select all visible tasks",
|
||||
"clearOrExit": "Clear selection or exit multi-select mode",
|
||||
"actions": "Actions",
|
||||
"markCompleted": "Mark selected tasks as completed",
|
||||
"deleteSelected": "Delete selected tasks",
|
||||
"interface": "Interface",
|
||||
"quickAdd": "Quick add new task"
|
||||
},
|
||||
"notifications": {
|
||||
"needTitle": "Need Notification?",
|
||||
"needBody": "You need to enable permission to receive notifications, do you want to enable it?",
|
||||
"keepDisabled": "No, Keep it Disabled"
|
||||
},
|
||||
"assigneeCard": {
|
||||
"loading": "Loading tasks by assignee...",
|
||||
"title": "Tasks by Assignee",
|
||||
"scheduled": "Scheduled",
|
||||
"pendingReview": "Pending Review"
|
||||
},
|
||||
"filter": {
|
||||
"status": {
|
||||
"inProgress": "In Progress"
|
||||
}
|
||||
},
|
||||
"impersonate": {
|
||||
"viewAs": "View tasks as",
|
||||
"switchTitle": "Switch to user view",
|
||||
"switchHint": "Tasks will be filtered to show only assignments for selected user",
|
||||
"chooseUser": "Choose User",
|
||||
"changeUser": "Change User"
|
||||
},
|
||||
"modals": {
|
||||
"changeDueDate": "Change due date",
|
||||
"completePast": "Save Chore that you completed in the past",
|
||||
"delegate": "Delegate to someone else",
|
||||
"selectPerformer": "Select a performer",
|
||||
"addNote": "Add note to attach to this completion:",
|
||||
"complete": "Complete"
|
||||
},
|
||||
"shortcuts": {
|
||||
"allSelectedTitle": "✅ All Tasks Selected",
|
||||
"someSelectedTitle": "🎯 Tasks Selected",
|
||||
"allSelectedAltTitle": "🎯 All Tasks Selected"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Chores Overviews",
|
||||
"search": "Search",
|
||||
"newChore": "New Chore",
|
||||
"unassigned": "Unassigned",
|
||||
"changeDueDate": "Change due date"
|
||||
},
|
||||
"dataError": {
|
||||
"attachmentsFailed": "Failed to fetch attachments"
|
||||
},
|
||||
"nudge": {
|
||||
"title": "Send Nudge",
|
||||
"customMessage": "Custom Message (optional)",
|
||||
"messagePlaceholder": "Add a personal message with your nudge...",
|
||||
"notifyAll": "Notify All Assignees",
|
||||
"notifyAllHint": "If enabled, all members who can see this task will be notified. Otherwise, only the assigned person will receive the nudge."
|
||||
},
|
||||
"nfc": {
|
||||
"errWrite": "Error writing to NFC tag. Please try again.",
|
||||
"errUnsupported": "NFC is not supported by this browser. Copy the URL and write it to an NFC tag using a compatible device.",
|
||||
"titleSuccess": "Tag written!",
|
||||
"titleError": "Something went wrong",
|
||||
"titleWaiting": "Hold near NFC tag",
|
||||
"subSuccess": "Your NFC tag is ready to use.",
|
||||
"subWaiting": "Keep your device near the tag until complete.",
|
||||
"subIdle": "Encode this task link onto any NFC tag.",
|
||||
"tagUrl": "Tag URL",
|
||||
"copyUrl": "Copy URL",
|
||||
"autoComplete": "Auto-complete on scan",
|
||||
"autoCompleteHint": "Mark task done when tag is tapped"
|
||||
},
|
||||
"duePicker": {
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"weekend": "Weekend",
|
||||
"nextWeek": "Next week"
|
||||
},
|
||||
"photoTask": {
|
||||
"title": "Scan photo to create task",
|
||||
"cameraUnavailable": "Camera not available",
|
||||
"uploadPhoto": "Upload Photo",
|
||||
"scanDocument": "Scan Document",
|
||||
"capture": "Capture",
|
||||
"processNatively": "Process Natively",
|
||||
"processImage": "Process Image",
|
||||
"createTask": "Create Task",
|
||||
"failedScan": "Failed scan"
|
||||
},
|
||||
"descriptionPlaceholder": "Enter description...",
|
||||
"notifTemplate": {
|
||||
"onDueDate": "On due date",
|
||||
"beforeDue": "{{count}} {{unit}} before due",
|
||||
"afterDue": "{{count}} {{unit}} after due",
|
||||
"errDuplicate": "This notification setting already exists. Please use a different timing.",
|
||||
"errOneDue": "Only one \"Due Alert\" notification is allowed.",
|
||||
"errAllConfigured": "All common {{type}} times are already configured.",
|
||||
"reminder": "Reminder",
|
||||
"dueAlert": "Due Alert",
|
||||
"followUp": "Follow-up",
|
||||
"rememberFuture": "Remember for Future Tasks"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,17 @@
|
||||
"quickAction": "Quick action",
|
||||
"navigation": "Navigation",
|
||||
"createTask": "Create a task",
|
||||
"createLabel": "Create a label",
|
||||
"createProject": "Create a project",
|
||||
"createFilter": "Create a filter",
|
||||
"viewAllTasks": "View all tasks",
|
||||
"viewArchivedTasks": "View archived tasks",
|
||||
"viewThings": "View things",
|
||||
"viewLabels": "View labels",
|
||||
"viewProjects": "View projects",
|
||||
"viewFilters": "View filters",
|
||||
"viewActivities": "View activities",
|
||||
"viewPoints": "View points",
|
||||
"openSettings": "Open settings",
|
||||
"filterTasks": "Show tasks matching “{{query}}”",
|
||||
"filterTasksSubtitle": "Filter the task list"
|
||||
@@ -131,7 +140,14 @@
|
||||
"tooLargeTitle": "File Too Large",
|
||||
"tooLargeMessage": "The file you are trying to upload is too large.",
|
||||
"deniedTitle": "Permission Denied",
|
||||
"deniedMessage": "You do not have permission to upload files."
|
||||
"deniedMessage": "You do not have permission to upload files.",
|
||||
"plusFeatureTitle": "Plus Feature",
|
||||
"plusFeatureMessage": "Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.",
|
||||
"upgradeTitle": "Upgrade Required",
|
||||
"upgradeMessage": "Image uploads are only available for Plus accounts.",
|
||||
"failedTitle": "Upload Failed",
|
||||
"failedMessage": "Failed to upload image.",
|
||||
"processingMessage": "An error occurred while processing the image."
|
||||
},
|
||||
"getStarted": "Get Started!",
|
||||
"imageLoadFailed": "Failed to load image.",
|
||||
@@ -154,5 +170,44 @@
|
||||
"showDetails": "Show error details",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copied": "Error details copied to clipboard"
|
||||
}
|
||||
},
|
||||
"userMenu": {
|
||||
"actAsAnother": "Act as another user",
|
||||
"stopImpersonating": "Stop Impersonating",
|
||||
"returnToAccount": "Return to your account",
|
||||
"accountPrefs": "Account & preferences",
|
||||
"invitePeople": "Invite People",
|
||||
"addMembers": "Add members to your circle",
|
||||
"sidePanelSettings": "Side Panel Settings"
|
||||
},
|
||||
"realtime": {
|
||||
"newTaskTitle": "New Task Created",
|
||||
"connected": "You are now receiving real-time as they happen.",
|
||||
"errorTitle": "Real-time Error",
|
||||
"parseTitle": "Message Error",
|
||||
"parseMessage": "Failed to parse server message",
|
||||
"disabledTitle": "Connection Temporarily Disabled",
|
||||
"failedTitle": "Connection Failed",
|
||||
"stateConnectionError": "Connection error occurred",
|
||||
"stateAuthExpired": "Authentication expired - refreshing token...",
|
||||
"stateTokenRefreshed": "Token refreshed - reconnecting...",
|
||||
"stateRefreshInProgress": "Token refresh in progress - reconnecting soon...",
|
||||
"stateSessionExpired": "Session expired - please log in again",
|
||||
"stateAuthFailed": "Authentication failed - please log in again",
|
||||
"stateAuthError": "Authentication error - please log in again",
|
||||
"stateTimeout": "Connection timeout - reconnecting...",
|
||||
"connectErrorTitle": "Connection Error",
|
||||
"connectErrorMessage": "Failed to establish real-time connection. Please try again."
|
||||
},
|
||||
"removeAll": "Remove all",
|
||||
"apply": "Apply",
|
||||
"cancelAll": "Cancel all",
|
||||
"sync": {
|
||||
"aria": "Open sync and network status",
|
||||
"allSynced": "All changes synced",
|
||||
"willSync": "Will sync when back online",
|
||||
"cancelAll": "Cancel All"
|
||||
},
|
||||
"attachments": "Attachments",
|
||||
"fileNumbered": "File {{index}}"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,11 @@
|
||||
"nameExists": "A filter with this name already exists",
|
||||
"tasks_one": "{{count}} task",
|
||||
"tasks_other": "{{count}} tasks",
|
||||
"overdue": "{{count}} overdue"
|
||||
"overdue": "{{count}} overdue",
|
||||
"delete": {
|
||||
"title": "Delete Filter"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No saved filters yet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@
|
||||
"hasNotes": "Has Notes",
|
||||
"hasPoints": "Has Points",
|
||||
"timePeriod": "Time Period",
|
||||
"user": "User"
|
||||
"user": "User",
|
||||
"completedBy": "Completed By",
|
||||
"completedAt": "Completed At"
|
||||
},
|
||||
"period": {
|
||||
"days_one": "{{count}} Day",
|
||||
@@ -85,5 +87,26 @@
|
||||
},
|
||||
"activities": {
|
||||
"title": "Activities"
|
||||
},
|
||||
"title": {
|
||||
"summary": "Task Summary",
|
||||
"activity": "Task Activity"
|
||||
},
|
||||
"noResults": {
|
||||
"clear": "Clear filters"
|
||||
},
|
||||
"toast": {
|
||||
"updateQueued": {
|
||||
"title": "History Update Queued"
|
||||
},
|
||||
"updated": {
|
||||
"title": "History Updated"
|
||||
},
|
||||
"deleteQueued": {
|
||||
"title": "History Delete Queued"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "History Deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,43 @@
|
||||
"message": "Are you sure you want to delete this label? This will remove the label from all tasks."
|
||||
},
|
||||
"loadError": "Failed to load labels. Please try again.",
|
||||
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task."
|
||||
"search": {
|
||||
"placeholder": "Search labels",
|
||||
"noResultsTitle": "No labels match",
|
||||
"noResultsDescription": "No label matches \"{{searchTerm}}\".",
|
||||
"noFilterResultsDescription": "No label matches the current filter.",
|
||||
"clear": "Clear search",
|
||||
"showAll": "Show all labels"
|
||||
},
|
||||
"detail": {
|
||||
"taskCount_one": "{{count}} task",
|
||||
"taskCount_other": "{{count}} tasks",
|
||||
"labelActions": "Label actions",
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"overdue": "Overdue",
|
||||
"today": "Today",
|
||||
"undated": "No date"
|
||||
},
|
||||
"noMatchingStatus": "No task in this label is in that state right now.",
|
||||
"clearFilters": "Show all tasks",
|
||||
"searchPlaceholder": "Search tasks in this label",
|
||||
"emptyTitle": "No tasks with this label",
|
||||
"emptyDescription": "Nothing is tagged \"{{label}}\" yet. Add the label to a task and it will show up here.",
|
||||
"browseTasks": "Browse tasks",
|
||||
"noResultsTitle": "No tasks match",
|
||||
"noResultsDescription": "No task in this label matches \"{{searchTerm}}\".",
|
||||
"notFoundTitle": "Label not found",
|
||||
"notFoundDescription": "This label may have been deleted or is no longer shared with you.",
|
||||
"backToLabels": "Back to labels"
|
||||
},
|
||||
"blurb": "Manage your labels and organize your tasks effectively. Labels will be automatically shared with your circle if they are used on a shared task.",
|
||||
"modal": {
|
||||
"errorEmptyName": "Name cannot be empty",
|
||||
"errorDuplicate": "Label with this name already exists",
|
||||
"errorNoColor": "Please select a color",
|
||||
"saveFailedTitle": "Failed to save label",
|
||||
"saveFailedMessage": "Unable to save label. Please try again.",
|
||||
"name": "Name"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
"message": "Are you sure you want to delete \"{{name}}\"? This will remove the project but keep all tasks (they'll move to the Default Project)."
|
||||
},
|
||||
"loadError": "Failed to load projects. Please try again.",
|
||||
"search": {
|
||||
"placeholder": "Search projects",
|
||||
"noResultsTitle": "No projects match",
|
||||
"noResultsDescription": "No project matches \"{{searchTerm}}\".",
|
||||
"noFilterResultsDescription": "No project matches the current filter.",
|
||||
"clear": "Clear search",
|
||||
"showAll": "Show all projects"
|
||||
},
|
||||
"blurb": "Organize your tasks into projects. Create custom workspaces to keep your tasks organized and easily accessible.",
|
||||
"defaultDescription": "All tasks without a specific project",
|
||||
"selector": {
|
||||
@@ -20,5 +28,12 @@
|
||||
"createNewDescription": "Add a custom project workspace",
|
||||
"manage": "Manage Projects",
|
||||
"manageDescription": "View, edit, and organize all projects"
|
||||
},
|
||||
"modal": {
|
||||
"errorNameRequired": "Project name is required",
|
||||
"errorUpdate": "Failed to update project",
|
||||
"errorCreate": "Failed to create project",
|
||||
"namePlaceholder": "Enter project name...",
|
||||
"descriptionPlaceholder": "Optional project description..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,8 @@
|
||||
"backupCodesModal": {
|
||||
"title": "New Backup Codes",
|
||||
"warning": "Your previous backup codes are now invalid. Save these new codes in a safe place. Each code can only be used once."
|
||||
}
|
||||
},
|
||||
"generateCodes": "Generate New Codes"
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API Tokens",
|
||||
@@ -576,5 +577,8 @@
|
||||
"selectOwner": "Select new owner",
|
||||
"confirmPrompt": "Please enter your password and type DELETE to confirm",
|
||||
"typeDelete": "DELETE"
|
||||
},
|
||||
"realtime": {
|
||||
"titleSse": "Real-time Updates (SSE)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,16 @@
|
||||
"sessionDeletedMessage": "Timer session has been deleted successfully.",
|
||||
"sessionDeleteErrorTitle": "Error deleting session",
|
||||
"deleteConfirmTitle": "Delete Timer Session",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete this timer session?"
|
||||
"deleteConfirmMessage": "Are you sure you want to delete this timer session?",
|
||||
"startedTitle": "Timer Started",
|
||||
"startedMessage": "Work session has been started successfully.",
|
||||
"startQueuedTitle": "Start queued",
|
||||
"startFailTitle": "Failed to start timer",
|
||||
"pausedTitle": "Timer Paused",
|
||||
"pausedMessage": "Work session has been paused.",
|
||||
"pauseQueuedTitle": "Pause queued",
|
||||
"pauseFailTitle": "Failed to pause timer",
|
||||
"deleteSessionTitle": "Delete Session"
|
||||
},
|
||||
"loading": "Loading timer data...",
|
||||
"noData": "No timer data found for this chore.",
|
||||
@@ -25,5 +34,6 @@
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"leaveEmpty": "Leave empty if session is ongoing",
|
||||
"noSessionForChore": "No timer session found for this chore."
|
||||
"noSessionForChore": "No timer session found for this chore.",
|
||||
"saveChanges": "Save Changes"
|
||||
}
|
||||
|
||||
@@ -492,4 +492,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,4 +72,4 @@
|
||||
"delete": "删除会话"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,26 +13,30 @@ import Option from '@mui/joy/Option'
|
||||
import Select from '@mui/joy/Select'
|
||||
import Typography from '@mui/joy/Typography'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
||||
import { TIME_UNITS } from '../utils/DurationUtils'
|
||||
|
||||
const timeUnits = TIME_UNITS
|
||||
|
||||
const timingOptions = [
|
||||
{ label: 'Before', value: 'before' },
|
||||
{ label: 'Due', value: 'ondue' },
|
||||
{ label: 'After', value: 'after' },
|
||||
{ value: 'before' },
|
||||
{ value: 'ondue' },
|
||||
{ value: 'after' },
|
||||
]
|
||||
|
||||
function getRelativeLabel(notification) {
|
||||
const { value, unit } = notification
|
||||
function getRelativeLabel(notification, t) {
|
||||
const { unit, value } = notification
|
||||
const numericValue = Number(value)
|
||||
if (numericValue === 0) {
|
||||
return 'On due date'
|
||||
return t('notifTemplate.onDueDate')
|
||||
}
|
||||
const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days'
|
||||
const unitName = t(`notifTemplate.unitName.${unit}`)
|
||||
const absValue = Math.abs(numericValue)
|
||||
return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due`
|
||||
return numericValue < 0
|
||||
? t('notifTemplate.beforeDue', { count: absValue, unit: unitName })
|
||||
: t('notifTemplate.afterDue', { count: absValue, unit: unitName })
|
||||
}
|
||||
|
||||
// Helper functions to convert between internal value and UI representation
|
||||
@@ -68,9 +72,10 @@ const NotificationTemplate = ({
|
||||
// Consumers that own an empty state themselves pass 0.
|
||||
minNotifications = 1,
|
||||
onChange,
|
||||
value,
|
||||
showTimeline = true,
|
||||
value,
|
||||
}) => {
|
||||
const { t } = useTranslation('chores')
|
||||
const [notifications, setNotifications] = useState(
|
||||
value?.templates ||
|
||||
JSON.parse(localStorage.getItem('defaultNotificationTemplate')) ||
|
||||
@@ -218,9 +223,7 @@ const NotificationTemplate = ({
|
||||
if (!currentNotification) return
|
||||
|
||||
if (isDuplicate(currentNotification, idx, currentList)) {
|
||||
setError(
|
||||
'This notification setting already exists. Please use a different timing.',
|
||||
)
|
||||
setError(t('notifTemplate.errDuplicate'))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -232,14 +235,14 @@ const NotificationTemplate = ({
|
||||
|
||||
if (type === 'due') {
|
||||
if (notificationsRef.current.some(n => Number(n.value) === 0)) {
|
||||
setError('Only one "Due Alert" notification is allowed.')
|
||||
setError(t('notifTemplate.errOneDue'))
|
||||
return
|
||||
}
|
||||
newNotification = { value: 0, unit: 'm' }
|
||||
} else {
|
||||
newNotification = getSmartSuggestion(type)
|
||||
if (!newNotification) {
|
||||
setError(`All common ${type} times are already configured.`)
|
||||
setError(t('notifTemplate.errAllConfigured', { type }))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -364,7 +367,7 @@ const NotificationTemplate = ({
|
||||
fontSize: '0.6rem',
|
||||
}}
|
||||
>
|
||||
Due Date
|
||||
{t('group.dueDate')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -398,7 +401,7 @@ const NotificationTemplate = ({
|
||||
zIndex: 10,
|
||||
},
|
||||
}}
|
||||
title={getRelativeLabel(n)}
|
||||
title={getRelativeLabel(n, t)}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={
|
||||
@@ -550,7 +553,7 @@ const NotificationTemplate = ({
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{getRelativeLabel(n)}
|
||||
{getRelativeLabel(n, t)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -575,7 +578,7 @@ const NotificationTemplate = ({
|
||||
value={opt.value}
|
||||
disabled={opt.value === 'ondue' && hasOnDueElsewhere}
|
||||
>
|
||||
{opt.label}
|
||||
{t(`notifTemplate.timing.${opt.value}`)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -638,7 +641,7 @@ const NotificationTemplate = ({
|
||||
>
|
||||
{timeUnits.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(`notifTemplate.unitShort.${opt.value}`)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -688,7 +691,7 @@ const NotificationTemplate = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
Reminder
|
||||
{t('notifTemplate.reminder')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addSmartNotification('due')}
|
||||
@@ -710,7 +713,7 @@ const NotificationTemplate = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
Due Alert
|
||||
{t('notifTemplate.dueAlert')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => addSmartNotification('followup')}
|
||||
@@ -729,7 +732,7 @@ const NotificationTemplate = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
Follow-up
|
||||
{t('notifTemplate.followUp')}
|
||||
</Button>
|
||||
</Box>
|
||||
{showSaveDefault && (
|
||||
@@ -762,7 +765,7 @@ const NotificationTemplate = ({
|
||||
setShowSaveDefault(false)
|
||||
}}
|
||||
>
|
||||
Remember for Future Tasks
|
||||
{t('notifTemplate.rememberFuture')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
|
||||
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
|
||||
|
||||
import { useSSEContext } from '../hooks/useSSEContext'
|
||||
|
||||
const SSEConnectionStatus = ({
|
||||
variant = 'minimal',
|
||||
showError = false,
|
||||
sx = {},
|
||||
variant = 'minimal',
|
||||
}) => {
|
||||
const { isConnected, isConnecting, error, getConnectionStatus } =
|
||||
const { error, getConnectionStatus, isConnected, isConnecting } =
|
||||
useSSEContext()
|
||||
|
||||
const getStatusColor = () => {
|
||||
|
||||
@@ -9,20 +9,23 @@ import {
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useSSEContext } from '../hooks/useSSEContext'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
import SSEConnectionStatus from './SSEConnectionStatus'
|
||||
|
||||
const SSESettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
error,
|
||||
getConnectionStatus,
|
||||
toggleSSEEnabled,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isSSEEnabled,
|
||||
toggleSSEEnabled,
|
||||
} = useSSEContext()
|
||||
|
||||
const handleToggle = () => {
|
||||
@@ -75,7 +78,7 @@ const SSESettings = () => {
|
||||
)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography level='title-md'>
|
||||
Real-time Updates (SSE)
|
||||
{t('realtime.titleSse')}
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Plus Feature
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Check, Star } from '@mui/icons-material'
|
||||
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'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const SubscriptionModal = ({ open, onClose }) => {
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||
import AppModal from './common/AppModal'
|
||||
import ModalActions from './common/ModalActions'
|
||||
|
||||
const SubscriptionModal = ({ onClose, open }) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const [selectedPlan, setSelectedPlan] = useState('yearly')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -81,7 +82,11 @@ const SubscriptionModal = ({ open, onClose }) => {
|
||||
footer={
|
||||
<ModalActions
|
||||
stackOnMobile
|
||||
secondary={{ label: t('accountSettings.cancel'), onClick: onClose, disabled: isLoading }}
|
||||
secondary={{
|
||||
label: t('accountSettings.cancel'),
|
||||
onClick: onClose,
|
||||
disabled: isLoading,
|
||||
}}
|
||||
primary={{
|
||||
label: t('subscription.subscribe'),
|
||||
onClick: handleSubscribe,
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import moment from 'moment'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
||||
import useStickyState from '../hooks/useStickyState'
|
||||
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
||||
@@ -37,15 +39,16 @@ import UserModal from '../views/Modals/Inputs/UserModal'
|
||||
import SubscriptionModal from './SubscriptionModal'
|
||||
|
||||
const UserProfileAvatar = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const navigate = useNavigate()
|
||||
const { mode, setMode } = useColorScheme()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const {
|
||||
canImpersonate,
|
||||
getEffectiveUser,
|
||||
isImpersonating,
|
||||
startImpersonation,
|
||||
stopImpersonation,
|
||||
canImpersonate,
|
||||
getEffectiveUser,
|
||||
} = useImpersonateUser()
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
@@ -127,7 +130,9 @@ const UserProfileAvatar = () => {
|
||||
}}
|
||||
/>
|
||||
<Avatar
|
||||
src={resolvePhotoURL(userProfile?.image || userProfile?.avatar)}
|
||||
src={resolvePhotoURL(
|
||||
userProfile?.image || userProfile?.avatar,
|
||||
)}
|
||||
alt={userProfile?.displayName || userProfile?.name}
|
||||
size='sm'
|
||||
sx={{
|
||||
@@ -297,7 +302,7 @@ const UserProfileAvatar = () => {
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Act as another user
|
||||
{t('userMenu.actAsAnother')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -319,13 +324,13 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Stop Impersonating
|
||||
{t('userMenu.stopImpersonating')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Return to your account
|
||||
{t('userMenu.returnToAccount')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -349,13 +354,13 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Settings
|
||||
{t('settings')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Account & preferences
|
||||
{t('userMenu.accountPrefs')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -374,13 +379,13 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Invite People
|
||||
{t('userMenu.invitePeople')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Add members to your circle
|
||||
{t('userMenu.addMembers')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
@@ -401,7 +406,7 @@ const UserProfileAvatar = () => {
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Side Panel Settings
|
||||
{t('userMenu.sidePanelSettings')}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
import React from 'react'
|
||||
import { Box } from '@mui/joy'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
import { useStaggeredAnimation, useReducedMotion } from '../../hooks/useAnimations'
|
||||
import './PageTransition.css'
|
||||
|
||||
const AnimatedList = ({
|
||||
children,
|
||||
staggerDelay = 50,
|
||||
animationType = 'stagger', // 'stagger', 'fade', 'slide'
|
||||
direction = 'up', // 'up', 'down', 'left', 'right'
|
||||
renderItem,
|
||||
import { Box } from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
|
||||
import {
|
||||
useReducedMotion,
|
||||
useStaggeredAnimation,
|
||||
} from '../../hooks/useAnimations'
|
||||
|
||||
const AnimatedList = ({
|
||||
animationType = 'stagger',
|
||||
children,
|
||||
direction = 'up', // 'stagger', 'fade', 'slide'
|
||||
items, // 'up', 'down', 'left', 'right'
|
||||
keyExtractor,
|
||||
items,
|
||||
...boxProps
|
||||
renderItem,
|
||||
staggerDelay = 50,
|
||||
...boxProps
|
||||
}) => {
|
||||
// Handle both children and items patterns
|
||||
let childrenArray
|
||||
if (items && renderItem) {
|
||||
childrenArray = items.map((item, index) =>
|
||||
React.cloneElement(renderItem(item, index), {
|
||||
key: keyExtractor ? keyExtractor(item, index) : index
|
||||
})
|
||||
childrenArray = items.map((item, index) =>
|
||||
React.cloneElement(renderItem(item, index), {
|
||||
key: keyExtractor ? keyExtractor(item, index) : index,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
childrenArray = React.Children.toArray(children)
|
||||
}
|
||||
|
||||
|
||||
const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay)
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
// If user prefers reduced motion, render without animations
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
{items && renderItem ? childrenArray : children}
|
||||
</Box>
|
||||
<Box {...boxProps}>{items && renderItem ? childrenArray : children}</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,7 +58,7 @@ const AnimatedList = ({
|
||||
<TransitionGroup component={null}>
|
||||
{childrenArray.map((child, index) => {
|
||||
const isVisible = visibleItems.has(index)
|
||||
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={child.key || index}
|
||||
|
||||
@@ -65,11 +65,7 @@ const LogoContainer = styled(Box)({
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LoadingScreen = ({
|
||||
message = null,
|
||||
showLogo = true,
|
||||
size = 'lg',
|
||||
}) => {
|
||||
const LoadingScreen = ({ message = null, showLogo = true, size = 'lg' }) => {
|
||||
const { t } = useTranslation('common')
|
||||
return (
|
||||
<LoadingContainer>
|
||||
@@ -90,7 +86,7 @@ const LoadingScreen = ({
|
||||
</Typography>
|
||||
</LogoContainer>
|
||||
)}
|
||||
|
||||
|
||||
<CircularProgress
|
||||
size={size}
|
||||
sx={{
|
||||
@@ -98,7 +94,7 @@ const LoadingScreen = ({
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
<PulsingText level='body-md'>{message ?? t('loading')}</PulsingText>
|
||||
</LoadingContent>
|
||||
</LoadingContainer>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import './PageTransition.css'
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import './PageTransition.css'
|
||||
|
||||
// Route hierarchy for determining navigation direction
|
||||
const routeHierarchy = {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Box, Skeleton } from '@mui/joy'
|
||||
|
||||
const SkeletonLoader = ({
|
||||
type = 'card',
|
||||
count = 1,
|
||||
height = 100,
|
||||
width = '100%',
|
||||
type = 'card',
|
||||
variant = 'rectangular',
|
||||
width = '100%',
|
||||
...props
|
||||
}) => {
|
||||
const renderSkeleton = () => {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import React from 'react'
|
||||
import { Card } from '@mui/joy'
|
||||
import { styled } from '@mui/joy/styles'
|
||||
import React from 'react'
|
||||
|
||||
const AnimatedCard = styled(Card)(({ theme }) => ({
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: 'translateZ(0)', // Enable GPU acceleration
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
|
||||
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px) translateZ(0)',
|
||||
boxShadow: theme.shadow.lg,
|
||||
},
|
||||
|
||||
|
||||
'&:active': {
|
||||
transform: 'translateY(-2px) translateZ(0)',
|
||||
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
|
||||
|
||||
// Subtle background animation on hover
|
||||
'&::before': {
|
||||
content: '""',
|
||||
@@ -26,49 +26,50 @@ const AnimatedCard = styled(Card)(({ theme }) => ({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
|
||||
background:
|
||||
'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
pointerEvents: 'none',
|
||||
borderRadius: 'inherit',
|
||||
},
|
||||
|
||||
|
||||
'&:hover::before': {
|
||||
opacity: 1,
|
||||
},
|
||||
|
||||
|
||||
// Focus states for accessibility
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: theme.palette.primary[500],
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
|
||||
|
||||
// Reduced motion support
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none !important',
|
||||
|
||||
|
||||
'&:hover': {
|
||||
transform: 'none',
|
||||
boxShadow: theme.shadow.md, // Still provide visual feedback
|
||||
},
|
||||
|
||||
|
||||
'&:active': {
|
||||
transform: 'none',
|
||||
},
|
||||
|
||||
|
||||
'&::before': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const SmoothCard = ({
|
||||
children,
|
||||
onClick,
|
||||
const SmoothCard = ({
|
||||
animationDisabled = false,
|
||||
...props
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
if (animationDisabled) {
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import './PageTransition.css'
|
||||
|
||||
import { Box } from '@mui/joy'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
import './PageTransition.css'
|
||||
|
||||
const StaggeredList = ({
|
||||
children,
|
||||
staggerDelay = 50,
|
||||
initialDelay = 0,
|
||||
animate = true,
|
||||
children,
|
||||
initialDelay = 0,
|
||||
staggerDelay = 50,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(!animate)
|
||||
|
||||
|
||||
@@ -17,27 +17,27 @@ const WIDTH_BY_SIZE = {
|
||||
const AppModal = forwardRef(
|
||||
(
|
||||
{
|
||||
open,
|
||||
onClose,
|
||||
backdropBlur = true,
|
||||
children,
|
||||
title,
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
contentSx,
|
||||
description,
|
||||
footer,
|
||||
size = 'md',
|
||||
footerSx,
|
||||
fullWidth = true,
|
||||
isMobile: isMobileProp,
|
||||
keepMounted = false,
|
||||
maxHeight = '90dvh',
|
||||
mobilePresentation = 'sheet',
|
||||
onClose,
|
||||
open,
|
||||
role = 'dialog',
|
||||
showCloseButton = true,
|
||||
showHandle = false,
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
backdropBlur = true,
|
||||
maxHeight = '90dvh',
|
||||
contentSx,
|
||||
footerSx,
|
||||
size = 'md',
|
||||
sx,
|
||||
title,
|
||||
unmountDelay = 180,
|
||||
...modalProps
|
||||
},
|
||||
@@ -183,6 +183,7 @@ const AppModal = forwardRef(
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
top: isSheet && showHandle ? 6 : 12,
|
||||
right: { xs: 10, sm: 16 },
|
||||
borderRadius: '50%',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Add, Remove } from '@mui/icons-material'
|
||||
import { Box, IconButton, Input, Option, Select } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
secondsToValueAndUnit,
|
||||
TIME_UNITS,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
* size – Joy UI size ('sm' | 'md')
|
||||
* minValue – minimum numeric value (default 1)
|
||||
*/
|
||||
const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => {
|
||||
const DurationInput = ({ minValue = 1, onChange, size = 'md', value }) => {
|
||||
const derived =
|
||||
value != null && value >= 0
|
||||
? secondsToValueAndUnit(value)
|
||||
@@ -26,7 +27,7 @@ const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (value != null && value >= 0) {
|
||||
const { value: v, unit: u } = secondsToValueAndUnit(value)
|
||||
const { unit: u, value: v } = secondsToValueAndUnit(value)
|
||||
setDisplayValue(v)
|
||||
setUnit(u)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ const SIZES = {
|
||||
}
|
||||
|
||||
const ActionButton = ({ action, ...buttonProps }) => {
|
||||
const { label, to, onClick, ...rest } = action
|
||||
const { label, onClick, to, ...rest } = action
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
@@ -73,15 +73,15 @@ ActionButton.propTypes = {
|
||||
}
|
||||
|
||||
const EmptyState = ({
|
||||
variant = 'empty',
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
fullHeight = false,
|
||||
icon,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
size = 'md',
|
||||
fullHeight = false,
|
||||
sx,
|
||||
title,
|
||||
variant = 'empty',
|
||||
...rest
|
||||
}) => {
|
||||
const tone = TONES[variant] || TONES.empty
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
|
||||
import AppModal from './AppModal'
|
||||
import ModalActions from './ModalActions'
|
||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||
import ModalActions from './ModalActions'
|
||||
|
||||
/**
|
||||
* Reusable filter bar component.
|
||||
@@ -141,14 +142,25 @@ const fmtDisplayDate = iso => {
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FilterBar = ({
|
||||
filterDefs,
|
||||
activeFilters,
|
||||
onSetFilter,
|
||||
filterDefs,
|
||||
onClearAll,
|
||||
onOpenChange,
|
||||
onSetFilter,
|
||||
open,
|
||||
// When the host renders its own trigger (e.g. an icon button in a toolbar
|
||||
// row), it drives the sheet through `open`/`onOpenChange` and hides ours.
|
||||
resultCount,
|
||||
showTrigger = true,
|
||||
totalCount,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [internalOpen, setInternalOpen] = useState(false)
|
||||
const isControlled = open !== undefined
|
||||
const isOpen = isControlled ? open : internalOpen
|
||||
const setIsOpen = next => {
|
||||
if (!isControlled) setInternalOpen(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
|
||||
// ── Active count ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -293,65 +305,75 @@ const FilterBar = ({
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const activeChips = filterDefs
|
||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
||||
.filter(({ label }) => !!label)
|
||||
.map(({ def, label }) => ({
|
||||
key: def.id,
|
||||
label,
|
||||
onClear: () => onSetFilter(def.id, null),
|
||||
}))
|
||||
|
||||
// With the trigger hoisted into a toolbar, the inline row has nothing to show
|
||||
// until a filter is on — rendering it anyway would leave a phantom gap.
|
||||
const showInlineBar = showTrigger || activeChips.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Inline bar ─────────────────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
{showInlineBar && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='md'
|
||||
variant={hasActive ? 'solid' : 'outlined'}
|
||||
color={hasActive ? 'primary' : 'neutral'}
|
||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 'xl',
|
||||
py: 0.5,
|
||||
px: 1,
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
'& .MuiButton-startDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Badge>
|
||||
{showTrigger && (
|
||||
<Badge
|
||||
badgeContent={activeFilterCount || null}
|
||||
color='primary'
|
||||
size='sm'
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Button
|
||||
size='md'
|
||||
variant={hasActive ? 'solid' : 'outlined'}
|
||||
color={hasActive ? 'primary' : 'neutral'}
|
||||
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
borderRadius: 'xl',
|
||||
py: 0.5,
|
||||
px: 1,
|
||||
gap: 0.5,
|
||||
alignItems: 'center',
|
||||
'& .MuiButton-startDecorator': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mr: 0.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<ActiveFilterChips
|
||||
chips={filterDefs
|
||||
.map(def => ({ def, label: getActiveChipLabel(def) }))
|
||||
.filter(({ label }) => !!label)
|
||||
.map(({ def, label }) => ({
|
||||
key: def.id,
|
||||
label,
|
||||
onClear: () => onSetFilter(def.id, null),
|
||||
}))}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClearAll={hasActive ? onClearAll : undefined}
|
||||
resultCount={hasActive ? resultCount : undefined}
|
||||
totalCount={hasActive ? totalCount : undefined}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
/>
|
||||
</Box>
|
||||
<ActiveFilterChips
|
||||
chips={activeChips}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClearAll={hasActive ? onClearAll : undefined}
|
||||
resultCount={hasActive ? resultCount : undefined}
|
||||
totalCount={hasActive ? totalCount : undefined}
|
||||
maxVisible={2}
|
||||
chipSize='md'
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── Bottom sheet ────────────────────────────────────── */}
|
||||
<AppModal
|
||||
|
||||
@@ -9,10 +9,10 @@ import PropTypes from 'prop-types'
|
||||
function KeyboardShortcutHint({
|
||||
shortcut,
|
||||
show = true,
|
||||
withCmd = true,
|
||||
withCtrl, // Legacy prop for backward compatibility
|
||||
withShift = false,
|
||||
sx = {},
|
||||
withCmd = true, // Legacy prop for backward compatibility
|
||||
withCtrl,
|
||||
withShift = false,
|
||||
...props
|
||||
}) {
|
||||
if (!show) return null
|
||||
|
||||
@@ -16,12 +16,12 @@ const ActionButton = ({ action, defaults, sx }) => {
|
||||
* primary action is always the final, highest-emphasis control.
|
||||
*/
|
||||
const ModalActions = ({
|
||||
children,
|
||||
primary,
|
||||
secondary,
|
||||
tertiary,
|
||||
children,
|
||||
stackOnMobile = false,
|
||||
sx,
|
||||
tertiary,
|
||||
}) => {
|
||||
const responsiveButtonStyles = stackOnMobile
|
||||
? { '& > button': { width: { xs: '100%', sm: 'auto' } } }
|
||||
|
||||
256
src/components/common/SortAndFilterMenu.jsx
Normal file
256
src/components/common/SortAndFilterMenu.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { ArrowDownward, ArrowUpward, Check, Sort } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
IconButton,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Radio,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Compact sort + filter menu, meant to sit next to a search input.
|
||||
*
|
||||
* Props:
|
||||
* sortOptions - [{ name, value }] shown under the sort header
|
||||
* selectedSort - currently selected sort value
|
||||
* onSortChange - (value) => void
|
||||
* sortDirection - 'asc' | 'desc'
|
||||
* onSortDirectionChange - (direction) => void
|
||||
* filterTitle - optional header for the filter section
|
||||
* filterOptions - optional [{ name, value }] rendered as radios
|
||||
* selectedFilter - currently selected filter value
|
||||
* onFilterChange - (value) => void
|
||||
* isActive - highlights the trigger button when a non-default choice is on
|
||||
*/
|
||||
const SortAndFilterMenu = ({
|
||||
filterOptions,
|
||||
filterTitle,
|
||||
icon = <Sort />,
|
||||
isActive,
|
||||
onFilterChange,
|
||||
onSortChange,
|
||||
onSortDirectionChange,
|
||||
selectedFilter,
|
||||
selectedSort,
|
||||
sortDirection = 'asc',
|
||||
sortOptions = [],
|
||||
title = 'Sort by',
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const menuRef = useRef(null)
|
||||
const buttonRef = useRef(null)
|
||||
|
||||
const handleMenuClose = () => setAnchorEl(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleMenuOutsideClick = event => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target) &&
|
||||
!buttonRef.current?.contains(event.target)
|
||||
) {
|
||||
handleMenuClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMenuOutsideClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const SectionHeader = ({ children }) => (
|
||||
<MenuItem
|
||||
disabled
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
cursor: 'default',
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
<ListItemContent>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={buttonRef}
|
||||
onClick={event => setAnchorEl(anchorEl ? null : event.currentTarget)}
|
||||
variant='outlined'
|
||||
color={isActive ? 'primary' : 'neutral'}
|
||||
size='sm'
|
||||
sx={{ height: 32, width: 32, borderRadius: '50%', flexShrink: 0 }}
|
||||
aria-label='Sort and filter options'
|
||||
title='Sort & Filter'
|
||||
>
|
||||
{icon}
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
placement='bottom-end'
|
||||
sx={{
|
||||
minWidth: 240,
|
||||
p: 1,
|
||||
'--List-gap': '4px',
|
||||
boxShadow: 'var(--joy-shadow-lg)',
|
||||
border: '1px solid var(--joy-palette-divider)',
|
||||
borderRadius: 'var(--joy-radius-md)',
|
||||
zIndex: 1300,
|
||||
}}
|
||||
>
|
||||
<SectionHeader>{title}</SectionHeader>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{sortOptions.map(option => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onSortChange(option.value)
|
||||
handleMenuClose()
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor:
|
||||
selectedSort === option.value
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
selectedSort === option.value
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight: selectedSort === option.value ? 600 : 400,
|
||||
color:
|
||||
selectedSort === option.value
|
||||
? 'var(--joy-palette-primary-600)'
|
||||
: 'var(--joy-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</Typography>
|
||||
{selectedSort === option.value && (
|
||||
<Check
|
||||
sx={{
|
||||
fontSize: '16px',
|
||||
color: 'var(--joy-palette-primary-500)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
{onSortDirectionChange && (
|
||||
<>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
onSortDirectionChange(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{sortDirection === 'asc' ? (
|
||||
<ArrowUpward sx={{ fontSize: '18px' }} />
|
||||
) : (
|
||||
<ArrowDownward sx={{ fontSize: '18px' }} />
|
||||
)}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm'>
|
||||
{sortDirection === 'asc' ? 'Ascending' : 'Descending'}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filterOptions?.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<SectionHeader>{filterTitle || 'Filter'}</SectionHeader>
|
||||
{filterOptions.map(option => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
onFilterChange(option.value)
|
||||
handleMenuClose()
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor:
|
||||
selectedFilter === option.value
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor:
|
||||
selectedFilter === option.value
|
||||
? 'var(--joy-palette-primary-softBg)'
|
||||
: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<Radio
|
||||
checked={selectedFilter === option.value}
|
||||
variant='outlined'
|
||||
/>
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
fontWeight: selectedFilter === option.value ? 600 : 400,
|
||||
color:
|
||||
selectedFilter === option.value
|
||||
? 'var(--joy-palette-primary-600)'
|
||||
: 'var(--joy-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortAndFilterMenu
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Add, Close } from '@mui/icons-material'
|
||||
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ActiveFilterChips = ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { initWidgetSync } from '../service/WidgetService'
|
||||
|
||||
const QueryContext = ({ children }) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import JoinCircleView from '../views/Circles/JoinCircle'
|
||||
import NotFound from '../views/components/NotFound'
|
||||
import FilterView from '../views/Filters/FilterView'
|
||||
import ChoreHistory from '../views/History/ChoreHistory'
|
||||
import LabelDetailView from '../views/Labels/LabelDetailView'
|
||||
import LabelView from '../views/Labels/LabelView'
|
||||
import Landing from '../views/Landing/Landing'
|
||||
import CircleSetupView from '../views/Onboarding/CircleSetupView'
|
||||
@@ -262,6 +263,10 @@ const Router = createBrowserRouter([
|
||||
path: 'labels/',
|
||||
element: <LabelView />,
|
||||
},
|
||||
{
|
||||
path: 'labels/:labelId',
|
||||
element: <LabelDetailView />,
|
||||
},
|
||||
{
|
||||
path: 'projects/',
|
||||
element: <ProjectView />,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import { useSSE } from '../hooks/useSSE'
|
||||
|
||||
export const SSEContext = createContext({
|
||||
|
||||
@@ -31,6 +31,32 @@ const primaryPalette = getPalette(primaryColor)
|
||||
const CONTROL_RADIUS = '12px'
|
||||
const ICON_BUTTON_RADIUS = '10px'
|
||||
|
||||
// Select and Autocomplete both render their popup through @mui/base's Popper,
|
||||
// which defaults to popper.js's `absolute` strategy — the popup is laid out in
|
||||
// *document* coordinates. That is fine on desktop, but inside a mobile bottom
|
||||
// sheet (viewport-pinned, body scroll locked by Modal) it puts the list below
|
||||
// the fold or lets an overflow:hidden ancestor clip it, so it reads as trapped
|
||||
// inside the modal. `fixed` positions it in viewport space instead, which no
|
||||
// ancestor can clip.
|
||||
//
|
||||
// Note that slotProps coming from defaultProps are not deep merged: a call site
|
||||
// passing its own slotProps.listbox replaces this wholesale.
|
||||
const POPUP_LISTBOX = {
|
||||
popperOptions: { strategy: 'fixed' },
|
||||
modifiers: [
|
||||
{ name: 'flip', options: { padding: 8 } },
|
||||
// altAxis with tether off lets the popup shift fully back into view rather
|
||||
// than staying glued to an anchor that has no room around it.
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: { padding: 8, altAxis: true, tether: false },
|
||||
},
|
||||
],
|
||||
// Joy hardcodes 44vh, and vh resolves to the *large* viewport on mobile —
|
||||
// taller than what is actually on screen while the browser chrome is up.
|
||||
sx: { maxHeight: 'min(44dvh, 320px)' },
|
||||
}
|
||||
|
||||
const themeConfig = {
|
||||
radius: {
|
||||
xs: '6px',
|
||||
@@ -163,6 +189,10 @@ const themeConfig = {
|
||||
theme.direction === 'rtl' ? buttonGroupRtlGeometry(ownerState) : {},
|
||||
},
|
||||
},
|
||||
JoySelect: { defaultProps: { slotProps: { listbox: POPUP_LISTBOX } } },
|
||||
JoyAutocomplete: {
|
||||
defaultProps: { slotProps: { listbox: POPUP_LISTBOX } },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Network } from '@capacitor/network'
|
||||
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
|
||||
class NetworkManager {
|
||||
|
||||
@@ -36,4 +36,4 @@ const useAcknowledgmentModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
export default useAcknowledgmentModal
|
||||
export default useAcknowledgmentModal
|
||||
|
||||
@@ -8,7 +8,7 @@ export const useReducedMotion = () => {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setPrefersReducedMotion(mediaQuery.matches)
|
||||
|
||||
const handleChange = (event) => {
|
||||
const handleChange = event => {
|
||||
setPrefersReducedMotion(event.matches)
|
||||
}
|
||||
|
||||
@@ -32,13 +32,13 @@ export const useStaggeredAnimation = (itemCount, delay = 50) => {
|
||||
}
|
||||
|
||||
const timeouts = []
|
||||
|
||||
|
||||
// Stagger the appearance of items
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
const timeout = setTimeout(() => {
|
||||
setVisibleItems(prev => new Set([...prev, i]))
|
||||
}, i * delay)
|
||||
|
||||
|
||||
timeouts.push(timeout)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const useInViewAnimation = (threshold = 0.1) => {
|
||||
([entry]) => {
|
||||
setIsInView(entry.isIntersecting)
|
||||
},
|
||||
{ threshold }
|
||||
{ threshold },
|
||||
)
|
||||
|
||||
observer.observe(element)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { clearAllTokens, saveTokens } from '../utils/TokenStorage'
|
||||
|
||||
@@ -40,4 +40,4 @@ const useConfirmationModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfirmationModal
|
||||
export default useConfirmationModal
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { patchDescriptionHtml } from '../utils/ImageCache'
|
||||
|
||||
// Returns description HTML safe to render: embedded images with an expired
|
||||
|
||||
@@ -10,8 +10,14 @@ import { Capacitor } from '@capacitor/core'
|
||||
function normalizeScannedImage(raw) {
|
||||
if (!raw) return null
|
||||
if (raw.startsWith('data:')) return raw
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('content://')) return raw
|
||||
if (raw.startsWith('/') || raw.startsWith('file://')) return Capacitor.convertFileSrc(raw)
|
||||
if (
|
||||
raw.startsWith('http://') ||
|
||||
raw.startsWith('https://') ||
|
||||
raw.startsWith('content://')
|
||||
)
|
||||
return raw
|
||||
if (raw.startsWith('/') || raw.startsWith('file://'))
|
||||
return Capacitor.convertFileSrc(raw)
|
||||
// iOS base64 without prefix
|
||||
return `data:image/jpeg;base64,${raw}`
|
||||
}
|
||||
@@ -25,11 +31,16 @@ function normalizeScannedImage(raw) {
|
||||
export function useDocumentScanner() {
|
||||
const isNativeScanner = Capacitor.isNativePlatform()
|
||||
|
||||
const scanDocument = async ({ maxDocuments = 1, quality = 90, letUserAdjustCrop = false } = {}) => {
|
||||
const scanDocument = async ({
|
||||
letUserAdjustCrop = false,
|
||||
maxDocuments = 1,
|
||||
quality = 90,
|
||||
} = {}) => {
|
||||
if (!isNativeScanner) return { image: null, cancelled: false }
|
||||
|
||||
try {
|
||||
const { DocumentScanner } = await import('@capgo/capacitor-document-scanner')
|
||||
const { DocumentScanner } =
|
||||
await import('@capgo/capacitor-document-scanner')
|
||||
const { scannedImages } = await DocumentScanner.scanDocument({
|
||||
croppedImageQuality: quality,
|
||||
maxNumDocuments: maxDocuments,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const useFileUpload = ({
|
||||
draftId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { commandQueue } from '../utils/CommandQueue'
|
||||
|
||||
// Hook to get pending commands for a specific chore (for showing pending badges/undo)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { createElement } from 'react'
|
||||
|
||||
import AppModal from '../components/common/AppModal'
|
||||
|
||||
const MobileAppModal = props =>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Capacitor } from '@capacitor/core'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useAlerts } from '../service/AlertsProvider'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
@@ -19,6 +21,7 @@ const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
|
||||
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
|
||||
|
||||
export const useSSE = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const { isAuthenticated, token } = useAuth()
|
||||
// Only fetch user profile if authenticated - prevents unnecessary API calls on landing page
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -97,7 +100,7 @@ export const useSSE = () => {
|
||||
case 'chore.created':
|
||||
showNotification({
|
||||
type: 'info',
|
||||
title: 'New Task Created',
|
||||
title: t('realtime.newTaskTitle'),
|
||||
message: `${eventData.data.user.displayName} created "${eventData.data.chore.name}"`,
|
||||
duration: 5000,
|
||||
})
|
||||
@@ -242,14 +245,14 @@ export const useSSE = () => {
|
||||
showAlert({
|
||||
type: 'success',
|
||||
color: 'success',
|
||||
message: 'You are now receiving real-time as they happen.',
|
||||
message: t('realtime.connected'),
|
||||
})
|
||||
break
|
||||
|
||||
case 'error':
|
||||
console.error('SSE error event:', eventData.data)
|
||||
showError({
|
||||
title: 'Real-time Error',
|
||||
title: t('realtime.errorTitle'),
|
||||
message:
|
||||
eventData.data.message ||
|
||||
'An error occurred with real-time updates',
|
||||
@@ -262,8 +265,8 @@ export const useSSE = () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message:', err)
|
||||
showError({
|
||||
title: 'Message Error',
|
||||
message: 'Failed to parse server message',
|
||||
title: t('realtime.parseTitle'),
|
||||
message: t('realtime.parseMessage'),
|
||||
})
|
||||
return // Stop processing if JSON parsing fails
|
||||
}
|
||||
@@ -315,7 +318,7 @@ export const useSSE = () => {
|
||||
if (isCircuitBreakerOpen) {
|
||||
console.log('SSE: Circuit breaker is open, preventing connection attempt')
|
||||
showError({
|
||||
title: 'Connection Temporarily Disabled',
|
||||
title: t('realtime.disabledTitle'),
|
||||
message:
|
||||
'Connection blocked due to repeated failures. Please try again later.',
|
||||
})
|
||||
@@ -328,7 +331,7 @@ export const useSSE = () => {
|
||||
)
|
||||
setIsCircuitBreakerOpen(true)
|
||||
showError({
|
||||
title: 'Connection Failed',
|
||||
title: t('realtime.failedTitle'),
|
||||
message:
|
||||
'Maximum connection attempts reached. SSE disabled for 10 minutes.',
|
||||
})
|
||||
@@ -378,7 +381,7 @@ export const useSSE = () => {
|
||||
const ticket = await fetchSSETicket()
|
||||
if (!ticket) {
|
||||
console.error('SSE: Failed to obtain connection ticket')
|
||||
setError('Connection error occurred')
|
||||
setError(t('realtime.stateConnectionError'))
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
scheduleReconnect(
|
||||
RECONNECT_INTERVALS[
|
||||
@@ -511,7 +514,7 @@ export const useSSE = () => {
|
||||
|
||||
if (is401Error) {
|
||||
console.log('SSE 401 error detected, attempting token refresh...')
|
||||
setError('Authentication expired - refreshing token...')
|
||||
setError(t('realtime.stateAuthExpired'))
|
||||
|
||||
try {
|
||||
const refreshResult = await apiClient.refreshToken()
|
||||
@@ -520,7 +523,7 @@ export const useSSE = () => {
|
||||
console.log(
|
||||
'Token refreshed successfully, retrying SSE connection...',
|
||||
)
|
||||
setError('Token refreshed - reconnecting...')
|
||||
setError(t('realtime.stateTokenRefreshed'))
|
||||
|
||||
if (apiClient.failedQueue && apiClient.failedQueue.length > 0) {
|
||||
console.log(
|
||||
@@ -553,7 +556,7 @@ export const useSSE = () => {
|
||||
console.log(
|
||||
'SSE: Token refresh in progress by another request, waiting...',
|
||||
)
|
||||
setError('Token refresh in progress - reconnecting soon...')
|
||||
setError(t('realtime.stateRefreshInProgress'))
|
||||
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
@@ -572,23 +575,23 @@ export const useSSE = () => {
|
||||
return
|
||||
} else if (refreshResult.error === 'Refresh token expired') {
|
||||
console.error('Refresh token expired, user must login again')
|
||||
setError('Session expired - please log in again')
|
||||
setError(t('realtime.stateSessionExpired'))
|
||||
return
|
||||
} else {
|
||||
console.error('Token refresh failed:', refreshResult.error)
|
||||
setError('Authentication failed - please log in again')
|
||||
setError(t('realtime.stateAuthFailed'))
|
||||
return
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh error:', refreshError)
|
||||
setError('Authentication error - please log in again')
|
||||
setError(t('realtime.stateAuthError'))
|
||||
return
|
||||
}
|
||||
} else if (isTimeoutError) {
|
||||
console.log('SSE timeout detected, attempting reconnection...')
|
||||
setError('Connection timeout - reconnecting...')
|
||||
setError(t('realtime.stateTimeout'))
|
||||
} else {
|
||||
setError('Connection error occurred')
|
||||
setError(t('realtime.stateConnectionError'))
|
||||
}
|
||||
|
||||
// Schedule reconnect for non-401 errors
|
||||
@@ -618,8 +621,8 @@ export const useSSE = () => {
|
||||
} catch (err) {
|
||||
console.error('Failed to create SSE connection:', err)
|
||||
showError({
|
||||
title: 'Connection Error',
|
||||
message: 'Failed to establish real-time connection. Please try again.',
|
||||
title: t('realtime.connectErrorTitle'),
|
||||
message: t('realtime.connectErrorMessage'),
|
||||
})
|
||||
setConnectionState(SSE_STATES.CLOSED)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { SSEContext } from '../contexts/SSEContext'
|
||||
|
||||
export const useSSEContext = () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation('settings') // or 'common', 'chores'
|
||||
|
||||
|
||||
return <h1>{t('title')}</h1>
|
||||
}
|
||||
```
|
||||
@@ -40,9 +40,9 @@ import { useLocalization } from '@/contexts/LocalizationContext'
|
||||
|
||||
function MyComponent() {
|
||||
const { formatDate, formatDateTime, formatRelative } = useLocalization()
|
||||
|
||||
|
||||
const date = new Date()
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Date: {formatDate(date)}</p>
|
||||
@@ -59,19 +59,10 @@ function MyComponent() {
|
||||
import { useLocalization } from '@/contexts/LocalizationContext'
|
||||
|
||||
function MyComponent() {
|
||||
const {
|
||||
language,
|
||||
setLanguage,
|
||||
dateFormat,
|
||||
setDateFormat,
|
||||
isRTL
|
||||
} = useLocalization()
|
||||
|
||||
return (
|
||||
<div dir={isRTL ? 'rtl' : 'ltr'}>
|
||||
Current language: {language}
|
||||
</div>
|
||||
)
|
||||
const { language, setLanguage, dateFormat, setDateFormat, isRTL } =
|
||||
useLocalization()
|
||||
|
||||
return <div dir={isRTL ? 'rtl' : 'ltr'}>Current language: {language}</div>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -90,6 +81,7 @@ function MyComponent() {
|
||||
## RTL Support
|
||||
|
||||
Languages in the `RTL_LANGUAGES` array automatically get:
|
||||
|
||||
- `dir="rtl"` on the document
|
||||
- RTL-specific CSS styles
|
||||
- Proper text alignment
|
||||
@@ -99,6 +91,7 @@ Currently supported RTL languages: Arabic (ar), Hebrew (he), Persian (fa), Urdu
|
||||
## Date Format Preferences
|
||||
|
||||
Users can choose from:
|
||||
|
||||
- MM/DD/YYYY (US)
|
||||
- DD/MM/YYYY (Europe)
|
||||
- YYYY-MM-DD (ISO)
|
||||
@@ -113,5 +106,6 @@ Users can choose from:
|
||||
## First Day of Week
|
||||
|
||||
Users can choose:
|
||||
|
||||
- Sunday
|
||||
- Monday
|
||||
|
||||
@@ -30,8 +30,6 @@ html {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Prevent iOS Safari from auto-zooming on input focus (triggered when font-size < 16px) */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
input,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { GetResource } from '../utils/Fetcher'
|
||||
|
||||
// Helper to check if we have a valid token
|
||||
@@ -13,7 +14,7 @@ const isTokenValid = () => {
|
||||
}
|
||||
|
||||
export const useResource = () => {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
const { data, error, isLoading, refetch } = useQuery({
|
||||
queryKey: ['resource'],
|
||||
queryFn: async () => {
|
||||
const response = await GetResource()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
|
||||
import { GetThingHistory } from '../utils/Fetcher'
|
||||
|
||||
export const useThingHistory = (thingId, limit = 10) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
ClearChoreTimer,
|
||||
DeleteTimeSession,
|
||||
@@ -56,7 +57,7 @@ export const useUpdateTimeSession = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, sessionId, sessionData }) =>
|
||||
mutationFn: ({ choreId, sessionData, sessionId }) =>
|
||||
UpdateTimeSession(choreId, sessionId, sessionData),
|
||||
onSuccess: (_, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
|
||||
@@ -152,7 +152,7 @@ export const GlobalSearchProvider = ({ children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = event => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
isOpen ? closeSearch() : openSearch()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
AddRounded,
|
||||
ArchiveOutlined,
|
||||
CheckCircleOutline,
|
||||
FilterAltOutlined,
|
||||
FolderOutlined,
|
||||
HistoryRounded,
|
||||
InboxOutlined,
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
PersonOutline,
|
||||
SearchRounded,
|
||||
SettingsOutlined,
|
||||
TollOutlined,
|
||||
WidgetsOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
@@ -56,29 +60,106 @@ const buildQuickActions = t => [
|
||||
provider: 'actions',
|
||||
title: t('search.actions.createTask'),
|
||||
subtitle: t('search.actions.quickAction'),
|
||||
route: '/chores/create',
|
||||
keywords: 'new task chore add create',
|
||||
// Reuses the widget deep-link param so this lands on the task list with the
|
||||
// quick-add modal open, instead of the full create page.
|
||||
route: '/chores?add_task=1',
|
||||
},
|
||||
{
|
||||
id: 'action:tasks',
|
||||
id: 'action:create-label',
|
||||
provider: 'actions',
|
||||
title: t('search.actions.viewAllTasks'),
|
||||
subtitle: t('search.actions.navigation'),
|
||||
route: '/chores',
|
||||
title: t('search.actions.createLabel'),
|
||||
subtitle: t('search.actions.quickAction'),
|
||||
keywords: 'new label tag add create',
|
||||
route: '/labels?create=1',
|
||||
},
|
||||
{
|
||||
id: 'action:archived',
|
||||
id: 'action:create-project',
|
||||
provider: 'actions',
|
||||
title: t('search.actions.viewArchivedTasks'),
|
||||
subtitle: t('search.actions.navigation'),
|
||||
route: '/archived',
|
||||
title: t('search.actions.createProject'),
|
||||
subtitle: t('search.actions.quickAction'),
|
||||
keywords: 'new project folder add create',
|
||||
route: '/projects?create=1',
|
||||
},
|
||||
{
|
||||
id: 'action:settings',
|
||||
id: 'action:create-filter',
|
||||
provider: 'actions',
|
||||
title: t('search.actions.openSettings'),
|
||||
subtitle: t('search.actions.navigation'),
|
||||
route: '/settings',
|
||||
title: t('search.actions.createFilter'),
|
||||
subtitle: t('search.actions.quickAction'),
|
||||
keywords: 'new filter view saved search add create',
|
||||
route: '/filters?create=1',
|
||||
},
|
||||
// Every destination in the nav drawer is reachable from here, so the palette
|
||||
// is a complete way to move around the app without opening the drawer.
|
||||
...[
|
||||
{
|
||||
id: 'action:tasks',
|
||||
title: t('search.actions.viewAllTasks'),
|
||||
keywords: 'tasks chores list all open',
|
||||
route: '/chores',
|
||||
icon: <InboxOutlined />,
|
||||
},
|
||||
{
|
||||
id: 'action:archived',
|
||||
title: t('search.actions.viewArchivedTasks'),
|
||||
keywords: 'archive archived tasks completed open',
|
||||
route: '/archived',
|
||||
icon: <ArchiveOutlined />,
|
||||
},
|
||||
{
|
||||
id: 'action:things',
|
||||
title: t('search.actions.viewThings'),
|
||||
keywords: 'things devices sensors trackers state open',
|
||||
route: '/things',
|
||||
icon: <WidgetsOutlined />,
|
||||
},
|
||||
{
|
||||
id: 'action:labels',
|
||||
title: t('search.actions.viewLabels'),
|
||||
keywords: 'labels tags open',
|
||||
route: '/labels',
|
||||
icon: <LabelOutlined />,
|
||||
},
|
||||
{
|
||||
id: 'action:projects',
|
||||
title: t('search.actions.viewProjects'),
|
||||
keywords: 'projects folders groups open',
|
||||
route: '/projects',
|
||||
icon: <FolderOutlined />,
|
||||
},
|
||||
{
|
||||
id: 'action:filters',
|
||||
title: t('search.actions.viewFilters'),
|
||||
keywords: 'filters saved views open',
|
||||
route: '/filters',
|
||||
icon: <FilterAltOutlined />,
|
||||
},
|
||||
{
|
||||
id: 'action:activities',
|
||||
title: t('search.actions.viewActivities'),
|
||||
keywords: 'activities history timeline log open',
|
||||
route: '/activities',
|
||||
icon: <HistoryRounded />,
|
||||
},
|
||||
{
|
||||
id: 'action:points',
|
||||
title: t('search.actions.viewPoints'),
|
||||
keywords: 'points rewards score leaderboard open',
|
||||
route: '/points',
|
||||
icon: <TollOutlined />,
|
||||
},
|
||||
{
|
||||
id: 'action:settings',
|
||||
title: t('search.actions.openSettings'),
|
||||
keywords: 'settings preferences configuration open',
|
||||
route: '/settings',
|
||||
icon: <SettingsOutlined />,
|
||||
},
|
||||
].map(action => ({
|
||||
...action,
|
||||
provider: 'actions',
|
||||
subtitle: t('search.actions.navigation'),
|
||||
})),
|
||||
]
|
||||
|
||||
const readRecents = () => {
|
||||
@@ -192,6 +273,22 @@ const GlobalSearchPalette = ({
|
||||
const [recents] = useState(readRecents)
|
||||
const selectedResultRef = useRef(null)
|
||||
|
||||
const quickActions = useMemo(() => buildQuickActions(t), [t])
|
||||
const quickActionIndex = useMemo(
|
||||
() =>
|
||||
new Fuse(quickActions, {
|
||||
threshold: 0.38,
|
||||
distance: 120,
|
||||
ignoreLocation: true,
|
||||
includeScore: true,
|
||||
keys: [
|
||||
{ name: 'title', weight: 0.7 },
|
||||
{ name: 'keywords', weight: 0.3 },
|
||||
],
|
||||
}),
|
||||
[quickActions],
|
||||
)
|
||||
|
||||
const searchIndexes = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -227,7 +324,7 @@ const GlobalSearchPalette = ({
|
||||
const recentResults = recents
|
||||
.map(item => currentById.get(item.id) || item)
|
||||
.filter(item => item.provider !== 'history' || currentById.has(item.id))
|
||||
return [...recentResults, ...buildQuickActions(t)]
|
||||
return [...recentResults, ...quickActions]
|
||||
}
|
||||
|
||||
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
|
||||
@@ -250,15 +347,40 @@ const GlobalSearchPalette = ({
|
||||
})
|
||||
.sort((a, b) => a.score - b.score),
|
||||
)
|
||||
grouped.push({
|
||||
id: 'action:filter-tasks',
|
||||
provider: 'actions',
|
||||
title: t('search.actions.filterTasks', { query: query.trim() }),
|
||||
subtitle: t('search.actions.filterTasksSubtitle'),
|
||||
route: `/chores?search=${encodeURIComponent(query.trim())}`,
|
||||
})
|
||||
return grouped
|
||||
}, [documents, query, recents, searchIndexes, t])
|
||||
const actionMatches = (
|
||||
quickActionIndex.search(normalized, { limit: 4 }) || []
|
||||
)
|
||||
.map(match => ({ ...match.item, score: match.score ?? 1 }))
|
||||
.sort((a, b) => a.score - b.score)
|
||||
|
||||
// An action whose title the query starts spelling out ("create la…") is
|
||||
// what the person is after, so it leads. Anything matched only through its
|
||||
// keywords stays below the real content it shares words with.
|
||||
const leadingActions = actionMatches.filter(action =>
|
||||
action.title.toLocaleLowerCase().startsWith(normalized),
|
||||
)
|
||||
const trailingActions = actionMatches.filter(
|
||||
action => !leadingActions.includes(action),
|
||||
)
|
||||
|
||||
return [
|
||||
...leadingActions,
|
||||
...grouped,
|
||||
...trailingActions,
|
||||
{
|
||||
id: 'action:filter-tasks',
|
||||
provider: 'actions',
|
||||
title: t('search.actions.filterTasks', { query: query.trim() }),
|
||||
subtitle: t('search.actions.filterTasksSubtitle'),
|
||||
route: `/chores?search=${encodeURIComponent(query.trim())}`,
|
||||
},
|
||||
]
|
||||
}, [documents, query, quickActionIndex, recents, searchIndexes, t])
|
||||
|
||||
// Everything except the always-present "filter the task list" fallback.
|
||||
const matchCount = results.filter(
|
||||
result => result.id !== 'action:filter-tasks',
|
||||
).length
|
||||
|
||||
useEffect(() => {
|
||||
selectedResultRef.current?.scrollIntoView({
|
||||
@@ -339,7 +461,7 @@ const GlobalSearchPalette = ({
|
||||
pb: 'var(--safe-area-inset-bottom, 0px)',
|
||||
}}
|
||||
>
|
||||
{!isLoading && query.trim() && results.length === 1 && (
|
||||
{!isLoading && query.trim() && matchCount === 0 && (
|
||||
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
|
||||
<InboxOutlined
|
||||
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
|
||||
@@ -393,7 +515,7 @@ const GlobalSearchPalette = ({
|
||||
<ListItemDecorator
|
||||
sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
|
||||
>
|
||||
{ICONS[result.provider]}
|
||||
{result.icon || ICONS[result.provider]}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography
|
||||
@@ -434,9 +556,7 @@ const GlobalSearchPalette = ({
|
||||
<Typography level='body-xs'>↵ {t('search.footer.open')}</Typography>
|
||||
<Typography level='body-xs' sx={{ ml: 'auto' }}>
|
||||
{query.trim()
|
||||
? t('search.footer.results', {
|
||||
count: Math.max(0, results.length - 1),
|
||||
})
|
||||
? t('search.footer.results', { count: matchCount })
|
||||
: t('search.footer.typeToSearch')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { SETTINGS_SECTIONS } from '../constants/settingsSections'
|
||||
|
||||
const stripHtml = value => {
|
||||
if (!value) return ''
|
||||
if (typeof globalThis.document === 'undefined')
|
||||
return String(value).replace(/<[^>]*>/g, ' ')
|
||||
const element = globalThis.document.createElement('div')
|
||||
element.innerHTML = String(value)
|
||||
return element.textContent || element.innerText || ''
|
||||
}
|
||||
import { stripHtml } from '../utils/Helpers'
|
||||
|
||||
const HISTORY_STATUS = {
|
||||
0: 'in progress',
|
||||
@@ -125,7 +117,7 @@ registerSearchProvider({
|
||||
title: label.name || 'Untitled label',
|
||||
subtitle: 'Label',
|
||||
keywords: 'tag label',
|
||||
route: '/labels',
|
||||
route: `/labels/${label.id}`,
|
||||
color: label.color,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -22,7 +22,9 @@ export function isCacheEnabled() {
|
||||
export function setCacheEnabled(enabled) {
|
||||
try {
|
||||
localStorage.setItem(ENABLED_KEY, String(enabled))
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function hashContent(content) {
|
||||
@@ -56,7 +58,9 @@ export function setCached(hash, value) {
|
||||
index.push(hash)
|
||||
localStorage.setItem(INDEX_KEY, JSON.stringify(index))
|
||||
}
|
||||
} catch { /* storage full, ignore */ }
|
||||
} catch {
|
||||
/* storage full, ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getCacheStats() {
|
||||
@@ -66,7 +70,15 @@ export function getCacheStats() {
|
||||
export function clearCache() {
|
||||
const index = getIndex()
|
||||
index.forEach(h => {
|
||||
try { localStorage.removeItem(ENTRY_PREFIX + h) } catch { /* ignore */ }
|
||||
try {
|
||||
localStorage.removeItem(ENTRY_PREFIX + h)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
try { localStorage.removeItem(INDEX_KEY) } catch { /* ignore */ }
|
||||
try {
|
||||
localStorage.removeItem(INDEX_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,13 +249,28 @@ export const submitErrorReport = async ({
|
||||
description,
|
||||
report,
|
||||
}) => {
|
||||
// The relay rejects reports without an error. Manual bug reports have no
|
||||
// thrown Error, so mark only the submitted copy while preserving the local
|
||||
// diagnostics as a manual report.
|
||||
const submittedReport =
|
||||
report.kind === 'bug'
|
||||
? {
|
||||
...report,
|
||||
error: {
|
||||
...report.error,
|
||||
name: 'ManualBugReport',
|
||||
message: 'Submitted manually from the app',
|
||||
},
|
||||
}
|
||||
: report
|
||||
|
||||
const payload = {
|
||||
source: 'donetick-app',
|
||||
kind: report.kind === 'bug' ? 'bug-report' : 'error-report',
|
||||
kind: 'error-report',
|
||||
reportId: report.reportId,
|
||||
description: description?.trim() || null,
|
||||
contactEmail: contactEmail?.trim() || null,
|
||||
report,
|
||||
report: submittedReport,
|
||||
}
|
||||
|
||||
// Enforced here, not only in the UI, so no future caller can relay a
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
import { getCached, hashContent, setCached } from './AIPromptCache'
|
||||
|
||||
// Native-only local AI service using @capacitor/local-llm.
|
||||
@@ -74,14 +75,19 @@ class LocalAIService {
|
||||
await this.warmup()
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { text: out } = await LocalLLM.prompt({ prompt: text, sessionId: this._sessionId })
|
||||
const { text: out } = await LocalLLM.prompt({
|
||||
prompt: text,
|
||||
sessionId: this._sessionId,
|
||||
})
|
||||
return out?.trim() || null
|
||||
} finally {
|
||||
try {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||
this._warmedUp = false
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +105,9 @@ class LocalAIService {
|
||||
try {
|
||||
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
|
||||
const userMsg = messages.find(m => m.role === 'user')?.content || ''
|
||||
const result = await this._nativePrompt(`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`)
|
||||
const result = await this._nativePrompt(
|
||||
`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`,
|
||||
)
|
||||
if (result) setCached(cacheHash, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
@@ -122,7 +130,10 @@ class LocalAIService {
|
||||
try {
|
||||
await this.warmup()
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
const { text } = await LocalLLM.prompt({ prompt, sessionId: this._sessionId })
|
||||
const { text } = await LocalLLM.prompt({
|
||||
prompt,
|
||||
sessionId: this._sessionId,
|
||||
})
|
||||
const result = text?.trim() || null
|
||||
if (result) setCached(cacheHash, result)
|
||||
return result
|
||||
@@ -133,7 +144,9 @@ class LocalAIService {
|
||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||
this._warmedUp = false
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ export const decodeNdefUrl = record => {
|
||||
|
||||
// Starts a native NFC write session. Calls onWaiting once scanning is active,
|
||||
// then onSuccess or onError when the write completes. Returns a cancel function.
|
||||
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => {
|
||||
export const startNativeNFCWrite = async (
|
||||
url,
|
||||
{ onError, onSuccess, onWaiting },
|
||||
) => {
|
||||
let listener = null
|
||||
let done = false
|
||||
|
||||
@@ -86,7 +89,7 @@ export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }
|
||||
|
||||
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
|
||||
// NDEF record is found, or onError on failure. Returns a cancel function.
|
||||
export const startNativeScan = async ({ onTag, onError }) => {
|
||||
export const startNativeScan = async ({ onError, onTag }) => {
|
||||
let listener = null
|
||||
let done = false
|
||||
|
||||
|
||||
@@ -113,9 +113,8 @@ class VoiceInputService {
|
||||
async isSupported() {
|
||||
if (this.isNative) {
|
||||
try {
|
||||
const { SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
)
|
||||
const { SpeechRecognition } =
|
||||
await import('@capacitor-community/speech-recognition')
|
||||
const { available } = await SpeechRecognition.available()
|
||||
return !!available
|
||||
} catch {
|
||||
@@ -134,9 +133,8 @@ class VoiceInputService {
|
||||
return 'granted'
|
||||
}
|
||||
try {
|
||||
const { SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
)
|
||||
const { SpeechRecognition } =
|
||||
await import('@capacitor-community/speech-recognition')
|
||||
const current = await SpeechRecognition.checkPermissions()
|
||||
if (current.speechRecognition === 'granted') return 'granted'
|
||||
const res = await SpeechRecognition.requestPermissions()
|
||||
@@ -189,9 +187,8 @@ class VoiceInputService {
|
||||
if (this.isNative) {
|
||||
let SpeechRecognition
|
||||
try {
|
||||
;({ SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
))
|
||||
;({ SpeechRecognition } =
|
||||
await import('@capacitor-community/speech-recognition'))
|
||||
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
|
||||
} catch {
|
||||
// recognizer may already be stopped
|
||||
@@ -282,9 +279,8 @@ class VoiceInputService {
|
||||
}
|
||||
|
||||
async _startNative() {
|
||||
const { SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
)
|
||||
const { SpeechRecognition } =
|
||||
await import('@capacitor-community/speech-recognition')
|
||||
await SpeechRecognition.removeAllListeners()
|
||||
|
||||
await SpeechRecognition.addListener('partialResults', ({ matches }) => {
|
||||
@@ -342,9 +338,8 @@ class VoiceInputService {
|
||||
}
|
||||
|
||||
async _doRestartNative() {
|
||||
const { SpeechRecognition } = await import(
|
||||
'@capacitor-community/speech-recognition'
|
||||
)
|
||||
const { SpeechRecognition } =
|
||||
await import('@capacitor-community/speech-recognition')
|
||||
try {
|
||||
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core'
|
||||
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
|
||||
// Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and
|
||||
|
||||
@@ -82,11 +82,15 @@
|
||||
|
||||
/* Height utilities that account for safe areas */
|
||||
.min-h-screen-safe {
|
||||
min-height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
|
||||
min-height: calc(
|
||||
100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom)
|
||||
);
|
||||
}
|
||||
|
||||
.h-screen-safe {
|
||||
height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
|
||||
height: calc(
|
||||
100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom)
|
||||
);
|
||||
}
|
||||
|
||||
/* Top positioning that accounts for safe area */
|
||||
|
||||
@@ -19,7 +19,11 @@ const allMonths = [
|
||||
* @param {Object} chore - The chore object (needed for nextDueDate null check)
|
||||
* @returns {string} The formatted due date text
|
||||
*/
|
||||
export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') => {
|
||||
export const getDueDateChipText = (
|
||||
nextDueDate,
|
||||
chore,
|
||||
timeFormat = 'h:mm A',
|
||||
) => {
|
||||
if (chore?.nextDueDate === null || nextDueDate === null) return 'No Due Date'
|
||||
|
||||
const dueDate = moment(nextDueDate)
|
||||
@@ -34,16 +38,22 @@ export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') =>
|
||||
sameElse: `MMM D ${timeFormat}`,
|
||||
}
|
||||
|
||||
|
||||
// if time is 23:59:59, treat as end-of-day (date only, no specific time)
|
||||
if (dueDate.hours() === 23 && dueDate.minutes() === 59 && dueDate.seconds() === 59) {
|
||||
if (
|
||||
dueDate.hours() === 23 &&
|
||||
dueDate.minutes() === 59 &&
|
||||
dueDate.seconds() === 59
|
||||
) {
|
||||
if (diff < 0) {
|
||||
// For overdue dates, show calendar format for recent dates
|
||||
const absDiff = Math.abs(diff)
|
||||
if (absDiff <= 48) {
|
||||
return (
|
||||
'Overdue ' +
|
||||
moment(nextDueDate).calendar(null, calendarFormat).split(' ')[0].toLowerCase()
|
||||
moment(nextDueDate)
|
||||
.calendar(null, calendarFormat)
|
||||
.split(' ')[0]
|
||||
.toLowerCase()
|
||||
)
|
||||
}
|
||||
return 'Overdue ' + dueDate.fromNow()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { TASK_COLOR } from './Colors.jsx'
|
||||
|
||||
const priorityOrder = [1, 2, 3, 4, 0]
|
||||
@@ -213,7 +214,7 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
}
|
||||
|
||||
case 'due_date': {
|
||||
var { dateGroups: dueDateGroups, anytime: dueAnytime } =
|
||||
var { anytime: dueAnytime, dateGroups: dueDateGroups } =
|
||||
buildActualDateGroups(chores)
|
||||
groups = [...dueDateGroups]
|
||||
if (dueAnytime.length > 0) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import moment from 'moment'
|
||||
|
||||
export const createDateFormatter = (
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
firstDayOfWeek,
|
||||
) => {
|
||||
export const createDateFormatter = (dateFormat, timeFormat, firstDayOfWeek) => {
|
||||
moment.updateLocale('en', {
|
||||
week: {
|
||||
dow: firstDayOfWeek,
|
||||
|
||||
@@ -740,7 +740,7 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
|
||||
const UploadChoreAttachment = (
|
||||
file,
|
||||
entityType,
|
||||
{ entityId, draftId } = {},
|
||||
{ draftId, entityId } = {},
|
||||
) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
@@ -986,11 +986,6 @@ const TrackFilterUsage = id => {
|
||||
|
||||
export {
|
||||
AcceptCircleMemberRequest,
|
||||
DeleteChoreAttachment,
|
||||
DeleteDraftAttachment,
|
||||
GetChoreAttachments,
|
||||
SignAssetURL,
|
||||
UploadChoreAttachment,
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
CancelSubscription,
|
||||
@@ -1010,8 +1005,10 @@ export {
|
||||
CreateThing,
|
||||
DeleteChildUser,
|
||||
DeleteChore,
|
||||
DeleteChoreAttachment,
|
||||
DeleteChoreHistory,
|
||||
DeleteCircleMember,
|
||||
DeleteDraftAttachment,
|
||||
DeleteFilter,
|
||||
DeleteLabel,
|
||||
DeleteLongLiveToken,
|
||||
@@ -1024,6 +1021,7 @@ export {
|
||||
GetAllUsers,
|
||||
GetArchivedChores,
|
||||
GetChildUsers,
|
||||
GetChoreAttachments,
|
||||
GetChoreByID,
|
||||
GetChoreDetailById,
|
||||
GetChoreHistory,
|
||||
@@ -1069,6 +1067,7 @@ export {
|
||||
SaveChore,
|
||||
SaveThing,
|
||||
SetupMFA,
|
||||
SignAssetURL,
|
||||
signUp,
|
||||
SkipChore,
|
||||
StartChore,
|
||||
@@ -1091,5 +1090,6 @@ export {
|
||||
UpdateThingState,
|
||||
UpdateTimeSession,
|
||||
UpdateUserDetails,
|
||||
UploadChoreAttachment,
|
||||
VerifyMFA,
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* @returns {boolean} - Whether the chore matches the condition
|
||||
*/
|
||||
export const evaluateCondition = (chore, condition, context = {}) => {
|
||||
const { type, operator, value } = condition
|
||||
const { operator, type, value } = condition
|
||||
|
||||
switch (type) {
|
||||
case 'assignee':
|
||||
@@ -343,7 +343,7 @@ export const getFilterOverdueCount = (chores, filter, context = {}) => {
|
||||
* @returns {Object} - { isValid: boolean, issues: Array }
|
||||
*/
|
||||
export const validateFilter = (filter, context = {}) => {
|
||||
const { members = [], labels = [], projects = [] } = context
|
||||
const { labels = [], members = [], projects = [] } = context
|
||||
const issues = []
|
||||
|
||||
if (!filter.conditions || filter.conditions.length === 0) {
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { apiClient } from './ApiClient'
|
||||
|
||||
const isPlusAccount = userProfile => {
|
||||
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
|
||||
}
|
||||
|
||||
// Turns rich-text/HTML content (task descriptions, notes) into plain text so it
|
||||
// can be indexed or matched by search.
|
||||
const stripHtml = value => {
|
||||
if (!value) return ''
|
||||
if (typeof globalThis.document === 'undefined')
|
||||
return String(value).replace(/<[^>]*>/g, ' ')
|
||||
const element = globalThis.document.createElement('div')
|
||||
element.innerHTML = String(value)
|
||||
return element.textContent || element.innerText || ''
|
||||
}
|
||||
|
||||
const resolvePhotoURL = url => {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http') || url.startsWith('https')) {
|
||||
@@ -83,4 +95,5 @@ export {
|
||||
isPlusAccount,
|
||||
isSignedUrlExpired,
|
||||
resolvePhotoURL,
|
||||
stripHtml,
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ const patchDescriptionHtml = async (html, meta = {}) => {
|
||||
const images = extractDescriptionImages(html)
|
||||
if (images.length === 0) return html
|
||||
let patched = html
|
||||
for (const { path, src, rawSrc } of images) {
|
||||
for (const { path, rawSrc, src } of images) {
|
||||
try {
|
||||
const nextSrc = await getImageSrc(path, src, {
|
||||
kind: 'description',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CapacitorSQLite } from '@capacitor-community/sqlite'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { CapacitorSQLite } from '@capacitor-community/sqlite'
|
||||
|
||||
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||
|
||||
const DB_NAME = 'donetick_offline'
|
||||
@@ -630,7 +631,7 @@ class IndexedDBBackend {
|
||||
async saveChores(chores) {
|
||||
if (!chores.length) return
|
||||
|
||||
const { tx, store } = await this._tx('cached_chores', 'readwrite')
|
||||
const { store, tx } = await this._tx('cached_chores', 'readwrite')
|
||||
|
||||
for (const chore of chores) {
|
||||
store.put({
|
||||
@@ -670,7 +671,7 @@ class IndexedDBBackend {
|
||||
|
||||
async deleteChores(ids) {
|
||||
if (!ids.length) return
|
||||
const { tx, store } = await this._tx('cached_chores', 'readwrite')
|
||||
const { store, tx } = await this._tx('cached_chores', 'readwrite')
|
||||
for (const id of ids) {
|
||||
store.delete(id)
|
||||
}
|
||||
@@ -693,7 +694,7 @@ class IndexedDBBackend {
|
||||
const choreIds = [...new Set(entries.map(e => Number(e.choreId)))]
|
||||
await this._deletePendingHistoryByChoreIds(choreIds)
|
||||
// Upsert real entries
|
||||
const { tx, store } = await this._tx('cached_history', 'readwrite')
|
||||
const { store, tx } = await this._tx('cached_history', 'readwrite')
|
||||
for (const entry of entries) {
|
||||
store.put({
|
||||
id: entry.id,
|
||||
@@ -736,7 +737,7 @@ class IndexedDBBackend {
|
||||
.map(row => row.id)
|
||||
if (!toDelete.length) return
|
||||
// Tx 2: delete them
|
||||
const { tx, store: writeStore } = await this._tx(
|
||||
const { store: writeStore, tx } = await this._tx(
|
||||
'cached_history',
|
||||
'readwrite',
|
||||
)
|
||||
@@ -772,7 +773,7 @@ class IndexedDBBackend {
|
||||
|
||||
async deleteHistory(ids) {
|
||||
if (!ids.length) return
|
||||
const { tx, store } = await this._tx('cached_history', 'readwrite')
|
||||
const { store, tx } = await this._tx('cached_history', 'readwrite')
|
||||
for (const id of ids) {
|
||||
store.delete(id)
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ class SyncEngine {
|
||||
break
|
||||
|
||||
case CommandType.COMPLETE_CHORE: {
|
||||
const { id, body, completedDate, performer } = cmd.payload
|
||||
const { body, completedDate, id, performer } = cmd.payload
|
||||
response = await MarkChoreComplete(
|
||||
id,
|
||||
body || {},
|
||||
@@ -221,7 +221,7 @@ class SyncEngine {
|
||||
break
|
||||
|
||||
case CommandType.UPDATE_CHORE_HISTORY: {
|
||||
const { choreId, historyId, historyData } = cmd.payload
|
||||
const { choreId, historyData, historyId } = cmd.payload
|
||||
response = await UpdateChoreHistory(choreId, historyId, historyData)
|
||||
break
|
||||
}
|
||||
@@ -233,7 +233,7 @@ class SyncEngine {
|
||||
}
|
||||
|
||||
case CommandType.RESCHEDULE_CHORE: {
|
||||
const { id, dueDate } = cmd.payload
|
||||
const { dueDate, id } = cmd.payload
|
||||
response = await UpdateDueDate(id, dueDate)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -5,48 +5,52 @@ export const USER_TYPES = {
|
||||
CHILD: 1,
|
||||
}
|
||||
|
||||
export const isParentUser = (user) => {
|
||||
export const isParentUser = user => {
|
||||
if (!user) return false
|
||||
return user.userType === USER_TYPES.PARENT && !user.parentUserId
|
||||
}
|
||||
|
||||
export const isChildUser = (user) => {
|
||||
export const isChildUser = user => {
|
||||
if (!user) return false
|
||||
return user.userType === USER_TYPES.CHILD && user.parentUserId !== null
|
||||
}
|
||||
|
||||
export const canManageChildUsers = (user) => {
|
||||
export const canManageChildUsers = user => {
|
||||
return isParentUser(user)
|
||||
}
|
||||
|
||||
export const canCreateChores = (user) => {
|
||||
export const canCreateChores = user => {
|
||||
// Both parent and child users can create chores
|
||||
return user && (isParentUser(user) || isChildUser(user))
|
||||
}
|
||||
|
||||
export const canManageCircle = (user) => {
|
||||
export const canManageCircle = user => {
|
||||
// Only parent users can manage circle settings
|
||||
return isParentUser(user)
|
||||
}
|
||||
|
||||
export const canAccessAdminSettings = (user) => {
|
||||
export const canAccessAdminSettings = user => {
|
||||
// Only parent users can access admin settings like API tokens, MFA, etc.
|
||||
return isParentUser(user)
|
||||
}
|
||||
|
||||
export const getUserDisplayInfo = (user) => {
|
||||
export const getUserDisplayInfo = user => {
|
||||
if (!user) return { displayName: '', username: '', userType: 'unknown' }
|
||||
|
||||
return {
|
||||
displayName: user.displayName || user.username,
|
||||
username: user.username,
|
||||
userType: isParentUser(user) ? 'parent' : isChildUser(user) ? 'child' : 'unknown',
|
||||
userType: isParentUser(user)
|
||||
? 'parent'
|
||||
: isChildUser(user)
|
||||
? 'child'
|
||||
: 'unknown',
|
||||
parentUserId: user.parentUserId,
|
||||
circleID: user.circleID,
|
||||
}
|
||||
}
|
||||
|
||||
export const getChildUsernameFromCombined = (combinedUsername) => {
|
||||
export const getChildUsernameFromCombined = combinedUsername => {
|
||||
// Extract child name from format: parent_child
|
||||
const parts = combinedUsername.split('_')
|
||||
if (parts.length >= 2) {
|
||||
@@ -55,7 +59,7 @@ export const getChildUsernameFromCombined = (combinedUsername) => {
|
||||
return combinedUsername
|
||||
}
|
||||
|
||||
export const getParentUsernameFromCombined = (combinedUsername) => {
|
||||
export const getParentUsernameFromCombined = combinedUsername => {
|
||||
// Extract parent name from format: parent_child
|
||||
const parts = combinedUsername.split('_')
|
||||
return parts[0] || combinedUsername
|
||||
@@ -63,4 +67,4 @@ export const getParentUsernameFromCombined = (combinedUsername) => {
|
||||
|
||||
export const buildChildUsername = (parentUsername, childName) => {
|
||||
return `${parentUsername}_${childName}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { authButtonSx, authInputSx } from './authStyles'
|
||||
|
||||
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
|
||||
|
||||
export const AuthField = ({ label, error, helper, children, ...formProps }) => (
|
||||
export const AuthField = ({ children, error, helper, label, ...formProps }) => (
|
||||
<FormControl error={Boolean(error)} {...formProps}>
|
||||
<FormLabel sx={labelSx}>{label}</FormLabel>
|
||||
{children}
|
||||
@@ -34,16 +35,16 @@ export const AuthField = ({ label, error, helper, children, ...formProps }) => (
|
||||
</FormControl>
|
||||
)
|
||||
|
||||
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
|
||||
export const AuthTextField = ({ error, helper, label, sx, ...inputProps }) => (
|
||||
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
|
||||
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
|
||||
</AuthField>
|
||||
)
|
||||
|
||||
export const AuthPasswordField = ({
|
||||
label,
|
||||
error,
|
||||
helper,
|
||||
label,
|
||||
sx,
|
||||
...inputProps
|
||||
}) => {
|
||||
@@ -92,7 +93,7 @@ export const AuthSubmitButton = ({ children, sx, ...props }) => (
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const SocialButton = ({ icon, children, sx, ...props }) => (
|
||||
export const SocialButton = ({ children, icon, sx, ...props }) => (
|
||||
<Button
|
||||
type='button'
|
||||
size='lg'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Box, Sheet, Typography } from '@mui/joy'
|
||||
|
||||
import Logo from '../../Logo'
|
||||
|
||||
/**
|
||||
@@ -8,18 +9,18 @@ import Logo from '../../Logo'
|
||||
* its own safe-area padding (the top inset is already reserved by NavBar).
|
||||
*/
|
||||
const AuthShell = ({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
footer,
|
||||
logoSize = 48,
|
||||
showLogo = !Capacitor.isNativePlatform(),
|
||||
subtitle,
|
||||
// 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(),
|
||||
title,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Box, Button, LinearProgress } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Box, Button, LinearProgress } from '@mui/joy'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
|
||||
import { GetUserProfile } from '../../utils/Fetcher'
|
||||
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Box, Button, Link, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ResetPassword } from '../../utils/Fetcher'
|
||||
import { AuthSubmitButton, AuthTextField, LegalLinks } from './AuthFields'
|
||||
|
||||
@@ -4,7 +4,9 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||
import WifiIcon from '@mui/icons-material/Wifi'
|
||||
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { API_URL } from '../../Config'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
@@ -12,7 +14,6 @@ import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { AuthSubmitButton, AuthTextField } from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 8000
|
||||
|
||||
@@ -106,8 +107,7 @@ const LoginSettings = () => {
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
t('server.dnsFailed'),
|
||||
message: t('server.dnsFailed'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -119,16 +119,14 @@ const LoginSettings = () => {
|
||||
// no-cors also timed out → server/host truly unreachable
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
t('server.unreachable'),
|
||||
message: t('server.unreachable'),
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback (should rarely hit)
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
t('server.unreachable'),
|
||||
message: t('server.unreachable'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,9 +143,7 @@ const LoginSettings = () => {
|
||||
|
||||
if (!isValidURL(trimmedURL)) {
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
t('server.invalidUrl'),
|
||||
)
|
||||
setErrorMessage(t('server.invalidUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const SegmentedControl = ({ value, onChange, options }) => (
|
||||
const SegmentedControl = ({ onChange, options, value }) => (
|
||||
<Box
|
||||
role='tablist'
|
||||
sx={{
|
||||
@@ -582,7 +582,7 @@ const LoginView = () => {
|
||||
discoveryDocs='claims_supported'
|
||||
access_type='online'
|
||||
isOnlyGetToken={true}
|
||||
onResolve={({ provider, data }) => {
|
||||
onResolve={({ data, provider }) => {
|
||||
loggedWithProvider(provider, data)
|
||||
}}
|
||||
onReject={() => {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { VerifyMFA } from '../../utils/Fetcher'
|
||||
import { authInputSx } from './authStyles'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MFAVerificationModal = ({
|
||||
open,
|
||||
onClose,
|
||||
sessionToken,
|
||||
onSuccess,
|
||||
onError,
|
||||
onSuccess,
|
||||
open,
|
||||
sessionToken,
|
||||
}) => {
|
||||
const { t } = useTranslation('auth')
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
@@ -38,8 +38,7 @@ const MFAVerificationModal = ({
|
||||
onSuccess(data)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
const message =
|
||||
errorData.message || t('mfaModal.invalidCode')
|
||||
const message = errorData.message || t('mfaModal.invalidCode')
|
||||
setError(message)
|
||||
onError?.(message)
|
||||
}
|
||||
@@ -77,9 +76,7 @@ const MFAVerificationModal = ({
|
||||
size='md'
|
||||
title={t('mfaModal.title')}
|
||||
description={
|
||||
isBackupCode
|
||||
? t('mfaModal.backupHint')
|
||||
: t('mfaModal.codeHint')
|
||||
isBackupCode ? t('mfaModal.backupHint') : t('mfaModal.codeHint')
|
||||
}
|
||||
closeOnBackdrop={!loading}
|
||||
closeOnEscape={!loading}
|
||||
@@ -112,7 +109,9 @@ const MFAVerificationModal = ({
|
||||
<Input
|
||||
id='mfa-code'
|
||||
size='lg'
|
||||
placeholder={isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'}
|
||||
placeholder={
|
||||
isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'
|
||||
}
|
||||
value={verificationCode}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -39,8 +39,7 @@ const SignupView = () => {
|
||||
if (!result.success) {
|
||||
showError({
|
||||
title: 'Almost there',
|
||||
message:
|
||||
t('signupSignInFailed'),
|
||||
message: t('signupSignInFailed'),
|
||||
})
|
||||
Navigate('/login')
|
||||
return
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
Archive,
|
||||
AttachFile,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
Edit,
|
||||
History,
|
||||
HourglassEmpty,
|
||||
LowPriority,
|
||||
MoreVert,
|
||||
OpenInFull,
|
||||
PeopleAlt,
|
||||
Person,
|
||||
@@ -25,18 +26,13 @@ import {
|
||||
Checkbox,
|
||||
Chip,
|
||||
Container,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Divider } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -45,6 +41,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import {
|
||||
useChoreDetails,
|
||||
@@ -68,27 +65,39 @@ import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import {
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
DeleteChore,
|
||||
GetChoreDetailById,
|
||||
MarkChoreComplete,
|
||||
NudgeChore,
|
||||
RejectChore,
|
||||
SaveChore,
|
||||
SkipChore,
|
||||
UnArchiveChore,
|
||||
UndoChoreAction,
|
||||
UpdateChoreAssignee,
|
||||
UpdateChorePriority,
|
||||
UpdateDueDate,
|
||||
} from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||
import DueDatePickerModal, {
|
||||
combineDueDate,
|
||||
splitDueDate,
|
||||
} from '../components/DueDatePickerModal'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import NudgeModal from '../Modals/Inputs/NudgeModal'
|
||||
import SelectModal from '../Modals/Inputs/SelectModal'
|
||||
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
|
||||
import TimePassedCard from './TimePassedCard.jsx'
|
||||
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
@@ -106,6 +115,11 @@ const decodeHtmlEntities = value => {
|
||||
|
||||
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
|
||||
|
||||
const getNFCUrl = choreId =>
|
||||
Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios'
|
||||
? `donetick://chores/${choreId}`
|
||||
: `${window.location.origin}/chores/${choreId}`
|
||||
|
||||
const ChoreView = () => {
|
||||
const { t } = useTranslation('chores')
|
||||
const { fmt } = useLocalization()
|
||||
@@ -116,7 +130,7 @@ const ChoreView = () => {
|
||||
const { choreId } = useParams()
|
||||
const [note, setNote] = useState(null)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccess, showError, showUndo } = useNotification()
|
||||
const { showError, showSuccess, showUndo } = useNotification()
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
@@ -124,7 +138,7 @@ const ChoreView = () => {
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({
|
||||
isOpen: false,
|
||||
})
|
||||
const [chorePriority, setChorePriority] = useState(null)
|
||||
const [activeModal, setActiveModal] = useState(null)
|
||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
|
||||
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
|
||||
@@ -165,7 +179,6 @@ const ChoreView = () => {
|
||||
return
|
||||
}
|
||||
setChore(choreData.res)
|
||||
setChorePriority(Priorities.find(p => p.value === choreData.res.priority))
|
||||
document.title = 'Donetick: ' + choreData.res.name
|
||||
|
||||
setPerformers(circleMembersData.res)
|
||||
@@ -236,7 +249,7 @@ const ChoreView = () => {
|
||||
UpdateChorePriority(choreId, priority.value).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(() => {
|
||||
setChorePriority(priority)
|
||||
setChore(prev => ({ ...prev, priority: priority.value }))
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
@@ -280,7 +293,7 @@ const ChoreView = () => {
|
||||
message: t('choreView.taskCompletionUndone'),
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to undo')
|
||||
throw new Error(t('choreView.unableUndo'))
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
@@ -318,7 +331,7 @@ const ChoreView = () => {
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — completion will sync when back online",
|
||||
message: t('choreView.offlineComplete'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
@@ -327,7 +340,7 @@ const ChoreView = () => {
|
||||
} else {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to complete task',
|
||||
message: error?.message || t('choreView.unableComplete'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -356,7 +369,7 @@ const ChoreView = () => {
|
||||
message: t('choreView.taskSkipUndone'),
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to undo')
|
||||
throw new Error(t('choreView.unableUndo'))
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
@@ -376,7 +389,7 @@ const ChoreView = () => {
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — skip will sync when back online",
|
||||
message: t('choreView.offlineSkip'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
@@ -385,7 +398,7 @@ const ChoreView = () => {
|
||||
} else {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to skip task',
|
||||
message: error?.message || t('choreView.unableSkip'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -411,7 +424,7 @@ const ChoreView = () => {
|
||||
setChore(startedChore)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — start will sync when back online",
|
||||
message: t('choreView.offlineStart'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
@@ -423,7 +436,7 @@ const ChoreView = () => {
|
||||
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to start task',
|
||||
message: error?.message || t('choreView.unableStart'),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -450,7 +463,7 @@ const ChoreView = () => {
|
||||
setChore(pausedChore)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — pause will sync when back online",
|
||||
message: t('choreView.offlinePause'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
@@ -462,7 +475,7 @@ const ChoreView = () => {
|
||||
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to pause task',
|
||||
message: error?.message || t('choreView.unablePause'),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -566,7 +579,7 @@ const ChoreView = () => {
|
||||
setChore({ ...chore, isActive: true })
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — restore will sync when back online",
|
||||
message: t('choreView.offlineRestore'),
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
await offlineDB.saveChores([{ ...chore, isActive: false }])
|
||||
@@ -576,13 +589,175 @@ const ChoreView = () => {
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to restore',
|
||||
message: error.message || 'Unable to restore task',
|
||||
title: t('choreView.restoreFailed'),
|
||||
message: error.message || t('choreView.unableRestore'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSkipTask = () => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: t('choreView.skipTask'),
|
||||
message: t('choreView.skipTaskConfirmation'),
|
||||
confirmText: t('choreView.skip'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
onClose: confirmed => {
|
||||
if (confirmed) {
|
||||
handleSkippingTask()
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleArchiveChore = async () => {
|
||||
try {
|
||||
const response = await ArchiveChore(choreId)
|
||||
if (response.ok) {
|
||||
await offlineDB.saveChores([{ ...chore, isActive: false }])
|
||||
setChore({ ...chore, isActive: false })
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to archive',
|
||||
message: error?.message || 'Unable to archive task',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteChore = () => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: 'Delete task',
|
||||
message: 'Are you sure you want to delete this task?',
|
||||
confirmText: 'Delete',
|
||||
cancelText: t('choreView.cancel'),
|
||||
onClose: async confirmed => {
|
||||
setConfirmModelConfig({})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
const response = await DeleteChore(choreId)
|
||||
if (response.ok) {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted successfully.',
|
||||
})
|
||||
navigate('/chores')
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to delete',
|
||||
message: error?.message || 'Unable to delete task',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDueDateChange = async newDate => {
|
||||
try {
|
||||
const response = await UpdateDueDate(choreId, newDate)
|
||||
if (response.ok) {
|
||||
setChore(prev => ({ ...prev, nextDueDate: newDate }))
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to reschedule',
|
||||
message: error?.message || 'Unable to change the due date',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveToProject = async project => {
|
||||
const projectId = project?.id ?? null
|
||||
try {
|
||||
const response = await SaveChore({ ...chore, projectId })
|
||||
if (response.ok) {
|
||||
setChore(prev => ({ ...prev, projectId }))
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
title: 'Task Moved',
|
||||
message: `Task moved to ${project?.name || 'Default Project'}.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to move task',
|
||||
message: error?.message || 'Unable to move task to project',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleNudge = async ({ message, notifyAllAssignees }) => {
|
||||
try {
|
||||
const response = await NudgeChore(choreId, {
|
||||
message,
|
||||
notifyAllAssignees,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send nudge')
|
||||
}
|
||||
const data = await response.json()
|
||||
showSuccess({
|
||||
title: 'Nudge Sent!',
|
||||
message: data.message || 'Nudge sent successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to Send Nudge',
|
||||
message: error?.message || 'Unable to send nudge at this time',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssigneeChange = async assigneeId => {
|
||||
try {
|
||||
const response = await UpdateChoreAssignee(choreId, assigneeId)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setChore(data.res)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to delegate',
|
||||
message: error?.message || 'Unable to change the assignee',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Actions the menu raises that ChoreView owns; the rest of its items either
|
||||
// navigate on their own or come in through the dedicated callbacks.
|
||||
const handleMenuAction = (type, _chore, extraData) => {
|
||||
switch (type) {
|
||||
case 'skip':
|
||||
confirmSkipTask()
|
||||
break
|
||||
case 'archive':
|
||||
handleArchiveChore()
|
||||
break
|
||||
case 'unarchive':
|
||||
handleUnarchiveChore()
|
||||
break
|
||||
case 'delete':
|
||||
confirmDeleteChore()
|
||||
break
|
||||
case 'changeDueDate':
|
||||
handleDueDateChange(extraData?.date?.toISOString() ?? null)
|
||||
break
|
||||
case 'moveToProject':
|
||||
handleMoveToProject(extraData?.project)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the current user can approve/reject (admin, manager, or task owner)
|
||||
const canApproveReject = () => {
|
||||
if (!circleMembersData?.res || !chore) return false
|
||||
@@ -794,64 +969,6 @@ const ChoreView = () => {
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
disabled={chore.isActive === false}
|
||||
color={
|
||||
chorePriority?.name === 'P1'
|
||||
? 'danger'
|
||||
: chorePriority?.name === 'P2'
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 1,
|
||||
width: '100%',
|
||||
}}
|
||||
variant='plain'
|
||||
>
|
||||
{chorePriority ? chorePriority.icon : <LowPriority />}
|
||||
{chorePriority ? chorePriority.name : t('choreView.noPriority')}
|
||||
</MenuButton>
|
||||
<Menu>
|
||||
{Priorities.map((priority, index) => (
|
||||
<MenuItem
|
||||
sx={{
|
||||
pr: 1,
|
||||
py: 1,
|
||||
}}
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleUpdatePriority(priority)
|
||||
}}
|
||||
color={priority.color}
|
||||
>
|
||||
{priority.icon}
|
||||
{priority.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider />
|
||||
<MenuItem
|
||||
sx={{
|
||||
pr: 1,
|
||||
py: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleUpdatePriority({
|
||||
name: t('choreView.noPriority'),
|
||||
value: 0,
|
||||
})
|
||||
setChorePriority(null)
|
||||
}}
|
||||
>
|
||||
{t('choreView.noPriority')}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
size='sm'
|
||||
color='neutral'
|
||||
@@ -889,6 +1006,38 @@ const ChoreView = () => {
|
||||
<Edit />
|
||||
Edit
|
||||
</Button>
|
||||
<ChoreActionMenu
|
||||
chore={chore}
|
||||
hiddenActions={['view']}
|
||||
onAction={handleMenuAction}
|
||||
onNudge={() => setActiveModal('nudge')}
|
||||
onWriteNFC={() => setActiveModal('writeNFC')}
|
||||
onCompleteWithNote={() => setNote('')}
|
||||
onCompleteWithPastDate={() =>
|
||||
setCompletedDate(moment(new Date()).format('YYYY-MM-DDTHH:00:00'))
|
||||
}
|
||||
onChangeAssignee={() => setActiveModal('changeAssignee')}
|
||||
onChangeDueDate={() => setActiveModal('changeDueDate')}
|
||||
onChangePriority={handleUpdatePriority}
|
||||
onDelete={confirmDeleteChore}
|
||||
trigger={
|
||||
<Button
|
||||
size='sm'
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
fullWidth
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<MoreVert />
|
||||
{t('choreView.more', 'More')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{chore.description && (
|
||||
@@ -1261,21 +1410,7 @@ const ChoreView = () => {
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
title: t('choreView.skipTask'),
|
||||
message: t('choreView.skipTaskConfirmation'),
|
||||
confirmText: t('choreView.skip'),
|
||||
cancelText: t('choreView.cancel'),
|
||||
onClose: confirmed => {
|
||||
if (confirmed) {
|
||||
handleSkippingTask()
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}}
|
||||
onClick={confirmSkipTask}
|
||||
disabled={
|
||||
notInCompletionWindow(chore) || chore.isActive === false
|
||||
}
|
||||
@@ -1348,6 +1483,52 @@ const ChoreView = () => {
|
||||
<ConfirmationModal config={confirmModelConfig} />
|
||||
<ConfirmationModal config={timerActionConfig} />
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
{activeModal === 'changeDueDate' && (
|
||||
<DueDatePickerModal
|
||||
open={true}
|
||||
title={t('choreView.changeDueDate', 'Change due date')}
|
||||
{...splitDueDate(chore.nextDueDate)}
|
||||
onClose={() => setActiveModal(null)}
|
||||
onApply={parts => {
|
||||
handleDueDateChange(combineDueDate(parts)?.toISOString() ?? null)
|
||||
setActiveModal(null)
|
||||
}}
|
||||
onRemove={() => {
|
||||
handleDueDateChange(null)
|
||||
setActiveModal(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeModal === 'changeAssignee' && (
|
||||
<SelectModal
|
||||
isOpen={true}
|
||||
options={performers}
|
||||
displayKey='displayName'
|
||||
title='Delegate to someone else'
|
||||
placeholder='Select a performer'
|
||||
onClose={() => setActiveModal(null)}
|
||||
onSave={selected => handleAssigneeChange(selected.id)}
|
||||
/>
|
||||
)}
|
||||
{activeModal === 'nudge' && (
|
||||
<NudgeModal
|
||||
config={{
|
||||
isOpen: true,
|
||||
choreId: chore.id,
|
||||
onClose: () => setActiveModal(null),
|
||||
onConfirm: handleNudge,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeModal === 'writeNFC' && (
|
||||
<WriteNFCModal
|
||||
config={{
|
||||
isOpen: true,
|
||||
url: getNFCUrl(choreId),
|
||||
onClose: () => setActiveModal(null),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AttachmentBrowserModal
|
||||
choreId={choreId}
|
||||
isOpen={attachmentBrowserOpen}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user