Merge branch 'develop' into l10n_develop
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.donetick.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 59
|
||||
versionName "1.2.38"
|
||||
versionCode 66
|
||||
versionName "1.2.45"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -21,6 +21,7 @@ dependencies {
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-document-scanner')
|
||||
implementation project(':capgo-capacitor-nfc')
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
<data android:scheme="donetick" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Verified App Link for Circle invites hosted by Donetick Cloud. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="app.donetick.com"
|
||||
android:pathPrefix="/circle/join" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- NFC NDEF dispatch: open app directly when a donetick:// tag is scanned -->
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
|
||||
@@ -38,6 +38,9 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/@capaci
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
|
||||
5
e2e/.gitignore
vendored
Normal file
5
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.auth/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.e2e-run/
|
||||
19
e2e/debug.mjs
Normal file
19
e2e/debug.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
const state = JSON.parse(await readFile('.auth/state.json', 'utf8'))
|
||||
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))
|
||||
|
||||
await page.goto('http://localhost:5173/chores/create')
|
||||
await page.getByTestId('chore-name-input').fill('Debug Chore ' + Date.now())
|
||||
await page.getByLabel('Repeat this task').click()
|
||||
await page.getByLabel('Daily').click()
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await page.waitForURL('**/chores', { timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
await browser.close()
|
||||
56
e2e/fixtures/auth.js
Normal file
56
e2e/fixtures/auth.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
* Fill and submit the signup form through the UI.
|
||||
* 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 }) {
|
||||
await page.goto('/signup')
|
||||
await page.locator('#username').fill(username)
|
||||
await page.locator('#email').fill(email)
|
||||
await page.locator('#password').fill(password)
|
||||
await page.locator('#displayName').fill(displayName)
|
||||
await page.getByRole('button', { name: 'Create account' }).click()
|
||||
|
||||
await page.waitForURL('**/circle-setup', { timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'Continue' }).click()
|
||||
|
||||
await page.waitForURL('**/ready', { timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'Continue' }).click()
|
||||
|
||||
await page.waitForURL('**/chores', { timeout: 10_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill and submit the login form through the UI.
|
||||
* After successful login the app redirects to /chores.
|
||||
*/
|
||||
export async function loginViaUI(page, { username, password }) {
|
||||
await page.goto('/login')
|
||||
await page.locator('#username').fill(username)
|
||||
await page.locator('#password').fill(password)
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
await page.waitForURL('**/chores', { timeout: 10_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* A Playwright test fixture that provides a page already authenticated as the
|
||||
* shared E2E user (via persisted storage state, no UI interaction required).
|
||||
*/
|
||||
export const test = base.extend({
|
||||
authenticatedPage: async ({ browser }, use) => {
|
||||
const ctx = await browser.newContext({
|
||||
storageState: path.join(__dirname, '..', '.auth', 'state.json'),
|
||||
})
|
||||
const page = await ctx.newPage()
|
||||
await use(page)
|
||||
await ctx.close()
|
||||
},
|
||||
})
|
||||
|
||||
export { expect }
|
||||
96
e2e/global-setup.js
Normal file
96
e2e/global-setup.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// FRONTEND_URL: what the browser navigates to (Playwright baseURL)
|
||||
// API_URL: where the Go API lives (may be the same in production builds)
|
||||
export const FRONTEND_URL =
|
||||
process.env.E2E_FRONTEND_URL ||
|
||||
process.env.E2E_BASE_URL ||
|
||||
'http://localhost:5173'
|
||||
export const API_URL =
|
||||
process.env.E2E_API_URL || process.env.E2E_BASE_URL || 'http://localhost:2021'
|
||||
|
||||
export const TEST_USER = {
|
||||
username: 'e2e.user',
|
||||
email: 'e2e@donetick.test',
|
||||
password: 'E2ePassword123!',
|
||||
displayName: 'E2E User',
|
||||
}
|
||||
|
||||
/**
|
||||
* Global setup: create the shared E2E test user (idempotent) and persist
|
||||
* the JWT token as a Playwright storage state so authenticated tests can
|
||||
* skip the login UI.
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
await waitForServer(API_URL)
|
||||
|
||||
// Create user – ignore 409/conflict so re-runs are safe
|
||||
const signupRes = await fetch(`${API_URL}/api/v1/auth/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(TEST_USER),
|
||||
})
|
||||
if (!signupRes.ok) {
|
||||
const body = await signupRes.text()
|
||||
// Treat "already exists" errors as success so re-runs are idempotent
|
||||
if (!body.includes('already exists') && !body.includes('already taken')) {
|
||||
throw new Error(`Signup failed (${signupRes.status}): ${body}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Login and grab the access token
|
||||
const loginRes = await fetch(`${API_URL}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: TEST_USER.username,
|
||||
password: TEST_USER.password,
|
||||
}),
|
||||
})
|
||||
if (!loginRes.ok) {
|
||||
const body = await loginRes.text()
|
||||
throw new Error(`Login failed (${loginRes.status}): ${body}`)
|
||||
}
|
||||
|
||||
const { token, expire } = await loginRes.json()
|
||||
|
||||
// Write a Playwright storage-state file containing the token in localStorage
|
||||
const stateDir = path.join(__dirname, '.auth')
|
||||
await mkdir(stateDir, { recursive: true })
|
||||
await writeFile(
|
||||
path.join(stateDir, 'state.json'),
|
||||
JSON.stringify({
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: FRONTEND_URL,
|
||||
localStorage: [
|
||||
{ name: 'token', value: token },
|
||||
{ name: 'token_expiry', value: expire },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
console.log('[global-setup] Test user ready, storage state saved.')
|
||||
}
|
||||
|
||||
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' } })
|
||||
if (res.status < 500) return
|
||||
} catch {
|
||||
// server not up yet
|
||||
}
|
||||
console.log(`[global-setup] Waiting for server… (${i + 1}/${retries})`)
|
||||
await new Promise(r => setTimeout(r, delayMs))
|
||||
}
|
||||
throw new Error(`Server at ${url} did not become ready in time`)
|
||||
}
|
||||
78
e2e/package-lock.json
generated
Normal file
78
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "donetick-e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "donetick-e2e",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.44.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
e2e/package.json
Normal file
15
e2e/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "donetick-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:report": "playwright show-report",
|
||||
"test:full": "./run-e2e.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.44.0"
|
||||
}
|
||||
}
|
||||
32
e2e/playwright.config.js
Normal file
32
e2e/playwright.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
// When frontend and backend are the same origin (embedded binary) set E2E_BASE_URL.
|
||||
// For dev, set E2E_FRONTEND_URL (default 5173) and E2E_API_URL (default 2021) separately.
|
||||
const FRONTEND_URL =
|
||||
process.env.E2E_FRONTEND_URL ||
|
||||
process.env.E2E_BASE_URL ||
|
||||
'http://localhost:5173'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // sequential – single SQLite DB
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
|
||||
use: {
|
||||
baseURL: FRONTEND_URL,
|
||||
headless: true,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
globalSetup: './global-setup.js',
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
})
|
||||
118
e2e/run-e2e.sh
Executable file
118
e2e/run-e2e.sh
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts an isolated backend + frontend for this repo, runs the Playwright
|
||||
# suite against them, then tears both servers down. Uses dedicated ports
|
||||
# (2022/5180) so it never touches dev servers you may have running elsewhere
|
||||
# (e.g. another worktree on the default 2021/5173).
|
||||
set -uo pipefail
|
||||
set -m # each backgrounded job gets its own process group, so we can kill it as a unit (portable alternative to setsid, which isn't available on macOS)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FRONTEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BACKEND_DIR="$HOME/workspace/donetick-core"
|
||||
|
||||
BACKEND_PORT=2022
|
||||
FRONTEND_PORT=5180
|
||||
BACKEND_URL="http://localhost:$BACKEND_PORT"
|
||||
FRONTEND_URL="http://localhost:$FRONTEND_PORT"
|
||||
|
||||
LOG_DIR="$SCRIPT_DIR/.e2e-run"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
BACKEND_PID=""
|
||||
FRONTEND_PID=""
|
||||
|
||||
cleanup() {
|
||||
echo "--- Stopping e2e servers ---"
|
||||
[[ -n "$FRONTEND_PID" ]] && kill -TERM -"$FRONTEND_PID" 2>/dev/null
|
||||
[[ -n "$BACKEND_PID" ]] && kill -TERM -"$BACKEND_PID" 2>/dev/null
|
||||
sleep 1
|
||||
[[ -n "$FRONTEND_PID" ]] && kill -KILL -"$FRONTEND_PID" 2>/dev/null
|
||||
[[ -n "$BACKEND_PID" ]] && kill -KILL -"$BACKEND_PID" 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
kill_port() {
|
||||
local port=$1
|
||||
local pids
|
||||
pids=$(lsof -ti tcp:"$port" 2>/dev/null || true)
|
||||
if [[ -n "$pids" ]]; then
|
||||
echo "Port $port in use by leftover process(es) $pids — killing."
|
||||
kill -TERM $pids 2>/dev/null
|
||||
sleep 1
|
||||
kill -KILL $pids 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_backend() {
|
||||
for ((i = 1; i <= 40; i++)); do
|
||||
if curl -s -o /dev/null -w '%{http_code}' -X POST "$BACKEND_URL/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' -d '{}' 2>/dev/null | grep -qE '^[0-9]+$'; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_frontend() {
|
||||
for ((i = 1; i <= 40; i++)); do
|
||||
if curl -sf -o /dev/null "$FRONTEND_URL" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
kill_port "$BACKEND_PORT"
|
||||
kill_port "$FRONTEND_PORT"
|
||||
|
||||
echo "--- Starting backend on :$BACKEND_PORT (log: $LOG_DIR/backend.log) ---"
|
||||
(
|
||||
cd "$BACKEND_DIR"
|
||||
exec env \
|
||||
DT_NAME=e2e-frontend-repo \
|
||||
DT_IS_DONE_TICK_DOT_COM=false \
|
||||
DT_IS_USER_CREATION_DISABLED=false \
|
||||
DT_DATABASE_TYPE=sqlite \
|
||||
DT_DATABASE_MIGRATION=true \
|
||||
DT_JWT_SECRET=e2e_test_secret_change_this_32chars \
|
||||
DT_JWT_SESSION_TIME=168h \
|
||||
DT_JWT_MAX_REFRESH=168h \
|
||||
DT_SERVER_PORT="$BACKEND_PORT" \
|
||||
DT_SERVER_READ_TIMEOUT=10s \
|
||||
DT_SERVER_WRITE_TIMEOUT=10s \
|
||||
DT_SERVER_RATE_PERIOD=60s \
|
||||
DT_SERVER_RATE_LIMIT=300 \
|
||||
DT_SERVER_CORS_ALLOW_ORIGINS="$FRONTEND_URL" \
|
||||
DT_SERVER_SERVE_FRONTEND=false \
|
||||
go run .
|
||||
) > "$LOG_DIR/backend.log" 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
echo "--- Starting frontend on :$FRONTEND_PORT (log: $LOG_DIR/frontend.log) ---"
|
||||
(
|
||||
cd "$FRONTEND_DIR"
|
||||
exec env VITE_APP_API_URL="$BACKEND_URL" npx vite --port "$FRONTEND_PORT" --strictPort
|
||||
) > "$LOG_DIR/frontend.log" 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
echo "Waiting for backend..."
|
||||
if ! wait_for_backend; then
|
||||
echo "Backend did not become ready. See $LOG_DIR/backend.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for frontend..."
|
||||
if ! wait_for_frontend; then
|
||||
echo "Frontend did not become ready. See $LOG_DIR/frontend.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Running Playwright tests ---"
|
||||
cd "$SCRIPT_DIR"
|
||||
E2E_FRONTEND_URL="$FRONTEND_URL" E2E_API_URL="$BACKEND_URL" npx playwright test "$@"
|
||||
TEST_EXIT=$?
|
||||
|
||||
exit $TEST_EXIT
|
||||
174
e2e/tests/add-task-modal.spec.js
Normal file
174
e2e/tests/add-task-modal.spec.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import { expect, test } from '../fixtures/auth.js'
|
||||
import { API_URL } from '../global-setup.js'
|
||||
|
||||
// Fetch the full chore list for the authenticated user via the API — used to
|
||||
// verify a task's persisted fields without relying on list-page badge markup.
|
||||
async function fetchChores(page) {
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'))
|
||||
const apiRes = await fetch(`${API_URL}/api/v1/chores/`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(apiRes.ok).toBe(true)
|
||||
const { res: chores } = await apiRes.json()
|
||||
return chores
|
||||
}
|
||||
|
||||
test.describe('AddTaskModal (quick-add)', () => {
|
||||
// All tests in this suite run as the pre-authenticated E2E user
|
||||
test.use({
|
||||
storageState: '.auth/state.json',
|
||||
})
|
||||
|
||||
test('creates a task from plain text title only and it appears in the list', async ({
|
||||
page,
|
||||
}) => {
|
||||
const taskName = `E2E Quick Task ${Date.now()}`
|
||||
|
||||
// ── Open the quick-add modal from the chores list ───────────────────────
|
||||
await page.goto('/chores')
|
||||
await page.getByTestId('open-add-task-modal').click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Create new task' })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// ── Type a plain title (no parseable tokens) and submit with Enter ──────
|
||||
const input = dialog.locator('textarea')
|
||||
await input.fill(taskName)
|
||||
await input.press('Enter')
|
||||
|
||||
// ── Modal closes and the task shows up in the list ──────────────────────
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(page.getByText(taskName)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// ── Verify it exists via the API too ─────────────────────────────────────
|
||||
const chores = await fetchChores(page)
|
||||
const created = chores.find(c => c.name === taskName)
|
||||
expect(created).toBeDefined()
|
||||
})
|
||||
|
||||
test('smart-parses a due date and priority from typed text', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Kept lowercase: parsePriority's cleanup step lowercases the whole
|
||||
// sentence when a priority token matches, so an already-lowercase prefix
|
||||
// sidesteps that quirk and round-trips unchanged.
|
||||
const taskName = `e2e parse task ${Date.now()}`
|
||||
|
||||
await page.goto('/chores')
|
||||
await page.getByTestId('open-add-task-modal').click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Create new task' })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const input = dialog.locator('textarea')
|
||||
await input.fill(`${taskName} !p1 tomorrow`)
|
||||
|
||||
// ── Confirm the smart parse is reflected in the pickers before saving ───
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'P1', exact: true }),
|
||||
).toBeVisible()
|
||||
// The due-date trigger only ever reads "Due" in its empty state, so once
|
||||
// parsing lands a date the button relabels itself away from that text.
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Due', exact: true }),
|
||||
).toHaveCount(0)
|
||||
|
||||
await input.press('Enter')
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(page.getByText(taskName)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// ── Verify the parsed priority + due date persisted via the API ─────────
|
||||
const chores = await fetchChores(page)
|
||||
const created = chores.find(c => c.name === taskName)
|
||||
expect(created).toBeDefined()
|
||||
expect(created.priority).toBe(1)
|
||||
expect(created.nextDueDate).toBeTruthy()
|
||||
})
|
||||
|
||||
test('creates a task using the picker UI directly, skipping smart-parse tokens', async ({
|
||||
page,
|
||||
}) => {
|
||||
const taskName = `E2E Picker Task ${Date.now()}`
|
||||
|
||||
await page.goto('/chores')
|
||||
await page.getByTestId('open-add-task-modal').click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Create new task' })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Plain title only — none of "due date"/"priority"/"repeat" tokens.
|
||||
await dialog.locator('textarea').fill(taskName)
|
||||
|
||||
// ── Set the due date via the picker (Due → Tomorrow → Apply) ────────────
|
||||
await dialog.getByRole('button', { name: 'Due', exact: true }).click()
|
||||
const dueDateDialog = page.getByRole('dialog', { name: 'Due Date' })
|
||||
await dueDateDialog
|
||||
.getByRole('checkbox', { name: 'Tomorrow', exact: true })
|
||||
.click()
|
||||
await dueDateDialog
|
||||
.getByRole('button', { name: 'Apply', exact: true })
|
||||
.click()
|
||||
|
||||
// ── Set the priority via the picker (Priority → P2) ──────────────────────
|
||||
await dialog.getByRole('button', { name: 'Priority', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'P2', exact: true }).click()
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'P2', exact: true }),
|
||||
).toBeVisible()
|
||||
|
||||
// ── Set the repeat schedule via the picker (Repeat → Daily → Apply) ─────
|
||||
await dialog.getByRole('button', { name: 'Repeat', exact: true }).click()
|
||||
const repeatDialog = page.getByRole('dialog', { name: 'Repeat Schedule' })
|
||||
await repeatDialog
|
||||
.getByRole('checkbox', { name: 'Daily', exact: true })
|
||||
.click()
|
||||
await repeatDialog
|
||||
.getByRole('button', { name: 'Apply', exact: true })
|
||||
.click()
|
||||
|
||||
await dialog.getByRole('button', { name: 'Create', exact: true }).click()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(page.getByText(taskName)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// ── Verify the picker-set values persisted via the API ───────────────────
|
||||
const chores = await fetchChores(page)
|
||||
const created = chores.find(c => c.name === taskName)
|
||||
expect(created).toBeDefined()
|
||||
expect(created.priority).toBe(2)
|
||||
expect(created.frequencyType).toBe('daily')
|
||||
expect(created.nextDueDate).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Create button is disabled until the title is non-empty', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/chores')
|
||||
await page.getByTestId('open-add-task-modal').click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Create new task' })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const createButton = dialog.getByRole('button', {
|
||||
name: 'Create',
|
||||
exact: true,
|
||||
})
|
||||
const input = dialog.locator('textarea')
|
||||
|
||||
// ── Empty title ───────────────────────────────────────────────────────
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
// ── Whitespace-only title still counts as empty ─────────────────────────
|
||||
await input.fill(' ')
|
||||
await expect(createButton).toBeDisabled()
|
||||
|
||||
// ── Real text enables the button ─────────────────────────────────────────
|
||||
await input.fill('E2E Enable Check')
|
||||
await expect(createButton).toBeEnabled()
|
||||
|
||||
// No task should be created by this test — back out via Cancel.
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
64
e2e/tests/auth.spec.js
Normal file
64
e2e/tests/auth.spec.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { signUpViaUI, loginViaUI } 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)],
|
||||
).join('')
|
||||
}
|
||||
|
||||
test.describe('Auth – Sign Up', () => {
|
||||
test('creates a new account and lands on /chores', async ({ page }) => {
|
||||
const suffix = randomSuffix()
|
||||
const user = {
|
||||
username: `test.signup.${suffix}`,
|
||||
email: `signup.${suffix}@donetick.test`,
|
||||
password: 'TestPassword123!',
|
||||
displayName: 'Test Signup User',
|
||||
}
|
||||
|
||||
await signUpViaUI(page, user)
|
||||
|
||||
await expect(page).toHaveURL(/\/chores/)
|
||||
})
|
||||
|
||||
test('shows an error when username is too short', async ({ page }) => {
|
||||
await page.goto('/signup')
|
||||
await page.locator('#username').fill('ab') // < 4 chars
|
||||
await page.locator('#email').fill('short@donetick.test')
|
||||
await page.locator('#password').fill('ValidPass123!')
|
||||
await page.locator('#displayName').fill('Short User')
|
||||
await page.getByRole('button', { name: 'Create account' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Username must be at least 4 characters'),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
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 }) => {
|
||||
await loginViaUI(page, {
|
||||
username: 'e2e.user',
|
||||
password: 'E2ePassword123!',
|
||||
})
|
||||
|
||||
await expect(page).toHaveURL(/\/chores/)
|
||||
})
|
||||
|
||||
test('shows an error for wrong password', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.locator('#username').fill('e2e.user')
|
||||
await page.locator('#password').fill('WrongPassword!')
|
||||
await page.getByRole('button', { name: 'Sign In' }).click()
|
||||
|
||||
// The notification snackbar / error message should appear
|
||||
await expect(
|
||||
page.getByText(/Login Failed|invalid|incorrect/i).first(),
|
||||
).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
150
e2e/tests/chore-edit.spec.js
Normal file
150
e2e/tests/chore-edit.spec.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import { expect, test } from '../fixtures/auth.js'
|
||||
import { API_URL } from '../global-setup.js'
|
||||
|
||||
test.describe('ChoreEdit', () => {
|
||||
// All tests in this suite run as the pre-authenticated E2E user
|
||||
test.use({
|
||||
storageState: '.auth/state.json',
|
||||
})
|
||||
|
||||
test('blocks save and shows an error when the name is left blank', async ({
|
||||
page,
|
||||
}) => {
|
||||
// ── Navigate to the create chore page, leave the form at defaults ──────
|
||||
await page.goto('/chores/create')
|
||||
|
||||
// ── Attempt to save without a name ──────────────────────────────────────
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
// ── The validation error is shown and we never leave the create page ───
|
||||
await expect(page.getByText('Name is required').first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
})
|
||||
await expect(page).toHaveURL(/\/chores\/create/)
|
||||
})
|
||||
|
||||
test('days-of-the-week repeat: blocks save with no days selected, then succeeds once days are chosen', async ({
|
||||
page,
|
||||
}) => {
|
||||
const choreName = `E2E Weekly Chore ${Date.now()}`
|
||||
|
||||
// ── Navigate to the create chore page ────────────────────────────────
|
||||
await page.goto('/chores/create')
|
||||
|
||||
// ── Fill in the chore name ────────────────────────────────────────────
|
||||
await page.locator('input').first().fill(choreName)
|
||||
|
||||
// ── Enable recurrence and switch to a custom "days of the week" schedule
|
||||
await page.getByLabel('Repeat this task').click()
|
||||
await page.getByLabel('Custom').click()
|
||||
await page.getByLabel('Days of the Week').click()
|
||||
|
||||
// ── Attempt to save with zero days selected ─────────────────────────────
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(
|
||||
page.getByText('Please select at least one day of the week').first(),
|
||||
).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page).toHaveURL(/\/chores\/create/)
|
||||
|
||||
// ── Select Monday and Wednesday, then save successfully ────────────────
|
||||
await page.getByLabel('Monday').click()
|
||||
await page.getByLabel('Wednesday').click()
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
await page.waitForURL('**/chores', { timeout: 15_000 })
|
||||
await expect(page.getByText(choreName)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// ── Verify the chore exists in the API with the selected days ─────────
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'))
|
||||
const apiRes = await fetch(`${API_URL}/api/v1/chores/`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(apiRes.ok).toBe(true)
|
||||
|
||||
const { res: chores } = await apiRes.json()
|
||||
const created = chores.find(c => c.name === choreName)
|
||||
expect(created).toBeDefined()
|
||||
expect(created.frequencyType).toBe('days_of_the_week')
|
||||
expect([...(created.frequencyMetadata?.days || [])].sort()).toEqual([
|
||||
'monday',
|
||||
'wednesday',
|
||||
])
|
||||
})
|
||||
|
||||
test('creates a chore, edits it after reload, and toggles Anyone assignment', async ({
|
||||
page,
|
||||
}) => {
|
||||
const originalName = `E2E Edit Chore ${Date.now()}`
|
||||
const updatedName = `${originalName} Updated`
|
||||
const updatedDescription = `Updated by e2e ${Date.now()}`
|
||||
// One week out, formatted as YYYY-MM-DD for the native date input
|
||||
const updatedDueDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10)
|
||||
|
||||
// ── Create a simple one-off chore with a due date ───────────────────────
|
||||
await page.goto('/chores/create')
|
||||
await page.locator('input').first().fill(originalName)
|
||||
|
||||
// Toggle "Anyone" on then off again — confirm it snaps back to the
|
||||
// default self-assignment before we submit.
|
||||
await page.getByLabel('Anyone').click()
|
||||
await expect(page.getByLabel('Anyone')).toBeChecked()
|
||||
await page.getByLabel('Anyone').click()
|
||||
await expect(page.getByLabel('Anyone')).not.toBeChecked()
|
||||
|
||||
// Give it a due date so it's a simple one-off chore
|
||||
await page.getByLabel('Give this task a due date').click()
|
||||
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await page.waitForURL('**/chores', { timeout: 15_000 })
|
||||
await expect(page.getByText(originalName)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// ── Look up the created chore via the API (id + self-assignment state) ─
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'))
|
||||
const listRes = await fetch(`${API_URL}/api/v1/chores/`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(listRes.ok).toBe(true)
|
||||
const { res: chores } = await listRes.json()
|
||||
const created = chores.find(c => c.name === originalName)
|
||||
expect(created).toBeDefined()
|
||||
// Default self-assignment: exactly one assignee, matching assignedTo.
|
||||
expect(created.assignees?.length).toBe(1)
|
||||
expect(created.assignedTo).toBe(created.assignees[0].userId)
|
||||
|
||||
// ── Edit: change name, description, due date, and switch to Anyone ─────
|
||||
await page.goto(`/chores/${created.id}/edit`)
|
||||
|
||||
await page.locator('input').first().fill(updatedName)
|
||||
await page.locator('.ql-editor').fill(updatedDescription)
|
||||
await page.locator('input[type="date"]').fill(updatedDueDate)
|
||||
await page.getByLabel('Anyone').click()
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await page.waitForURL('**/chores', { timeout: 15_000 })
|
||||
|
||||
// ── Reload the edit page and confirm the new values persisted in the UI
|
||||
await page.goto(`/chores/${created.id}/edit`)
|
||||
await expect(page.locator('input').first()).toHaveValue(updatedName)
|
||||
await expect(page.locator('.ql-editor')).toContainText(updatedDescription)
|
||||
await expect(page.locator('input[type="date"]')).toHaveValue(updatedDueDate)
|
||||
await expect(page.getByLabel('Anyone')).toBeChecked()
|
||||
|
||||
// ── Confirm persisted via the API too ───────────────────────────────────
|
||||
const detailRes = await fetch(`${API_URL}/api/v1/chores/${created.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(detailRes.ok).toBe(true)
|
||||
const { res: updated } = await detailRes.json()
|
||||
expect(updated.name).toBe(updatedName)
|
||||
expect(updated.description).toContain(updatedDescription)
|
||||
// Due-date persistence is already confirmed via the reloaded date input
|
||||
// above; comparing the raw UTC `nextDueDate` string here would be
|
||||
// timezone-fragile since the app stores it as an end-of-local-day UTC
|
||||
// instant.
|
||||
// "Anyone" assignment: no specific assignees, no fixed assignedTo.
|
||||
expect(updated.assignees ?? []).toHaveLength(0)
|
||||
expect(updated.assignedTo).toBeFalsy()
|
||||
})
|
||||
})
|
||||
53
e2e/tests/chores.spec.js
Normal file
53
e2e/tests/chores.spec.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from '../fixtures/auth.js'
|
||||
import { API_URL } from '../global-setup.js'
|
||||
|
||||
test.describe('Chores – Create', () => {
|
||||
// All tests in this suite run as the pre-authenticated E2E user
|
||||
test.use({
|
||||
storageState: '.auth/state.json',
|
||||
})
|
||||
|
||||
test('creates a daily recurring chore and confirms it appears in the list', async ({
|
||||
page,
|
||||
}) => {
|
||||
const choreName = `E2E Daily Chore ${Date.now()}`
|
||||
|
||||
// ── Navigate to the create chore page ────────────────────────────────
|
||||
await page.goto('/chores/create')
|
||||
|
||||
// ── Fill in the chore name ────────────────────────────────────────────
|
||||
// Name input is the first <input> on the page (no id/placeholder)
|
||||
await page.locator('input').first().fill(choreName)
|
||||
|
||||
// ── Enable recurrence ─────────────────────────────────────────────────
|
||||
// Check the "Repeat this task" checkbox to reveal frequency options
|
||||
await page.getByLabel('Repeat this task').click()
|
||||
|
||||
// Select "Daily" from the frequency chips
|
||||
await page.getByLabel('Daily').click()
|
||||
|
||||
// The due date is auto-populated to today once a repeating type is selected.
|
||||
// No manual date entry required unless testing date-specific behaviour.
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
// After save the app navigates back to the chore list
|
||||
await page.waitForURL('**/chores', { timeout: 15_000 })
|
||||
|
||||
// ── Verify the chore appears in the UI ───────────────────────────────
|
||||
await expect(page.getByText(choreName)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// ── Verify the chore exists in the API ───────────────────────────────
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'))
|
||||
const apiRes = await fetch(`${API_URL}/api/v1/chores/`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(apiRes.ok).toBe(true)
|
||||
|
||||
const { res: chores } = await apiRes.json()
|
||||
const created = chores.find(c => c.name === choreName)
|
||||
expect(created).toBeDefined()
|
||||
expect(created.frequencyType).toBe('daily')
|
||||
})
|
||||
})
|
||||
164
e2e/tests/projects-filters.spec.js
Normal file
164
e2e/tests/projects-filters.spec.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { expect, test } from '../fixtures/auth.js'
|
||||
import { API_URL } from '../global-setup.js'
|
||||
|
||||
test.describe('Projects & Filters – Create and navigate', () => {
|
||||
// All tests in this suite run as the pre-authenticated E2E user
|
||||
test.use({
|
||||
storageState: '.auth/state.json',
|
||||
})
|
||||
|
||||
test('creates a project via the modal, tracks its task count, and filters chores by it', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectName = `E2E Project ${Date.now()}`
|
||||
const choreName = `E2E Project Chore ${Date.now()}`
|
||||
|
||||
// ── Create the project via the modal ─────────────────────────────────
|
||||
await page.goto('/projects')
|
||||
await page.getByTestId('open-add-project-modal').click()
|
||||
|
||||
const projectDialog = page.getByRole('dialog')
|
||||
await projectDialog.getByLabel('Project Name').fill(projectName)
|
||||
await projectDialog.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
// ── Verify the project appears in the list with a 0 task count ───────
|
||||
// Scope to the row: the name Typography's parent Box also holds the
|
||||
// "N tasks" chip as a sibling, so one level up covers both.
|
||||
const projectRow = page
|
||||
.getByText(projectName, { exact: true })
|
||||
.locator('xpath=..')
|
||||
await expect(projectRow.getByText('0 tasks')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// ── Look up the created project via the API to get its id ────────────
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'))
|
||||
const projectsRes = await fetch(`${API_URL}/api/v1/projects`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(projectsRes.ok).toBe(true)
|
||||
const projects = await projectsRes.json()
|
||||
const project = projects.find(p => p.name === projectName)
|
||||
expect(project).toBeDefined()
|
||||
|
||||
// ── Create a chore inside the project directly via the API ───────────
|
||||
// Cheaper than driving the full chore-create UI, and this project is
|
||||
// brand new so its task count is guaranteed to go from 0 to 1.
|
||||
const choreRes = await fetch(`${API_URL}/api/v1/chores/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: choreName,
|
||||
frequencyType: 'once',
|
||||
assignStrategy: 'no_assignee',
|
||||
projectId: project.id,
|
||||
}),
|
||||
})
|
||||
expect(choreRes.ok).toBe(true)
|
||||
|
||||
// ── Reload so the project list reflects the new task count ───────────
|
||||
await page.reload()
|
||||
await expect(projectRow.getByText('1 tasks')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// ── Click the project card and confirm navigation + filtered results ─
|
||||
await page.getByText(projectName, { exact: true }).click()
|
||||
await page.waitForURL(new RegExp(`/chores\\?project=${project.id}(&|$)`), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
await expect(page.getByText(choreName)).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('creates a filter via AdvancedFilterBuilder, tracks its task count, and filters chores by it', async ({
|
||||
page,
|
||||
}) => {
|
||||
const filterProjectName = `E2E Filter Project ${Date.now()}`
|
||||
const filterName = `E2E Filter ${Date.now()}`
|
||||
const choreName = `E2E Filter Chore ${Date.now()}`
|
||||
|
||||
// ── Seed a throwaway project via the API to use as the filter's ──────
|
||||
// condition target. A brand-new project has zero chores, so the
|
||||
// filter's task count is guaranteed to start at 0 regardless of
|
||||
// whatever else has accumulated in the shared test database.
|
||||
await page.goto('/filters')
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'))
|
||||
const createProjectRes = await fetch(`${API_URL}/api/v1/projects`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name: filterProjectName }),
|
||||
})
|
||||
expect(createProjectRes.ok).toBe(true)
|
||||
const { res: filterProject } = await createProjectRes.json()
|
||||
|
||||
// Reload so the modal's project list (fetched on page load) includes it
|
||||
await page.reload()
|
||||
|
||||
// ── Create the filter via AdvancedFilterBuilder ───────────────────────
|
||||
await page.getByTestId('open-add-filter-modal').click()
|
||||
|
||||
const filterDialog = page.getByRole('dialog')
|
||||
await filterDialog
|
||||
.getByPlaceholder('e.g. Overdue tasks for Alice')
|
||||
.fill(filterName)
|
||||
// Condition: Projects is <the seeded project> — one condition is enough
|
||||
await filterDialog
|
||||
.getByRole('button', { name: filterProjectName, exact: true })
|
||||
.click()
|
||||
await filterDialog.getByRole('button', { name: 'Save Filter' }).click()
|
||||
|
||||
// ── Verify the filter appears in the list with a 0 task count ────────
|
||||
// The name Typography sits inside a name-row Box, itself inside the
|
||||
// content Box that also holds the "N tasks" chip — two levels up.
|
||||
const filterRow = page
|
||||
.getByText(filterName, { exact: true })
|
||||
.locator('xpath=../..')
|
||||
await expect(filterRow.getByText('0 tasks')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// ── Look up the created filter via the API to get its id ─────────────
|
||||
const filtersRes = await fetch(`${API_URL}/api/v1/filters`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(filtersRes.ok).toBe(true)
|
||||
const filters = await filtersRes.json()
|
||||
const filter = filters.find(f => f.name === filterName)
|
||||
expect(filter).toBeDefined()
|
||||
|
||||
// ── Create a chore matching the filter's project condition ───────────
|
||||
const choreRes = await fetch(`${API_URL}/api/v1/chores/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: choreName,
|
||||
frequencyType: 'once',
|
||||
assignStrategy: 'no_assignee',
|
||||
projectId: filterProject.id,
|
||||
}),
|
||||
})
|
||||
expect(choreRes.ok).toBe(true)
|
||||
|
||||
// ── Reload so the filter list reflects the new task count ────────────
|
||||
await page.reload()
|
||||
await expect(filterRow.getByText('1 tasks')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
|
||||
// ── Click the filter card and confirm navigation + filtered results ──
|
||||
await page.getByText(filterName, { exact: true }).click()
|
||||
await page.waitForURL(new RegExp(`/chores\\?filterId=${filter.id}(&|$)`), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
await expect(page.getByText(choreName)).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
@@ -462,12 +462,12 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
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.38;
|
||||
MARKETING_VERSION = 1.2.45;
|
||||
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 = 59;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
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.38;
|
||||
MARKETING_VERSION = 1.2.45;
|
||||
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 = 59;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
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.38;
|
||||
MARKETING_VERSION = 1.2.45;
|
||||
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 = 59;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
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.38;
|
||||
MARKETING_VERSION = 1.2.45;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:app.donetick.com</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.donetick.app</string>
|
||||
|
||||
@@ -23,6 +23,7 @@ def capacitor_pods
|
||||
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
|
||||
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
||||
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
|
||||
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||
pod 'CapgoCapacitorDocumentScanner', :path => '../../node_modules/@capgo/capacitor-document-scanner'
|
||||
pod 'CapgoCapacitorNfc', :path => '../../node_modules/@capgo/capacitor-nfc'
|
||||
|
||||
@@ -43,6 +43,8 @@ PODS:
|
||||
- Capacitor
|
||||
- CapacitorPushNotifications (8.1.2):
|
||||
- Capacitor
|
||||
- CapacitorShare (8.0.1):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (8.0.3):
|
||||
- Capacitor
|
||||
- CapgoCapacitorDocumentScanner (8.4.2):
|
||||
@@ -157,6 +159,7 @@ DEPENDENCIES:
|
||||
- CapacitorPluginSafeArea (from `../../node_modules/capacitor-plugin-safe-area`)
|
||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
||||
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
|
||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||
- "CapgoCapacitorDocumentScanner (from `../../node_modules/@capgo/capacitor-document-scanner`)"
|
||||
- "CapgoCapacitorNfc (from `../../node_modules/@capgo/capacitor-nfc`)"
|
||||
@@ -222,6 +225,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/preferences"
|
||||
CapacitorPushNotifications:
|
||||
:path: "../../node_modules/@capacitor/push-notifications"
|
||||
CapacitorShare:
|
||||
:path: "../../node_modules/@capacitor/share"
|
||||
CapacitorStatusBar:
|
||||
:path: "../../node_modules/@capacitor/status-bar"
|
||||
CapgoCapacitorDocumentScanner:
|
||||
@@ -256,6 +261,7 @@ SPEC CHECKSUMS:
|
||||
CapacitorPluginSafeArea: 874619c00586248f1694210e72038123d422c2d9
|
||||
CapacitorPreferences: cca2021f386efb75947c850334447d9ff22b14f1
|
||||
CapacitorPushNotifications: 32a7f840815f319fd9ba1c1c9b0b914be9d95237
|
||||
CapacitorShare: 0c58305114538568059bfc07111f22dcb9cb2a82
|
||||
CapacitorStatusBar: eca7bc2b58d9f886f1ef9edb66e57a9b29c121af
|
||||
CapgoCapacitorDocumentScanner: 262bb84b73707f9e2e59071ca58013e3f9acf942
|
||||
CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027
|
||||
@@ -283,6 +289,6 @@ SPEC CHECKSUMS:
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
|
||||
PODFILE CHECKSUM: 0170e6b548e03117ef7d6814596b8b140a6acce4
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
56
package-lock.json
generated
56
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"version": "1.2.33",
|
||||
"version": "1.2.45",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "donetick",
|
||||
"version": "1.2.33",
|
||||
"version": "1.2.45",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@capacitor-community/in-app-review": "^8.0.0",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@capacitor/network": "^8.0.0",
|
||||
"@capacitor/preferences": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-document-scanner": "^8.4.0",
|
||||
"@capgo/capacitor-nfc": "^8.0.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
@@ -76,6 +78,8 @@
|
||||
"reactjs-social-login": "^2.6.3",
|
||||
"recharts": "^2.15.0",
|
||||
"reusify": "^1.0.4",
|
||||
"stylis": "^4.4.0",
|
||||
"stylis-plugin-rtl": "^2.1.1",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
},
|
||||
@@ -2174,6 +2178,15 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/share": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz",
|
||||
"integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz",
|
||||
@@ -2301,8 +2314,16 @@
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin/node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/cache": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
|
||||
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
@@ -2312,6 +2333,12 @@
|
||||
"stylis": "4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/cache/node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"license": "MIT"
|
||||
@@ -7470,6 +7497,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/cssjanus": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cssjanus/-/cssjanus-2.3.1.tgz",
|
||||
"integrity": "sha512-gWZQ/S0tthU2KCc55C5zbjxQN0XPK1sT0qufCqwCNUauGIsPDOfdBMBWosP+Kd4wf2A2Nxm/z6JAP6yNGnS7QQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"license": "MIT"
|
||||
@@ -14053,9 +14089,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
|
||||
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stylis-plugin-rtl": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz",
|
||||
"integrity": "sha512-q6xIkri6fBufIO/sV55md2CbgS5c6gg9EhSVATtHHCdOnbN/jcI0u3lYhNVeuI65c4lQPo67g8xmq5jrREvzlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssjanus": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"stylis": "4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.0",
|
||||
"dev": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "1.2.38",
|
||||
"version": "1.2.45",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
@@ -56,6 +56,7 @@
|
||||
"@capacitor/network": "^8.0.0",
|
||||
"@capacitor/preferences": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-document-scanner": "^8.4.0",
|
||||
"@capgo/capacitor-nfc": "^8.0.0",
|
||||
@@ -63,6 +64,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/cache": "^11.14.0",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
@@ -108,6 +110,8 @@
|
||||
"reactjs-social-login": "^2.6.3",
|
||||
"recharts": "^2.15.0",
|
||||
"reusify": "^1.0.4",
|
||||
"stylis": "^4.4.0",
|
||||
"stylis-plugin-rtl": "^2.1.1",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
},
|
||||
|
||||
11
public/.well-known/apple-app-site-association
Normal file
11
public/.well-known/apple-app-site-association
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "6UJJ78R3BS.com.donetick.app",
|
||||
"paths": ["/circle/join*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
public/.well-known/apple-app-site-association.json
Normal file
11
public/.well-known/apple-app-site-association.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "6UJJ78R3BS.com.donetick.app",
|
||||
"paths": ["/circle/join*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
public/.well-known/assetlinks.json
Normal file
13
public/.well-known/assetlinks.json
Normal file
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.donetick.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"EF:45:27:40:A2:D2:11:E4:27:AB:9A:7A:C6:E1:3B:CA:D4:DE:6A:0A:C3:81:05:58:D8:89:F1:FA:4E:CB:44:F3",
|
||||
"67:0B:E5:30:FB:8A:7F:E6:9A:54:51:7F:06:AA:B0:1D:1A:26:61:5B:2A:60:53:4A:31:75:72:DA:F6:FA:EC:5A"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
8
public/_headers
Normal file
8
public/_headers
Normal file
@@ -0,0 +1,8 @@
|
||||
/.well-known/apple-app-site-association
|
||||
Content-Type: application/json
|
||||
|
||||
/.well-known/apple-app-site-association.json
|
||||
Content-Type: application/json
|
||||
|
||||
/.well-known/assetlinks.json
|
||||
Content-Type: application/json
|
||||
1
public/_redirects
Normal file
1
public/_redirects
Normal file
@@ -0,0 +1 @@
|
||||
/.well-known/apple-app-site-association /.well-known/apple-app-site-association.json 200
|
||||
@@ -20,6 +20,7 @@
|
||||
"logout": "تسجيل الخروج",
|
||||
"version": "النسخة",
|
||||
"navigation": {
|
||||
"search": "بحث",
|
||||
"allTasks": "جميع المهام",
|
||||
"archived": "المؤرشفة",
|
||||
"things": "الأشياء",
|
||||
@@ -29,5 +30,47 @@
|
||||
"activities": "الأنشطة",
|
||||
"points": "النقاط",
|
||||
"settings": "الإعدادات"
|
||||
},
|
||||
"search": {
|
||||
"title": "بحث",
|
||||
"placeholder": "ابحث في Donetick",
|
||||
"inputAriaLabel": "ابحث في المهام والسجل والمشاريع والتسميات والإعدادات",
|
||||
"deviceNote": "يتم البحث في المحتوى المتوفر على هذا الجهاز",
|
||||
"escape": "Esc",
|
||||
"recent": "الأخيرة",
|
||||
"empty": {
|
||||
"title": "لا توجد نتائج مطابقة",
|
||||
"subtitle": "لا يزال بإمكانك تصفية قائمة المهام باستخدام هذا البحث."
|
||||
},
|
||||
"groups": {
|
||||
"tasks": "المهام",
|
||||
"history": "الملاحظات",
|
||||
"projects": "المشاريع",
|
||||
"labels": "التسميات",
|
||||
"people": "الأشخاص",
|
||||
"settings": "الإعدادات",
|
||||
"actions": "إجراءات سريعة"
|
||||
},
|
||||
"actions": {
|
||||
"quickAction": "إجراء سريع",
|
||||
"navigation": "التنقل",
|
||||
"createTask": "إنشاء مهمة",
|
||||
"viewAllTasks": "عرض جميع المهام",
|
||||
"viewArchivedTasks": "عرض المهام المؤرشفة",
|
||||
"openSettings": "فتح الإعدادات",
|
||||
"filterTasks": "عرض المهام المطابقة لـ «{{query}}»",
|
||||
"filterTasksSubtitle": "تصفية قائمة المهام"
|
||||
},
|
||||
"footer": {
|
||||
"navigate": "تنقل",
|
||||
"open": "فتح",
|
||||
"results_zero": "لا توجد نتائج",
|
||||
"results_one": "نتيجة واحدة",
|
||||
"results_two": "نتيجتان",
|
||||
"results_few": "{{count}} نتائج",
|
||||
"results_many": "{{count}} نتيجة",
|
||||
"results_other": "{{count}} نتيجة",
|
||||
"typeToSearch": "اكتب للبحث"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,29 @@
|
||||
{
|
||||
"title": "الإعدادات",
|
||||
"circleSettings": {
|
||||
"title": "إعدادات الدائرة",
|
||||
"description": "يتم ربط حسابك تلقائيًا بدائرة عند إنشاء واحدة أو الانضمام إليها. ادعُ الأصدقاء بسهولة من خلال مشاركة رمز الدائرة الفريد أو الرابط أدناه. ستتلقى إشعارًا أدناه عندما يطلب شخص ما الانضمام إلى دائرتك. إذا كنت ترغب في المغادرة، فما عليك سوى الضغط على زر 'مغادرة الدائرة'.",
|
||||
"circleCode": "رمز الدائرة",
|
||||
"copyCode": "نسخ الرمز",
|
||||
"copyLink": "نسخ الرابط",
|
||||
"codeCopied": "تم نسخ رمز الدائرة!",
|
||||
"linkCopied": "تم نسخ الرابط!",
|
||||
"joinCircle": "الانضمام إلى دائرة",
|
||||
"joinCirclePlaceholder": "أدخل رمز الدائرة",
|
||||
"join": "انضمام",
|
||||
"leave": "مغادرة الدائرة",
|
||||
"leaveConfirmTitle": "مغادرة الدائرة",
|
||||
"leaveConfirmMessage": "هل أنت متأكد من أنك تريد مغادرة هذه الدائرة؟",
|
||||
"circleMembers": "أعضاء الدائرة",
|
||||
"circleMemberRequests": "طلبات انضمام الأعضاء",
|
||||
"admin": "مشرف",
|
||||
"member": "عضو",
|
||||
"pending": "قيد الانتظار",
|
||||
"accept": "قبول",
|
||||
"reject": "رفض",
|
||||
"makeAdmin": "جعله مشرف",
|
||||
"makeMember": "جعله عضو",
|
||||
"remove": "إزالة",
|
||||
"webhookURL": "رابط Webhook",
|
||||
"webhookDescription": "أدخل رابط webhook لتلقي إشعارات أحداث الدائرة",
|
||||
"webhookPlaceholder": "https://your-webhook-url.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "إعدادات الحساب",
|
||||
"subscription": "الاشتراك",
|
||||
"subscriptionStatus": "الخطة الحالية",
|
||||
"free": "مجاني",
|
||||
"plus": "بلس",
|
||||
"upgrade": "ترقية",
|
||||
"cancel": "إلغاء",
|
||||
"changePassword": "تغيير كلمة المرور",
|
||||
"password": "كلمة المرور",
|
||||
"dangerZone": "منطقة الخطر",
|
||||
"dangerZoneDescription": "بمجرد حذف حسابك، لا يمكن التراجع. يرجى التأكد.",
|
||||
"deleteAccount": "حذف الحساب"
|
||||
},
|
||||
"localization": {
|
||||
"title": "التوطين",
|
||||
"description": "تخصيص اللغة وتنسيق التاريخ والتفضيلات الإقليمية لحسابك.",
|
||||
"language": "اللغة",
|
||||
"languageDescription": "اختر لغتك المفضلة",
|
||||
"dateFormat": "تنسيق التاريخ",
|
||||
"dateFormatDescription": "اختر كيفية عرض التواريخ في التطبيق",
|
||||
"timeFormat": "تنسيق الوقت",
|
||||
"timeFormatDescription": "اختر تنسيق 12 أو 24 ساعة",
|
||||
"12hour": "12 ساعة (ص/م)",
|
||||
"24hour": "24 ساعة",
|
||||
"firstDayOfWeek": "أول يوم في الأسبوع",
|
||||
"firstDayOfWeekDescription": "اختر اليوم الذي يبدأ به أسبوعك",
|
||||
"sunday": "الأحد",
|
||||
"monday": "الاثنين",
|
||||
"saturday": "السبت",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/YYYY (الولايات المتحدة)",
|
||||
"dmy": "DD/MM/YYYY (أوروبا)",
|
||||
"ymd": "YYYY-MM-DD (ISO)",
|
||||
"long": "تنسيق طويل (مثل 1 يناير 2024)",
|
||||
"short": "تنسيق قصير (مثل 1 يناير 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "تخصيص اللوحة الجانبية",
|
||||
"description": "قم بتخصيص تخطيط ورؤية البطاقات في اللوحة الجانبية. هذا القسم متاح فقط على أجهزة الشاشة الكبيرة مثل الأجهزة اللوحية وأجهزة سطح المكتب."
|
||||
},
|
||||
"theme": {
|
||||
"title": "تفضيلات المظهر",
|
||||
"description": "اختر كيف يبدو الموقع لك. حدد مظهرًا واحدًا أو قم بالمزامنة مع نظامك والتبديل تلقائيًا بين مظاهر النهار والليل.",
|
||||
"themeMode": "وضع المظهر",
|
||||
"light": "فاتح",
|
||||
"dark": "داكن",
|
||||
"system": "النظام"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "تم حفظ الإعدادات بنجاح",
|
||||
"settingsSaveFailed": "فشل حفظ الإعدادات",
|
||||
"invalidWebhook": "رابط webhook غير صالح"
|
||||
},
|
||||
"profile": {
|
||||
"title": "إعدادات الملف الشخصي",
|
||||
"description": "تحديث اسم العرض وصورة الملف الشخصي.",
|
||||
"photoUpdated": "تم تحديث الصورة",
|
||||
"photoUpdatedMessage": "تم تحديث صورة ملفك الشخصي بنجاح!",
|
||||
"uploadFailed": "فشل التحميل",
|
||||
"uploadFailedMessage": "فشل تحميل صورتك. يرجى المحاولة مرة أخرى.",
|
||||
"profileUpdated": "تم تحديث الملف الشخصي",
|
||||
"profileUpdatedMessage": "تم حفظ معلومات ملفك الشخصي بنجاح!",
|
||||
"updateFailed": "فشل التحديث",
|
||||
"updateFailedMessage": "تعذر تحديث ملفك الشخصي. يرجى التحقق من اتصالك والمحاولة مرة أخرى.",
|
||||
"changePhoto": "تغيير الصورة",
|
||||
"displayName": "اسم العرض",
|
||||
"displayNamePlaceholder": "أدخل اسم العرض الخاص بك",
|
||||
"timezone": "المنطقة الزمنية",
|
||||
"timezonePlaceholder": "اختر منطقتك الزمنية",
|
||||
"common": {
|
||||
"save": "حفظ",
|
||||
"cancel": "إلغاء"
|
||||
"cancel": "إلغاء",
|
||||
"confirm": "تأكيد",
|
||||
"remove": "إزالة",
|
||||
"delete": "حذف",
|
||||
"refresh": "تحديث",
|
||||
"loading": "جارٍ التحميل…",
|
||||
"on": "مفعّل",
|
||||
"off": "متوقف",
|
||||
"error": "خطأ",
|
||||
"success": "تم بنجاح",
|
||||
"plusFeature": "ميزة Plus",
|
||||
"earlyAccess": "وصول مبكر"
|
||||
},
|
||||
"overview": {
|
||||
"title": "الإعدادات",
|
||||
"subtitle": "قم بتخصيص تجربتك وإدارة تفضيلات حسابك",
|
||||
"subtitle": "خصّص تجربتك وأدر تفضيلات حسابك",
|
||||
"upgrade": {
|
||||
"title": "الترقية إلى بلس",
|
||||
"description": "افتح ميزات قوية لتعزيز إنتاجيتك",
|
||||
"button": "الترقية الآن",
|
||||
"title": "الترقية إلى Plus",
|
||||
"description": "افتح ميزات قوية تزيد من إنتاجيتك",
|
||||
"button": "ترقية الآن",
|
||||
"features": {
|
||||
"richText": "أوصاف نصية منسقة",
|
||||
"richText": "أوصاف بنص منسّق",
|
||||
"notifications": "إشعارات المهام",
|
||||
"apiIntegrations": "تكاملات API",
|
||||
"advancedAutomation": "أتمتة متقدمة"
|
||||
@@ -119,56 +32,463 @@
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "إعدادات الملف الشخصي",
|
||||
"description": "تحديث معلومات ملفك الشخصي والصورة واسم العرض وتفضيلات المنطقة الزمنية."
|
||||
"description": "حدّث معلومات ملفك الشخصي وصورتك واسم العرض والمنطقة الزمنية."
|
||||
},
|
||||
"circle": {
|
||||
"title": "إعدادات الدائرة",
|
||||
"description": "إدارة دائرتك ودعوة الأعضاء والتعامل مع طلبات الانضمام."
|
||||
"description": "أدر دائرتك، وادعُ الأعضاء، وتعامل مع طلبات الانضمام."
|
||||
},
|
||||
"account": {
|
||||
"title": "إعدادات الحساب",
|
||||
"description": "إدارة اشتراكك وتغيير كلمة المرور وخيارات حذف الحساب."
|
||||
"description": "أدر اشتراكك، وغيّر كلمة المرور، واحذف حسابك."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "الحسابات المُدارة",
|
||||
"description": "إنشاء وإدارة حسابات فرعية لتسجيل الدخول وإكمال المهام المعينة."
|
||||
"description": "أنشئ وأدر حسابات فرعية يمكنها تسجيل الدخول وإنجاز المهام المسندة إليها."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "الإشعارات",
|
||||
"description": "تكوين الإشعارات الفورية وتنبيهات البريد الإلكتروني ووجهات الإشعارات للمهام."
|
||||
"description": "اضبط الإشعارات الفورية وتنبيهات البريد ووجهات إشعارات المهام."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "المصادقة متعددة العوامل",
|
||||
"description": "إضافة طبقة إضافية من الأمان باستخدام MFA مع تطبيقات المصادقة."
|
||||
"description": "أضف طبقة حماية إضافية عبر المصادقة متعددة العوامل باستخدام تطبيقات المصادقة."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "رموز API",
|
||||
"description": "إنشاء وإدارة رموز الوصول لتكاملات الطرف الثالث والوصول إلى API."
|
||||
"description": "أنشئ وأدر رموز الوصول للتكاملات الخارجية والوصول إلى API."
|
||||
},
|
||||
"storage": {
|
||||
"title": "إعدادات التخزين",
|
||||
"description": "نسخ احتياطي واستعادة بياناتك وإدارة التخزين المحلي وتفضيلات المزامنة."
|
||||
"description": "انسخ بياناتك احتياطيًا واستعدها، وأدر التخزين المحلي وتفضيلات المزامنة."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "تخصيص اللوحة الجانبية",
|
||||
"description": "قم بتخصيص تخطيط ورؤية البطاقات في واجهة اللوحة الجانبية."
|
||||
"description": "خصّص ترتيب البطاقات وظهورها في اللوحة الجانبية."
|
||||
},
|
||||
"theme": {
|
||||
"title": "تفضيلات المظهر",
|
||||
"description": "اختر مظهرك المفضل وقم بتكوين إعدادات الوضع الداكن/الفاتح."
|
||||
"description": "اختر المظهر المفضل لديك واضبط الوضع الفاتح/الداكن."
|
||||
},
|
||||
"localization": {
|
||||
"title": "التوطين",
|
||||
"description": "تخصيص اللغة وتنسيق التاريخ وتنسيق الوقت والتفضيلات الإقليمية."
|
||||
"title": "الإعدادات المحلية",
|
||||
"description": "خصّص اللغة وتنسيق التاريخ والوقت والتفضيلات الإقليمية."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "الإعدادات المتقدمة",
|
||||
"description": "تكوين webhooks والتحديثات في الوقت الفعلي وميزات متقدمة أخرى لتعزيز الإنتاجية."
|
||||
"description": "اضبط الـ Webhooks والتحديثات الفورية وغيرها من الميزات المتقدمة."
|
||||
},
|
||||
"developer": {
|
||||
"title": "إعدادات المطور",
|
||||
"description": "عرض المعلومات الفنية حول رموز المصادقة واتصالات SSE وبيانات التصحيح."
|
||||
"title": "إعدادات المطوّر",
|
||||
"description": "اعرض معلومات تقنية عن رموز المصادقة واتصالات SSE وبيانات التصحيح."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "إرسال ملاحظات",
|
||||
"description": "أخبرنا كيف يعمل Donetick معك أو اطلب ميزة جديدة."
|
||||
},
|
||||
"bugReport": {
|
||||
"title": "الإبلاغ عن خلل",
|
||||
"description": "هناك شيء لا يعمل كما ينبغي؟ أرسل لنا التفاصيل مع لقطة تقنية."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "إعدادات الملف الشخصي",
|
||||
"description": "حدّث اسم العرض وصورة ملفك الشخصي.",
|
||||
"photoUpdated": "تم تحديث الصورة",
|
||||
"photoUpdatedMessage": "تم تحديث صورة ملفك الشخصي بنجاح!",
|
||||
"uploadFailed": "فشل الرفع",
|
||||
"uploadFailedMessage": "تعذّر رفع صورتك. يرجى المحاولة مرة أخرى.",
|
||||
"profileUpdated": "تم تحديث الملف الشخصي",
|
||||
"profileUpdatedMessage": "تم حفظ معلومات ملفك الشخصي بنجاح!",
|
||||
"updateFailed": "فشل التحديث",
|
||||
"updateFailedMessage": "تعذّر تحديث ملفك الشخصي. تحقق من اتصالك وحاول مرة أخرى.",
|
||||
"changePhoto": "تغيير الصورة",
|
||||
"editPhoto": "تحرير صورة الملف الشخصي",
|
||||
"displayName": "اسم العرض",
|
||||
"displayNamePlaceholder": "أدخل اسم العرض",
|
||||
"timezone": "المنطقة الزمنية",
|
||||
"timezonePlaceholder": "اختر منطقتك الزمنية",
|
||||
"save": "حفظ",
|
||||
"cancel": "إلغاء"
|
||||
},
|
||||
"circleSettings": {
|
||||
"title": "إعدادات الدائرة",
|
||||
"description": "يرتبط حسابك تلقائيًا بدائرة عند إنشائها أو الانضمام إليها. ادعُ أصدقاءك بسهولة عبر مشاركة رمز الدائرة أو الرابط أدناه. وسيصلك إشعار هنا عندما يطلب أحدهم الانضمام إلى دائرتك.",
|
||||
"memberOf": "أنت جزء من {{name}}",
|
||||
"yourCircleCode": "رمز دائرتك هو:",
|
||||
"copyCode": "نسخ الرمز",
|
||||
"shareInvite": "مشاركة الدعوة",
|
||||
"codeCopied": "تم نسخ الرمز إلى الحافظة",
|
||||
"linkCopied": "تم نسخ رابط الدعوة إلى الحافظة",
|
||||
"myCircle": "دائرتي",
|
||||
"shareTitle": "انضم إلى {{name}} على Donetick",
|
||||
"shareText": "أودّ دعوتك للانضمام إلى {{name}} على Donetick.",
|
||||
"shareDialogTitle": "مشاركة دعوة الدائرة",
|
||||
"leave": "مغادرة الدائرة",
|
||||
"leaveConfirmTitle": "مغادرة الدائرة",
|
||||
"leaveConfirmMessage": "هل تريد بالتأكيد مغادرة دائرتك؟",
|
||||
"leaveConfirmButton": "مغادرة",
|
||||
"leftCircle": "تمت مغادرة الدائرة بنجاح",
|
||||
"leaveFailed": "تعذّرت مغادرة الدائرة",
|
||||
"circleMembers": "أعضاء الدائرة",
|
||||
"you": "(أنت)",
|
||||
"pendingApproval": "بانتظار الموافقة",
|
||||
"joinedOn": "انضم في {{date}}",
|
||||
"requestedToJoin": "طلب الانضمام {{date}}",
|
||||
"roles": {
|
||||
"member": "عضو",
|
||||
"memberDescription": "عضو عادي في الدائرة",
|
||||
"manager": "مدير",
|
||||
"managerDescription": "يمكنه انتحال هوية المستخدمين وتنفيذ إجراءات نيابةً عنهم",
|
||||
"admin": "مسؤول",
|
||||
"adminDescription": "صلاحية كاملة على الدائرة"
|
||||
},
|
||||
"roleUpdateFailed": "تعذّر تحديث الدور",
|
||||
"removeMemberTitle": "إزالة عضو",
|
||||
"removeMemberMessage": "هل تريد بالتأكيد إزالة {{name}} من دائرتك؟",
|
||||
"memberRemoved": "تمت إزالة العضو بنجاح",
|
||||
"circleMemberRequests": "طلبات الانضمام إلى الدائرة",
|
||||
"lastUpdated": "آخر تحديث: {{time}}",
|
||||
"refreshing": "جارٍ التحديث…",
|
||||
"refreshFailed": "تعذّر تحديث طلبات الانضمام",
|
||||
"wantsToJoin": "يريد {{name}} الانضمام إلى دائرتك.",
|
||||
"accept": "قبول",
|
||||
"acceptRequestTitle": "قبول طلب الانضمام",
|
||||
"acceptRequestMessage": "هل تريد بالتأكيد قبول {{name}} (اسم المستخدم: {{username}}) للانضمام إلى دائرتك؟",
|
||||
"requestAccepted": "تم قبول الطلب بنجاح",
|
||||
"or": "أو",
|
||||
"joinOtherDescription": "تريد الانضمام إلى دائرة شخص آخر؟ اطلب منه رمز الدائرة أو رابط الانضمام، ثم أدخل الرمز أدناه للانضمام.",
|
||||
"enterCircleCode": "أدخل رمز الدائرة:",
|
||||
"enterCodePlaceholder": "أدخل الرمز",
|
||||
"joinCircle": "الانضمام إلى الدائرة",
|
||||
"joinedPending": "تم الانضمام إلى الدائرة بنجاح، انتظر موافقة مالك الدائرة على طلبك.",
|
||||
"alreadyMember": "أنت عضو في هذه الدائرة بالفعل",
|
||||
"joinFailed": "تعذّر الانضمام إلى الدائرة"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "إعدادات الحساب",
|
||||
"description": "غيّر إعدادات حسابك أو نوع خطتك أو كلمة المرور",
|
||||
"accountType": "نوع الحساب: {{type}}",
|
||||
"free": "مجاني",
|
||||
"plus": "Plus",
|
||||
"plusUntil": "Plus (حتى {{date}})",
|
||||
"activeDescription": "أنت مشترك حاليًا في خطة Plus. سيتجدد اشتراكك في {{date}}.",
|
||||
"cancelledDescription": "لقد ألغيت اشتراكك. سيتم خفض حسابك إلى الخطة المجانية في {{date}}.",
|
||||
"freeDescription": "أنت حاليًا على الخطة المجانية. رقِّ إلى Plus لفتح المزيد من الميزات.",
|
||||
"upgrade": "ترقية",
|
||||
"cancel": "إلغاء الاشتراك",
|
||||
"password": "كلمة المرور:",
|
||||
"changePassword": "تغيير كلمة المرور",
|
||||
"passwordChanged": "تم تغيير كلمة المرور بنجاح",
|
||||
"passwordChangeFailed": "فشل تغيير كلمة المرور",
|
||||
"dangerZone": "منطقة الخطر",
|
||||
"dangerZoneDescription": "بمجرد حذف حسابك لا يمكن التراجع. يرجى التأكد قبل المتابعة.",
|
||||
"deleteAccount": "حذف الحساب",
|
||||
"accountDeleted": "تم حذف الحساب بنجاح",
|
||||
"subscriptionCancelled": "تم إلغاء الاشتراك",
|
||||
"subscriptionCancelFailed": "تعذّر إلغاء الاشتراك",
|
||||
"purchase": {
|
||||
"success": "تمت عملية الشراء بنجاح! أعد تشغيل التطبيق للوصول إلى ميزات Plus.",
|
||||
"storeConnection": "هناك مشكلة في الاتصال بالمتجر. تحقق من شبكتك وحاول مرة أخرى.",
|
||||
"notAllowed": "عمليات الشراء غير مسموح بها على هذا الجهاز. يرجى مراجعة قيود جهازك.",
|
||||
"unavailable": "هذا الاشتراك غير متاح. يرجى المحاولة لاحقًا.",
|
||||
"alreadyProcessed": "تمت معالجة هذه العملية من قبل. إذا كنت ترى أن هذا خطأ، تواصل مع الدعم.",
|
||||
"receiptMissing": "إيصال الشراء مفقود. يرجى إعادة محاولة الشراء.",
|
||||
"networkError": "خطأ في الشبكة. تحقق من اتصالك وحاول مرة أخرى.",
|
||||
"invalidReceipt": "إيصال الشراء غير صالح. تواصل مع الدعم إذا استمرت المشكلة.",
|
||||
"pending": "الدفعة بانتظار الموافقة. ستحصل على الوصول فور اعتمادها.",
|
||||
"failed": "فشلت عملية الشراء: {{error}}. حاول مرة أخرى أو تواصل مع الدعم.",
|
||||
"unknownError": "خطأ غير معروف"
|
||||
}
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "الحسابات المُدارة",
|
||||
"description": "أدر الحسابات الفرعية. يمكن لمستخدمي الحسابات الفرعية تسجيل الدخول وإنجاز المهام المسندة إليهم.",
|
||||
"notParentTitle": "إدارة الحسابات الفرعية",
|
||||
"notParentMessage": "الحسابات الرئيسية فقط هي التي يمكنها إدارة الحسابات الفرعية.",
|
||||
"freePlanNotice": "الخطة المجانية تتيح حسابًا فرعيًا واحدًا. رقِّ إلى Plus للحصول على ما يصل إلى 5 حسابات فرعية.",
|
||||
"count": "الحسابات الفرعية ({{count}})",
|
||||
"add": "إضافة حساب فرعي",
|
||||
"loading": "جارٍ تحميل الحسابات الفرعية…",
|
||||
"emptyTitle": "لا توجد حسابات فرعية",
|
||||
"emptyDescription": "أنشئ حسابات فرعية ليتمكن أعضاء الفريق من تسجيل الدخول وإنجاز المهام المسندة إليهم.",
|
||||
"addFirst": "أضف أول حساب فرعي",
|
||||
"username": "اسم المستخدم: {{username}}",
|
||||
"created": "أُنشئ في: {{date}}",
|
||||
"changePassword": "تغيير كلمة المرور",
|
||||
"deleteAccount": "حذف الحساب",
|
||||
"createdSuccess": "تم إنشاء الحساب الفرعي «{{name}}» بنجاح!",
|
||||
"createFailed": "تعذّر إنشاء الحساب الفرعي: {{error}}",
|
||||
"createFailedGeneric": "تعذّر إنشاء الحساب الفرعي",
|
||||
"passwordUpdated": "تم تحديث كلمة مرور الحساب الفرعي بنجاح",
|
||||
"passwordUpdateFailed": "تعذّر تحديث كلمة المرور: {{error}}",
|
||||
"passwordUpdateFailedGeneric": "تعذّر تحديث كلمة المرور",
|
||||
"deleteConfirmTitle": "حذف الحساب الفرعي",
|
||||
"deleteConfirmMessage": "هل تريد بالتأكيد حذف الحساب الفرعي «{{name}}»؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"deleted": "تم حذف الحساب الفرعي «{{name}}» بنجاح",
|
||||
"deleteFailed": "تعذّر حذف الحساب الفرعي: {{error}}",
|
||||
"deleteFailedGeneric": "تعذّر حذف الحساب الفرعي",
|
||||
"howItWorksTitle": "كيف تعمل الحسابات المُدارة",
|
||||
"howItWorks1": "تُنشئ الحسابات المُدارة من الحساب الرئيسي، وهي مخصّصة لمن تريد أن تكون لديك إمكانية حذف حسابه وإعادة تعيين كلمة مروره.",
|
||||
"howItWorks2": "يمكن للحسابات الفرعية تسجيل الدخول باسم مستخدم وكلمة مرور خاصين بها.",
|
||||
"howItWorks3": "يمكن للحسابات المُدارة إنجاز المهام لكن صلاحياتها الإدارية محدودة",
|
||||
"howItWorks4": "تُضاف الحسابات المُدارة تلقائيًا إلى دائرتك"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "إعدادات الإشعارات",
|
||||
"deviceSection": "إشعارات الجهاز",
|
||||
"deviceSectionDescription": "أدر إشعارات جهازك",
|
||||
"deviceLabel": "إشعار الجهاز",
|
||||
"deviceHelper": "تلقَّ إشعارًا على جهازك عند استحقاق مهمة",
|
||||
"mobileOnly": "هذه الميزة متاحة على الأجهزة المحمولة فقط",
|
||||
"testNotification": "إشعار تجريبي",
|
||||
"testNotificationBody": "لديك مهمة تستحق قريبًا",
|
||||
"dueTitle": "إشعار الاستحقاق",
|
||||
"dueLabel": "إشعار عند استحقاق المهمة",
|
||||
"preDueTitle": "إشعار قبل الاستحقاق",
|
||||
"preDueLabel": "إشعار قبل ساعات من استحقاق المهمة",
|
||||
"overdueTitle": "إشعار التأخر",
|
||||
"overdueLabel": "إشعار عند تأخر المهمة عن موعدها",
|
||||
"pushLabel": "الإشعارات الفورية",
|
||||
"pushHelper": "تلقَّ التذكيرات والإعلانات وإسنادات المهام عبر الإشعارات الفورية",
|
||||
"registeredDevices": "الأجهزة المسجّلة ({{count}}/5)",
|
||||
"registeredDevicesDescription": "الأجهزة المسجّلة لتلقّي الإشعارات الفورية الخاصة بحسابك",
|
||||
"currentDevice": "الجهاز الحالي: {{platform}} {{model}}",
|
||||
"currentDeviceNotRegistered": "هذا الجهاز غير مسجّل لتلقّي الإشعارات الفورية",
|
||||
"limitReached": "بلغت الحد الأقصى",
|
||||
"registerDevice": "تسجيل الجهاز",
|
||||
"unknownDevice": "جهاز غير معروف",
|
||||
"deviceCreatedAt": "أُنشئ في: {{date}}",
|
||||
"noDevices": "لا توجد أجهزة مسجّلة لتلقّي الإشعارات الفورية",
|
||||
"customSection": "إشعار مخصّص",
|
||||
"customSectionDescription": "الإشعار عبر منصة أخرى مثل Telegram أو Pushover",
|
||||
"customLabel": "إشعار مخصّص",
|
||||
"customHelper": "تلقَّ الإشعارات على منصة أخرى",
|
||||
"targetNone": "بدون",
|
||||
"targetTelegram": "Telegram",
|
||||
"targetPushover": "Pushover",
|
||||
"targetWebhooks": "Webhooks",
|
||||
"telegramBotHelpBefore": "عليك إرسال رسالة إلى البوت أولًا حتى تعمل إشعارات Telegram",
|
||||
"telegramBotHelpAfter": "لبدء محادثة",
|
||||
"clickHere": "اضغط هنا",
|
||||
"chatId": "معرّف المحادثة",
|
||||
"chatIdPlaceholder": "معرّف المستخدم / معرّف المحادثة",
|
||||
"telegramChatIdHelpBefore": "إذا كنت لا تعرف معرّف محادثتك، ابدأ محادثة مع userinfobot وسيرسله إليك.",
|
||||
"telegramChatIdHelpAfter": "لبدء محادثة مع userinfobot",
|
||||
"userKey": "مفتاح المستخدم",
|
||||
"userKeyPlaceholder": "معرّف المستخدم",
|
||||
"chatIdRequired": "معرّف المحادثة مطلوب",
|
||||
"chatIdInvalid": "معرّف المحادثة غير صالح",
|
||||
"userKeyRequired": "مفتاح المستخدم مطلوب",
|
||||
"targetUpdated": "تم تحديث وجهة الإشعارات",
|
||||
"targetUpdateFailed": "حدث خطأ أثناء تحديث وجهة الإشعارات: {{error}}",
|
||||
"deviceRegistered": "تم تسجيل الجهاز بنجاح لتلقّي الإشعارات الفورية.",
|
||||
"deviceLimitTitle": "بلغت الحد الأقصى للأجهزة",
|
||||
"deviceLimitMessage": "لقد بلغت الحد الأقصى وهو 5 أجهزة مسجّلة. يرجى إزالة جهاز قبل تسجيل هذا الجهاز.",
|
||||
"registrationFailedTitle": "فشل التسجيل",
|
||||
"registrationFailedMessage": "تعذّر تسجيل الجهاز تلقائيًا. يرجى المحاولة مرة أخرى.",
|
||||
"permissionRequiredTitle": "الإذن مطلوب",
|
||||
"permissionRequiredMessage": "يلزم إذن الإشعارات الفورية لتسجيل هذا الجهاز.",
|
||||
"registrationInitiatedTitle": "بدأ التسجيل",
|
||||
"registrationInitiatedMessage": "بدأ تسجيل الإشعارات الفورية. سيُسجَّل الجهاز تلقائيًا.",
|
||||
"registerDeviceFailed": "تعذّر تسجيل الجهاز. يرجى المحاولة مرة أخرى.",
|
||||
"permissionDeniedTitle": "تم رفض إذن الإشعارات",
|
||||
"permissionDeniedMessage": "لقد رفضت أذونات الإشعارات. يمكنك تفعيلها لاحقًا من إعدادات جهازك.",
|
||||
"pushPermissionDeniedTitle": "تم رفض إذن الإشعارات الفورية",
|
||||
"pushPermissionDeniedMessage": "تم تعطيل الإشعارات الفورية. يمكنك تفعيلها من إعدادات جهازك عند الحاجة.",
|
||||
"unregisterFailed": "تعذّر إلغاء تسجيل الجهاز"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "المصادقة متعددة العوامل",
|
||||
"description": "أضف طبقة حماية إضافية لحسابك عبر المصادقة متعددة العوامل (MFA). عند تفعيلها، ستحتاج إلى إدخال رمز تحقق من تطبيق المصادقة إلى جانب كلمة المرور عند تسجيل الدخول.",
|
||||
"twoFactor": "المصادقة الثنائية",
|
||||
"enabledSubtitle": "حسابك محمي بالمصادقة الثنائية",
|
||||
"disabledSubtitle": "أمّن حسابك باستخدام تطبيق مصادقة",
|
||||
"enable": "تفعيل",
|
||||
"disable": "تعطيل",
|
||||
"enabledSuccess": "تم تفعيل المصادقة متعددة العوامل بنجاح!",
|
||||
"disabledSuccess": "تم تعطيل المصادقة متعددة العوامل بنجاح!",
|
||||
"errors": {
|
||||
"qrGenerationFailed": "تعذّر إنشاء رمز QR",
|
||||
"invalidResponse": "استجابة غير صالحة من الخادم. رمز QR أو المفتاح السري مفقود.",
|
||||
"notFound": "لم يُعثر على نقطة إعداد المصادقة متعددة العوامل. قد لا تكون هذه الميزة متاحة بعد.",
|
||||
"unauthorized": "غير مصرّح. يرجى تسجيل الدخول مرة أخرى.",
|
||||
"serverError": "خطأ في الخادم. يرجى المحاولة لاحقًا.",
|
||||
"setupFailed": "تعذّر إعداد المصادقة متعددة العوامل ({{status}}). يرجى المحاولة مرة أخرى.",
|
||||
"networkError": "خطأ في الشبكة. تحقق من اتصالك وحاول مرة أخرى.",
|
||||
"invalidCode": "رمز التحقق غير صالح. يرجى المحاولة مرة أخرى.",
|
||||
"confirmFailed": "تعذّر تأكيد المصادقة متعددة العوامل. يرجى المحاولة مرة أخرى.",
|
||||
"disableFailed": "تعذّر تعطيل المصادقة متعددة العوامل. يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"setup": {
|
||||
"title": "إعداد المصادقة متعددة العوامل",
|
||||
"addedAccount": "لقد أضفت الحساب",
|
||||
"back": "رجوع",
|
||||
"verifyAndEnable": "التحقق والتفعيل",
|
||||
"savedBackupCodes": "لقد حفظت رموز النسخ الاحتياطي",
|
||||
"step1Label": "الخطوة 1:",
|
||||
"step1": "امسح رمز QR أدناه باستخدام تطبيق المصادقة (Google Authenticator أو Authy أو غيرهما)",
|
||||
"qrAlt": "رمز QR للمصادقة متعددة العوامل",
|
||||
"qrFailed": "تعذّر إنشاء رمز QR. حاول مرة أخرى أو استخدم مفتاح الإدخال اليدوي أدناه.",
|
||||
"manualKey": "مفتاح الإدخال اليدوي:",
|
||||
"step2Label": "الخطوة 2:",
|
||||
"step2": "أدخل رمز التحقق المكوّن من 6 أرقام من تطبيق المصادقة",
|
||||
"codePlaceholder": "أدخل الرمز المكوّن من 6 أرقام",
|
||||
"successTitle": "تم تفعيل المصادقة متعددة العوامل بنجاح!",
|
||||
"backupCodesTitle": "احفظ رموز النسخ الاحتياطي هذه في مكان آمن",
|
||||
"backupCodesDescription": "يمكنك استخدام هذه الرموز للوصول إلى حسابك في حال فقدت جهاز المصادقة. يمكن استخدام كل رمز مرة واحدة فقط."
|
||||
},
|
||||
"disableModal": {
|
||||
"title": "تعطيل المصادقة متعددة العوامل",
|
||||
"warning": "تعطيل المصادقة متعددة العوامل يجعل حسابك أقل أمانًا. هل تريد بالتأكيد المتابعة؟",
|
||||
"prompt": "أدخل رمز تحقق من تطبيق المصادقة للتأكيد:",
|
||||
"confirm": "تعطيل المصادقة متعددة العوامل"
|
||||
},
|
||||
"backupCodesModal": {
|
||||
"title": "رموز نسخ احتياطي جديدة",
|
||||
"warning": "لم تعد رموز النسخ الاحتياطي السابقة صالحة. احفظ هذه الرموز الجديدة في مكان آمن. يمكن استخدام كل رمز مرة واحدة فقط."
|
||||
}
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "رموز API",
|
||||
"accessToken": "رمز الوصول",
|
||||
"description": "أنشئ رمزًا لاستخدامه مع API لتحديث الأشياء التي تُطلق المهام",
|
||||
"plusNotice": "رموز API غير متاحة في الخطة الأساسية. رقِّ إلى Plus لإنشاء رموز API للتكامل مع الأنظمة الخارجية وأتمتة مهامك.",
|
||||
"showToken": "إظهار الرمز",
|
||||
"hideToken": "إخفاء الرمز",
|
||||
"removeTitle": "إزالة الرمز",
|
||||
"removeMessage": "هل تريد بالتأكيد إزالة {{name}}؟",
|
||||
"removedTitle": "تمت الإزالة",
|
||||
"removedMessage": "تمت إزالة رمز API",
|
||||
"tokenCopied": "تم نسخ الرمز إلى الحافظة",
|
||||
"generateNew": "إنشاء رمز جديد",
|
||||
"nameModalTitle": "امنح رمزك الجديد اسمًا يساعدك على تذكّره.",
|
||||
"generateToken": "إنشاء الرمز"
|
||||
},
|
||||
"storage": {
|
||||
"title": "إعدادات التخزين",
|
||||
"serverTitle": "استخدام تخزين الخادم",
|
||||
"serverDescription": "هذه هي المساحة التي يستخدمها حسابك على خوادمنا (مثل الملفات والصور والبيانات التي رفعتها).",
|
||||
"usagePlaceholder": "-- ميغابايت مستخدمة / -- ميغابايت إجمالًا (--)",
|
||||
"usage": "{{used}} ميغابايت مستخدمة / {{total}} ميغابايت إجمالًا ({{percent}}%)",
|
||||
"basicPlanNotice": "تخزين الخادم غير متاح في الخطة الأساسية. رقِّ إلى Plus لتتبّع استخدامك لتخزين الخادم.",
|
||||
"localTitleApp": "التخزين المحلي والذاكرة المؤقتة للتطبيق",
|
||||
"localTitleBrowser": "التخزين المحلي والذاكرة المؤقتة للمتصفح",
|
||||
"localDescription": "هذه بيانات محفوظة محليًا في متصفحك لتسريع الوصول. مسحها لن يؤثر على بياناتك على الخادم، لكنه قد يسجّل خروجك.",
|
||||
"clearLocal": "مسح كل التخزين المحلي والذاكرة المؤقتة",
|
||||
"clearLocalTitle": "مسح كل التخزين المحلي",
|
||||
"clearLocalMessage": "هل تريد بالتأكيد مسح التخزين المحلي والذاكرة المؤقتة؟ سيؤدي ذلك إلى إزالة جميع بياناتك من هذا المتصفح وسيتطلب تسجيل الدخول من جديد.",
|
||||
"clearAll": "مسح الكل",
|
||||
"appPreferences": "تفضيلات التطبيق",
|
||||
"deviceOnly": "على الجهاز فقط",
|
||||
"appPreferencesDescription": "هذه تفضيلات وإعدادات يحفظها التطبيق محليًا على جهازك. مسحها سيعيد ضبط إعدادات التطبيق وقد يسجّل خروجك، لكنه لن يؤثر على بياناتك على الخادم.",
|
||||
"clearPreferences": "مسح تفضيلات التطبيق",
|
||||
"clearPreferencesTitle": "مسح تفضيلات التطبيق",
|
||||
"clearPreferencesMessage": "هل تريد بالتأكيد مسح جميع تفضيلات التطبيق؟ سيؤدي ذلك إلى إعادة ضبط إعداداتك وقد يتطلب تسجيل الدخول من جديد."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "تخصيص اللوحة الجانبية",
|
||||
"heading": "إعدادات اللوحة الجانبية",
|
||||
"description": "حدّد البطاقات التي تظهر في اللوحة الجانبية وترتيبها. اسحب وأفلت لإعادة الترتيب، أو بدّل ظهور كل بطاقة.",
|
||||
"resetToDefaults": "استعادة الإعدادات الافتراضية",
|
||||
"resetHelper": "سيؤدي ذلك إلى استعادة الظهور والترتيب الافتراضيين لجميع البطاقات.",
|
||||
"cards": {
|
||||
"welcome": {
|
||||
"name": "تبديل المستخدم",
|
||||
"description": "يتيح للمسؤولين والمديرين عرض المهام بصفة مستخدمين آخرين"
|
||||
},
|
||||
"smartInsights": {
|
||||
"name": "رؤى ذكية",
|
||||
"description": "إجراءات سريعة بناءً على مهامك"
|
||||
},
|
||||
"assignees": {
|
||||
"name": "المهام حسب المكلَّف",
|
||||
"description": "يجمع المهام حسب الشخص المكلَّف بها"
|
||||
},
|
||||
"calendar": {
|
||||
"name": "عرض التقويم",
|
||||
"description": "يعرض المهام بتنسيق تقويم"
|
||||
},
|
||||
"activities": {
|
||||
"name": "الأنشطة الأخيرة",
|
||||
"description": "يعرض المهام المنجزة والأنشطة الأخيرة"
|
||||
},
|
||||
"weeklyGoals": {
|
||||
"name": "الأهداف الأسبوعية",
|
||||
"description": "يعرض التقدّم الأسبوعي وإحصاءات إنجاز العائلة"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"title": "تفضيلات المظهر",
|
||||
"description": "اختر الشكل الذي يظهر به الموقع لك. حدّد مظهرًا ثابتًا أو زامنه مع نظامك للتبديل تلقائيًا بين المظهر النهاري والليلي.",
|
||||
"themeMode": "وضع المظهر",
|
||||
"light": "فاتح",
|
||||
"dark": "داكن",
|
||||
"system": "النظام"
|
||||
},
|
||||
"localization": {
|
||||
"title": "الإعدادات المحلية",
|
||||
"description": "خصّص اللغة وتنسيق التاريخ والتفضيلات الإقليمية لحسابك.",
|
||||
"language": "اللغة",
|
||||
"languageDescription": "اختر لغتك المفضلة",
|
||||
"rtlNotice": "تُكتب هذه اللغة من اليمين إلى اليسار (RTL)",
|
||||
"dateFormat": "تنسيق التاريخ",
|
||||
"dateFormatDescription": "اختر طريقة عرض التواريخ في جميع أنحاء التطبيق",
|
||||
"timeFormat": "تنسيق الوقت",
|
||||
"timeFormatDescription": "اختر نظام 12 ساعة أو 24 ساعة",
|
||||
"preview": "معاينة: {{value}}",
|
||||
"12hour": "12 ساعة (ص/م)",
|
||||
"24hour": "24 ساعة",
|
||||
"firstDayOfWeek": "أول أيام الأسبوع",
|
||||
"firstDayOfWeekDescription": "اختر اليوم الذي يبدأ به أسبوعك",
|
||||
"sunday": "الأحد",
|
||||
"monday": "الاثنين",
|
||||
"saturday": "السبت",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/YYYY (الولايات المتحدة)",
|
||||
"dmy": "DD/MM/YYYY (أوروبا)",
|
||||
"ymd": "YYYY-MM-DD (ISO)",
|
||||
"long": "تنسيق طويل (مثال: 1 يناير 2024)",
|
||||
"short": "تنسيق قصير (مثال: 1 ينا 2024)"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "الإعدادات المتقدمة",
|
||||
"description": "اضبط الميزات المتقدمة مثل الـ Webhooks والتحديثات الفورية لتعزيز إنتاجيتك.",
|
||||
"offlineTitle": "دعم العمل دون اتصال",
|
||||
"offlineDescription": "واصل استخدام Donetick دون اتصال على هذا الجهاز/المتصفح. تُحفظ تغييراتك محليًا وتُزامن عند عودتك للاتصال.",
|
||||
"offlineToggle": "تفعيل دعم العمل دون اتصال",
|
||||
"offlineHelper": "إيقاف هذا الخيار يزيل التغييرات غير المزامنة والبيانات المحفوظة دون اتصال من هذا الجهاز/المتصفح.",
|
||||
"offlineEnabled": "تم تفعيل وضع العمل دون اتصال على هذا الجهاز/المتصفح",
|
||||
"offlineDisabled": "تم إيقاف وضع العمل دون اتصال ومسح البيانات المحلية",
|
||||
"offlineDisabledPartial": "تم إيقاف وضع العمل دون اتصال، لكن قد تبقى بعض البيانات المحلية مخزّنة",
|
||||
"offlineDisableTitle": "إيقاف وضع العمل دون اتصال",
|
||||
"offlineDisableMessage": "إيقاف وضع العمل دون اتصال سيزيل التغييرات غير المزامنة والبيانات المحفوظة دون اتصال على هذا الجهاز/المتصفح. هل تريد المتابعة؟",
|
||||
"offlineDisableConfirm": "الإيقاف ومسح البيانات",
|
||||
"webhookTitle": "تكامل Webhook",
|
||||
"webhookDescription": "تتيح لك الـ Webhooks إرسال إشعارات فورية إلى خدمات أخرى عند وقوع أحداث في دائرتك. اضبط رابط Webhook لتلقّي التحديثات الفورية.",
|
||||
"webhookPlusNotice": "إشعارات Webhook غير متاحة في الخطة الأساسية. رقِّ إلى Plus لتلقّي التحديثات الفورية عبر الـ Webhooks.",
|
||||
"webhookToggle": "تفعيل Webhook",
|
||||
"webhookHelper": "فعّل إشعارات Webhook لتحديثات المهام والأشياء.",
|
||||
"webhookURL": "رابط Webhook",
|
||||
"webhookUpdated": "تم تحديث رابط Webhook بنجاح",
|
||||
"webhookUpdateFailed": "تعذّر تحديث رابط Webhook",
|
||||
"realtimeTitle": "التحديثات الفورية",
|
||||
"realtimeDescription": "اضبط كيفية تلقّي التحديثات المباشرة عند تغيّر المهام والأنشطة في دائرتك.",
|
||||
"realtime": {
|
||||
"toggleLabel": "تفعيل التحديثات الفورية",
|
||||
"title": "التحديثات الفورية",
|
||||
"subtitle": "تلقَّ إشعارات فورية عند تحديث المهام",
|
||||
"statusLabel": "الحالة:",
|
||||
"basicPlan": "التحديثات الفورية غير متاحة في الخطة الأساسية. رقِّ إلى Plus لتلقّي إشعارات فورية عند تحديث المهام.",
|
||||
"disabled": "التحديثات الفورية معطّلة. فعّلها لترى التغييرات المباشرة عندما تنجز أنت أو أعضاء دائرتك المهام أو تتخطّونها أو تعدّلونها.",
|
||||
"connected": "التحديثات الفورية تعمل. سترى التغييرات المباشرة عندما تنجز أنت أو أعضاء دائرتك المهام أو تتخطّونها أو تعدّلونها.",
|
||||
"connecting": "جارٍ الاتصال بالتحديثات الفورية…",
|
||||
"errored": "التحديثات الفورية مفعّلة لكنها لا تعمل: {{error}}",
|
||||
"notConnected": "التحديثات الفورية مفعّلة لكن لا يوجد اتصال حاليًا.",
|
||||
"basicPlanNotice": "التحديثات الفورية غير متاحة في الخطة الأساسية. رقِّ إلى Plus لتلقّي إشعارات فورية عندما تنجز أنت أو أعضاء دائرتك المهام أو تتخطّونها أو تعدّلونها."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,11 @@
|
||||
"footer": {
|
||||
"navigate": "تنقل",
|
||||
"open": "فتح",
|
||||
"results_zero": "لا توجد نتائج",
|
||||
"results_one": "نتيجة واحدة",
|
||||
"results_two": "نتيجتان",
|
||||
"results_few": "{{count}} نتائج",
|
||||
"results_many": "{{count}} نتيجة",
|
||||
"results_other": "{{count}} نتيجة",
|
||||
"typeToSearch": "اكتب للبحث"
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
"description": "غيّر إعدادات حسابك أو نوع خطتك أو كلمة المرور",
|
||||
"accountType": "نوع الحساب: {{type}}",
|
||||
"free": "مجاني",
|
||||
"plus": "بلس",
|
||||
"plus": "Plus",
|
||||
"plusUntil": "Plus (حتى {{date}})",
|
||||
"activeDescription": "أنت مشترك حاليًا في خطة Plus. سيتجدد اشتراكك في {{date}}.",
|
||||
"cancelledDescription": "لقد ألغيت اشتراكك. سيتم خفض حسابك إلى الخطة المجانية في {{date}}.",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"logout": "Logout",
|
||||
"version": "Version",
|
||||
"navigation": {
|
||||
"search": "Search",
|
||||
"allTasks": "All Tasks",
|
||||
"archived": "Archived",
|
||||
"things": "Things",
|
||||
@@ -30,6 +31,44 @@
|
||||
"points": "Points",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"placeholder": "Search Donetick",
|
||||
"inputAriaLabel": "Search tasks, history, projects, labels and settings",
|
||||
"deviceNote": "Searching content available on this device",
|
||||
"escape": "Esc",
|
||||
"recent": "Recent",
|
||||
"empty": {
|
||||
"title": "No direct matches",
|
||||
"subtitle": "You can still filter the task list with this search."
|
||||
},
|
||||
"groups": {
|
||||
"tasks": "Tasks",
|
||||
"history": "Notes",
|
||||
"projects": "Projects",
|
||||
"labels": "Labels",
|
||||
"people": "People",
|
||||
"settings": "Settings",
|
||||
"actions": "Quick actions"
|
||||
},
|
||||
"actions": {
|
||||
"quickAction": "Quick action",
|
||||
"navigation": "Navigation",
|
||||
"createTask": "Create a task",
|
||||
"viewAllTasks": "View all tasks",
|
||||
"viewArchivedTasks": "View archived tasks",
|
||||
"openSettings": "Open settings",
|
||||
"filterTasks": "Show tasks matching “{{query}}”",
|
||||
"filterTasksSubtitle": "Filter the task list"
|
||||
},
|
||||
"footer": {
|
||||
"navigate": "Navigate",
|
||||
"open": "Open",
|
||||
"results_one": "{{count}} result",
|
||||
"results_other": "{{count}} results",
|
||||
"typeToSearch": "Type to search"
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
|
||||
@@ -1,106 +1,19 @@
|
||||
{
|
||||
"title": "Settings",
|
||||
"circleSettings": {
|
||||
"title": "Circle settings",
|
||||
"description": "Your account is automatically connected to a Circle when you create or join one. Easily invite friends by sharing the unique Circle code or link below. You'll receive a notification below when someone requests to join your Circle. If you'd like to leave, simply hit the 'Leave Circle' button.",
|
||||
"circleCode": "Circle Code",
|
||||
"copyCode": "Copy Code",
|
||||
"copyLink": "Copy Link",
|
||||
"codeCopied": "Circle code copied!",
|
||||
"linkCopied": "Circle link copied!",
|
||||
"joinCircle": "Join a Circle",
|
||||
"joinCirclePlaceholder": "Enter Circle Code",
|
||||
"join": "Join",
|
||||
"leave": "Leave Circle",
|
||||
"leaveConfirmTitle": "Leave Circle",
|
||||
"leaveConfirmMessage": "Are you sure you want to leave this circle?",
|
||||
"circleMembers": "Circle Members",
|
||||
"circleMemberRequests": "Circle Member Requests",
|
||||
"admin": "Admin",
|
||||
"member": "Member",
|
||||
"pending": "Pending",
|
||||
"accept": "Accept",
|
||||
"reject": "Reject",
|
||||
"makeAdmin": "Make Admin",
|
||||
"makeMember": "Make Member",
|
||||
"remove": "Remove",
|
||||
"webhookURL": "Webhook URL",
|
||||
"webhookDescription": "Enter a webhook URL to receive notifications for circle events",
|
||||
"webhookPlaceholder": "https://your-webhook-url.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Account Settings",
|
||||
"subscription": "Subscription",
|
||||
"subscriptionStatus": "Current Plan",
|
||||
"free": "Free",
|
||||
"plus": "Plus",
|
||||
"upgrade": "Upgrade",
|
||||
"cancel": "Cancel",
|
||||
"changePassword": "Change Password",
|
||||
"password": "Password",
|
||||
"dangerZone": "Danger Zone",
|
||||
"dangerZoneDescription": "Once you delete your account, there is no going back. Please be certain.",
|
||||
"deleteAccount": "Delete Account"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localization",
|
||||
"description": "Customize language, date format, and regional preferences for your account.",
|
||||
"language": "Language",
|
||||
"languageDescription": "Select your preferred language",
|
||||
"dateFormat": "Date Format",
|
||||
"dateFormatDescription": "Choose how dates should be displayed throughout the application",
|
||||
"timeFormat": "Time Format",
|
||||
"timeFormatDescription": "Select 12-hour or 24-hour time format",
|
||||
"12hour": "12-hour (AM/PM)",
|
||||
"24hour": "24-hour",
|
||||
"firstDayOfWeek": "First Day of Week",
|
||||
"firstDayOfWeekDescription": "Select which day starts your week",
|
||||
"sunday": "Sunday",
|
||||
"monday": "Monday",
|
||||
"saturday": "Saturday",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/YYYY (US)",
|
||||
"dmy": "DD/MM/YYYY (Europe)",
|
||||
"ymd": "YYYY-MM-DD (ISO)",
|
||||
"long": "Long format (e.g., January 1, 2024)",
|
||||
"short": "Short format (e.g., Jan 1, 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Sidepanel Customization",
|
||||
"description": "Customize the layout and visibility of cards in the sidepanel. This section is only available on large screen devices such as tablets and desktops."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme preferences",
|
||||
"description": "Choose how the site looks to you. Select a single theme, or sync with your system and automatically switch between day and night themes.",
|
||||
"themeMode": "Theme mode",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "Settings saved successfully",
|
||||
"settingsSaveFailed": "Failed to save settings",
|
||||
"invalidWebhook": "Invalid webhook URL"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile Settings",
|
||||
"description": "Update your display name and profile photo.",
|
||||
"photoUpdated": "Photo Updated",
|
||||
"photoUpdatedMessage": "Your profile photo has been updated successfully!",
|
||||
"uploadFailed": "Upload Failed",
|
||||
"uploadFailedMessage": "Failed to upload your photo. Please try again.",
|
||||
"profileUpdated": "Profile Updated",
|
||||
"profileUpdatedMessage": "Your profile information has been saved successfully!",
|
||||
"updateFailed": "Update Failed",
|
||||
"updateFailedMessage": "Unable to update your profile. Please check your connection and try again.",
|
||||
"changePhoto": "Change Photo",
|
||||
"displayName": "Display Name",
|
||||
"displayNamePlaceholder": "Enter your display name",
|
||||
"timezone": "Timezone",
|
||||
"timezonePlaceholder": "Select your timezone",
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"remove": "Remove",
|
||||
"delete": "Delete",
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading...",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"plusFeature": "Plus Feature",
|
||||
"earlyAccess": "Early Access"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Settings",
|
||||
@@ -178,5 +91,404 @@
|
||||
"description": "Something not working right? Send us the details along with a technical snapshot."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile Settings",
|
||||
"description": "Update your display name and profile photo.",
|
||||
"photoUpdated": "Photo Updated",
|
||||
"photoUpdatedMessage": "Your profile photo has been updated successfully!",
|
||||
"uploadFailed": "Upload Failed",
|
||||
"uploadFailedMessage": "Failed to upload your photo. Please try again.",
|
||||
"profileUpdated": "Profile Updated",
|
||||
"profileUpdatedMessage": "Your profile information has been saved successfully!",
|
||||
"updateFailed": "Update Failed",
|
||||
"updateFailedMessage": "Unable to update your profile. Please check your connection and try again.",
|
||||
"changePhoto": "Change Photo",
|
||||
"editPhoto": "Edit profile photo",
|
||||
"displayName": "Display Name",
|
||||
"displayNamePlaceholder": "Enter your display name",
|
||||
"timezone": "Timezone",
|
||||
"timezonePlaceholder": "Select your timezone",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"circleSettings": {
|
||||
"title": "Circle Settings",
|
||||
"description": "Your account is automatically connected to a Circle when you create or join one. Easily invite friends by sharing the unique Circle code or link below. You'll receive a notification below when someone requests to join your Circle.",
|
||||
"memberOf": "You are part of {{name}}",
|
||||
"yourCircleCode": "Your circle code is:",
|
||||
"copyCode": "Copy Code",
|
||||
"shareInvite": "Share Invite",
|
||||
"codeCopied": "Code copied to clipboard",
|
||||
"linkCopied": "Invite link copied to clipboard",
|
||||
"myCircle": "my Circle",
|
||||
"shareTitle": "Join {{name}} on Donetick",
|
||||
"shareText": "I'd like to invite you to join {{name}} on Donetick.",
|
||||
"shareDialogTitle": "Share Circle invite",
|
||||
"leave": "Leave Circle",
|
||||
"leaveConfirmTitle": "Leave Circle",
|
||||
"leaveConfirmMessage": "Are you sure you want to leave your circle?",
|
||||
"leaveConfirmButton": "Leave",
|
||||
"leftCircle": "Left circle successfully",
|
||||
"leaveFailed": "Failed to leave circle",
|
||||
"circleMembers": "Circle Members",
|
||||
"you": "(You)",
|
||||
"pendingApproval": "Pending Approval",
|
||||
"joinedOn": "Joined on {{date}}",
|
||||
"requestedToJoin": "Request to join {{date}}",
|
||||
"roles": {
|
||||
"member": "Member",
|
||||
"memberDescription": "Just a regular member of the circle",
|
||||
"manager": "Manager",
|
||||
"managerDescription": "Can impersonate users and perform actions on their behalf",
|
||||
"admin": "Admin",
|
||||
"adminDescription": "Full access to the circle"
|
||||
},
|
||||
"roleUpdateFailed": "Failed to update role",
|
||||
"removeMemberTitle": "Remove Member",
|
||||
"removeMemberMessage": "Are you sure you want to remove {{name}} from your circle?",
|
||||
"memberRemoved": "Removed member successfully",
|
||||
"circleMemberRequests": "Circle Member Requests",
|
||||
"lastUpdated": "Last updated: {{time}}",
|
||||
"refreshing": "Refreshing...",
|
||||
"refreshFailed": "Failed to refresh member requests",
|
||||
"wantsToJoin": "{{name}} wants to join your circle.",
|
||||
"accept": "Accept",
|
||||
"acceptRequestTitle": "Accept Member Request",
|
||||
"acceptRequestMessage": "Are you sure you want to accept {{name}} (username: {{username}}) to join your circle?",
|
||||
"requestAccepted": "Accepted request successfully",
|
||||
"or": "or",
|
||||
"joinOtherDescription": "Want to join someone else's Circle? Ask them for their unique Circle code or join link. Enter the code below to join their Circle.",
|
||||
"enterCircleCode": "Enter Circle code:",
|
||||
"enterCodePlaceholder": "Enter code",
|
||||
"joinCircle": "Join Circle",
|
||||
"joinedPending": "Joined circle successfully, wait for the circle owner to accept your request.",
|
||||
"alreadyMember": "You are already a member of this circle",
|
||||
"joinFailed": "Failed to join circle"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Account Settings",
|
||||
"description": "Change your account settings, type or update your password",
|
||||
"accountType": "Account Type : {{type}}",
|
||||
"free": "Free",
|
||||
"plus": "Plus",
|
||||
"plusUntil": "Plus (until {{date}})",
|
||||
"activeDescription": "You are currently subscribed to the Plus plan. Your subscription will renew on {{date}}.",
|
||||
"cancelledDescription": "You have cancelled your subscription. Your account will be downgraded to the Free plan on {{date}}.",
|
||||
"freeDescription": "You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.",
|
||||
"upgrade": "Upgrade",
|
||||
"cancel": "Cancel",
|
||||
"password": "Password :",
|
||||
"changePassword": "Change Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeFailed": "Password change failed",
|
||||
"dangerZone": "Danger Zone",
|
||||
"dangerZoneDescription": "Once you delete your account, there is no going back. Please be certain.",
|
||||
"deleteAccount": "Delete Account",
|
||||
"accountDeleted": "Account deleted successfully",
|
||||
"subscriptionCancelled": "Subscription cancelled",
|
||||
"subscriptionCancelFailed": "Failed to cancel subscription",
|
||||
"purchase": {
|
||||
"success": "Purchase successful! Please restart the app to access Plus features.",
|
||||
"storeConnection": "Store connection issue. Please check your network and try again.",
|
||||
"notAllowed": "Purchases are not allowed on this device. Please check your device restrictions.",
|
||||
"unavailable": "This subscription is not available. Please try again later.",
|
||||
"alreadyProcessed": "This purchase has already been processed. If you believe this is an error, please contact support.",
|
||||
"receiptMissing": "Purchase receipt missing. Please try purchasing again.",
|
||||
"networkError": "Network error. Please check your connection and try again.",
|
||||
"invalidReceipt": "Invalid purchase receipt. Please contact support if this persists.",
|
||||
"pending": "Payment is pending approval. You will receive access once approved.",
|
||||
"failed": "Purchase failed: {{error}}. Please try again or contact support.",
|
||||
"unknownError": "Unknown error"
|
||||
}
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Managed Accounts",
|
||||
"description": "Manage sub accounts. Sub account users can log in and complete assigned tasks.",
|
||||
"notParentTitle": "Sub Account Management",
|
||||
"notParentMessage": "Only primary users can manage sub accounts.",
|
||||
"freePlanNotice": "Sub account limited to 1 on Free plan. Upgrade to Plus to have up to 5 sub accounts.",
|
||||
"count": "Sub Accounts ({{count}})",
|
||||
"add": "Add Sub Account",
|
||||
"loading": "Loading sub accounts...",
|
||||
"emptyTitle": "No Sub Accounts",
|
||||
"emptyDescription": "Create sub accounts so team members can log in and complete their assigned tasks.",
|
||||
"addFirst": "Add Your First Sub Account",
|
||||
"username": "Username: {{username}}",
|
||||
"created": "Created: {{date}}",
|
||||
"changePassword": "Change Password",
|
||||
"deleteAccount": "Delete Account",
|
||||
"createdSuccess": "Child account \"{{name}}\" created successfully!",
|
||||
"createFailed": "Failed to create child account: {{error}}",
|
||||
"createFailedGeneric": "Failed to create child user",
|
||||
"passwordUpdated": "Child password updated successfully",
|
||||
"passwordUpdateFailed": "Failed to update password: {{error}}",
|
||||
"passwordUpdateFailedGeneric": "Failed to update password",
|
||||
"deleteConfirmTitle": "Delete Sub Account",
|
||||
"deleteConfirmMessage": "Are you sure you want to delete the child account \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Sub account \"{{name}}\" deleted successfully",
|
||||
"deleteFailed": "Failed to delete Sub account: {{error}}",
|
||||
"deleteFailedGeneric": "Failed to delete Sub user",
|
||||
"howItWorksTitle": "How Managed Accounts Work",
|
||||
"howItWorks1": "Managed accounts created by the primary user, these specific for user you want to have ability to delete and reset password.",
|
||||
"howItWorks2": "Sub accounts can log in with their own username and password.",
|
||||
"howItWorks3": "Managed accounts can complete tasks but have limited administrative permissions",
|
||||
"howItWorks4": "Managed accounts automatically added to your circle"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notification Settings",
|
||||
"deviceSection": "Device Notification",
|
||||
"deviceSectionDescription": "Manage your Device Notification",
|
||||
"deviceLabel": "Device Notification",
|
||||
"deviceHelper": "Receive notification on your device when a task is due",
|
||||
"mobileOnly": "This feature is only available on mobile devices",
|
||||
"testNotification": "Test Notification",
|
||||
"testNotificationBody": "You have a task due soon",
|
||||
"dueTitle": "Due Date Notification",
|
||||
"dueLabel": "Notification when the task is due",
|
||||
"preDueTitle": "Pre-Due Date Notification",
|
||||
"preDueLabel": "Notification a few hours before the task is due",
|
||||
"overdueTitle": "Overdue Notification",
|
||||
"overdueLabel": "Notification when the task is overdue",
|
||||
"pushLabel": "Push Notifications",
|
||||
"pushHelper": "Receive Nudges, Announcements, and Chore Assignments via Push Notifications",
|
||||
"registeredDevices": "Registered Devices ({{count}}/5)",
|
||||
"registeredDevicesDescription": "Devices registered to receive push notifications for your account",
|
||||
"currentDevice": "Current Device: {{platform}} {{model}}",
|
||||
"currentDeviceNotRegistered": "This device is not registered for push notifications",
|
||||
"limitReached": "Limit Reached",
|
||||
"registerDevice": "Register Device",
|
||||
"unknownDevice": "Unknown Device",
|
||||
"deviceCreatedAt": "Created At: {{date}}",
|
||||
"noDevices": "No devices registered for push notifications",
|
||||
"customSection": "Custom Notification",
|
||||
"customSectionDescription": "Notification through other platform like Telegram or Pushover",
|
||||
"customLabel": "Custom Notification",
|
||||
"customHelper": "Receive notification on other platform",
|
||||
"targetNone": "None",
|
||||
"targetTelegram": "Telegram",
|
||||
"targetPushover": "Pushover",
|
||||
"targetWebhooks": "Webhooks",
|
||||
"telegramBotHelpBefore": "You need to initiate a message to the bot in order for the Telegram notification to work",
|
||||
"telegramBotHelpAfter": "to start a chat",
|
||||
"clickHere": "Click here",
|
||||
"chatId": "Chat ID",
|
||||
"chatIdPlaceholder": "User ID / Chat ID",
|
||||
"telegramChatIdHelpBefore": "If you don't know your Chat ID, start chat with userinfobot and it will send you your Chat ID.",
|
||||
"telegramChatIdHelpAfter": "to start chat with userinfobot",
|
||||
"userKey": "User key",
|
||||
"userKeyPlaceholder": "User ID",
|
||||
"chatIdRequired": "Chat ID is required",
|
||||
"chatIdInvalid": "Invalid Chat ID",
|
||||
"userKeyRequired": "User key is required",
|
||||
"targetUpdated": "Notification target updated",
|
||||
"targetUpdateFailed": "Error while updating notification target: {{error}}",
|
||||
"deviceRegistered": "Device registered successfully for push notifications.",
|
||||
"deviceLimitTitle": "Device Limit Reached",
|
||||
"deviceLimitMessage": "You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.",
|
||||
"registrationFailedTitle": "Registration Failed",
|
||||
"registrationFailedMessage": "Failed to register device automatically. Please try again.",
|
||||
"permissionRequiredTitle": "Permission Required",
|
||||
"permissionRequiredMessage": "Push notification permission is required to register this device.",
|
||||
"registrationInitiatedTitle": "Registration Initiated",
|
||||
"registrationInitiatedMessage": "Push notification registration has been initiated. The device will be registered automatically.",
|
||||
"registerDeviceFailed": "Failed to register device. Please try again.",
|
||||
"permissionDeniedTitle": "Notification Permission Denied",
|
||||
"permissionDeniedMessage": "You have denied notification permissions. You can enable them later in your device settings.",
|
||||
"pushPermissionDeniedTitle": "Push Notification Permission Denied",
|
||||
"pushPermissionDeniedMessage": "Push notifications have been disabled. You can enable them in your device settings if needed.",
|
||||
"unregisterFailed": "Failed to unregister device"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Multi-Factor Authentication",
|
||||
"description": "Add an extra layer of security to your account with multi-factor authentication (MFA). When enabled, you'll need to provide a verification code from your authenticator app in addition to your password when signing in.",
|
||||
"twoFactor": "Two-Factor Authentication",
|
||||
"enabledSubtitle": "Your account is protected with 2FA",
|
||||
"disabledSubtitle": "Secure your account with an authenticator app",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enabledSuccess": "MFA has been successfully enabled!",
|
||||
"disabledSuccess": "MFA has been disabled successfully!",
|
||||
"errors": {
|
||||
"qrGenerationFailed": "Failed to generate QR code",
|
||||
"invalidResponse": "Invalid response from server. Missing QR code or secret.",
|
||||
"notFound": "MFA setup endpoint not found. This feature may not be available yet.",
|
||||
"unauthorized": "Unauthorized. Please login again.",
|
||||
"serverError": "Server error. Please try again later.",
|
||||
"setupFailed": "Failed to setup MFA ({{status}}). Please try again.",
|
||||
"networkError": "Network error. Please check your connection and try again.",
|
||||
"invalidCode": "Invalid verification code. Please try again.",
|
||||
"confirmFailed": "Failed to confirm MFA. Please try again.",
|
||||
"disableFailed": "Failed to disable MFA. Please try again."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Set up Multi-Factor Authentication",
|
||||
"addedAccount": "I've added the account",
|
||||
"back": "Back",
|
||||
"verifyAndEnable": "Verify & Enable",
|
||||
"savedBackupCodes": "I've saved my backup codes",
|
||||
"step1Label": "Step 1:",
|
||||
"step1": "Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)",
|
||||
"qrAlt": "MFA QR Code",
|
||||
"qrFailed": "QR code could not be generated. Please try again or use the manual entry key below.",
|
||||
"manualKey": "Manual entry key:",
|
||||
"step2Label": "Step 2:",
|
||||
"step2": "Enter the 6-digit verification code from your authenticator app",
|
||||
"codePlaceholder": "Enter 6-digit code",
|
||||
"successTitle": "MFA Successfully Enabled!",
|
||||
"backupCodesTitle": "Save these backup codes in a safe place",
|
||||
"backupCodesDescription": "You can use these codes to access your account if you lose your authenticator device. Each code can only be used once."
|
||||
},
|
||||
"disableModal": {
|
||||
"title": "Disable Multi-Factor Authentication",
|
||||
"warning": "Disabling MFA will make your account less secure. Are you sure you want to continue?",
|
||||
"prompt": "Enter a verification code from your authenticator app to confirm:",
|
||||
"confirm": "Disable MFA"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API Tokens",
|
||||
"accessToken": "Access Token",
|
||||
"description": "Create token to use with the API to update things that trigger task or chores",
|
||||
"plusNotice": "API tokens are not available in the Basic plan. Upgrade to Plus to generate API tokens for integrating with external systems and automating your tasks.",
|
||||
"showToken": "Show Token",
|
||||
"hideToken": "Hide Token",
|
||||
"removeTitle": "Remove Token",
|
||||
"removeMessage": "Are you sure you want to remove {{name}}?",
|
||||
"removedTitle": "Removed",
|
||||
"removedMessage": "API token has been removed",
|
||||
"tokenCopied": "Token copied to clipboard",
|
||||
"generateNew": "Generate New Token",
|
||||
"nameModalTitle": "Give a name for your new token, something to remember it by.",
|
||||
"generateToken": "Generate Token"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage Settings",
|
||||
"serverTitle": "Server Storage Usage",
|
||||
"serverDescription": "This is the storage used by your account on our servers (e.g. files, images, and data you have uploaded).",
|
||||
"usagePlaceholder": "-- MB used / -- MB total (--)",
|
||||
"usage": "{{used}} MB used / {{total}} MB total ({{percent}}%)",
|
||||
"basicPlanNotice": "Server storage is not available in the Basic plan. Upgrade to Plus to track your server storage usage.",
|
||||
"localTitleApp": "App Local Storage & Cache",
|
||||
"localTitleBrowser": "Browser Local Storage & Cache",
|
||||
"localDescription": "This is data stored locally in your browser for faster access. Clearing this will not affect your server data, but may log you out.",
|
||||
"clearLocal": "Clear All Local Storage and Cache",
|
||||
"clearLocalTitle": "Clear All Local Storage",
|
||||
"clearLocalMessage": "Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.",
|
||||
"clearAll": "Clear All",
|
||||
"appPreferences": "App Preferences",
|
||||
"deviceOnly": "Device Only",
|
||||
"appPreferencesDescription": "These are preferences and settings stored locally on your device by the app. Clearing them will reset app-specific settings and may log you out, but will not affect your server data.",
|
||||
"clearPreferences": "Clear App Preferences",
|
||||
"clearPreferencesTitle": "Clear App Preferences",
|
||||
"clearPreferencesMessage": "Are you sure you want to clear all app preferences? This will reset your app settings and may require you to log in again."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Sidepanel Customization",
|
||||
"heading": "Sidepanel Settings",
|
||||
"description": "Customize which cards appear in the sidepanel and their order. Drag and drop to reorder, or toggle visibility for each card.",
|
||||
"resetToDefaults": "Reset to Defaults",
|
||||
"resetHelper": "This will restore all cards to their default visibility and order.",
|
||||
"cards": {
|
||||
"welcome": {
|
||||
"name": "User Switcher",
|
||||
"description": "Allows admins/managers to view tasks as different users"
|
||||
},
|
||||
"smartInsights": {
|
||||
"name": "Smart Insights",
|
||||
"description": "Quick actions based on your tasks"
|
||||
},
|
||||
"assignees": {
|
||||
"name": "Tasks by Assignee",
|
||||
"description": "Groups tasks by who they are assigned to"
|
||||
},
|
||||
"calendar": {
|
||||
"name": "Calendar View",
|
||||
"description": "Shows tasks in a calendar format"
|
||||
},
|
||||
"activities": {
|
||||
"name": "Recent Activities",
|
||||
"description": "Shows recent task completions and activities"
|
||||
},
|
||||
"weeklyGoals": {
|
||||
"name": "Weekly Goals",
|
||||
"description": "Shows weekly progress and family completion stats"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme Preferences",
|
||||
"description": "Choose how the site looks to you. Select a single theme, or sync with your system and automatically switch between day and night themes.",
|
||||
"themeMode": "Theme mode",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localization",
|
||||
"description": "Customize language, date format, and regional preferences for your account.",
|
||||
"language": "Language",
|
||||
"languageDescription": "Select your preferred language",
|
||||
"rtlNotice": "This language uses right-to-left (RTL) text direction",
|
||||
"dateFormat": "Date Format",
|
||||
"dateFormatDescription": "Choose how dates should be displayed throughout the application",
|
||||
"timeFormat": "Time Format",
|
||||
"timeFormatDescription": "Select 12-hour or 24-hour time format",
|
||||
"preview": "Preview: {{value}}",
|
||||
"12hour": "12-hour (AM/PM)",
|
||||
"24hour": "24-hour",
|
||||
"firstDayOfWeek": "First Day of Week",
|
||||
"firstDayOfWeekDescription": "Select which day starts your week",
|
||||
"sunday": "Sunday",
|
||||
"monday": "Monday",
|
||||
"saturday": "Saturday",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/YYYY (US)",
|
||||
"dmy": "DD/MM/YYYY (Europe)",
|
||||
"ymd": "YYYY-MM-DD (ISO)",
|
||||
"long": "Long format (e.g., January 1, 2024)",
|
||||
"short": "Short format (e.g., Jan 1, 2024)"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced Settings",
|
||||
"description": "Configure advanced features like webhooks and real-time updates for enhanced productivity.",
|
||||
"offlineTitle": "Offline Support",
|
||||
"offlineDescription": "Keep using Donetick when you're offline on this device/browser. Your changes are saved locally and synced when you're back online.",
|
||||
"offlineToggle": "Enable Offline Support",
|
||||
"offlineHelper": "Turning this off removes unsynced offline changes and saved offline data from this device/browser.",
|
||||
"offlineEnabled": "Offline mode turned on for this device/browser",
|
||||
"offlineDisabled": "Offline mode turned off and local data was cleared",
|
||||
"offlineDisabledPartial": "Offline mode was turned off, but some local data may still be stored",
|
||||
"offlineDisableTitle": "Turn Off Offline Mode",
|
||||
"offlineDisableMessage": "Turning off offline mode will remove unsynced offline changes and saved offline data on this device/browser. Do you want to continue?",
|
||||
"offlineDisableConfirm": "Turn Off & Clear Data",
|
||||
"webhookTitle": "Webhook Integration",
|
||||
"webhookDescription": "Webhooks allow you to send real-time notifications to other services when events happen in your Circle. Configure a webhook URL to receive real-time updates.",
|
||||
"webhookPlusNotice": "Webhook notifications are not available in the Basic plan. Upgrade to Plus to receive real-time updates via webhooks.",
|
||||
"webhookToggle": "Enable Webhook",
|
||||
"webhookHelper": "Enable webhook notifications for tasks and things updates.",
|
||||
"webhookURL": "Webhook URL",
|
||||
"webhookUpdated": "Webhook URL updated successfully",
|
||||
"webhookUpdateFailed": "Failed to update webhook URL",
|
||||
"realtimeTitle": "Real-time Updates",
|
||||
"realtimeDescription": "Configure how you receive live updates when tasks and activities change in your circle.",
|
||||
"realtime": {
|
||||
"toggleLabel": "Enable Real-time Updates",
|
||||
"title": "Real-time Updates",
|
||||
"subtitle": "Get instant notifications when tasks are updated",
|
||||
"statusLabel": "Status:",
|
||||
"basicPlan": "Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when tasks are updated.",
|
||||
"disabled": "Real-time updates are disabled. Enable them to see live changes when you or other circle members complete, skip, or modify tasks.",
|
||||
"connected": "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify tasks.",
|
||||
"connecting": "Connecting to real-time updates...",
|
||||
"errored": "Real-time updates are enabled but not working: {{error}}",
|
||||
"notConnected": "Real-time updates are enabled but not currently connected.",
|
||||
"basicPlanNotice": "Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when you or other circle members complete, skip, or modify tasks."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"logout": "Cerrar Sesión",
|
||||
"version": "Versión",
|
||||
"navigation": {
|
||||
"search": "Buscar",
|
||||
"allTasks": "Todas las Tareas",
|
||||
"archived": "Archivadas",
|
||||
"things": "Cosas",
|
||||
@@ -29,5 +30,43 @@
|
||||
"activities": "Actividades",
|
||||
"points": "Puntos",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"search": {
|
||||
"title": "Buscar",
|
||||
"placeholder": "Buscar en Donetick",
|
||||
"inputAriaLabel": "Buscar tareas, historial, proyectos, etiquetas y configuración",
|
||||
"deviceNote": "Se busca en el contenido disponible en este dispositivo",
|
||||
"escape": "Esc",
|
||||
"recent": "Recientes",
|
||||
"empty": {
|
||||
"title": "Sin coincidencias directas",
|
||||
"subtitle": "Aún puedes filtrar la lista de tareas con esta búsqueda."
|
||||
},
|
||||
"groups": {
|
||||
"tasks": "Tareas",
|
||||
"history": "Notas",
|
||||
"projects": "Proyectos",
|
||||
"labels": "Etiquetas",
|
||||
"people": "Personas",
|
||||
"settings": "Configuración",
|
||||
"actions": "Acciones rápidas"
|
||||
},
|
||||
"actions": {
|
||||
"quickAction": "Acción rápida",
|
||||
"navigation": "Navegación",
|
||||
"createTask": "Crear una tarea",
|
||||
"viewAllTasks": "Ver todas las tareas",
|
||||
"viewArchivedTasks": "Ver tareas archivadas",
|
||||
"openSettings": "Abrir configuración",
|
||||
"filterTasks": "Mostrar tareas que coincidan con «{{query}}»",
|
||||
"filterTasksSubtitle": "Filtrar la lista de tareas"
|
||||
},
|
||||
"footer": {
|
||||
"navigate": "Navegar",
|
||||
"open": "Abrir",
|
||||
"results_one": "{{count}} resultado",
|
||||
"results_other": "{{count}} resultados",
|
||||
"typeToSearch": "Escribe para buscar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +1,494 @@
|
||||
{
|
||||
"title": "Configuración",
|
||||
"circleSettings": {
|
||||
"title": "Configuración del círculo",
|
||||
"description": "Tu cuenta se conecta automáticamente a un Círculo cuando creas o te unes a uno. Invita fácilmente a amigos compartiendo el código único del Círculo o el enlace a continuación.",
|
||||
"circleCode": "Código del Círculo",
|
||||
"copyCode": "Copiar Código",
|
||||
"copyLink": "Copiar Enlace",
|
||||
"codeCopied": "¡Código del círculo copiado!",
|
||||
"linkCopied": "¡Enlace copiado!",
|
||||
"joinCircle": "Unirse a un Círculo",
|
||||
"joinCirclePlaceholder": "Ingresa el Código del Círculo",
|
||||
"join": "Unirse",
|
||||
"leave": "Salir del Círculo",
|
||||
"leaveConfirmTitle": "Salir del Círculo",
|
||||
"leaveConfirmMessage": "¿Estás seguro de que quieres salir de este círculo?",
|
||||
"circleMembers": "Miembros del Círculo",
|
||||
"circleMemberRequests": "Solicitudes de Miembros del Círculo",
|
||||
"admin": "Administrador",
|
||||
"member": "Miembro",
|
||||
"pending": "Pendiente",
|
||||
"accept": "Aceptar",
|
||||
"reject": "Rechazar",
|
||||
"makeAdmin": "Hacer Administrador",
|
||||
"makeMember": "Hacer Miembro",
|
||||
"remove": "Eliminar",
|
||||
"webhookURL": "URL del Webhook",
|
||||
"webhookDescription": "Ingresa una URL de webhook para recibir notificaciones de eventos del círculo",
|
||||
"webhookPlaceholder": "https://tu-url-webhook.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Configuración de la Cuenta",
|
||||
"subscription": "Suscripción",
|
||||
"subscriptionStatus": "Plan Actual",
|
||||
"free": "Gratis",
|
||||
"plus": "Plus",
|
||||
"upgrade": "Actualizar",
|
||||
"cancel": "Cancelar",
|
||||
"changePassword": "Cambiar Contraseña",
|
||||
"password": "Contraseña",
|
||||
"dangerZone": "Zona de Peligro",
|
||||
"dangerZoneDescription": "Una vez que elimines tu cuenta, no hay vuelta atrás. Por favor, está seguro.",
|
||||
"deleteAccount": "Eliminar Cuenta"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localización",
|
||||
"description": "Personaliza el idioma, formato de fecha y preferencias regionales para tu cuenta.",
|
||||
"language": "Idioma",
|
||||
"languageDescription": "Selecciona tu idioma preferido",
|
||||
"dateFormat": "Formato de Fecha",
|
||||
"dateFormatDescription": "Elige cómo se deben mostrar las fechas en toda la aplicación",
|
||||
"timeFormat": "Formato de Hora",
|
||||
"timeFormatDescription": "Selecciona formato de 12 o 24 horas",
|
||||
"12hour": "12 horas (AM/PM)",
|
||||
"24hour": "24 horas",
|
||||
"firstDayOfWeek": "Primer Día de la Semana",
|
||||
"firstDayOfWeekDescription": "Selecciona qué día comienza tu semana",
|
||||
"sunday": "Domingo",
|
||||
"monday": "Lunes",
|
||||
"saturday": "Sábado",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/AAAA (EE.UU.)",
|
||||
"dmy": "DD/MM/AAAA (Europa)",
|
||||
"ymd": "AAAA-MM-DD (ISO)",
|
||||
"long": "Formato largo (ej., 1 de enero de 2024)",
|
||||
"short": "Formato corto (ej., 1 ene 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personalización del Panel Lateral",
|
||||
"description": "Personaliza el diseño y la visibilidad de las tarjetas en el panel lateral. Esta sección solo está disponible en dispositivos de pantalla grande como tabletas y computadoras de escritorio."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Preferencias de tema",
|
||||
"description": "Elige cómo se ve el sitio para ti. Selecciona un solo tema o sincronízalo con tu sistema y cambia automáticamente entre temas de día y noche.",
|
||||
"themeMode": "Modo de tema",
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "Configuración guardada con éxito",
|
||||
"settingsSaveFailed": "Error al guardar la configuración",
|
||||
"invalidWebhook": "URL de webhook no válida"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Configuración del Perfil",
|
||||
"description": "Actualiza tu nombre para mostrar y foto de perfil.",
|
||||
"photoUpdated": "Foto Actualizada",
|
||||
"photoUpdatedMessage": "¡Tu foto de perfil ha sido actualizada con éxito!",
|
||||
"uploadFailed": "Error al Subir",
|
||||
"uploadFailedMessage": "Error al subir tu foto. Por favor, inténtalo de nuevo.",
|
||||
"profileUpdated": "Perfil Actualizado",
|
||||
"profileUpdatedMessage": "¡Tu información de perfil ha sido guardada con éxito!",
|
||||
"updateFailed": "Error al Actualizar",
|
||||
"updateFailedMessage": "No se pudo actualizar tu perfil. Por favor, verifica tu conexión e inténtalo de nuevo.",
|
||||
"changePhoto": "Cambiar Foto",
|
||||
"displayName": "Nombre para Mostrar",
|
||||
"displayNamePlaceholder": "Ingresa tu nombre para mostrar",
|
||||
"timezone": "Zona Horaria",
|
||||
"timezonePlaceholder": "Selecciona tu zona horaria",
|
||||
"title": "Ajustes",
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar"
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"remove": "Eliminar",
|
||||
"delete": "Borrar",
|
||||
"refresh": "Actualizar",
|
||||
"loading": "Cargando…",
|
||||
"on": "Sí",
|
||||
"off": "No",
|
||||
"error": "Error",
|
||||
"success": "Listo",
|
||||
"plusFeature": "Función Plus",
|
||||
"earlyAccess": "Acceso anticipado"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Configuración",
|
||||
"subtitle": "Personaliza tu experiencia y administra las preferencias de tu cuenta",
|
||||
"title": "Ajustes",
|
||||
"subtitle": "Personaliza tu experiencia y gestiona las preferencias de tu cuenta",
|
||||
"upgrade": {
|
||||
"title": "Actualizar a Plus",
|
||||
"description": "Desbloquea funciones potentes para mejorar tu productividad",
|
||||
"button": "Actualizar Ahora",
|
||||
"title": "Mejora a Plus",
|
||||
"description": "Desbloquea funciones potentes para aumentar tu productividad",
|
||||
"button": "Mejorar ahora",
|
||||
"features": {
|
||||
"richText": "Descripciones en texto enriquecido",
|
||||
"richText": "Descripciones con texto enriquecido",
|
||||
"notifications": "Notificaciones de tareas",
|
||||
"apiIntegrations": "Integraciones API",
|
||||
"apiIntegrations": "Integraciones con API",
|
||||
"advancedAutomation": "Automatización avanzada"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Configuración del Perfil",
|
||||
"description": "Actualiza tu información de perfil, foto, nombre para mostrar y preferencias de zona horaria."
|
||||
"title": "Ajustes de perfil",
|
||||
"description": "Actualiza tu información de perfil, foto, nombre visible y zona horaria."
|
||||
},
|
||||
"circle": {
|
||||
"title": "Configuración del Círculo",
|
||||
"description": "Administra tu círculo, invita miembros y gestiona solicitudes de unión."
|
||||
"title": "Ajustes del círculo",
|
||||
"description": "Gestiona tu círculo, invita miembros y atiende las solicitudes de acceso."
|
||||
},
|
||||
"account": {
|
||||
"title": "Configuración de la Cuenta",
|
||||
"description": "Administra tu suscripción, cambia la contraseña y opciones de eliminación de cuenta."
|
||||
"title": "Ajustes de cuenta",
|
||||
"description": "Gestiona tu suscripción, cambia la contraseña y elimina tu cuenta."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Cuentas Administradas",
|
||||
"description": "Crea y administra subcuentas para iniciar sesión y completar tareas asignadas."
|
||||
"title": "Cuentas gestionadas",
|
||||
"description": "Crea y gestiona subcuentas para que puedan iniciar sesión y completar las tareas asignadas."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"description": "Configura notificaciones push, alertas por correo electrónico y destinos de notificación para tareas."
|
||||
"description": "Configura notificaciones push, avisos por correo y destinos de notificación para las tareas."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Autenticación Multifactor",
|
||||
"description": "Agrega una capa adicional de seguridad con MFA usando aplicaciones de autenticación."
|
||||
"title": "Autenticación multifactor",
|
||||
"description": "Añade una capa extra de seguridad con MFA usando apps de autenticación."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "Tokens API",
|
||||
"description": "Genera y administra tokens de acceso para integraciones de terceros y acceso a la API."
|
||||
"title": "Tokens de API",
|
||||
"description": "Genera y gestiona tokens de acceso para integraciones de terceros y acceso a la API."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Configuración de Almacenamiento",
|
||||
"description": "Respalda y restaura tus datos, administra el almacenamiento local y las preferencias de sincronización."
|
||||
"title": "Ajustes de almacenamiento",
|
||||
"description": "Haz copias de seguridad y restaura tus datos, gestiona el almacenamiento local y la sincronización."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personalización del Panel Lateral",
|
||||
"description": "Personaliza el diseño y la visibilidad de las tarjetas en la interfaz del panel lateral."
|
||||
"title": "Personalización del panel lateral",
|
||||
"description": "Personaliza la disposición y la visibilidad de las tarjetas del panel lateral."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Preferencias de Tema",
|
||||
"description": "Elige tu tema preferido y configura los ajustes de modo oscuro/claro."
|
||||
"title": "Preferencias de tema",
|
||||
"description": "Elige tu tema preferido y configura el modo claro/oscuro."
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localización",
|
||||
"description": "Personaliza el idioma, formato de fecha, formato de hora y preferencias regionales."
|
||||
"description": "Personaliza el idioma, el formato de fecha y hora y las preferencias regionales."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Configuración Avanzada",
|
||||
"description": "Configura webhooks, actualizaciones en tiempo real y otras funciones avanzadas para mejorar la productividad."
|
||||
"title": "Ajustes avanzados",
|
||||
"description": "Configura webhooks, actualizaciones en tiempo real y otras funciones avanzadas."
|
||||
},
|
||||
"developer": {
|
||||
"title": "Configuración de Desarrollador",
|
||||
"description": "Ver información técnica sobre tokens de autenticación, conexiones SSE y datos de depuración."
|
||||
"title": "Ajustes de desarrollador",
|
||||
"description": "Consulta información técnica sobre tokens de autenticación, conexiones SSE y datos de depuración."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Enviar comentarios",
|
||||
"description": "Cuéntanos qué tal te funciona Donetick o pide una función."
|
||||
},
|
||||
"bugReport": {
|
||||
"title": "Informar de un error",
|
||||
"description": "¿Algo no funciona bien? Envíanos los detalles junto con una instantánea técnica."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Ajustes de perfil",
|
||||
"description": "Actualiza tu nombre visible y tu foto de perfil.",
|
||||
"photoUpdated": "Foto actualizada",
|
||||
"photoUpdatedMessage": "¡Tu foto de perfil se ha actualizado correctamente!",
|
||||
"uploadFailed": "Error al subir",
|
||||
"uploadFailedMessage": "No se pudo subir tu foto. Inténtalo de nuevo.",
|
||||
"profileUpdated": "Perfil actualizado",
|
||||
"profileUpdatedMessage": "¡La información de tu perfil se ha guardado correctamente!",
|
||||
"updateFailed": "Error al actualizar",
|
||||
"updateFailedMessage": "No se pudo actualizar tu perfil. Comprueba tu conexión e inténtalo de nuevo.",
|
||||
"changePhoto": "Cambiar foto",
|
||||
"editPhoto": "Editar foto de perfil",
|
||||
"displayName": "Nombre visible",
|
||||
"displayNamePlaceholder": "Escribe tu nombre visible",
|
||||
"timezone": "Zona horaria",
|
||||
"timezonePlaceholder": "Selecciona tu zona horaria",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"circleSettings": {
|
||||
"title": "Ajustes del círculo",
|
||||
"description": "Tu cuenta se conecta automáticamente a un círculo cuando creas uno o te unes. Invita a tus amigos compartiendo el código o el enlace del círculo que aparece abajo. Recibirás un aviso aquí cuando alguien pida unirse a tu círculo.",
|
||||
"memberOf": "Formas parte de {{name}}",
|
||||
"yourCircleCode": "Tu código de círculo es:",
|
||||
"copyCode": "Copiar código",
|
||||
"shareInvite": "Compartir invitación",
|
||||
"codeCopied": "Código copiado al portapapeles",
|
||||
"linkCopied": "Enlace de invitación copiado al portapapeles",
|
||||
"myCircle": "mi círculo",
|
||||
"shareTitle": "Únete a {{name}} en Donetick",
|
||||
"shareText": "Me gustaría invitarte a unirte a {{name}} en Donetick.",
|
||||
"shareDialogTitle": "Compartir invitación al círculo",
|
||||
"leave": "Salir del círculo",
|
||||
"leaveConfirmTitle": "Salir del círculo",
|
||||
"leaveConfirmMessage": "¿Seguro que quieres salir de tu círculo?",
|
||||
"leaveConfirmButton": "Salir",
|
||||
"leftCircle": "Has salido del círculo correctamente",
|
||||
"leaveFailed": "No se pudo salir del círculo",
|
||||
"circleMembers": "Miembros del círculo",
|
||||
"you": "(Tú)",
|
||||
"pendingApproval": "Pendiente de aprobación",
|
||||
"joinedOn": "Se unió el {{date}}",
|
||||
"requestedToJoin": "Solicitud de acceso {{date}}",
|
||||
"roles": {
|
||||
"member": "Miembro",
|
||||
"memberDescription": "Un miembro normal del círculo",
|
||||
"manager": "Gestor",
|
||||
"managerDescription": "Puede suplantar a otras personas y actuar en su nombre",
|
||||
"admin": "Administrador",
|
||||
"adminDescription": "Acceso total al círculo"
|
||||
},
|
||||
"roleUpdateFailed": "No se pudo actualizar el rol",
|
||||
"removeMemberTitle": "Eliminar miembro",
|
||||
"removeMemberMessage": "¿Seguro que quieres eliminar a {{name}} de tu círculo?",
|
||||
"memberRemoved": "Miembro eliminado correctamente",
|
||||
"circleMemberRequests": "Solicitudes de acceso al círculo",
|
||||
"lastUpdated": "Última actualización: {{time}}",
|
||||
"refreshing": "Actualizando…",
|
||||
"refreshFailed": "No se pudieron actualizar las solicitudes de acceso",
|
||||
"wantsToJoin": "{{name}} quiere unirse a tu círculo.",
|
||||
"accept": "Aceptar",
|
||||
"acceptRequestTitle": "Aceptar solicitud de acceso",
|
||||
"acceptRequestMessage": "¿Seguro que quieres aceptar a {{name}} (usuario: {{username}}) en tu círculo?",
|
||||
"requestAccepted": "Solicitud aceptada correctamente",
|
||||
"or": "o",
|
||||
"joinOtherDescription": "¿Quieres unirte al círculo de otra persona? Pídele su código de círculo o su enlace de invitación. Escribe el código abajo para unirte.",
|
||||
"enterCircleCode": "Introduce el código del círculo:",
|
||||
"enterCodePlaceholder": "Introduce el código",
|
||||
"joinCircle": "Unirse al círculo",
|
||||
"joinedPending": "Te has unido al círculo correctamente; espera a que la persona propietaria acepte tu solicitud.",
|
||||
"alreadyMember": "Ya eres miembro de este círculo",
|
||||
"joinFailed": "No se pudo unir al círculo"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Ajustes de cuenta",
|
||||
"description": "Cambia los ajustes de tu cuenta, el tipo de plan o tu contraseña",
|
||||
"accountType": "Tipo de cuenta: {{type}}",
|
||||
"free": "Gratis",
|
||||
"plus": "Plus",
|
||||
"plusUntil": "Plus (hasta el {{date}})",
|
||||
"activeDescription": "Actualmente tienes el plan Plus. Tu suscripción se renovará el {{date}}.",
|
||||
"cancelledDescription": "Has cancelado tu suscripción. Tu cuenta pasará al plan Gratis el {{date}}.",
|
||||
"freeDescription": "Actualmente tienes el plan Gratis. Mejora a Plus para desbloquear más funciones.",
|
||||
"upgrade": "Mejorar",
|
||||
"cancel": "Cancelar",
|
||||
"password": "Contraseña:",
|
||||
"changePassword": "Cambiar contraseña",
|
||||
"passwordChanged": "Contraseña cambiada correctamente",
|
||||
"passwordChangeFailed": "No se pudo cambiar la contraseña",
|
||||
"dangerZone": "Zona de peligro",
|
||||
"dangerZoneDescription": "Una vez que elimines tu cuenta, no hay vuelta atrás. Asegúrate antes de continuar.",
|
||||
"deleteAccount": "Eliminar cuenta",
|
||||
"accountDeleted": "Cuenta eliminada correctamente",
|
||||
"subscriptionCancelled": "Suscripción cancelada",
|
||||
"subscriptionCancelFailed": "No se pudo cancelar la suscripción",
|
||||
"purchase": {
|
||||
"success": "¡Compra realizada! Reinicia la app para acceder a las funciones Plus.",
|
||||
"storeConnection": "Problema de conexión con la tienda. Comprueba tu red e inténtalo de nuevo.",
|
||||
"notAllowed": "Las compras no están permitidas en este dispositivo. Revisa las restricciones de tu dispositivo.",
|
||||
"unavailable": "Esta suscripción no está disponible. Inténtalo más tarde.",
|
||||
"alreadyProcessed": "Esta compra ya se ha procesado. Si crees que es un error, ponte en contacto con el soporte.",
|
||||
"receiptMissing": "Falta el recibo de compra. Intenta comprar de nuevo.",
|
||||
"networkError": "Error de red. Comprueba tu conexión e inténtalo de nuevo.",
|
||||
"invalidReceipt": "Recibo de compra no válido. Ponte en contacto con el soporte si el problema persiste.",
|
||||
"pending": "El pago está pendiente de aprobación. Tendrás acceso en cuanto se apruebe.",
|
||||
"failed": "La compra ha fallado: {{error}}. Inténtalo de nuevo o contacta con el soporte.",
|
||||
"unknownError": "Error desconocido"
|
||||
}
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Cuentas gestionadas",
|
||||
"description": "Gestiona las subcuentas. Sus usuarios pueden iniciar sesión y completar las tareas asignadas.",
|
||||
"notParentTitle": "Gestión de subcuentas",
|
||||
"notParentMessage": "Solo las cuentas principales pueden gestionar subcuentas.",
|
||||
"freePlanNotice": "El plan Gratis permite 1 subcuenta. Mejora a Plus para tener hasta 5 subcuentas.",
|
||||
"count": "Subcuentas ({{count}})",
|
||||
"add": "Añadir subcuenta",
|
||||
"loading": "Cargando subcuentas…",
|
||||
"emptyTitle": "No hay subcuentas",
|
||||
"emptyDescription": "Crea subcuentas para que los miembros del equipo puedan iniciar sesión y completar sus tareas.",
|
||||
"addFirst": "Añade tu primera subcuenta",
|
||||
"username": "Usuario: {{username}}",
|
||||
"created": "Creada: {{date}}",
|
||||
"changePassword": "Cambiar contraseña",
|
||||
"deleteAccount": "Eliminar cuenta",
|
||||
"createdSuccess": "¡La subcuenta «{{name}}» se ha creado correctamente!",
|
||||
"createFailed": "No se pudo crear la subcuenta: {{error}}",
|
||||
"createFailedGeneric": "No se pudo crear la subcuenta",
|
||||
"passwordUpdated": "Contraseña de la subcuenta actualizada correctamente",
|
||||
"passwordUpdateFailed": "No se pudo actualizar la contraseña: {{error}}",
|
||||
"passwordUpdateFailedGeneric": "No se pudo actualizar la contraseña",
|
||||
"deleteConfirmTitle": "Eliminar subcuenta",
|
||||
"deleteConfirmMessage": "¿Seguro que quieres eliminar la subcuenta «{{name}}»? Esta acción no se puede deshacer.",
|
||||
"deleted": "Subcuenta «{{name}}» eliminada correctamente",
|
||||
"deleteFailed": "No se pudo eliminar la subcuenta: {{error}}",
|
||||
"deleteFailedGeneric": "No se pudo eliminar la subcuenta",
|
||||
"howItWorksTitle": "Cómo funcionan las cuentas gestionadas",
|
||||
"howItWorks1": "Las cuentas gestionadas las crea la cuenta principal; son para personas cuya cuenta quieres poder eliminar y cuya contraseña quieres poder restablecer.",
|
||||
"howItWorks2": "Las subcuentas pueden iniciar sesión con su propio usuario y contraseña.",
|
||||
"howItWorks3": "Las cuentas gestionadas pueden completar tareas, pero tienen permisos administrativos limitados",
|
||||
"howItWorks4": "Las cuentas gestionadas se añaden automáticamente a tu círculo"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Ajustes de notificaciones",
|
||||
"deviceSection": "Notificaciones del dispositivo",
|
||||
"deviceSectionDescription": "Gestiona las notificaciones de tu dispositivo",
|
||||
"deviceLabel": "Notificación del dispositivo",
|
||||
"deviceHelper": "Recibe una notificación en tu dispositivo cuando venza una tarea",
|
||||
"mobileOnly": "Esta función solo está disponible en dispositivos móviles",
|
||||
"testNotification": "Notificación de prueba",
|
||||
"testNotificationBody": "Tienes una tarea que vence pronto",
|
||||
"dueTitle": "Notificación de vencimiento",
|
||||
"dueLabel": "Notificación cuando la tarea vence",
|
||||
"preDueTitle": "Notificación previa al vencimiento",
|
||||
"preDueLabel": "Notificación unas horas antes de que venza la tarea",
|
||||
"overdueTitle": "Notificación de retraso",
|
||||
"overdueLabel": "Notificación cuando la tarea está vencida",
|
||||
"pushLabel": "Notificaciones push",
|
||||
"pushHelper": "Recibe recordatorios, anuncios y asignaciones de tareas por notificación push",
|
||||
"registeredDevices": "Dispositivos registrados ({{count}}/5)",
|
||||
"registeredDevicesDescription": "Dispositivos registrados para recibir notificaciones push de tu cuenta",
|
||||
"currentDevice": "Dispositivo actual: {{platform}} {{model}}",
|
||||
"currentDeviceNotRegistered": "Este dispositivo no está registrado para notificaciones push",
|
||||
"limitReached": "Límite alcanzado",
|
||||
"registerDevice": "Registrar dispositivo",
|
||||
"unknownDevice": "Dispositivo desconocido",
|
||||
"deviceCreatedAt": "Creado el: {{date}}",
|
||||
"noDevices": "No hay dispositivos registrados para notificaciones push",
|
||||
"customSection": "Notificación personalizada",
|
||||
"customSectionDescription": "Notificaciones a través de otras plataformas como Telegram o Pushover",
|
||||
"customLabel": "Notificación personalizada",
|
||||
"customHelper": "Recibe notificaciones en otra plataforma",
|
||||
"targetNone": "Ninguna",
|
||||
"targetTelegram": "Telegram",
|
||||
"targetPushover": "Pushover",
|
||||
"targetWebhooks": "Webhooks",
|
||||
"telegramBotHelpBefore": "Debes escribir primero al bot para que funcionen las notificaciones de Telegram",
|
||||
"telegramBotHelpAfter": "para iniciar un chat",
|
||||
"clickHere": "Haz clic aquí",
|
||||
"chatId": "ID de chat",
|
||||
"chatIdPlaceholder": "ID de usuario / ID de chat",
|
||||
"telegramChatIdHelpBefore": "Si no conoces tu ID de chat, inicia un chat con userinfobot y te lo enviará.",
|
||||
"telegramChatIdHelpAfter": "para iniciar un chat con userinfobot",
|
||||
"userKey": "Clave de usuario",
|
||||
"userKeyPlaceholder": "ID de usuario",
|
||||
"chatIdRequired": "El ID de chat es obligatorio",
|
||||
"chatIdInvalid": "ID de chat no válido",
|
||||
"userKeyRequired": "La clave de usuario es obligatoria",
|
||||
"targetUpdated": "Destino de notificación actualizado",
|
||||
"targetUpdateFailed": "Error al actualizar el destino de notificación: {{error}}",
|
||||
"deviceRegistered": "Dispositivo registrado correctamente para notificaciones push.",
|
||||
"deviceLimitTitle": "Límite de dispositivos alcanzado",
|
||||
"deviceLimitMessage": "Has alcanzado el máximo de 5 dispositivos registrados. Elimina uno antes de registrar este.",
|
||||
"registrationFailedTitle": "Error de registro",
|
||||
"registrationFailedMessage": "No se pudo registrar el dispositivo automáticamente. Inténtalo de nuevo.",
|
||||
"permissionRequiredTitle": "Permiso necesario",
|
||||
"permissionRequiredMessage": "Se necesita permiso de notificaciones push para registrar este dispositivo.",
|
||||
"registrationInitiatedTitle": "Registro iniciado",
|
||||
"registrationInitiatedMessage": "Se ha iniciado el registro de notificaciones push. El dispositivo se registrará automáticamente.",
|
||||
"registerDeviceFailed": "No se pudo registrar el dispositivo. Inténtalo de nuevo.",
|
||||
"permissionDeniedTitle": "Permiso de notificaciones denegado",
|
||||
"permissionDeniedMessage": "Has denegado los permisos de notificación. Puedes activarlos más tarde en los ajustes de tu dispositivo.",
|
||||
"pushPermissionDeniedTitle": "Permiso de notificaciones push denegado",
|
||||
"pushPermissionDeniedMessage": "Se han desactivado las notificaciones push. Puedes activarlas en los ajustes de tu dispositivo si lo necesitas.",
|
||||
"unregisterFailed": "No se pudo dar de baja el dispositivo"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Autenticación multifactor",
|
||||
"description": "Añade una capa extra de seguridad a tu cuenta con la autenticación multifactor (MFA). Cuando está activada, al iniciar sesión tendrás que introducir un código de verificación de tu app de autenticación además de tu contraseña.",
|
||||
"twoFactor": "Autenticación en dos pasos",
|
||||
"enabledSubtitle": "Tu cuenta está protegida con 2FA",
|
||||
"disabledSubtitle": "Protege tu cuenta con una app de autenticación",
|
||||
"enable": "Activar",
|
||||
"disable": "Desactivar",
|
||||
"enabledSuccess": "¡La MFA se ha activado correctamente!",
|
||||
"disabledSuccess": "¡La MFA se ha desactivado correctamente!",
|
||||
"errors": {
|
||||
"qrGenerationFailed": "No se pudo generar el código QR",
|
||||
"invalidResponse": "Respuesta del servidor no válida. Falta el código QR o la clave.",
|
||||
"notFound": "No se encontró el endpoint de configuración de MFA. Puede que esta función aún no esté disponible.",
|
||||
"unauthorized": "No autorizado. Inicia sesión de nuevo.",
|
||||
"serverError": "Error del servidor. Inténtalo más tarde.",
|
||||
"setupFailed": "No se pudo configurar la MFA ({{status}}). Inténtalo de nuevo.",
|
||||
"networkError": "Error de red. Comprueba tu conexión e inténtalo de nuevo.",
|
||||
"invalidCode": "Código de verificación no válido. Inténtalo de nuevo.",
|
||||
"confirmFailed": "No se pudo confirmar la MFA. Inténtalo de nuevo.",
|
||||
"disableFailed": "No se pudo desactivar la MFA. Inténtalo de nuevo."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Configurar la autenticación multifactor",
|
||||
"addedAccount": "Ya he añadido la cuenta",
|
||||
"back": "Atrás",
|
||||
"verifyAndEnable": "Verificar y activar",
|
||||
"savedBackupCodes": "He guardado mis códigos de respaldo",
|
||||
"step1Label": "Paso 1:",
|
||||
"step1": "Escanea el código QR de abajo con tu app de autenticación (Google Authenticator, Authy, etc.)",
|
||||
"qrAlt": "Código QR de MFA",
|
||||
"qrFailed": "No se pudo generar el código QR. Inténtalo de nuevo o usa la clave manual de abajo.",
|
||||
"manualKey": "Clave de introducción manual:",
|
||||
"step2Label": "Paso 2:",
|
||||
"step2": "Introduce el código de verificación de 6 dígitos de tu app de autenticación",
|
||||
"codePlaceholder": "Introduce el código de 6 dígitos",
|
||||
"successTitle": "¡MFA activada correctamente!",
|
||||
"backupCodesTitle": "Guarda estos códigos de respaldo en un lugar seguro",
|
||||
"backupCodesDescription": "Puedes usar estos códigos para acceder a tu cuenta si pierdes tu dispositivo de autenticación. Cada código solo se puede usar una vez."
|
||||
},
|
||||
"disableModal": {
|
||||
"title": "Desactivar la autenticación multifactor",
|
||||
"warning": "Desactivar la MFA hará que tu cuenta sea menos segura. ¿Seguro que quieres continuar?",
|
||||
"prompt": "Introduce un código de verificación de tu app de autenticación para confirmar:",
|
||||
"confirm": "Desactivar MFA"
|
||||
},
|
||||
"backupCodesModal": {
|
||||
"title": "Nuevos códigos de respaldo",
|
||||
"warning": "Tus códigos de respaldo anteriores ya no son válidos. Guarda estos nuevos códigos en un lugar seguro. Cada código solo se puede usar una vez."
|
||||
}
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "Tokens de API",
|
||||
"accessToken": "Token de acceso",
|
||||
"description": "Crea un token para usar con la API y actualizar cosas que activan tareas",
|
||||
"plusNotice": "Los tokens de API no están disponibles en el plan Básico. Mejora a Plus para generar tokens de API e integrarte con sistemas externos y automatizar tus tareas.",
|
||||
"showToken": "Mostrar token",
|
||||
"hideToken": "Ocultar token",
|
||||
"removeTitle": "Eliminar token",
|
||||
"removeMessage": "¿Seguro que quieres eliminar {{name}}?",
|
||||
"removedTitle": "Eliminado",
|
||||
"removedMessage": "El token de API se ha eliminado",
|
||||
"tokenCopied": "Token copiado al portapapeles",
|
||||
"generateNew": "Generar nuevo token",
|
||||
"nameModalTitle": "Dale un nombre a tu nuevo token, algo con lo que lo reconozcas.",
|
||||
"generateToken": "Generar token"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Ajustes de almacenamiento",
|
||||
"serverTitle": "Uso del almacenamiento en el servidor",
|
||||
"serverDescription": "Este es el almacenamiento que usa tu cuenta en nuestros servidores (por ejemplo, archivos, imágenes y datos que has subido).",
|
||||
"usagePlaceholder": "-- MB usados / -- MB en total (--)",
|
||||
"usage": "{{used}} MB usados / {{total}} MB en total ({{percent}} %)",
|
||||
"basicPlanNotice": "El almacenamiento en el servidor no está disponible en el plan Básico. Mejora a Plus para consultar tu uso.",
|
||||
"localTitleApp": "Almacenamiento local y caché de la app",
|
||||
"localTitleBrowser": "Almacenamiento local y caché del navegador",
|
||||
"localDescription": "Son datos guardados localmente en tu navegador para un acceso más rápido. Borrarlos no afectará a tus datos del servidor, pero puede cerrar tu sesión.",
|
||||
"clearLocal": "Borrar todo el almacenamiento local y la caché",
|
||||
"clearLocalTitle": "Borrar todo el almacenamiento local",
|
||||
"clearLocalMessage": "¿Seguro que quieres borrar tu almacenamiento local y la caché? Se eliminarán todos tus datos de este navegador y tendrás que iniciar sesión de nuevo.",
|
||||
"clearAll": "Borrar todo",
|
||||
"appPreferences": "Preferencias de la app",
|
||||
"deviceOnly": "Solo en el dispositivo",
|
||||
"appPreferencesDescription": "Son preferencias y ajustes que la app guarda localmente en tu dispositivo. Al borrarlos se restablecerán los ajustes de la app y puede que se cierre tu sesión, pero no afectará a tus datos del servidor.",
|
||||
"clearPreferences": "Borrar preferencias de la app",
|
||||
"clearPreferencesTitle": "Borrar preferencias de la app",
|
||||
"clearPreferencesMessage": "¿Seguro que quieres borrar todas las preferencias de la app? Se restablecerán tus ajustes y puede que tengas que iniciar sesión de nuevo."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personalización del panel lateral",
|
||||
"heading": "Ajustes del panel lateral",
|
||||
"description": "Elige qué tarjetas aparecen en el panel lateral y en qué orden. Arrastra y suelta para reordenarlas o cambia su visibilidad.",
|
||||
"resetToDefaults": "Restablecer valores predeterminados",
|
||||
"resetHelper": "Se restablecerán la visibilidad y el orden predeterminados de todas las tarjetas.",
|
||||
"cards": {
|
||||
"welcome": {
|
||||
"name": "Cambio de usuario",
|
||||
"description": "Permite a administradores y gestores ver las tareas como otras personas"
|
||||
},
|
||||
"smartInsights": {
|
||||
"name": "Ideas inteligentes",
|
||||
"description": "Acciones rápidas basadas en tus tareas"
|
||||
},
|
||||
"assignees": {
|
||||
"name": "Tareas por responsable",
|
||||
"description": "Agrupa las tareas según a quién estén asignadas"
|
||||
},
|
||||
"calendar": {
|
||||
"name": "Vista de calendario",
|
||||
"description": "Muestra las tareas en formato de calendario"
|
||||
},
|
||||
"activities": {
|
||||
"name": "Actividad reciente",
|
||||
"description": "Muestra las tareas completadas y la actividad reciente"
|
||||
},
|
||||
"weeklyGoals": {
|
||||
"name": "Objetivos semanales",
|
||||
"description": "Muestra el progreso semanal y las estadísticas de la familia"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"title": "Preferencias de tema",
|
||||
"description": "Elige cómo se ve la aplicación. Selecciona un tema fijo o sincronízalo con tu sistema para cambiar automáticamente entre el modo claro y oscuro.",
|
||||
"themeMode": "Modo de tema",
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localización",
|
||||
"description": "Personaliza el idioma, el formato de fecha y las preferencias regionales de tu cuenta.",
|
||||
"language": "Idioma",
|
||||
"languageDescription": "Selecciona tu idioma preferido",
|
||||
"rtlNotice": "Este idioma se escribe de derecha a izquierda (RTL)",
|
||||
"dateFormat": "Formato de fecha",
|
||||
"dateFormatDescription": "Elige cómo se muestran las fechas en toda la aplicación",
|
||||
"timeFormat": "Formato de hora",
|
||||
"timeFormatDescription": "Selecciona el formato de 12 o 24 horas",
|
||||
"preview": "Vista previa: {{value}}",
|
||||
"12hour": "12 horas (a. m./p. m.)",
|
||||
"24hour": "24 horas",
|
||||
"firstDayOfWeek": "Primer día de la semana",
|
||||
"firstDayOfWeekDescription": "Elige con qué día empieza tu semana",
|
||||
"sunday": "Domingo",
|
||||
"monday": "Lunes",
|
||||
"saturday": "Sábado",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/AAAA (EE. UU.)",
|
||||
"dmy": "DD/MM/AAAA (Europa)",
|
||||
"ymd": "AAAA-MM-DD (ISO)",
|
||||
"long": "Formato largo (p. ej., 1 de enero de 2024)",
|
||||
"short": "Formato corto (p. ej., 1 ene 2024)"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Ajustes avanzados",
|
||||
"description": "Configura funciones avanzadas como webhooks y actualizaciones en tiempo real para mejorar tu productividad.",
|
||||
"offlineTitle": "Modo sin conexión",
|
||||
"offlineDescription": "Sigue usando Donetick sin conexión en este dispositivo o navegador. Tus cambios se guardan localmente y se sincronizan cuando vuelvas a estar en línea.",
|
||||
"offlineToggle": "Activar el modo sin conexión",
|
||||
"offlineHelper": "Al desactivarlo se eliminan los cambios sin conexión no sincronizados y los datos guardados en este dispositivo o navegador.",
|
||||
"offlineEnabled": "Modo sin conexión activado en este dispositivo o navegador",
|
||||
"offlineDisabled": "Modo sin conexión desactivado y datos locales borrados",
|
||||
"offlineDisabledPartial": "Se desactivó el modo sin conexión, pero puede que aún queden algunos datos locales guardados",
|
||||
"offlineDisableTitle": "Desactivar el modo sin conexión",
|
||||
"offlineDisableMessage": "Al desactivar el modo sin conexión se eliminarán los cambios sin sincronizar y los datos guardados en este dispositivo o navegador. ¿Quieres continuar?",
|
||||
"offlineDisableConfirm": "Desactivar y borrar datos",
|
||||
"webhookTitle": "Integración con webhooks",
|
||||
"webhookDescription": "Los webhooks te permiten enviar notificaciones en tiempo real a otros servicios cuando ocurre algo en tu círculo. Configura una URL de webhook para recibir actualizaciones en tiempo real.",
|
||||
"webhookPlusNotice": "Las notificaciones por webhook no están disponibles en el plan Básico. Mejora a Plus para recibir actualizaciones en tiempo real por webhook.",
|
||||
"webhookToggle": "Activar webhook",
|
||||
"webhookHelper": "Activa las notificaciones por webhook para las actualizaciones de tareas y cosas.",
|
||||
"webhookURL": "URL del webhook",
|
||||
"webhookUpdated": "URL del webhook actualizada correctamente",
|
||||
"webhookUpdateFailed": "No se pudo actualizar la URL del webhook",
|
||||
"realtimeTitle": "Actualizaciones en tiempo real",
|
||||
"realtimeDescription": "Configura cómo recibes actualizaciones en vivo cuando cambian las tareas y actividades de tu círculo.",
|
||||
"realtime": {
|
||||
"toggleLabel": "Activar actualizaciones en tiempo real",
|
||||
"title": "Actualizaciones en tiempo real",
|
||||
"subtitle": "Recibe notificaciones al instante cuando se actualicen las tareas",
|
||||
"statusLabel": "Estado:",
|
||||
"basicPlan": "Las actualizaciones en tiempo real no están disponibles en el plan Básico. Mejora a Plus para recibir notificaciones instantáneas cuando se actualicen las tareas.",
|
||||
"disabled": "Las actualizaciones en tiempo real están desactivadas. Actívalas para ver los cambios en vivo cuando tú u otros miembros del círculo completéis, omitáis o modifiquéis tareas.",
|
||||
"connected": "Las actualizaciones en tiempo real funcionan. Verás los cambios en vivo cuando tú u otros miembros del círculo completéis, omitáis o modifiquéis tareas.",
|
||||
"connecting": "Conectando a las actualizaciones en tiempo real…",
|
||||
"errored": "Las actualizaciones en tiempo real están activadas pero no funcionan: {{error}}",
|
||||
"notConnected": "Las actualizaciones en tiempo real están activadas pero ahora mismo no hay conexión.",
|
||||
"basicPlanNotice": "Las actualizaciones en tiempo real no están disponibles en el plan Básico. Mejora a Plus para recibir notificaciones instantáneas cuando tú u otros miembros del círculo completéis, omitáis o modifiquéis tareas."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"logout": "Déconnexion",
|
||||
"version": "Version",
|
||||
"navigation": {
|
||||
"search": "Recherche",
|
||||
"allTasks": "Toutes les Tâches",
|
||||
"archived": "Archivées",
|
||||
"things": "Objets",
|
||||
@@ -29,5 +30,43 @@
|
||||
"activities": "Activités",
|
||||
"points": "Points",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"search": {
|
||||
"title": "Recherche",
|
||||
"placeholder": "Rechercher dans Donetick",
|
||||
"inputAriaLabel": "Rechercher des tâches, l’historique, des projets, des étiquettes et des paramètres",
|
||||
"deviceNote": "Recherche dans le contenu disponible sur cet appareil",
|
||||
"escape": "Échap",
|
||||
"recent": "Récents",
|
||||
"empty": {
|
||||
"title": "Aucune correspondance directe",
|
||||
"subtitle": "Vous pouvez tout de même filtrer la liste des tâches avec cette recherche."
|
||||
},
|
||||
"groups": {
|
||||
"tasks": "Tâches",
|
||||
"history": "Notes",
|
||||
"projects": "Projets",
|
||||
"labels": "Étiquettes",
|
||||
"people": "Personnes",
|
||||
"settings": "Paramètres",
|
||||
"actions": "Actions rapides"
|
||||
},
|
||||
"actions": {
|
||||
"quickAction": "Action rapide",
|
||||
"navigation": "Navigation",
|
||||
"createTask": "Créer une tâche",
|
||||
"viewAllTasks": "Voir toutes les tâches",
|
||||
"viewArchivedTasks": "Voir les tâches archivées",
|
||||
"openSettings": "Ouvrir les paramètres",
|
||||
"filterTasks": "Afficher les tâches correspondant à « {{query}} »",
|
||||
"filterTasksSubtitle": "Filtrer la liste des tâches"
|
||||
},
|
||||
"footer": {
|
||||
"navigate": "Naviguer",
|
||||
"open": "Ouvrir",
|
||||
"results_one": "{{count}} résultat",
|
||||
"results_other": "{{count}} résultats",
|
||||
"typeToSearch": "Saisissez du texte pour rechercher"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,27 @@
|
||||
{
|
||||
"title": "Paramètres",
|
||||
"circleSettings": {
|
||||
"title": "Paramètres du cercle",
|
||||
"description": "Votre compte est automatiquement connecté à un Cercle lorsque vous en créez un ou en rejoignez un. Invitez facilement des amis en partageant le code unique du Cercle ou le lien ci-dessous.",
|
||||
"circleCode": "Code du Cercle",
|
||||
"copyCode": "Copier le Code",
|
||||
"copyLink": "Copier le Lien",
|
||||
"codeCopied": "Code du cercle copié !",
|
||||
"linkCopied": "Lien copié !",
|
||||
"joinCircle": "Rejoindre un Cercle",
|
||||
"joinCirclePlaceholder": "Entrer le Code du Cercle",
|
||||
"join": "Rejoindre",
|
||||
"leave": "Quitter le Cercle",
|
||||
"leaveConfirmTitle": "Quitter le Cercle",
|
||||
"leaveConfirmMessage": "Êtes-vous sûr de vouloir quitter ce cercle ?",
|
||||
"circleMembers": "Membres du Cercle",
|
||||
"circleMemberRequests": "Demandes de Membres du Cercle",
|
||||
"admin": "Administrateur",
|
||||
"member": "Membre",
|
||||
"pending": "En attente",
|
||||
"accept": "Accepter",
|
||||
"reject": "Rejeter",
|
||||
"makeAdmin": "Nommer Administrateur",
|
||||
"makeMember": "Nommer Membre",
|
||||
"remove": "Supprimer",
|
||||
"webhookURL": "URL du Webhook",
|
||||
"webhookDescription": "Entrez une URL de webhook pour recevoir des notifications pour les événements du cercle",
|
||||
"webhookPlaceholder": "https://votre-url-webhook.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Paramètres du Compte",
|
||||
"subscription": "Abonnement",
|
||||
"subscriptionStatus": "Plan Actuel",
|
||||
"free": "Gratuit",
|
||||
"plus": "Plus",
|
||||
"upgrade": "Mettre à Niveau",
|
||||
"cancel": "Annuler",
|
||||
"changePassword": "Changer le Mot de Passe",
|
||||
"password": "Mot de passe",
|
||||
"dangerZone": "Zone Dangereuse",
|
||||
"dangerZoneDescription": "Une fois votre compte supprimé, il n'y a pas de retour en arrière. Veuillez être certain.",
|
||||
"deleteAccount": "Supprimer le Compte"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localisation",
|
||||
"description": "Personnalisez la langue, le format de date et les préférences régionales pour votre compte.",
|
||||
"language": "Langue",
|
||||
"languageDescription": "Sélectionnez votre langue préférée",
|
||||
"dateFormat": "Format de Date",
|
||||
"dateFormatDescription": "Choisissez comment les dates doivent être affichées dans l'application",
|
||||
"timeFormat": "Format de l'Heure",
|
||||
"timeFormatDescription": "Sélectionnez le format 12 heures ou 24 heures",
|
||||
"12hour": "12 heures (AM/PM)",
|
||||
"24hour": "24 heures",
|
||||
"firstDayOfWeek": "Premier Jour de la Semaine",
|
||||
"firstDayOfWeekDescription": "Sélectionnez quel jour commence votre semaine",
|
||||
"sunday": "Dimanche",
|
||||
"monday": "Lundi",
|
||||
"saturday": "Samedi",
|
||||
"formats": {
|
||||
"mdy": "MM/JJ/AAAA (États-Unis)",
|
||||
"dmy": "JJ/MM/AAAA (Europe)",
|
||||
"ymd": "AAAA-MM-JJ (ISO)",
|
||||
"long": "Format long (ex., 1 janvier 2024)",
|
||||
"short": "Format court (ex., 1 janv. 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personnalisation du Panneau Latéral",
|
||||
"description": "Personnalisez la disposition et la visibilité des cartes dans le panneau latéral. Cette section n'est disponible que sur les grands écrans comme les tablettes et les ordinateurs de bureau."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Préférences de thème",
|
||||
"description": "Choisissez comment le site vous apparaît. Sélectionnez un thème unique ou synchronisez avec votre système pour basculer automatiquement entre les thèmes jour et nuit.",
|
||||
"themeMode": "Mode de thème",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "Paramètres enregistrés avec succès",
|
||||
"settingsSaveFailed": "Échec de l'enregistrement des paramètres",
|
||||
"invalidWebhook": "URL de webhook invalide"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Paramètres du Profil",
|
||||
"description": "Mettez à jour votre nom d'affichage et votre photo de profil.",
|
||||
"photoUpdated": "Photo Mise à Jour",
|
||||
"photoUpdatedMessage": "Votre photo de profil a été mise à jour avec succès !",
|
||||
"uploadFailed": "Échec du Téléchargement",
|
||||
"uploadFailedMessage": "Échec du téléchargement de votre photo. Veuillez réessayer.",
|
||||
"profileUpdated": "Profil Mis à Jour",
|
||||
"profileUpdatedMessage": "Vos informations de profil ont été enregistrées avec succès !",
|
||||
"updateFailed": "Échec de la Mise à Jour",
|
||||
"updateFailedMessage": "Impossible de mettre à jour votre profil. Veuillez vérifier votre connexion et réessayer.",
|
||||
"changePhoto": "Changer la Photo",
|
||||
"displayName": "Nom d'Affichage",
|
||||
"displayNamePlaceholder": "Entrez votre nom d'affichage",
|
||||
"timezone": "Fuseau Horaire",
|
||||
"timezonePlaceholder": "Sélectionnez votre fuseau horaire",
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"remove": "Retirer",
|
||||
"delete": "Supprimer",
|
||||
"refresh": "Actualiser",
|
||||
"loading": "Chargement…",
|
||||
"on": "Activé",
|
||||
"off": "Désactivé",
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
"plusFeature": "Fonctionnalité Plus",
|
||||
"earlyAccess": "Accès anticipé"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Paramètres",
|
||||
"subtitle": "Personnalisez votre expérience et gérez les préférences de votre compte",
|
||||
"upgrade": {
|
||||
"title": "Passer à Plus",
|
||||
"description": "Débloquez des fonctionnalités puissantes pour améliorer votre productivité",
|
||||
"button": "Mettre à Niveau Maintenant",
|
||||
"description": "Débloquez des fonctionnalités puissantes pour gagner en productivité",
|
||||
"button": "Passer à Plus",
|
||||
"features": {
|
||||
"richText": "Descriptions en texte enrichi",
|
||||
"notifications": "Notifications de tâches",
|
||||
@@ -118,57 +31,464 @@
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Paramètres du Profil",
|
||||
"description": "Mettez à jour vos informations de profil, photo, nom d'affichage et préférences de fuseau horaire."
|
||||
"title": "Paramètres du profil",
|
||||
"description": "Mettez à jour vos informations de profil, votre photo, votre nom affiché et votre fuseau horaire."
|
||||
},
|
||||
"circle": {
|
||||
"title": "Paramètres du Cercle",
|
||||
"description": "Gérez votre cercle, invitez des membres et gérez les demandes d'adhésion."
|
||||
"title": "Paramètres du cercle",
|
||||
"description": "Gérez votre cercle, invitez des membres et traitez les demandes d'adhésion."
|
||||
},
|
||||
"account": {
|
||||
"title": "Paramètres du Compte",
|
||||
"description": "Gérez votre abonnement, changez le mot de passe et les options de suppression de compte."
|
||||
"title": "Paramètres du compte",
|
||||
"description": "Gérez votre abonnement, changez votre mot de passe et supprimez votre compte."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Comptes Gérés",
|
||||
"description": "Créez et gérez des sous-comptes pour vous connecter et accomplir les tâches assignées."
|
||||
"title": "Comptes gérés",
|
||||
"description": "Créez et gérez des sous-comptes pouvant se connecter et accomplir les tâches assignées."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Configurez les notifications push, les alertes par e-mail et les cibles de notification pour les tâches."
|
||||
"description": "Configurez les notifications push, les alertes par e-mail et les destinations de notification des tâches."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Authentification Multi-Facteurs",
|
||||
"description": "Ajoutez une couche de sécurité supplémentaire avec MFA en utilisant des applications d'authentification."
|
||||
"title": "Authentification multifacteur",
|
||||
"description": "Ajoutez une couche de sécurité supplémentaire avec la MFA via une application d'authentification."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "Jetons API",
|
||||
"title": "Jetons d'API",
|
||||
"description": "Générez et gérez des jetons d'accès pour les intégrations tierces et l'accès à l'API."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Paramètres de Stockage",
|
||||
"description": "Sauvegardez et restaurez vos données, gérez le stockage local et les préférences de synchronisation."
|
||||
"title": "Paramètres de stockage",
|
||||
"description": "Sauvegardez et restaurez vos données, gérez le stockage local et la synchronisation."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personnalisation du Panneau Latéral",
|
||||
"description": "Personnalisez la disposition et la visibilité des cartes dans l'interface du panneau latéral."
|
||||
"title": "Personnalisation du panneau latéral",
|
||||
"description": "Personnalisez la disposition et la visibilité des cartes du panneau latéral."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Préférences de Thème",
|
||||
"description": "Choisissez votre thème préféré et configurez les paramètres de mode sombre/clair."
|
||||
"title": "Préférences de thème",
|
||||
"description": "Choisissez votre thème préféré et configurez le mode clair/sombre."
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localisation",
|
||||
"description": "Personnalisez la langue, le format de date, le format de l'heure et les préférences régionales."
|
||||
"description": "Personnalisez la langue, le format de date et d'heure et les préférences régionales."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Paramètres Avancés",
|
||||
"description": "Configurez les webhooks, les mises à jour en temps réel et d'autres fonctionnalités avancées pour améliorer la productivité."
|
||||
"title": "Paramètres avancés",
|
||||
"description": "Configurez les webhooks, les mises à jour en temps réel et d'autres fonctionnalités avancées."
|
||||
},
|
||||
"developer": {
|
||||
"title": "Paramètres Développeur",
|
||||
"title": "Paramètres développeur",
|
||||
"description": "Consultez les informations techniques sur les jetons d'authentification, les connexions SSE et les données de débogage."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Envoyer un retour",
|
||||
"description": "Dites-nous ce que vous pensez de Donetick ou demandez une fonctionnalité."
|
||||
},
|
||||
"bugReport": {
|
||||
"title": "Signaler un bug",
|
||||
"description": "Quelque chose ne fonctionne pas ? Envoyez-nous les détails avec un instantané technique."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Paramètres du profil",
|
||||
"description": "Mettez à jour votre nom affiché et votre photo de profil.",
|
||||
"photoUpdated": "Photo mise à jour",
|
||||
"photoUpdatedMessage": "Votre photo de profil a été mise à jour avec succès !",
|
||||
"uploadFailed": "Échec de l'envoi",
|
||||
"uploadFailedMessage": "Impossible d'envoyer votre photo. Veuillez réessayer.",
|
||||
"profileUpdated": "Profil mis à jour",
|
||||
"profileUpdatedMessage": "Les informations de votre profil ont été enregistrées avec succès !",
|
||||
"updateFailed": "Échec de la mise à jour",
|
||||
"updateFailedMessage": "Impossible de mettre à jour votre profil. Vérifiez votre connexion et réessayez.",
|
||||
"changePhoto": "Changer de photo",
|
||||
"editPhoto": "Modifier la photo de profil",
|
||||
"displayName": "Nom affiché",
|
||||
"displayNamePlaceholder": "Saisissez votre nom affiché",
|
||||
"timezone": "Fuseau horaire",
|
||||
"timezonePlaceholder": "Sélectionnez votre fuseau horaire",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"circleSettings": {
|
||||
"title": "Paramètres du cercle",
|
||||
"description": "Votre compte est automatiquement rattaché à un cercle lorsque vous en créez un ou que vous en rejoignez un. Invitez facilement vos proches en partageant le code ou le lien du cercle ci-dessous. Vous recevrez une notification ici lorsque quelqu'un demandera à rejoindre votre cercle.",
|
||||
"memberOf": "Vous faites partie de {{name}}",
|
||||
"yourCircleCode": "Votre code de cercle est :",
|
||||
"copyCode": "Copier le code",
|
||||
"shareInvite": "Partager l'invitation",
|
||||
"codeCopied": "Code copié dans le presse-papiers",
|
||||
"linkCopied": "Lien d'invitation copié dans le presse-papiers",
|
||||
"myCircle": "mon cercle",
|
||||
"shareTitle": "Rejoignez {{name}} sur Donetick",
|
||||
"shareText": "J'aimerais vous inviter à rejoindre {{name}} sur Donetick.",
|
||||
"shareDialogTitle": "Partager l'invitation au cercle",
|
||||
"leave": "Quitter le cercle",
|
||||
"leaveConfirmTitle": "Quitter le cercle",
|
||||
"leaveConfirmMessage": "Voulez-vous vraiment quitter votre cercle ?",
|
||||
"leaveConfirmButton": "Quitter",
|
||||
"leftCircle": "Vous avez quitté le cercle",
|
||||
"leaveFailed": "Impossible de quitter le cercle",
|
||||
"circleMembers": "Membres du cercle",
|
||||
"you": "(Vous)",
|
||||
"pendingApproval": "En attente d'approbation",
|
||||
"joinedOn": "A rejoint le {{date}}",
|
||||
"requestedToJoin": "Demande d'adhésion {{date}}",
|
||||
"roles": {
|
||||
"member": "Membre",
|
||||
"memberDescription": "Un membre ordinaire du cercle",
|
||||
"manager": "Gestionnaire",
|
||||
"managerDescription": "Peut se faire passer pour d'autres personnes et agir en leur nom",
|
||||
"admin": "Administrateur",
|
||||
"adminDescription": "Accès complet au cercle"
|
||||
},
|
||||
"roleUpdateFailed": "Impossible de mettre à jour le rôle",
|
||||
"removeMemberTitle": "Retirer le membre",
|
||||
"removeMemberMessage": "Voulez-vous vraiment retirer {{name}} de votre cercle ?",
|
||||
"memberRemoved": "Membre retiré avec succès",
|
||||
"circleMemberRequests": "Demandes d'adhésion au cercle",
|
||||
"lastUpdated": "Dernière mise à jour : {{time}}",
|
||||
"refreshing": "Actualisation…",
|
||||
"refreshFailed": "Impossible d'actualiser les demandes d'adhésion",
|
||||
"wantsToJoin": "{{name}} souhaite rejoindre votre cercle.",
|
||||
"accept": "Accepter",
|
||||
"acceptRequestTitle": "Accepter la demande d'adhésion",
|
||||
"acceptRequestMessage": "Voulez-vous vraiment accepter {{name}} (identifiant : {{username}}) dans votre cercle ?",
|
||||
"requestAccepted": "Demande acceptée avec succès",
|
||||
"or": "ou",
|
||||
"joinOtherDescription": "Vous souhaitez rejoindre le cercle de quelqu'un d'autre ? Demandez-lui son code de cercle ou son lien d'invitation. Saisissez le code ci-dessous pour le rejoindre.",
|
||||
"enterCircleCode": "Saisissez le code du cercle :",
|
||||
"enterCodePlaceholder": "Saisir le code",
|
||||
"joinCircle": "Rejoindre le cercle",
|
||||
"joinedPending": "Cercle rejoint avec succès, attendez que la personne propriétaire accepte votre demande.",
|
||||
"alreadyMember": "Vous êtes déjà membre de ce cercle",
|
||||
"joinFailed": "Impossible de rejoindre le cercle"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Paramètres du compte",
|
||||
"description": "Modifiez les paramètres de votre compte, votre formule ou votre mot de passe",
|
||||
"accountType": "Type de compte : {{type}}",
|
||||
"free": "Gratuit",
|
||||
"plus": "Plus",
|
||||
"plusUntil": "Plus (jusqu'au {{date}})",
|
||||
"activeDescription": "Vous êtes actuellement abonné à la formule Plus. Votre abonnement sera renouvelé le {{date}}.",
|
||||
"cancelledDescription": "Vous avez résilié votre abonnement. Votre compte repassera à la formule Gratuite le {{date}}.",
|
||||
"freeDescription": "Vous utilisez actuellement la formule Gratuite. Passez à Plus pour débloquer plus de fonctionnalités.",
|
||||
"upgrade": "Passer à Plus",
|
||||
"cancel": "Résilier",
|
||||
"password": "Mot de passe :",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"passwordChanged": "Mot de passe modifié avec succès",
|
||||
"passwordChangeFailed": "Échec du changement de mot de passe",
|
||||
"dangerZone": "Zone sensible",
|
||||
"dangerZoneDescription": "Une fois votre compte supprimé, il n'y a pas de retour en arrière. Soyez-en bien sûr.",
|
||||
"deleteAccount": "Supprimer le compte",
|
||||
"accountDeleted": "Compte supprimé avec succès",
|
||||
"subscriptionCancelled": "Abonnement résilié",
|
||||
"subscriptionCancelFailed": "Impossible de résilier l'abonnement",
|
||||
"purchase": {
|
||||
"success": "Achat réussi ! Redémarrez l'application pour accéder aux fonctionnalités Plus.",
|
||||
"storeConnection": "Problème de connexion au store. Vérifiez votre réseau et réessayez.",
|
||||
"notAllowed": "Les achats ne sont pas autorisés sur cet appareil. Vérifiez les restrictions de votre appareil.",
|
||||
"unavailable": "Cet abonnement n'est pas disponible. Veuillez réessayer plus tard.",
|
||||
"alreadyProcessed": "Cet achat a déjà été traité. Si vous pensez qu'il s'agit d'une erreur, contactez le support.",
|
||||
"receiptMissing": "Reçu d'achat manquant. Veuillez réessayer l'achat.",
|
||||
"networkError": "Erreur réseau. Vérifiez votre connexion et réessayez.",
|
||||
"invalidReceipt": "Reçu d'achat invalide. Contactez le support si le problème persiste.",
|
||||
"pending": "Le paiement est en attente d'approbation. Vous aurez accès dès qu'il sera approuvé.",
|
||||
"failed": "Échec de l'achat : {{error}}. Réessayez ou contactez le support.",
|
||||
"unknownError": "Erreur inconnue"
|
||||
}
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Comptes gérés",
|
||||
"description": "Gérez les sous-comptes. Leurs utilisateurs peuvent se connecter et accomplir les tâches qui leur sont assignées.",
|
||||
"notParentTitle": "Gestion des sous-comptes",
|
||||
"notParentMessage": "Seuls les comptes principaux peuvent gérer des sous-comptes.",
|
||||
"freePlanNotice": "La formule Gratuite permet 1 sous-compte. Passez à Plus pour en avoir jusqu'à 5.",
|
||||
"count": "Sous-comptes ({{count}})",
|
||||
"add": "Ajouter un sous-compte",
|
||||
"loading": "Chargement des sous-comptes…",
|
||||
"emptyTitle": "Aucun sous-compte",
|
||||
"emptyDescription": "Créez des sous-comptes pour que les membres de l'équipe puissent se connecter et accomplir leurs tâches.",
|
||||
"addFirst": "Ajouter votre premier sous-compte",
|
||||
"username": "Identifiant : {{username}}",
|
||||
"created": "Créé le : {{date}}",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"deleteAccount": "Supprimer le compte",
|
||||
"createdSuccess": "Le sous-compte « {{name}} » a été créé avec succès !",
|
||||
"createFailed": "Impossible de créer le sous-compte : {{error}}",
|
||||
"createFailedGeneric": "Impossible de créer le sous-compte",
|
||||
"passwordUpdated": "Mot de passe du sous-compte mis à jour avec succès",
|
||||
"passwordUpdateFailed": "Impossible de mettre à jour le mot de passe : {{error}}",
|
||||
"passwordUpdateFailedGeneric": "Impossible de mettre à jour le mot de passe",
|
||||
"deleteConfirmTitle": "Supprimer le sous-compte",
|
||||
"deleteConfirmMessage": "Voulez-vous vraiment supprimer le sous-compte « {{name}} » ? Cette action est irréversible.",
|
||||
"deleted": "Sous-compte « {{name}} » supprimé avec succès",
|
||||
"deleteFailed": "Impossible de supprimer le sous-compte : {{error}}",
|
||||
"deleteFailedGeneric": "Impossible de supprimer le sous-compte",
|
||||
"howItWorksTitle": "Comment fonctionnent les comptes gérés",
|
||||
"howItWorks1": "Les comptes gérés sont créés par le compte principal ; ils sont destinés aux personnes dont vous voulez pouvoir supprimer le compte et réinitialiser le mot de passe.",
|
||||
"howItWorks2": "Les sous-comptes peuvent se connecter avec leur propre identifiant et mot de passe.",
|
||||
"howItWorks3": "Les comptes gérés peuvent accomplir des tâches mais disposent de permissions administratives limitées",
|
||||
"howItWorks4": "Les comptes gérés sont automatiquement ajoutés à votre cercle"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Paramètres de notification",
|
||||
"deviceSection": "Notifications de l'appareil",
|
||||
"deviceSectionDescription": "Gérez les notifications de votre appareil",
|
||||
"deviceLabel": "Notification de l'appareil",
|
||||
"deviceHelper": "Recevez une notification sur votre appareil lorsqu'une tâche arrive à échéance",
|
||||
"mobileOnly": "Cette fonctionnalité n'est disponible que sur mobile",
|
||||
"testNotification": "Notification de test",
|
||||
"testNotificationBody": "Une tâche arrive bientôt à échéance",
|
||||
"dueTitle": "Notification d'échéance",
|
||||
"dueLabel": "Notification lorsque la tâche arrive à échéance",
|
||||
"preDueTitle": "Notification avant échéance",
|
||||
"preDueLabel": "Notification quelques heures avant l'échéance de la tâche",
|
||||
"overdueTitle": "Notification de retard",
|
||||
"overdueLabel": "Notification lorsque la tâche est en retard",
|
||||
"pushLabel": "Notifications push",
|
||||
"pushHelper": "Recevez les rappels, annonces et attributions de tâches par notification push",
|
||||
"registeredDevices": "Appareils enregistrés ({{count}}/5)",
|
||||
"registeredDevicesDescription": "Appareils enregistrés pour recevoir les notifications push de votre compte",
|
||||
"currentDevice": "Appareil actuel : {{platform}} {{model}}",
|
||||
"currentDeviceNotRegistered": "Cet appareil n'est pas enregistré pour les notifications push",
|
||||
"limitReached": "Limite atteinte",
|
||||
"registerDevice": "Enregistrer l'appareil",
|
||||
"unknownDevice": "Appareil inconnu",
|
||||
"deviceCreatedAt": "Créé le : {{date}}",
|
||||
"noDevices": "Aucun appareil enregistré pour les notifications push",
|
||||
"customSection": "Notification personnalisée",
|
||||
"customSectionDescription": "Notification via une autre plateforme comme Telegram ou Pushover",
|
||||
"customLabel": "Notification personnalisée",
|
||||
"customHelper": "Recevoir les notifications sur une autre plateforme",
|
||||
"targetNone": "Aucune",
|
||||
"targetTelegram": "Telegram",
|
||||
"targetPushover": "Pushover",
|
||||
"targetWebhooks": "Webhooks",
|
||||
"telegramBotHelpBefore": "Vous devez d'abord envoyer un message au bot pour que les notifications Telegram fonctionnent",
|
||||
"telegramBotHelpAfter": "pour démarrer une conversation",
|
||||
"clickHere": "Cliquez ici",
|
||||
"chatId": "ID de discussion",
|
||||
"chatIdPlaceholder": "ID utilisateur / ID de discussion",
|
||||
"telegramChatIdHelpBefore": "Si vous ne connaissez pas votre ID de discussion, démarrez une conversation avec userinfobot : il vous l'enverra.",
|
||||
"telegramChatIdHelpAfter": "pour démarrer une conversation avec userinfobot",
|
||||
"userKey": "Clé utilisateur",
|
||||
"userKeyPlaceholder": "ID utilisateur",
|
||||
"chatIdRequired": "L'ID de discussion est obligatoire",
|
||||
"chatIdInvalid": "ID de discussion invalide",
|
||||
"userKeyRequired": "La clé utilisateur est obligatoire",
|
||||
"targetUpdated": "Destination de notification mise à jour",
|
||||
"targetUpdateFailed": "Erreur lors de la mise à jour de la destination de notification : {{error}}",
|
||||
"deviceRegistered": "Appareil enregistré avec succès pour les notifications push.",
|
||||
"deviceLimitTitle": "Limite d'appareils atteinte",
|
||||
"deviceLimitMessage": "Vous avez atteint la limite de 5 appareils enregistrés. Retirez un appareil avant d'enregistrer celui-ci.",
|
||||
"registrationFailedTitle": "Échec de l'enregistrement",
|
||||
"registrationFailedMessage": "Impossible d'enregistrer l'appareil automatiquement. Veuillez réessayer.",
|
||||
"permissionRequiredTitle": "Autorisation requise",
|
||||
"permissionRequiredMessage": "L'autorisation de notifications push est nécessaire pour enregistrer cet appareil.",
|
||||
"registrationInitiatedTitle": "Enregistrement lancé",
|
||||
"registrationInitiatedMessage": "L'enregistrement des notifications push a été lancé. L'appareil sera enregistré automatiquement.",
|
||||
"registerDeviceFailed": "Impossible d'enregistrer l'appareil. Veuillez réessayer.",
|
||||
"permissionDeniedTitle": "Autorisation de notification refusée",
|
||||
"permissionDeniedMessage": "Vous avez refusé les autorisations de notification. Vous pourrez les activer plus tard dans les réglages de votre appareil.",
|
||||
"pushPermissionDeniedTitle": "Autorisation de notifications push refusée",
|
||||
"pushPermissionDeniedMessage": "Les notifications push ont été désactivées. Vous pouvez les activer dans les réglages de votre appareil si besoin.",
|
||||
"unregisterFailed": "Impossible de désenregistrer l'appareil"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Authentification multifacteur",
|
||||
"description": "Renforcez la sécurité de votre compte avec l'authentification multifacteur (MFA). Une fois activée, vous devrez saisir un code de vérification de votre application d'authentification en plus de votre mot de passe lors de la connexion.",
|
||||
"twoFactor": "Authentification à deux facteurs",
|
||||
"enabledSubtitle": "Votre compte est protégé par la 2FA",
|
||||
"disabledSubtitle": "Sécurisez votre compte avec une application d'authentification",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"enabledSuccess": "La MFA a été activée avec succès !",
|
||||
"disabledSuccess": "La MFA a été désactivée avec succès !",
|
||||
"errors": {
|
||||
"qrGenerationFailed": "Impossible de générer le code QR",
|
||||
"invalidResponse": "Réponse invalide du serveur. Code QR ou clé secrète manquant.",
|
||||
"notFound": "Point de terminaison de configuration MFA introuvable. Cette fonctionnalité n'est peut-être pas encore disponible.",
|
||||
"unauthorized": "Non autorisé. Veuillez vous reconnecter.",
|
||||
"serverError": "Erreur serveur. Veuillez réessayer plus tard.",
|
||||
"setupFailed": "Échec de la configuration de la MFA ({{status}}). Veuillez réessayer.",
|
||||
"networkError": "Erreur réseau. Vérifiez votre connexion et réessayez.",
|
||||
"invalidCode": "Code de vérification invalide. Veuillez réessayer.",
|
||||
"confirmFailed": "Impossible de confirmer la MFA. Veuillez réessayer.",
|
||||
"disableFailed": "Impossible de désactiver la MFA. Veuillez réessayer."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Configurer l'authentification multifacteur",
|
||||
"addedAccount": "J'ai ajouté le compte",
|
||||
"back": "Retour",
|
||||
"verifyAndEnable": "Vérifier et activer",
|
||||
"savedBackupCodes": "J'ai enregistré mes codes de secours",
|
||||
"step1Label": "Étape 1 :",
|
||||
"step1": "Scannez le code QR ci-dessous avec votre application d'authentification (Google Authenticator, Authy, etc.)",
|
||||
"qrAlt": "Code QR MFA",
|
||||
"qrFailed": "Le code QR n'a pas pu être généré. Réessayez ou utilisez la clé de saisie manuelle ci-dessous.",
|
||||
"manualKey": "Clé de saisie manuelle :",
|
||||
"step2Label": "Étape 2 :",
|
||||
"step2": "Saisissez le code de vérification à 6 chiffres de votre application d'authentification",
|
||||
"codePlaceholder": "Saisir le code à 6 chiffres",
|
||||
"successTitle": "MFA activée avec succès !",
|
||||
"backupCodesTitle": "Conservez ces codes de secours en lieu sûr",
|
||||
"backupCodesDescription": "Ces codes vous permettent d'accéder à votre compte si vous perdez votre appareil d'authentification. Chaque code ne peut être utilisé qu'une seule fois."
|
||||
},
|
||||
"disableModal": {
|
||||
"title": "Désactiver l'authentification multifacteur",
|
||||
"warning": "Désactiver la MFA rendra votre compte moins sûr. Voulez-vous vraiment continuer ?",
|
||||
"prompt": "Saisissez un code de vérification de votre application d'authentification pour confirmer :",
|
||||
"confirm": "Désactiver la MFA"
|
||||
},
|
||||
"backupCodesModal": {
|
||||
"title": "Nouveaux codes de secours",
|
||||
"warning": "Vos anciens codes de secours ne sont plus valides. Conservez ces nouveaux codes en lieu sûr. Chaque code ne peut être utilisé qu'une seule fois."
|
||||
}
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "Jetons d'API",
|
||||
"accessToken": "Jeton d'accès",
|
||||
"description": "Créez un jeton à utiliser avec l'API pour mettre à jour les éléments qui déclenchent des tâches",
|
||||
"plusNotice": "Les jetons d'API ne sont pas disponibles dans la formule de base. Passez à Plus pour générer des jetons d'API, vous intégrer à des systèmes externes et automatiser vos tâches.",
|
||||
"showToken": "Afficher le jeton",
|
||||
"hideToken": "Masquer le jeton",
|
||||
"removeTitle": "Retirer le jeton",
|
||||
"removeMessage": "Voulez-vous vraiment retirer {{name}} ?",
|
||||
"removedTitle": "Retiré",
|
||||
"removedMessage": "Le jeton d'API a été retiré",
|
||||
"tokenCopied": "Jeton copié dans le presse-papiers",
|
||||
"generateNew": "Générer un nouveau jeton",
|
||||
"nameModalTitle": "Donnez un nom à votre nouveau jeton, quelque chose qui vous permettra de le reconnaître.",
|
||||
"generateToken": "Générer le jeton"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Paramètres de stockage",
|
||||
"serverTitle": "Utilisation du stockage serveur",
|
||||
"serverDescription": "Il s'agit de l'espace occupé par votre compte sur nos serveurs (fichiers, images et données que vous avez envoyés).",
|
||||
"usagePlaceholder": "-- Mo utilisés / -- Mo au total (--)",
|
||||
"usage": "{{used}} Mo utilisés / {{total}} Mo au total ({{percent}} %)",
|
||||
"basicPlanNotice": "Le stockage serveur n'est pas disponible dans la formule de base. Passez à Plus pour suivre votre utilisation.",
|
||||
"localTitleApp": "Stockage local et cache de l'application",
|
||||
"localTitleBrowser": "Stockage local et cache du navigateur",
|
||||
"localDescription": "Ce sont les données enregistrées localement dans votre navigateur pour un accès plus rapide. Les effacer n'affectera pas vos données serveur, mais pourra vous déconnecter.",
|
||||
"clearLocal": "Effacer tout le stockage local et le cache",
|
||||
"clearLocalTitle": "Effacer tout le stockage local",
|
||||
"clearLocalMessage": "Voulez-vous vraiment effacer votre stockage local et le cache ? Toutes vos données seront supprimées de ce navigateur et vous devrez vous reconnecter.",
|
||||
"clearAll": "Tout effacer",
|
||||
"appPreferences": "Préférences de l'application",
|
||||
"deviceOnly": "Appareil uniquement",
|
||||
"appPreferencesDescription": "Ce sont les préférences et réglages enregistrés localement sur votre appareil par l'application. Les effacer réinitialisera les réglages de l'application et pourra vous déconnecter, sans affecter vos données serveur.",
|
||||
"clearPreferences": "Effacer les préférences de l'application",
|
||||
"clearPreferencesTitle": "Effacer les préférences de l'application",
|
||||
"clearPreferencesMessage": "Voulez-vous vraiment effacer toutes les préférences de l'application ? Vos réglages seront réinitialisés et vous devrez peut-être vous reconnecter."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personnalisation du panneau latéral",
|
||||
"heading": "Paramètres du panneau latéral",
|
||||
"description": "Choisissez les cartes qui apparaissent dans le panneau latéral et leur ordre. Faites-les glisser pour les réorganiser ou modifiez leur visibilité.",
|
||||
"resetToDefaults": "Réinitialiser par défaut",
|
||||
"resetHelper": "La visibilité et l'ordre par défaut de toutes les cartes seront rétablis.",
|
||||
"cards": {
|
||||
"welcome": {
|
||||
"name": "Changement d'utilisateur",
|
||||
"description": "Permet aux administrateurs et gestionnaires de voir les tâches d'autres personnes"
|
||||
},
|
||||
"smartInsights": {
|
||||
"name": "Aperçus intelligents",
|
||||
"description": "Actions rapides basées sur vos tâches"
|
||||
},
|
||||
"assignees": {
|
||||
"name": "Tâches par personne assignée",
|
||||
"description": "Regroupe les tâches selon la personne à qui elles sont assignées"
|
||||
},
|
||||
"calendar": {
|
||||
"name": "Vue calendrier",
|
||||
"description": "Affiche les tâches au format calendrier"
|
||||
},
|
||||
"activities": {
|
||||
"name": "Activités récentes",
|
||||
"description": "Affiche les tâches récemment terminées et les activités"
|
||||
},
|
||||
"weeklyGoals": {
|
||||
"name": "Objectifs hebdomadaires",
|
||||
"description": "Affiche la progression hebdomadaire et les statistiques de la famille"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"title": "Préférences de thème",
|
||||
"description": "Choisissez l'apparence du site. Sélectionnez un thème fixe ou synchronisez-le avec votre système pour basculer automatiquement entre thème clair et sombre.",
|
||||
"themeMode": "Mode de thème",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localisation",
|
||||
"description": "Personnalisez la langue, le format de date et les préférences régionales de votre compte.",
|
||||
"language": "Langue",
|
||||
"languageDescription": "Sélectionnez votre langue préférée",
|
||||
"rtlNotice": "Cette langue s'écrit de droite à gauche (RTL)",
|
||||
"dateFormat": "Format de date",
|
||||
"dateFormatDescription": "Choisissez comment les dates s'affichent dans toute l'application",
|
||||
"timeFormat": "Format d'heure",
|
||||
"timeFormatDescription": "Sélectionnez le format 12 ou 24 heures",
|
||||
"preview": "Aperçu : {{value}}",
|
||||
"12hour": "12 heures (AM/PM)",
|
||||
"24hour": "24 heures",
|
||||
"firstDayOfWeek": "Premier jour de la semaine",
|
||||
"firstDayOfWeekDescription": "Choisissez le jour qui commence votre semaine",
|
||||
"sunday": "Dimanche",
|
||||
"monday": "Lundi",
|
||||
"saturday": "Samedi",
|
||||
"formats": {
|
||||
"mdy": "MM/JJ/AAAA (États-Unis)",
|
||||
"dmy": "JJ/MM/AAAA (Europe)",
|
||||
"ymd": "AAAA-MM-JJ (ISO)",
|
||||
"long": "Format long (ex. : 1 janvier 2024)",
|
||||
"short": "Format court (ex. : 1 janv. 2024)"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Paramètres avancés",
|
||||
"description": "Configurez des fonctionnalités avancées comme les webhooks et les mises à jour en temps réel pour gagner en productivité.",
|
||||
"offlineTitle": "Mode hors ligne",
|
||||
"offlineDescription": "Continuez à utiliser Donetick hors ligne sur cet appareil ou navigateur. Vos modifications sont enregistrées localement et synchronisées dès votre retour en ligne.",
|
||||
"offlineToggle": "Activer le mode hors ligne",
|
||||
"offlineHelper": "Le désactiver supprime les modifications hors ligne non synchronisées et les données enregistrées sur cet appareil ou navigateur.",
|
||||
"offlineEnabled": "Mode hors ligne activé pour cet appareil/navigateur",
|
||||
"offlineDisabled": "Mode hors ligne désactivé et données locales effacées",
|
||||
"offlineDisabledPartial": "Le mode hors ligne a été désactivé, mais certaines données locales peuvent subsister",
|
||||
"offlineDisableTitle": "Désactiver le mode hors ligne",
|
||||
"offlineDisableMessage": "Désactiver le mode hors ligne supprimera les modifications non synchronisées et les données hors ligne enregistrées sur cet appareil ou navigateur. Voulez-vous continuer ?",
|
||||
"offlineDisableConfirm": "Désactiver et effacer les données",
|
||||
"webhookTitle": "Intégration webhook",
|
||||
"webhookDescription": "Les webhooks vous permettent d'envoyer des notifications en temps réel à d'autres services lorsqu'un événement survient dans votre cercle. Configurez une URL de webhook pour recevoir des mises à jour en temps réel.",
|
||||
"webhookPlusNotice": "Les notifications par webhook ne sont pas disponibles dans la formule de base. Passez à Plus pour recevoir des mises à jour en temps réel via webhooks.",
|
||||
"webhookToggle": "Activer le webhook",
|
||||
"webhookHelper": "Activez les notifications webhook pour les mises à jour des tâches et des éléments.",
|
||||
"webhookURL": "URL du webhook",
|
||||
"webhookUpdated": "URL du webhook mise à jour avec succès",
|
||||
"webhookUpdateFailed": "Impossible de mettre à jour l'URL du webhook",
|
||||
"realtimeTitle": "Mises à jour en temps réel",
|
||||
"realtimeDescription": "Configurez la façon dont vous recevez les mises à jour en direct lorsque les tâches et activités changent dans votre cercle.",
|
||||
"realtime": {
|
||||
"toggleLabel": "Activer les mises à jour en temps réel",
|
||||
"title": "Mises à jour en temps réel",
|
||||
"subtitle": "Recevez des notifications instantanées lorsque les tâches sont mises à jour",
|
||||
"statusLabel": "Statut :",
|
||||
"basicPlan": "Les mises à jour en temps réel ne sont pas disponibles dans la formule de base. Passez à Plus pour recevoir des notifications instantanées lorsque les tâches sont mises à jour.",
|
||||
"disabled": "Les mises à jour en temps réel sont désactivées. Activez-les pour voir les changements en direct lorsque vous ou d'autres membres du cercle terminez, passez ou modifiez des tâches.",
|
||||
"connected": "Les mises à jour en temps réel fonctionnent. Vous verrez les changements en direct lorsque vous ou d'autres membres du cercle terminez, passez ou modifiez des tâches.",
|
||||
"connecting": "Connexion aux mises à jour en temps réel…",
|
||||
"errored": "Les mises à jour en temps réel sont activées mais ne fonctionnent pas : {{error}}",
|
||||
"notConnected": "Les mises à jour en temps réel sont activées mais non connectées actuellement.",
|
||||
"basicPlanNotice": "Les mises à jour en temps réel ne sont pas disponibles dans la formule de base. Passez à Plus pour recevoir des notifications instantanées lorsque vous ou d'autres membres du cercle terminez, passez ou modifiez des tâches."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
public/locales/he/chores.json
Normal file
75
public/locales/he/chores.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"title": "מטלות",
|
||||
"myChores": "המטלות שלי",
|
||||
"allChores": "כל המטלות",
|
||||
"addChore": "הוספת מטלה",
|
||||
"editChore": "עריכת מטלה",
|
||||
"deleteChore": "מחיקת מטלה",
|
||||
"completeChore": "השלמת מטלה",
|
||||
"dueDate": "תאריך יעד",
|
||||
"assignedTo": "מוקצה ל",
|
||||
"priority": "עדיפות",
|
||||
"status": "סטטוס",
|
||||
"description": "תיאור",
|
||||
"choreView": {
|
||||
"assignment": "הקצאה",
|
||||
"assigned": "מוקצה",
|
||||
"last": "אחרון",
|
||||
"schedule": "לוח זמנים",
|
||||
"due": "מועד",
|
||||
"statistics": "סטטיסטיקה",
|
||||
"completed": "הושלם",
|
||||
"times": "פעמים",
|
||||
"details": "פרטים",
|
||||
"createdBy": "נוצר על ידי",
|
||||
"na": "לא זמין",
|
||||
"taskCompleted": "המטלה הושלמה",
|
||||
"taskCompletedMessage": "המטלה סומנה כהושלמה",
|
||||
"taskCompletionUndone": "סימון המטלה כהושלמה בוטל.",
|
||||
"taskSkipUndone": "הדילוג על המטלה בוטל.",
|
||||
"undoSuccessful": "הפעולה בוטלה בהצלחה",
|
||||
"undoFailed": "ביטול הפעולה נכשל",
|
||||
"undoFailedMessage": "לא ניתן לבטל את הפעולה. נסו שוב.",
|
||||
"resetTimer": "איפוס טיימר",
|
||||
"resetTimerConfirmation": "האם אתם בטוחים שברצונכם לאפס את הטיימר? פעולה זו תמחק את כל רישומי הזמן מאז שהתחלתם את המטלה.",
|
||||
"clearAllTimeRecords": "מחיקת כל רישומי הזמן",
|
||||
"clearAllTimeConfirmation": "פעולה זו תמחק לצמיתות את כל הטיימרים של מטלה זו ותחזיר אותה למצב \"טרם התחילה\".",
|
||||
"descriptionTitle": "תיאור",
|
||||
"description": "תיאור:",
|
||||
"previousNote": "הערה קודמת",
|
||||
"previousNoteLabel": "הערה קודמת:",
|
||||
"subtasksLabel": "תתי-משימות:",
|
||||
"taskActions": "פעולות מטלה",
|
||||
"addNote": "הוספת הערה",
|
||||
"additionalNotes": "הערות נוספות:",
|
||||
"notePlaceholder": "הוסיפו הערה לגבי השלמת המטלה...",
|
||||
"setCustomCompletionTime": "הגדרת זמן השלמה מותאם אישית",
|
||||
"skipTask": "דילוג על מטלה",
|
||||
"skipTaskConfirmation": "האם אתם בטוחים שברצונכם לדלג על מטלה זו?",
|
||||
"markComplete": "סימון כהושלמה",
|
||||
"markAsDone": "סימון כהושלמה",
|
||||
"edit": "עריכה",
|
||||
"archive": "העברה לארכיון",
|
||||
"unarchive": "הוצאה מהארכיון",
|
||||
"viewHistory": "הצגת היסטוריה",
|
||||
"history": "היסטוריה",
|
||||
"startTimer": "הפעלת טיימר",
|
||||
"start": "התחלה",
|
||||
"pauseTimer": "השהיית טיימר",
|
||||
"approve": "אישור",
|
||||
"reject": "דחייה",
|
||||
"pendingApproval": "ממתין לאישור",
|
||||
"undo": "ביטול פעולה",
|
||||
"skip": "דילוג",
|
||||
"cancel": "ביטול",
|
||||
"noPriority": "ללא עדיפות",
|
||||
"subtasks": "תתי-משימות",
|
||||
"noDescription": "אין תיאור זמין",
|
||||
"timer": {
|
||||
"active": "הטיימר פעיל",
|
||||
"paused": "הטיימר מושהה",
|
||||
"reset": "איפוס טיימר",
|
||||
"delete": "מחיקת סשן"
|
||||
}
|
||||
}
|
||||
}
|
||||
118
public/locales/he/common.json
Normal file
118
public/locales/he/common.json
Normal file
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"save": "שמור",
|
||||
"cancel": "ביטול",
|
||||
"delete": "מחיקה",
|
||||
"edit": "עריכה",
|
||||
"close": "סגירה",
|
||||
"confirm": "אישור",
|
||||
"loading": "טוען...",
|
||||
"error": "שגיאה",
|
||||
"success": "הצלחה",
|
||||
"warning": "אזהרה",
|
||||
"refresh": "רענון",
|
||||
"copy": "העתקה",
|
||||
"copied": "הועתק!",
|
||||
"settings": "הגדרות",
|
||||
"yes": "כן",
|
||||
"no": "לא",
|
||||
"back": "חזרה",
|
||||
"backToCalendar": "חזרה ללוח השנה",
|
||||
"logout": "התנתקות",
|
||||
"version": "גרסה",
|
||||
"navigation": {
|
||||
"search": "חיפוש",
|
||||
"allTasks": "כל המשימות",
|
||||
"archived": "בארכיון",
|
||||
"things": "דברים",
|
||||
"labels": "תגיות",
|
||||
"projects": "פרויקטים",
|
||||
"filters": "מסננים",
|
||||
"activities": "פעילויות",
|
||||
"points": "נקודות",
|
||||
"settings": "הגדרות"
|
||||
},
|
||||
"search": {
|
||||
"title": "חיפוש",
|
||||
"placeholder": "חיפוש ב-Donetick",
|
||||
"inputAriaLabel": "חיפוש במשימות, בהיסטוריה, בפרויקטים, בתגיות ובהגדרות",
|
||||
"deviceNote": "חיפוש בתוכן הזמין במכשיר הזה",
|
||||
"escape": "Esc",
|
||||
"recent": "חיפושים אחרונים",
|
||||
"empty": {
|
||||
"title": "אין התאמות ישירות",
|
||||
"subtitle": "עדיין תוכלו לסנן את רשימת המשימות באמצעות החיפוש הזה."
|
||||
},
|
||||
"groups": {
|
||||
"tasks": "משימות",
|
||||
"history": "היסטוריה",
|
||||
"projects": "פרויקטים",
|
||||
"labels": "תגיות",
|
||||
"people": "אנשים",
|
||||
"settings": "הגדרות",
|
||||
"actions": "פעולות מהירות"
|
||||
},
|
||||
"actions": {
|
||||
"quickAction": "פעולה מהירה",
|
||||
"navigation": "ניווט",
|
||||
"createTask": "יצירת משימה",
|
||||
"viewAllTasks": "הצגת כל המשימות",
|
||||
"viewArchivedTasks": "הצגת משימות שבארכיון",
|
||||
"openSettings": "פתיחת הגדרות",
|
||||
"filterTasks": "הצגת משימות התואמות ל־„{{query}}“",
|
||||
"filterTasksSubtitle": "סינון רשימת המשימות"
|
||||
},
|
||||
"footer": {
|
||||
"navigate": "ניווט",
|
||||
"open": "פתיחה",
|
||||
"results_one": "{{count}} תוצאה",
|
||||
"results_other": "{{count}} תוצאות",
|
||||
"typeToSearch": "הקלידו כדי לחפש"
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
"later": "אולי מאוחר יותר",
|
||||
"sentiment": {
|
||||
"title": "איך Donetick עובד עבורכם?",
|
||||
"subtitle": "התשובה שלכם עוזרת לנו להחליט מה לפתח בהמשך.",
|
||||
"options": {
|
||||
"love": "אני אוהב/ת את זה",
|
||||
"okay": "בסדר",
|
||||
"issues": "נתקלתי בבעיות"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "באגים",
|
||||
"missingFeature": "תכונה חסרה",
|
||||
"tooComplicated": "מסובך מדי",
|
||||
"slow": "איטי",
|
||||
"notifications": "התראות",
|
||||
"ai": "AI",
|
||||
"other": "אחר"
|
||||
},
|
||||
"details": {
|
||||
"title": "מה נוכל לשפר?",
|
||||
"messageLabel": "ספרו לנו עוד",
|
||||
"messagePlaceholder": "מה קרה, או מה היה הופך את השימוש לטוב יותר?",
|
||||
"contextNote": "נצרף את גרסת האפליקציה, המכשיר והפלטפורמה כדי שנוכל לשחזר בעיות.",
|
||||
"submit": "שליחת משוב",
|
||||
"contextNoteSelfHosted": "אתם משתמשים בשרת עצמאי, לכן שום דבר לא נשלח מהשרת שלכם — נפתח עבורכם דיווח GitHub מלא מראש, שתוכלו לבדוק ולערוך לפני השליחה.",
|
||||
"submitSelfHosted": "המשך ל-GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "שמחים שאתם נהנים מ-Donetick!",
|
||||
"subtitle": "דירוג או כוכב עוזרים לאנשים אחרים למצוא את Donetick.",
|
||||
"github": "מתן כוכב ב-GitHub",
|
||||
"appStore": "דירוג ב-App Store",
|
||||
"playStore": "דירוג ב-Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "תודה על המשוב",
|
||||
"subtitle": "אנחנו קוראים כל תגובה והיא עוזרת לנו להחליט על מה לעבוד בהמשך."
|
||||
},
|
||||
"github": {
|
||||
"title": "דיווח ב-GitHub",
|
||||
"subtitle": "מילאנו עבורכם דיווח עם ההערות ופרטי הגרסה. שום דבר עדיין לא נשלח — תוכלו לבדוק את הדיווח ולפרסם אותו כשתהיו מוכנים.",
|
||||
"open": "פתיחת הדיווח"
|
||||
}
|
||||
}
|
||||
}
|
||||
495
public/locales/he/settings.json
Normal file
495
public/locales/he/settings.json
Normal file
@@ -0,0 +1,495 @@
|
||||
{
|
||||
"title": "הגדרות",
|
||||
"common": {
|
||||
"save": "שמור",
|
||||
"cancel": "ביטול",
|
||||
"confirm": "אישור",
|
||||
"remove": "הסרה",
|
||||
"delete": "מחיקה",
|
||||
"refresh": "רענון",
|
||||
"loading": "טוען...",
|
||||
"on": "מופעל",
|
||||
"off": "מושבת",
|
||||
"error": "שגיאה",
|
||||
"success": "הצלחה",
|
||||
"plusFeature": "תכונת Plus",
|
||||
"earlyAccess": "גישה מוקדמת"
|
||||
},
|
||||
"overview": {
|
||||
"title": "הגדרות",
|
||||
"subtitle": "התאימו את חוויית השימוש שלכם ונהלו את העדפות החשבון",
|
||||
"upgrade": {
|
||||
"title": "שדרוג ל-Plus",
|
||||
"description": "פתחו תכונות מתקדמות לשיפור הפרודוקטיביות שלכם",
|
||||
"button": "שדרוג עכשיו",
|
||||
"features": {
|
||||
"richText": "תיאורים עם עיצוב טקסט עשיר",
|
||||
"notifications": "התראות על משימות",
|
||||
"apiIntegrations": "אינטגרציות API",
|
||||
"advancedAutomation": "אוטומציה מתקדמת"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "הגדרות פרופיל",
|
||||
"description": "עדכנו את פרטי הפרופיל, תמונת הפרופיל, שם התצוגה והעדפות אזור הזמן."
|
||||
},
|
||||
"circle": {
|
||||
"title": "הגדרות מעגל",
|
||||
"description": "נהלו את המעגל, הזמינו חברים וטפלו בבקשות להצטרפות."
|
||||
},
|
||||
"account": {
|
||||
"title": "הגדרות חשבון",
|
||||
"description": "נהלו את המינוי, שנו את הסיסמה ואפשרויות מחיקת החשבון."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "חשבונות מנוהלים",
|
||||
"description": "צרו ונהלו חשבונות מנוהלים שיכולים להתחבר ולהשלים משימות שהוקצו להם."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "התראות",
|
||||
"description": "הגדירו התראות Push, התראות בדוא״ל ויעדי התראות עבור משימות."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "אימות רב-שלבי",
|
||||
"description": "הוסיפו שכבת אבטחה נוספת באמצעות אימות רב-שלבי עם אפליקציית מאמת."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "אסימוני API",
|
||||
"description": "צרו ונהלו אסימוני גישה עבור אינטגרציות של צד שלישי וגישה ל-API."
|
||||
},
|
||||
"storage": {
|
||||
"title": "הגדרות אחסון",
|
||||
"description": "גבו ושחזרו את הנתונים שלכם, ונהלו את האחסון המקומי והעדפות הסנכרון."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "התאמת סרגל הצד",
|
||||
"description": "התאימו את הפריסה ואת הנראות של הכרטיסים בסרגל הצד."
|
||||
},
|
||||
"theme": {
|
||||
"title": "העדפות ערכת נושא",
|
||||
"description": "בחרו את ערכת הנושא המועדפת עליכם והגדירו מצב בהיר או כהה."
|
||||
},
|
||||
"localization": {
|
||||
"title": "הגדרות אזוריות",
|
||||
"description": "התאימו את השפה, תבנית התאריך, תבנית השעה וההעדפות האזוריות."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "הגדרות מתקדמות",
|
||||
"description": "הגדירו Webhooks, עדכונים בזמן אמת ותכונות מתקדמות נוספות לשיפור הפרודוקטיביות."
|
||||
},
|
||||
"developer": {
|
||||
"title": "הגדרות מפתחים",
|
||||
"description": "הציגו מידע טכני על אסימוני אימות, חיבורי SSE ונתוני ניפוי שגיאות."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "שליחת משוב",
|
||||
"description": "ספרו לנו כיצד Donetick עובד עבורכם או בקשו תכונה חדשה."
|
||||
},
|
||||
"bugReport": {
|
||||
"title": "דיווח על באג",
|
||||
"description": "משהו לא עובד כראוי? שלחו לנו את הפרטים יחד עם מידע טכני שיעזור לנו לאתר את הבעיה."
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "הגדרות פרופיל",
|
||||
"description": "עדכנו את שם התצוגה ואת תמונת הפרופיל.",
|
||||
"photoUpdated": "התמונה עודכנה",
|
||||
"photoUpdatedMessage": "תמונת הפרופיל עודכנה בהצלחה!",
|
||||
"uploadFailed": "ההעלאה נכשלה",
|
||||
"uploadFailedMessage": "לא ניתן להעלות את התמונה. נסו שוב.",
|
||||
"profileUpdated": "הפרופיל עודכן",
|
||||
"profileUpdatedMessage": "פרטי הפרופיל נשמרו בהצלחה!",
|
||||
"updateFailed": "העדכון נכשל",
|
||||
"updateFailedMessage": "לא ניתן לעדכן את הפרופיל. בדקו את החיבור ונסו שוב.",
|
||||
"changePhoto": "שינוי תמונה",
|
||||
"editPhoto": "עריכת תמונת פרופיל",
|
||||
"displayName": "שם תצוגה",
|
||||
"displayNamePlaceholder": "הזינו את שם התצוגה שלכם",
|
||||
"timezone": "אזור זמן",
|
||||
"timezonePlaceholder": "בחרו את אזור הזמן שלכם",
|
||||
"save": "שמור",
|
||||
"cancel": "ביטול"
|
||||
},
|
||||
"circleSettings": {
|
||||
"title": "הגדרות מעגל",
|
||||
"description": "החשבון שלכם מחובר אוטומטית למעגל כשאתם יוצרים מעגל או מצטרפים אליו. תוכלו להזמין בקלות חברים על ידי שיתוף קוד המעגל הייחודי או הקישור שלמטה. תקבלו כאן התראה כשמישהו יבקש להצטרף למעגל שלכם.",
|
||||
"memberOf": "אתם חלק מ-{{name}}",
|
||||
"yourCircleCode": "קוד המעגל שלכם:",
|
||||
"copyCode": "העתקת קוד",
|
||||
"shareInvite": "שיתוף הזמנה",
|
||||
"codeCopied": "הקוד הועתק ללוח",
|
||||
"linkCopied": "קישור ההזמנה הועתק ללוח",
|
||||
"myCircle": "המעגל שלי",
|
||||
"shareTitle": "הצטרפו ל-{{name}} ב-Donetick",
|
||||
"shareText": "אני רוצה להזמין אתכם להצטרף ל-{{name}} ב-Donetick.",
|
||||
"shareDialogTitle": "שיתוף הזמנה למעגל",
|
||||
"leave": "עזיבת המעגל",
|
||||
"leaveConfirmTitle": "עזיבת המעגל",
|
||||
"leaveConfirmMessage": "האם אתם בטוחים שברצונכם לעזוב את המעגל?",
|
||||
"leaveConfirmButton": "עזיבה",
|
||||
"leftCircle": "עזבתם את המעגל בהצלחה",
|
||||
"leaveFailed": "עזיבת המעגל נכשלה",
|
||||
"circleMembers": "חברי המעגל",
|
||||
"you": "(אתם)",
|
||||
"pendingApproval": "ממתין לאישור",
|
||||
"joinedOn": "הצטרפות בתאריך {{date}}",
|
||||
"requestedToJoin": "בקשת הצטרפות בתאריך {{date}}",
|
||||
"roles": {
|
||||
"member": "חבר",
|
||||
"memberDescription": "חבר רגיל במעגל",
|
||||
"manager": "מנהל",
|
||||
"managerDescription": "יכול לפעול בשם משתמשים אחרים ולבצע פעולות מטעמם",
|
||||
"admin": "מנהל מערכת",
|
||||
"adminDescription": "גישה מלאה למעגל"
|
||||
},
|
||||
"roleUpdateFailed": "עדכון התפקיד נכשל",
|
||||
"removeMemberTitle": "הסרת חבר",
|
||||
"removeMemberMessage": "האם אתם בטוחים שברצונכם להסיר את {{name}} מהמעגל?",
|
||||
"memberRemoved": "החבר הוסר בהצלחה",
|
||||
"circleMemberRequests": "בקשות הצטרפות למעגל",
|
||||
"lastUpdated": "עודכן לאחרונה: {{time}}",
|
||||
"refreshing": "מרענן...",
|
||||
"refreshFailed": "רענון בקשות החברים נכשל",
|
||||
"wantsToJoin": "{{name}} רוצה להצטרף למעגל שלכם.",
|
||||
"accept": "קבלה",
|
||||
"acceptRequestTitle": "אישור בקשת חבר",
|
||||
"acceptRequestMessage": "האם אתם בטוחים שברצונכם לאשר את {{name}} (שם משתמש: {{username}}) להצטרף למעגל?",
|
||||
"requestAccepted": "הבקשה אושרה בהצלחה",
|
||||
"or": "או",
|
||||
"joinOtherDescription": "רוצים להצטרף למעגל של מישהו אחר? בקשו ממנו את קוד המעגל הייחודי או את קישור ההצטרפות. הזינו את הקוד למטה כדי להצטרף למעגל.",
|
||||
"enterCircleCode": "הזינו את קוד המעגל:",
|
||||
"enterCodePlaceholder": "הזינו קוד",
|
||||
"joinCircle": "הצטרפות למעגל",
|
||||
"joinedPending": "הצטרפתם למעגל בהצלחה. המתינו לאישור של בעל המעגל.",
|
||||
"alreadyMember": "אתם כבר חברים במעגל הזה",
|
||||
"joinFailed": "ההצטרפות למעגל נכשלה"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "הגדרות חשבון",
|
||||
"description": "שנו את הגדרות החשבון, או עדכנו את הסיסמה שלכם",
|
||||
"accountType": "סוג חשבון: {{type}}",
|
||||
"free": "חינמי",
|
||||
"plus": "Plus",
|
||||
"plusUntil": "Plus (עד {{date}})",
|
||||
"activeDescription": "אתם מנויים כרגע לתוכנית Plus. המינוי שלכם יתחדש בתאריך {{date}}.",
|
||||
"cancelledDescription": "ביטלתם את המינוי. החשבון שלכם יעבור לתוכנית Free בתאריך {{date}}.",
|
||||
"freeDescription": "אתם משתמשים כרגע בתוכנית Free. שדרגו ל-Plus כדי לפתוח תכונות נוספות.",
|
||||
"upgrade": "שדרוג",
|
||||
"cancel": "ביטול",
|
||||
"password": "סיסמה:",
|
||||
"changePassword": "שינוי סיסמה",
|
||||
"passwordChanged": "הסיסמה שונתה בהצלחה",
|
||||
"passwordChangeFailed": "שינוי הסיסמה נכשל",
|
||||
"dangerZone": "אזור מסוכן",
|
||||
"dangerZoneDescription": "לאחר מחיקת החשבון לא ניתן לבטל את הפעולה. ודאו שאתם בטוחים לפני שתמשיכו.",
|
||||
"deleteAccount": "מחיקת חשבון",
|
||||
"accountDeleted": "החשבון נמחק בהצלחה",
|
||||
"subscriptionCancelled": "המינוי בוטל",
|
||||
"subscriptionCancelFailed": "ביטול המינוי נכשל",
|
||||
"purchase": {
|
||||
"success": "הרכישה בוצעה בהצלחה! הפעילו מחדש את האפליקציה כדי לגשת לתכונות Plus.",
|
||||
"storeConnection": "בעיה בחיבור לחנות. בדקו את הרשת ונסו שוב.",
|
||||
"notAllowed": "רכישות אינן מותרות במכשיר זה. בדקו את הגבלות המכשיר.",
|
||||
"unavailable": "המינוי הזה אינו זמין. נסו שוב מאוחר יותר.",
|
||||
"alreadyProcessed": "הרכישה הזו כבר טופלה. אם לדעתכם מדובר בשגיאה, פנו לתמיכה.",
|
||||
"receiptMissing": "קבלת הרכישה חסרה. נסו לבצע את הרכישה שוב.",
|
||||
"networkError": "שגיאת רשת. בדקו את החיבור ונסו שוב.",
|
||||
"invalidReceipt": "קבלת רכישה לא תקינה. אם הבעיה נמשכת, פנו לתמיכה.",
|
||||
"pending": "התשלום ממתין לאישור. תקבלו גישה לאחר האישור.",
|
||||
"failed": "הרכישה נכשלה: {{error}}. נסו שוב או פנו לתמיכה.",
|
||||
"unknownError": "שגיאה לא ידועה"
|
||||
}
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "חשבונות מנוהלים",
|
||||
"description": "נהלו חשבונות מנוהלים. משתמשים בחשבונות מנוהלים יכולים להתחבר ולהשלים משימות שהוקצו להם.",
|
||||
"notParentTitle": "ניהול חשבונות מנוהלים",
|
||||
"notParentMessage": "רק משתמשים ראשיים יכולים לנהל חשבונות מנוהלים.",
|
||||
"freePlanNotice": "בתוכנית Free ניתן ליצור חשבון מנוהל אחד בלבד. שדרגו ל-Plus כדי ליצור עד 5 חשבונות מנוהלים.",
|
||||
"count": "חשבונות מנוהלים ({{count}})",
|
||||
"add": "הוספת חשבון מנוהל",
|
||||
"loading": "טוען חשבונות מנוהלים...",
|
||||
"emptyTitle": "אין חשבונות מנוהלים",
|
||||
"emptyDescription": "צרו חשבונות מנוהלים כדי שמשתמשים אחרים יוכלו להתחבר ולהשלים את המשימות שהוקצו להם.",
|
||||
"addFirst": "הוספת החשבון המנוהל הראשון",
|
||||
"username": "שם משתמש: {{username}}",
|
||||
"created": "נוצר בתאריך: {{date}}",
|
||||
"changePassword": "שינוי סיסמה",
|
||||
"deleteAccount": "מחיקת חשבון",
|
||||
"createdSuccess": "החשבון המנוהל \"{{name}}\" נוצר בהצלחה!",
|
||||
"createFailed": "יצירת החשבון המנוהל נכשלה: {{error}}",
|
||||
"createFailedGeneric": "יצירת החשבון המנוהל נכשלה",
|
||||
"passwordUpdated": "הסיסמה של החשבון המנוהל עודכנה בהצלחה",
|
||||
"passwordUpdateFailed": "עדכון הסיסמה נכשל: {{error}}",
|
||||
"passwordUpdateFailedGeneric": "עדכון הסיסמה נכשל",
|
||||
"deleteConfirmTitle": "מחיקת חשבון מנוהל",
|
||||
"deleteConfirmMessage": "האם אתם בטוחים שברצונכם למחוק את החשבון המנוהל \"{{name}}\"? לא ניתן לבטל פעולה זו.",
|
||||
"deleted": "החשבון המנוהל \"{{name}}\" נמחק בהצלחה",
|
||||
"deleteFailed": "מחיקת החשבון המנוהל נכשלה: {{error}}",
|
||||
"deleteFailedGeneric": "מחיקת החשבון המנוהל נכשלה",
|
||||
"howItWorksTitle": "כיצד פועלים חשבונות מנוהלים",
|
||||
"howItWorks1": "המשתמש הראשי יוצר חשבונות מנוהלים עבור משתמשים שצריכים אפשרות להתחבר, להשלים משימות ולאפס את הסיסמה שלהם.",
|
||||
"howItWorks2": "משתמשים בחשבונות מנוהלים יכולים להתחבר באמצעות שם המשתמש והסיסמה שלהם.",
|
||||
"howItWorks3": "משתמשים בחשבונות מנוהלים יכולים להשלים משימות, אך יש להם הרשאות ניהול מוגבלות.",
|
||||
"howItWorks4": "חשבונות מנוהלים מתווספים אוטומטית למעגל שלכם."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "הגדרות התראות",
|
||||
"deviceSection": "התראות במכשיר",
|
||||
"deviceSectionDescription": "נהלו את ההתראות במכשיר שלכם",
|
||||
"deviceLabel": "התראות במכשיר",
|
||||
"deviceHelper": "קבלו התראה במכשיר כאשר מועד משימה מגיע",
|
||||
"mobileOnly": "תכונה זו זמינה רק במכשירים ניידים",
|
||||
"testNotification": "בדיקת התראה",
|
||||
"testNotificationBody": "יש לכם משימה שמועד הביצוע שלה מתקרב",
|
||||
"dueTitle": "התראה במועד המשימה",
|
||||
"dueLabel": "התראה כאשר מגיע מועד המשימה",
|
||||
"preDueTitle": "התראה לפני מועד המשימה",
|
||||
"preDueLabel": "התראה מספר שעות לפני מועד המשימה",
|
||||
"overdueTitle": "התראה על משימה באיחור",
|
||||
"overdueLabel": "התראה כאשר המשימה באיחור",
|
||||
"pushLabel": "התראות Push",
|
||||
"pushHelper": "קבלו תזכורות, הודעות והקצאות משימות באמצעות התראות Push",
|
||||
"registeredDevices": "מכשירים רשומים ({{count}}/5)",
|
||||
"registeredDevicesDescription": "מכשירים הרשומים לקבלת התראות Push עבור החשבון שלכם",
|
||||
"currentDevice": "המכשיר הנוכחי: {{platform}} {{model}}",
|
||||
"currentDeviceNotRegistered": "המכשיר הזה אינו רשום לקבלת התראות Push",
|
||||
"limitReached": "הגעתם למגבלה",
|
||||
"registerDevice": "רישום מכשיר",
|
||||
"unknownDevice": "מכשיר לא ידוע",
|
||||
"deviceCreatedAt": "נוצר בתאריך: {{date}}",
|
||||
"noDevices": "אין מכשירים הרשומים לקבלת התראות Push",
|
||||
"customSection": "התראות מותאמות אישית",
|
||||
"customSectionDescription": "קבלת התראות דרך פלטפורמות אחרות כמו Telegram או Pushover",
|
||||
"customLabel": "התראה מותאמת אישית",
|
||||
"customHelper": "קבלו התראות בפלטפורמה אחרת",
|
||||
"targetNone": "ללא",
|
||||
"targetTelegram": "Telegram",
|
||||
"targetPushover": "Pushover",
|
||||
"targetWebhooks": "Webhooks",
|
||||
"telegramBotHelpBefore": "יש לשלוח הודעה לבוט כדי לאפשר קבלת התראות Telegram",
|
||||
"telegramBotHelpAfter": "כדי להתחיל שיחה",
|
||||
"clickHere": "לחצו כאן",
|
||||
"chatId": "מזהה שיחה",
|
||||
"chatIdPlaceholder": "מזהה משתמש / מזהה שיחה",
|
||||
"telegramChatIdHelpBefore": "אם אינכם יודעים מהו מזהה השיחה שלכם, התחילו שיחה עם userinfobot והוא ישלח לכם את מזהה השיחה.",
|
||||
"telegramChatIdHelpAfter": "כדי להתחיל שיחה עם userinfobot",
|
||||
"userKey": "מפתח משתמש",
|
||||
"userKeyPlaceholder": "מזהה משתמש",
|
||||
"chatIdRequired": "נדרש מזהה שיחה",
|
||||
"chatIdInvalid": "מזהה שיחה לא תקין",
|
||||
"userKeyRequired": "נדרש מפתח משתמש",
|
||||
"targetUpdated": "יעד ההתראות עודכן",
|
||||
"targetUpdateFailed": "שגיאה בעדכון יעד ההתראות: {{error}}",
|
||||
"deviceRegistered": "המכשיר נרשם בהצלחה לקבלת התראות Push.",
|
||||
"deviceLimitTitle": "הגעתם למגבלת המכשירים",
|
||||
"deviceLimitMessage": "הגעתם למספר המרבי של 5 מכשירים רשומים. הסירו מכשיר לפני רישום המכשיר הזה.",
|
||||
"registrationFailedTitle": "הרישום נכשל",
|
||||
"registrationFailedMessage": "רישום המכשיר האוטומטי נכשל. נסו שוב.",
|
||||
"permissionRequiredTitle": "נדרשת הרשאה",
|
||||
"permissionRequiredMessage": "נדרשת הרשאה לקבלת התראות Push כדי לרשום את המכשיר.",
|
||||
"registrationInitiatedTitle": "הרישום התחיל",
|
||||
"registrationInitiatedMessage": "רישום ההתראות התחיל. המכשיר יירשם באופן אוטומטי.",
|
||||
"registerDeviceFailed": "רישום המכשיר נכשל. נסו שוב.",
|
||||
"permissionDeniedTitle": "הרשאת ההתראות נדחתה",
|
||||
"permissionDeniedMessage": "דחיתם את הרשאת ההתראות. תוכלו להפעיל אותה מאוחר יותר בהגדרות המכשיר.",
|
||||
"pushPermissionDeniedTitle": "הרשאת התראות Push נדחתה",
|
||||
"pushPermissionDeniedMessage": "התראות Push הושבתו. תוכלו להפעיל אותן בהגדרות המכשיר אם תרצו.",
|
||||
"unregisterFailed": "ביטול רישום המכשיר נכשל"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "אימות רב-שלבי",
|
||||
"description": "הוסיפו שכבת אבטחה נוספת לחשבון באמצעות אימות רב-שלבי (MFA). כאשר האפשרות מופעלת, תצטרכו להזין קוד אימות מאפליקציית המאמת בנוסף לסיסמה בעת הכניסה.",
|
||||
"twoFactor": "אימות דו-שלבי",
|
||||
"enabledSubtitle": "החשבון שלכם מוגן באמצעות אימות דו-שלבי",
|
||||
"disabledSubtitle": "אבטחו את החשבון באמצעות אפליקציית מאמת",
|
||||
"enable": "הפעלה",
|
||||
"disable": "השבתה",
|
||||
"enabledSuccess": "אימות MFA הופעל בהצלחה!",
|
||||
"disabledSuccess": "אימות MFA הושבת בהצלחה!",
|
||||
"errors": {
|
||||
"qrGenerationFailed": "יצירת קוד ה-QR נכשלה",
|
||||
"invalidResponse": "תגובה לא תקינה מהשרת. קוד ה-QR או הסוד חסרים.",
|
||||
"notFound": "נקודת הקצה להגדרת MFA לא נמצאה. ייתכן שתכונה זו עדיין אינה זמינה.",
|
||||
"unauthorized": "אין הרשאה. התחברו שוב.",
|
||||
"serverError": "שגיאת שרת. נסו שוב מאוחר יותר.",
|
||||
"setupFailed": "הגדרת MFA נכשלה ({{status}}). נסו שוב.",
|
||||
"networkError": "שגיאת רשת. בדקו את החיבור ונסו שוב.",
|
||||
"invalidCode": "קוד אימות לא תקין. נסו שוב.",
|
||||
"confirmFailed": "אישור MFA נכשל. נסו שוב.",
|
||||
"disableFailed": "השבתת MFA נכשלה. נסו שוב."
|
||||
},
|
||||
"setup": {
|
||||
"title": "הגדרת אימות רב-שלבי",
|
||||
"addedAccount": "הוספתי את החשבון",
|
||||
"back": "חזרה",
|
||||
"verifyAndEnable": "אימות והפעלה",
|
||||
"savedBackupCodes": "שמרתי את קודי הגיבוי",
|
||||
"step1Label": "שלב 1:",
|
||||
"step1": "סרקו את קוד ה-QR שלמטה באמצעות אפליקציית המאמת שלכם (Google Authenticator, Authy וכו׳).",
|
||||
"qrAlt": "קוד QR של MFA",
|
||||
"qrFailed": "לא ניתן ליצור את קוד ה-QR. נסו שוב או השתמשו במפתח ההזנה הידנית שלמטה.",
|
||||
"manualKey": "מפתח להזנה ידנית:",
|
||||
"step2Label": "שלב 2:",
|
||||
"step2": "הזינו את קוד האימות בן 6 הספרות מאפליקציית המאמת שלכם",
|
||||
"codePlaceholder": "הזינו קוד בן 6 ספרות",
|
||||
"successTitle": "MFA הופעל בהצלחה!",
|
||||
"backupCodesTitle": "שמרו את קודי הגיבוי האלה במקום בטוח",
|
||||
"backupCodesDescription": "תוכלו להשתמש בקודים האלה כדי לגשת לחשבון אם תאבדו את מכשיר המאמת. ניתן להשתמש בכל קוד פעם אחת בלבד."
|
||||
},
|
||||
"disableModal": {
|
||||
"title": "השבתת אימות רב-שלבי",
|
||||
"warning": "השבתת MFA תהפוך את החשבון שלכם לפחות מאובטח. האם אתם בטוחים שברצונכם להמשיך?",
|
||||
"prompt": "הזינו קוד אימות מאפליקציית המאמת כדי לאשר:",
|
||||
"confirm": "השבתת MFA"
|
||||
},
|
||||
"backupCodesModal": {
|
||||
"title": "קודי גיבוי חדשים",
|
||||
"warning": "קודי הגיבוי הקודמים שלכם אינם תקפים יותר. שמרו את הקודים החדשים במקום בטוח. ניתן להשתמש בכל קוד פעם אחת בלבד."
|
||||
}
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "אסימוני API",
|
||||
"accessToken": "אסימון גישה",
|
||||
"description": "צרו אסימון לשימוש ב-API כדי לעדכן דברים שמפעילים משימות או מטלות.",
|
||||
"plusNotice": "אסימוני API אינם זמינים בתוכנית Basic. שדרגו ל-Plus כדי ליצור אסימוני API לצורך אינטגרציה עם מערכות חיצוניות ואוטומציה של המשימות שלכם.",
|
||||
"showToken": "הצגת אסימון",
|
||||
"hideToken": "הסתרת אסימון",
|
||||
"removeTitle": "הסרת אסימון",
|
||||
"removeMessage": "האם אתם בטוחים שברצונכם להסיר את {{name}}?",
|
||||
"removedTitle": "הוסר",
|
||||
"removedMessage": "אסימון ה-API הוסר",
|
||||
"tokenCopied": "האסימון הועתק ללוח",
|
||||
"generateNew": "יצירת אסימון חדש",
|
||||
"nameModalTitle": "תנו שם לאסימון החדש כדי שתוכלו לזהות אותו בקלות.",
|
||||
"generateToken": "יצירת אסימון"
|
||||
},
|
||||
"storage": {
|
||||
"title": "הגדרות אחסון",
|
||||
"serverTitle": "שימוש באחסון בשרת",
|
||||
"serverDescription": "זהו שטח האחסון שבו משתמש החשבון שלכם בשרתים שלנו, למשל עבור קבצים, תמונות ונתונים שהעליתם.",
|
||||
"usagePlaceholder": "-- MB בשימוש / -- MB בסך הכול (--)",
|
||||
"usage": "{{used}} MB בשימוש / {{total}} MB בסך הכול ({{percent}}%)",
|
||||
"basicPlanNotice": "אחסון בשרת אינו זמין בתוכנית Basic. שדרגו ל-Plus כדי לעקוב אחר השימוש באחסון בשרת.",
|
||||
"localTitleApp": "אחסון מקומי ומטמון באפליקציה",
|
||||
"localTitleBrowser": "אחסון מקומי ומטמון בדפדפן",
|
||||
"localDescription": "אלה נתונים המאוחסנים באופן מקומי בדפדפן כדי לאפשר גישה מהירה יותר. ניקוי שלהם לא ישפיע על הנתונים שלכם בשרת, אך ייתכן שתנותקו מהחשבון.",
|
||||
"clearLocal": "ניקוי כל האחסון והמטמון המקומיים",
|
||||
"clearLocalTitle": "ניקוי כל האחסון המקומי",
|
||||
"clearLocalMessage": "האם אתם בטוחים שברצונכם לנקות את האחסון והמטמון המקומיים? פעולה זו תסיר את כל הנתונים שלכם מהדפדפן ותדרוש התחברות מחדש.",
|
||||
"clearAll": "ניקוי הכול",
|
||||
"appPreferences": "העדפות האפליקציה",
|
||||
"deviceOnly": "במכשיר בלבד",
|
||||
"appPreferencesDescription": "אלה העדפות והגדרות שהאפליקציה שומרת באופן מקומי במכשיר שלכם. ניקוי שלהן יאפס את הגדרות האפליקציה ועלול לנתק אתכם מהחשבון, אך לא ישפיע על הנתונים שלכם בשרת.",
|
||||
"clearPreferences": "ניקוי העדפות האפליקציה",
|
||||
"clearPreferencesTitle": "ניקוי העדפות האפליקציה",
|
||||
"clearPreferencesMessage": "האם אתם בטוחים שברצונכם לנקות את כל העדפות האפליקציה? פעולה זו תאפס את הגדרות האפליקציה ועלולה לדרוש התחברות מחדש."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "התאמת סרגל הצד",
|
||||
"heading": "הגדרות סרגל הצד",
|
||||
"description": "התאימו אילו כרטיסים יופיעו בסרגל הצד ואת הסדר שלהם. גררו ושחררו כדי לשנות את הסדר, או הפעילו וכבו את הנראות של כל כרטיס.",
|
||||
"resetToDefaults": "איפוס לברירת המחדל",
|
||||
"resetHelper": "פעולה זו תשחזר את הנראות והסדר של כל הכרטיסים לברירת המחדל.",
|
||||
"cards": {
|
||||
"welcome": {
|
||||
"name": "החלפת משתמש",
|
||||
"description": "מאפשרת למנהלים ולממונים לצפות במשימות כמשתמשים שונים"
|
||||
},
|
||||
"smartInsights": {
|
||||
"name": "תובנות חכמות",
|
||||
"description": "פעולות מהירות המבוססות על המשימות שלכם"
|
||||
},
|
||||
"assignees": {
|
||||
"name": "משימות לפי אחראי",
|
||||
"description": "קיבוץ משימות לפי האדם שאליו הן הוקצו"
|
||||
},
|
||||
"calendar": {
|
||||
"name": "תצוגת לוח שנה",
|
||||
"description": "הצגת משימות בפורמט של לוח שנה"
|
||||
},
|
||||
"activities": {
|
||||
"name": "פעילות אחרונה",
|
||||
"description": "הצגת השלמות משימות ופעילות אחרונה"
|
||||
},
|
||||
"weeklyGoals": {
|
||||
"name": "יעדים שבועיים",
|
||||
"description": "הצגת ההתקדמות השבועית ונתוני השלמת המשימות של המשפחה"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"title": "העדפות ערכת נושא",
|
||||
"description": "בחרו כיצד האתר יוצג עבורכם. בחרו ערכת נושא אחת, או הסתנכרנו עם הגדרות המערכת כדי לעבור אוטומטית בין מצב יום ללילה.",
|
||||
"themeMode": "מצב ערכת נושא",
|
||||
"light": "בהיר",
|
||||
"dark": "כהה",
|
||||
"system": "מערכת"
|
||||
},
|
||||
"localization": {
|
||||
"title": "הגדרות אזוריות",
|
||||
"description": "התאמת השפה, תבנית התאריך וההעדפות האזוריות של החשבון.",
|
||||
"language": "שפה",
|
||||
"languageDescription": "בחירת השפה המועדפת",
|
||||
"rtlNotice": "שפה זו משתמשת בכיוון טקסט מימין לשמאל (RTL)",
|
||||
"dateFormat": "תבנית תאריך",
|
||||
"dateFormatDescription": "בחירת אופן הצגת התאריכים ברחבי האפליקציה",
|
||||
"timeFormat": "תבנית שעה",
|
||||
"timeFormatDescription": "בחירת תבנית שעה של 12 או 24 שעות",
|
||||
"preview": "תצוגה מקדימה: {{value}}",
|
||||
"12hour": "12 שעות (AM/PM)",
|
||||
"24hour": "24 שעות",
|
||||
"firstDayOfWeek": "היום הראשון בשבוע",
|
||||
"firstDayOfWeekDescription": "בחירת היום שבו מתחיל השבוע",
|
||||
"sunday": "יום ראשון",
|
||||
"monday": "יום שני",
|
||||
"saturday": "שבת",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/YYYY (ארה״ב)",
|
||||
"dmy": "DD/MM/YYYY (אירופה)",
|
||||
"ymd": "YYYY-MM-DD (ISO)",
|
||||
"long": "תבנית ארוכה (לדוגמה, 1 בינואר 2024)",
|
||||
"short": "תבנית קצרה (לדוגמה, 1 בינו׳ 2024)"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "הגדרות מתקדמות",
|
||||
"description": "הגדירו תכונות מתקדמות כמו Webhooks ועדכונים בזמן אמת לשיפור הפרודוקטיביות.",
|
||||
"offlineTitle": "תמיכה במצב לא מקוון",
|
||||
"offlineDescription": "המשיכו להשתמש ב-Donetick גם ללא חיבור לאינטרנט במכשיר או בדפדפן הזה. השינויים שלכם יישמרו באופן מקומי ויסונכרנו כשתחזרו להיות מחוברים.",
|
||||
"offlineToggle": "הפעלת מצב לא מקוון",
|
||||
"offlineHelper": "כיבוי האפשרות הזו ימחק שינויים לא מסונכרנים ונתונים שנשמרו במצב לא מקוון מהמכשיר או מהדפדפן הזה.",
|
||||
"offlineEnabled": "מצב לא מקוון הופעל עבור המכשיר או הדפדפן הזה",
|
||||
"offlineDisabled": "מצב לא מקוון כובה והנתונים המקומיים נמחקו",
|
||||
"offlineDisabledPartial": "מצב לא מקוון כובה, אך ייתכן שחלק מהנתונים המקומיים עדיין נשמרו",
|
||||
"offlineDisableTitle": "כיבוי מצב לא מקוון",
|
||||
"offlineDisableMessage": "כיבוי מצב לא מקוון ימחק שינויים לא מסונכרנים ונתונים שנשמרו במצב לא מקוון במכשיר או בדפדפן הזה. האם ברצונכם להמשיך?",
|
||||
"offlineDisableConfirm": "כיבוי וניקוי הנתונים",
|
||||
"webhookTitle": "אינטגרציית Webhook",
|
||||
"webhookDescription": "Webhooks מאפשרים לשלוח התראות בזמן אמת לשירותים אחרים כאשר מתרחשים אירועים במעגל שלכם. הגדירו כתובת URL של Webhook כדי לקבל עדכונים בזמן אמת.",
|
||||
"webhookPlusNotice": "התראות Webhook אינן זמינות בתוכנית Basic. שדרגו ל-Plus כדי לקבל עדכונים בזמן אמת באמצעות Webhooks.",
|
||||
"webhookToggle": "הפעלת Webhook",
|
||||
"webhookHelper": "הפעילו התראות Webhook עבור עדכוני משימות ופריטים.",
|
||||
"webhookURL": "כתובת URL של Webhook",
|
||||
"webhookUpdated": "כתובת ה-URL של ה-Webhook עודכנה בהצלחה",
|
||||
"webhookUpdateFailed": "עדכון כתובת ה-URL של ה-Webhook נכשל",
|
||||
"realtimeTitle": "עדכונים בזמן אמת",
|
||||
"realtimeDescription": "הגדירו כיצד לקבל עדכונים בזמן אמת כאשר משימות ופעילויות משתנות במעגל שלכם.",
|
||||
"realtime": {
|
||||
"toggleLabel": "הפעלת עדכונים בזמן אמת",
|
||||
"title": "עדכונים בזמן אמת",
|
||||
"subtitle": "קבלו התראות מיידיות כאשר משימות מתעדכנות",
|
||||
"statusLabel": "סטטוס:",
|
||||
"basicPlan": "עדכונים בזמן אמת אינם זמינים בתוכנית Basic. שדרגו ל-Plus כדי לקבל התראות מיידיות כאשר משימות מתעדכנות.",
|
||||
"disabled": "עדכונים בזמן אמת מושבתים. הפעילו אותם כדי לראות שינויים בזמן אמת כאשר אתם או חברים אחרים במעגל משלימים, מדלגים או משנים משימות.",
|
||||
"connected": "עדכונים בזמן אמת פועלים. תראו שינויים בזמן אמת כאשר אתם או חברים אחרים במעגל משלימים, מדלגים או משנים משימות.",
|
||||
"connecting": "מתחבר לעדכונים בזמן אמת...",
|
||||
"errored": "עדכונים בזמן אמת מופעלים אך אינם פועלים: {{error}}",
|
||||
"notConnected": "עדכונים בזמן אמת מופעלים אך אינם מחוברים כרגע.",
|
||||
"basicPlanNotice": "עדכונים בזמן אמת אינם זמינים בתוכנית Basic. שדרגו ל-Plus כדי לקבל התראות מיידיות כאשר אתם או חברים אחרים במעגל משלימים, מדלגים או משנים משימות."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
"footer": {
|
||||
"navigate": "移動",
|
||||
"open": "開く",
|
||||
"results_one": "{{count}} result",
|
||||
"results_other": "{{count}} 件の結果",
|
||||
"typeToSearch": "入力して検索"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"logout": "退出登录",
|
||||
"version": "版本",
|
||||
"navigation": {
|
||||
"search": "搜索",
|
||||
"allTasks": "所有任务",
|
||||
"archived": "已归档",
|
||||
"things": "事项",
|
||||
@@ -29,5 +30,42 @@
|
||||
"activities": "活动",
|
||||
"points": "积分",
|
||||
"settings": "设置"
|
||||
},
|
||||
"search": {
|
||||
"title": "搜索",
|
||||
"placeholder": "搜索 Donetick",
|
||||
"inputAriaLabel": "搜索任务、历史、项目、标签和设置",
|
||||
"deviceNote": "正在搜索此设备上可用的内容",
|
||||
"escape": "Esc",
|
||||
"recent": "最近",
|
||||
"empty": {
|
||||
"title": "没有直接匹配的结果",
|
||||
"subtitle": "你仍然可以用此搜索筛选任务列表。"
|
||||
},
|
||||
"groups": {
|
||||
"tasks": "任务",
|
||||
"history": "备注",
|
||||
"projects": "项目",
|
||||
"labels": "标签",
|
||||
"people": "成员",
|
||||
"settings": "设置",
|
||||
"actions": "快捷操作"
|
||||
},
|
||||
"actions": {
|
||||
"quickAction": "快捷操作",
|
||||
"navigation": "导航",
|
||||
"createTask": "创建任务",
|
||||
"viewAllTasks": "查看所有任务",
|
||||
"viewArchivedTasks": "查看已归档任务",
|
||||
"openSettings": "打开设置",
|
||||
"filterTasks": "显示匹配“{{query}}”的任务",
|
||||
"filterTasksSubtitle": "筛选任务列表"
|
||||
},
|
||||
"footer": {
|
||||
"navigate": "导航",
|
||||
"open": "打开",
|
||||
"results_other": "{{count}} 个结果",
|
||||
"typeToSearch": "输入以搜索"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,113 +1,26 @@
|
||||
{
|
||||
"title": "设置",
|
||||
"circleSettings": {
|
||||
"title": "圈子设置",
|
||||
"description": "当您创建或加入圈子时,您的账户会自动连接到该圈子。通过分享下方的唯一圈子代码或链接,即可轻松邀请好友。当有人请求加入您的圈子时,您会在下方收到通知。如果您想离开,只需点击“离开圈子”按钮。",
|
||||
"circleCode": "圈子代码",
|
||||
"copyCode": "复制代码",
|
||||
"copyLink": "复制链接",
|
||||
"codeCopied": "圈子代码已复制!",
|
||||
"linkCopied": "圈子链接已复制!",
|
||||
"joinCircle": "加入圈子",
|
||||
"joinCirclePlaceholder": "输入圈子代码",
|
||||
"join": "加入",
|
||||
"leave": "离开圈子",
|
||||
"leaveConfirmTitle": "离开圈子",
|
||||
"leaveConfirmMessage": "您确定要离开此圈子吗?",
|
||||
"circleMembers": "圈子成员",
|
||||
"circleMemberRequests": "圈子成员申请",
|
||||
"admin": "管理员",
|
||||
"member": "成员",
|
||||
"pending": "待处理",
|
||||
"accept": "接受",
|
||||
"reject": "拒绝",
|
||||
"makeAdmin": "设为管理员",
|
||||
"makeMember": "设为成员",
|
||||
"remove": "移除",
|
||||
"webhookURL": "Webhook URL",
|
||||
"webhookDescription": "输入 Webhook URL 以接收圈子事件通知",
|
||||
"webhookPlaceholder": "https://your-webhook-url.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "账户设置",
|
||||
"subscription": "订阅",
|
||||
"subscriptionStatus": "当前方案",
|
||||
"free": "免费版",
|
||||
"plus": "Plus 版",
|
||||
"upgrade": "升级",
|
||||
"cancel": "取消",
|
||||
"changePassword": "修改密码",
|
||||
"password": "密码",
|
||||
"dangerZone": "危险区域",
|
||||
"dangerZoneDescription": "一旦删除账户,将无法恢复。请谨慎操作。",
|
||||
"deleteAccount": "删除账户"
|
||||
},
|
||||
"localization": {
|
||||
"title": "本地化",
|
||||
"description": "自定义您账户的语言、日期格式和区域偏好。",
|
||||
"language": "语言",
|
||||
"languageDescription": "选择您的首选语言",
|
||||
"dateFormat": "日期格式",
|
||||
"dateFormatDescription": "选择整个应用中日期的显示方式",
|
||||
"timeFormat": "时间格式",
|
||||
"timeFormatDescription": "选择 12 小时制或 24 小时制",
|
||||
"12hour": "12 小时制 (AM/PM)",
|
||||
"24hour": "24 小时制",
|
||||
"firstDayOfWeek": "每周起始日",
|
||||
"firstDayOfWeekDescription": "选择每周的第一天",
|
||||
"sunday": "星期日",
|
||||
"monday": "星期一",
|
||||
"saturday": "星期六",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/YYYY (美国)",
|
||||
"dmy": "DD/MM/YYYY (欧洲)",
|
||||
"ymd": "YYYY-MM-DD (ISO)",
|
||||
"long": "长格式 (例如:2024年1月1日)",
|
||||
"short": "短格式 (例如:2024年1月1日)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "侧边栏自定义",
|
||||
"description": "自定义侧边栏中卡片的布局和可见性。此部分仅适用于平板电脑和台式机等大屏设备。"
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题偏好",
|
||||
"description": "选择网站的显示外观。您可以选择单一主题,或与系统同步,自动切换日间和夜间主题。",
|
||||
"themeMode": "主题模式",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "跟随系统"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "设置保存成功",
|
||||
"settingsSaveFailed": "设置保存失败",
|
||||
"invalidWebhook": "无效的 Webhook URL"
|
||||
},
|
||||
"profile": {
|
||||
"title": "个人资料设置",
|
||||
"description": "更新您的显示名称和个人头像。",
|
||||
"photoUpdated": "头像已更新",
|
||||
"photoUpdatedMessage": "您的个人头像已成功更新!",
|
||||
"uploadFailed": "上传失败",
|
||||
"uploadFailedMessage": "头像上传失败。请重试。",
|
||||
"profileUpdated": "资料已更新",
|
||||
"profileUpdatedMessage": "您的个人资料已成功保存!",
|
||||
"updateFailed": "更新失败",
|
||||
"updateFailedMessage": "无法更新您的个人资料。请检查网络连接后重试。",
|
||||
"changePhoto": "更换头像",
|
||||
"displayName": "显示名称",
|
||||
"displayNamePlaceholder": "输入您的显示名称",
|
||||
"timezone": "时区",
|
||||
"timezonePlaceholder": "选择您的时区",
|
||||
"common": {
|
||||
"save": "保存",
|
||||
"cancel": "取消"
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"remove": "移除",
|
||||
"delete": "删除",
|
||||
"refresh": "刷新",
|
||||
"loading": "加载中…",
|
||||
"on": "开",
|
||||
"off": "关",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
"plusFeature": "Plus 功能",
|
||||
"earlyAccess": "抢先体验"
|
||||
},
|
||||
"overview": {
|
||||
"title": "设置",
|
||||
"subtitle": "自定义您的体验并管理账户偏好",
|
||||
"subtitle": "定制你的使用体验并管理账户偏好",
|
||||
"upgrade": {
|
||||
"title": "升级到 Plus",
|
||||
"description": "解锁强大功能,提升您的效率",
|
||||
"description": "解锁强大功能,提升你的效率",
|
||||
"button": "立即升级",
|
||||
"features": {
|
||||
"richText": "富文本描述",
|
||||
@@ -119,56 +32,463 @@
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "个人资料设置",
|
||||
"description": "更新您的个人资料信息、头像、显示名称和时区偏好。"
|
||||
"description": "更新你的个人资料、头像、显示名称和时区偏好。"
|
||||
},
|
||||
"circle": {
|
||||
"title": "圈子设置",
|
||||
"description": "管理您的圈子、邀请成员并处理加入申请。"
|
||||
"description": "管理你的圈子、邀请成员并处理加入请求。"
|
||||
},
|
||||
"account": {
|
||||
"title": "账户设置",
|
||||
"description": "管理订阅、修改密码及账户删除选项。"
|
||||
"description": "管理订阅、修改密码以及删除账户。"
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "托管账户",
|
||||
"description": "创建和管理子账户,以便登录并完成分配的任务。"
|
||||
"title": "受管账户",
|
||||
"description": "创建和管理子账户,让他们登录并完成分配的任务。"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知",
|
||||
"description": "配置推送通知、邮件提醒及任务通知目标。"
|
||||
"description": "配置推送通知、邮件提醒以及任务的通知目标。"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "多因素认证",
|
||||
"description": "使用身份验证器应用启用 MFA,增加一层安全保障。"
|
||||
"title": "多重身份验证",
|
||||
"description": "通过身份验证器应用启用 MFA,为账户增加一层安全保护。"
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "API 令牌",
|
||||
"description": "生成和管理用于第三方集成及 API 访问的访问令牌。"
|
||||
"description": "生成和管理用于第三方集成与 API 访问的访问令牌。"
|
||||
},
|
||||
"storage": {
|
||||
"title": "存储设置",
|
||||
"description": "备份和恢复数据,管理本地存储及同步偏好。"
|
||||
"description": "备份和恢复数据,管理本地存储与同步偏好。"
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "侧边栏自定义",
|
||||
"description": "自定义侧边栏界面中卡片的布局和可见性。"
|
||||
"description": "自定义侧边栏中卡片的布局和显示状态。"
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题偏好",
|
||||
"description": "选择您喜欢的主题并配置深色/浅色模式设置。"
|
||||
"description": "选择你喜欢的主题并配置深色/浅色模式。"
|
||||
},
|
||||
"localization": {
|
||||
"title": "本地化",
|
||||
"description": "自定义语言、日期格式、时间格式及区域偏好。"
|
||||
"description": "自定义语言、日期格式、时间格式和区域偏好。"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高级设置",
|
||||
"description": "配置 Webhook、实时更新及其他高级功能以提升效率。"
|
||||
"description": "配置 Webhook、实时更新以及其他高级功能。"
|
||||
},
|
||||
"developer": {
|
||||
"title": "开发者设置",
|
||||
"description": "查看有关身份验证令牌、SSE 连接及调试数据的技术信息。"
|
||||
"description": "查看身份验证令牌、SSE 连接和调试数据等技术信息。"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "发送反馈",
|
||||
"description": "告诉我们 Donetick 用起来如何,或提出功能建议。"
|
||||
},
|
||||
"bugReport": {
|
||||
"title": "报告问题",
|
||||
"description": "有什么不对劲吗?把详情和技术快照一起发给我们。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "个人资料设置",
|
||||
"description": "更新你的显示名称和头像。",
|
||||
"photoUpdated": "头像已更新",
|
||||
"photoUpdatedMessage": "你的头像已成功更新!",
|
||||
"uploadFailed": "上传失败",
|
||||
"uploadFailedMessage": "无法上传你的照片,请重试。",
|
||||
"profileUpdated": "资料已更新",
|
||||
"profileUpdatedMessage": "你的个人资料已成功保存!",
|
||||
"updateFailed": "更新失败",
|
||||
"updateFailedMessage": "无法更新你的资料,请检查网络后重试。",
|
||||
"changePhoto": "更换头像",
|
||||
"editPhoto": "编辑头像",
|
||||
"displayName": "显示名称",
|
||||
"displayNamePlaceholder": "输入你的显示名称",
|
||||
"timezone": "时区",
|
||||
"timezonePlaceholder": "选择你的时区",
|
||||
"save": "保存",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"circleSettings": {
|
||||
"title": "圈子设置",
|
||||
"description": "当你创建或加入圈子时,账户会自动连接到该圈子。分享下方的专属圈子代码或链接即可轻松邀请好友。有人申请加入你的圈子时,下方会出现通知。",
|
||||
"memberOf": "你是 {{name}} 的成员",
|
||||
"yourCircleCode": "你的圈子代码是:",
|
||||
"copyCode": "复制代码",
|
||||
"shareInvite": "分享邀请",
|
||||
"codeCopied": "代码已复制到剪贴板",
|
||||
"linkCopied": "邀请链接已复制到剪贴板",
|
||||
"myCircle": "我的圈子",
|
||||
"shareTitle": "在 Donetick 加入 {{name}}",
|
||||
"shareText": "我想邀请你加入 Donetick 上的 {{name}}。",
|
||||
"shareDialogTitle": "分享圈子邀请",
|
||||
"leave": "退出圈子",
|
||||
"leaveConfirmTitle": "退出圈子",
|
||||
"leaveConfirmMessage": "确定要退出你的圈子吗?",
|
||||
"leaveConfirmButton": "退出",
|
||||
"leftCircle": "已成功退出圈子",
|
||||
"leaveFailed": "退出圈子失败",
|
||||
"circleMembers": "圈子成员",
|
||||
"you": "(你)",
|
||||
"pendingApproval": "等待审批",
|
||||
"joinedOn": "加入于 {{date}}",
|
||||
"requestedToJoin": "申请加入 {{date}}",
|
||||
"roles": {
|
||||
"member": "成员",
|
||||
"memberDescription": "圈子的普通成员",
|
||||
"manager": "管理者",
|
||||
"managerDescription": "可以以其他用户身份操作并代其执行",
|
||||
"admin": "管理员",
|
||||
"adminDescription": "对圈子拥有完全权限"
|
||||
},
|
||||
"roleUpdateFailed": "更新角色失败",
|
||||
"removeMemberTitle": "移除成员",
|
||||
"removeMemberMessage": "确定要将 {{name}} 移出你的圈子吗?",
|
||||
"memberRemoved": "已成功移除成员",
|
||||
"circleMemberRequests": "圈子加入请求",
|
||||
"lastUpdated": "上次更新:{{time}}",
|
||||
"refreshing": "正在刷新…",
|
||||
"refreshFailed": "刷新加入请求失败",
|
||||
"wantsToJoin": "{{name}} 想加入你的圈子。",
|
||||
"accept": "接受",
|
||||
"acceptRequestTitle": "接受加入请求",
|
||||
"acceptRequestMessage": "确定要接受 {{name}}(用户名:{{username}})加入你的圈子吗?",
|
||||
"requestAccepted": "已成功接受请求",
|
||||
"or": "或",
|
||||
"joinOtherDescription": "想加入别人的圈子?向对方索取专属圈子代码或加入链接,然后在下方输入代码即可加入。",
|
||||
"enterCircleCode": "输入圈子代码:",
|
||||
"enterCodePlaceholder": "输入代码",
|
||||
"joinCircle": "加入圈子",
|
||||
"joinedPending": "已成功加入圈子,请等待圈主接受你的请求。",
|
||||
"alreadyMember": "你已经是该圈子的成员",
|
||||
"joinFailed": "加入圈子失败"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "账户设置",
|
||||
"description": "修改你的账户设置、套餐类型或密码",
|
||||
"accountType": "账户类型:{{type}}",
|
||||
"free": "免费版",
|
||||
"plus": "Plus",
|
||||
"plusUntil": "Plus(至 {{date}})",
|
||||
"activeDescription": "你当前已订阅 Plus 套餐,订阅将于 {{date}} 续期。",
|
||||
"cancelledDescription": "你已取消订阅,账户将于 {{date}} 降级为免费套餐。",
|
||||
"freeDescription": "你当前使用的是免费套餐。升级到 Plus 可解锁更多功能。",
|
||||
"upgrade": "升级",
|
||||
"cancel": "取消订阅",
|
||||
"password": "密码:",
|
||||
"changePassword": "修改密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeFailed": "密码修改失败",
|
||||
"dangerZone": "危险操作",
|
||||
"dangerZoneDescription": "账户一旦删除便无法恢复,请谨慎操作。",
|
||||
"deleteAccount": "删除账户",
|
||||
"accountDeleted": "账户已成功删除",
|
||||
"subscriptionCancelled": "订阅已取消",
|
||||
"subscriptionCancelFailed": "取消订阅失败",
|
||||
"purchase": {
|
||||
"success": "购买成功!请重启应用以使用 Plus 功能。",
|
||||
"storeConnection": "应用商店连接异常,请检查网络后重试。",
|
||||
"notAllowed": "此设备不允许购买,请检查设备的限制设置。",
|
||||
"unavailable": "此订阅暂不可用,请稍后再试。",
|
||||
"alreadyProcessed": "此次购买已处理。如果你认为这是错误,请联系客服。",
|
||||
"receiptMissing": "缺少购买凭据,请重新尝试购买。",
|
||||
"networkError": "网络错误,请检查网络连接后重试。",
|
||||
"invalidReceipt": "购买凭据无效。如果问题持续存在,请联系客服。",
|
||||
"pending": "付款正在等待审核,通过后你将获得访问权限。",
|
||||
"failed": "购买失败:{{error}}。请重试或联系客服。",
|
||||
"unknownError": "未知错误"
|
||||
}
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "受管账户",
|
||||
"description": "管理子账户。子账户用户可以登录并完成分配给他们的任务。",
|
||||
"notParentTitle": "子账户管理",
|
||||
"notParentMessage": "只有主账户才能管理子账户。",
|
||||
"freePlanNotice": "免费套餐限 1 个子账户。升级到 Plus 最多可拥有 5 个子账户。",
|
||||
"count": "子账户({{count}})",
|
||||
"add": "添加子账户",
|
||||
"loading": "正在加载子账户…",
|
||||
"emptyTitle": "暂无子账户",
|
||||
"emptyDescription": "创建子账户,让团队成员可以登录并完成分配给他们的任务。",
|
||||
"addFirst": "添加第一个子账户",
|
||||
"username": "用户名:{{username}}",
|
||||
"created": "创建于:{{date}}",
|
||||
"changePassword": "修改密码",
|
||||
"deleteAccount": "删除账户",
|
||||
"createdSuccess": "子账户“{{name}}”创建成功!",
|
||||
"createFailed": "创建子账户失败:{{error}}",
|
||||
"createFailedGeneric": "创建子账户失败",
|
||||
"passwordUpdated": "子账户密码更新成功",
|
||||
"passwordUpdateFailed": "更新密码失败:{{error}}",
|
||||
"passwordUpdateFailedGeneric": "更新密码失败",
|
||||
"deleteConfirmTitle": "删除子账户",
|
||||
"deleteConfirmMessage": "确定要删除子账户“{{name}}”吗?此操作无法撤销。",
|
||||
"deleted": "子账户“{{name}}”已成功删除",
|
||||
"deleteFailed": "删除子账户失败:{{error}}",
|
||||
"deleteFailedGeneric": "删除子账户失败",
|
||||
"howItWorksTitle": "受管账户如何运作",
|
||||
"howItWorks1": "受管账户由主账户创建,适用于你希望能够删除其账户并重置其密码的用户。",
|
||||
"howItWorks2": "子账户可以使用自己的用户名和密码登录。",
|
||||
"howItWorks3": "受管账户可以完成任务,但管理权限有限",
|
||||
"howItWorks4": "受管账户会自动加入你的圈子"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "通知设置",
|
||||
"deviceSection": "设备通知",
|
||||
"deviceSectionDescription": "管理你的设备通知",
|
||||
"deviceLabel": "设备通知",
|
||||
"deviceHelper": "任务到期时在设备上接收通知",
|
||||
"mobileOnly": "此功能仅在移动设备上可用",
|
||||
"testNotification": "测试通知",
|
||||
"testNotificationBody": "你有一项任务即将到期",
|
||||
"dueTitle": "到期通知",
|
||||
"dueLabel": "任务到期时通知",
|
||||
"preDueTitle": "到期前通知",
|
||||
"preDueLabel": "任务到期前几小时通知",
|
||||
"overdueTitle": "逾期通知",
|
||||
"overdueLabel": "任务逾期时通知",
|
||||
"pushLabel": "推送通知",
|
||||
"pushHelper": "通过推送通知接收提醒、公告和任务分配",
|
||||
"registeredDevices": "已注册设备({{count}}/5)",
|
||||
"registeredDevicesDescription": "已注册接收你账户推送通知的设备",
|
||||
"currentDevice": "当前设备:{{platform}} {{model}}",
|
||||
"currentDeviceNotRegistered": "此设备尚未注册推送通知",
|
||||
"limitReached": "已达上限",
|
||||
"registerDevice": "注册设备",
|
||||
"unknownDevice": "未知设备",
|
||||
"deviceCreatedAt": "创建于:{{date}}",
|
||||
"noDevices": "尚无设备注册推送通知",
|
||||
"customSection": "自定义通知",
|
||||
"customSectionDescription": "通过 Telegram、Pushover 等其他平台接收通知",
|
||||
"customLabel": "自定义通知",
|
||||
"customHelper": "在其他平台上接收通知",
|
||||
"targetNone": "无",
|
||||
"targetTelegram": "Telegram",
|
||||
"targetPushover": "Pushover",
|
||||
"targetWebhooks": "Webhook",
|
||||
"telegramBotHelpBefore": "你需要先向机器人发送一条消息,Telegram 通知才能生效",
|
||||
"telegramBotHelpAfter": "开始聊天",
|
||||
"clickHere": "点击这里",
|
||||
"chatId": "Chat ID",
|
||||
"chatIdPlaceholder": "用户 ID / Chat ID",
|
||||
"telegramChatIdHelpBefore": "如果你不知道自己的 Chat ID,可以和 userinfobot 开始聊天,它会告诉你。",
|
||||
"telegramChatIdHelpAfter": "与 userinfobot 开始聊天",
|
||||
"userKey": "用户密钥",
|
||||
"userKeyPlaceholder": "用户 ID",
|
||||
"chatIdRequired": "Chat ID 为必填项",
|
||||
"chatIdInvalid": "Chat ID 无效",
|
||||
"userKeyRequired": "用户密钥为必填项",
|
||||
"targetUpdated": "通知目标已更新",
|
||||
"targetUpdateFailed": "更新通知目标时出错:{{error}}",
|
||||
"deviceRegistered": "设备已成功注册推送通知。",
|
||||
"deviceLimitTitle": "已达设备数量上限",
|
||||
"deviceLimitMessage": "你已达到 5 台已注册设备的上限。请先移除一台设备,再注册此设备。",
|
||||
"registrationFailedTitle": "注册失败",
|
||||
"registrationFailedMessage": "无法自动注册设备,请重试。",
|
||||
"permissionRequiredTitle": "需要授权",
|
||||
"permissionRequiredMessage": "注册此设备需要推送通知权限。",
|
||||
"registrationInitiatedTitle": "已开始注册",
|
||||
"registrationInitiatedMessage": "推送通知注册已开始,设备将自动完成注册。",
|
||||
"registerDeviceFailed": "注册设备失败,请重试。",
|
||||
"permissionDeniedTitle": "通知权限被拒绝",
|
||||
"permissionDeniedMessage": "你拒绝了通知权限。之后可以在设备设置中重新开启。",
|
||||
"pushPermissionDeniedTitle": "推送通知权限被拒绝",
|
||||
"pushPermissionDeniedMessage": "推送通知已关闭。如有需要,可在设备设置中重新开启。",
|
||||
"unregisterFailed": "取消注册设备失败"
|
||||
},
|
||||
"mfa": {
|
||||
"title": "多重身份验证",
|
||||
"description": "使用多重身份验证(MFA)为账户增加一层安全保护。开启后,登录时除密码外,还需输入身份验证器应用中的验证码。",
|
||||
"twoFactor": "两步验证",
|
||||
"enabledSubtitle": "你的账户已启用两步验证保护",
|
||||
"disabledSubtitle": "使用身份验证器应用保护你的账户",
|
||||
"enable": "开启",
|
||||
"disable": "关闭",
|
||||
"enabledSuccess": "MFA 已成功开启!",
|
||||
"disabledSuccess": "MFA 已成功关闭!",
|
||||
"errors": {
|
||||
"qrGenerationFailed": "无法生成二维码",
|
||||
"invalidResponse": "服务器响应无效,缺少二维码或密钥。",
|
||||
"notFound": "未找到 MFA 设置接口,此功能可能尚未开放。",
|
||||
"unauthorized": "未授权,请重新登录。",
|
||||
"serverError": "服务器错误,请稍后再试。",
|
||||
"setupFailed": "设置 MFA 失败({{status}}),请重试。",
|
||||
"networkError": "网络错误,请检查网络连接后重试。",
|
||||
"invalidCode": "验证码无效,请重试。",
|
||||
"confirmFailed": "确认 MFA 失败,请重试。",
|
||||
"disableFailed": "关闭 MFA 失败,请重试。"
|
||||
},
|
||||
"setup": {
|
||||
"title": "设置多重身份验证",
|
||||
"addedAccount": "我已添加账户",
|
||||
"back": "返回",
|
||||
"verifyAndEnable": "验证并开启",
|
||||
"savedBackupCodes": "我已保存备用代码",
|
||||
"step1Label": "第 1 步:",
|
||||
"step1": "用身份验证器应用(Google Authenticator、Authy 等)扫描下方二维码",
|
||||
"qrAlt": "MFA 二维码",
|
||||
"qrFailed": "无法生成二维码,请重试或使用下方的手动输入密钥。",
|
||||
"manualKey": "手动输入密钥:",
|
||||
"step2Label": "第 2 步:",
|
||||
"step2": "输入身份验证器应用中的 6 位验证码",
|
||||
"codePlaceholder": "输入 6 位验证码",
|
||||
"successTitle": "MFA 已成功开启!",
|
||||
"backupCodesTitle": "请将这些备用代码保存在安全的地方",
|
||||
"backupCodesDescription": "如果你丢失了身份验证器设备,可以使用这些代码访问账户。每个代码只能使用一次。"
|
||||
},
|
||||
"disableModal": {
|
||||
"title": "关闭多重身份验证",
|
||||
"warning": "关闭 MFA 会降低账户的安全性。确定要继续吗?",
|
||||
"prompt": "请输入身份验证器应用中的验证码以确认:",
|
||||
"confirm": "关闭 MFA"
|
||||
},
|
||||
"backupCodesModal": {
|
||||
"title": "新的备用代码",
|
||||
"warning": "你之前的备用代码已失效。请将这些新代码保存在安全的地方。每个代码只能使用一次。"
|
||||
}
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API 令牌",
|
||||
"accessToken": "访问令牌",
|
||||
"description": "创建令牌,通过 API 更新可触发任务的物件",
|
||||
"plusNotice": "基础套餐不支持 API 令牌。升级到 Plus 即可生成 API 令牌,用于对接外部系统并实现任务自动化。",
|
||||
"showToken": "显示令牌",
|
||||
"hideToken": "隐藏令牌",
|
||||
"removeTitle": "移除令牌",
|
||||
"removeMessage": "确定要移除 {{name}} 吗?",
|
||||
"removedTitle": "已移除",
|
||||
"removedMessage": "API 令牌已移除",
|
||||
"tokenCopied": "令牌已复制到剪贴板",
|
||||
"generateNew": "生成新令牌",
|
||||
"nameModalTitle": "给新令牌起个名字,方便你日后识别。",
|
||||
"generateToken": "生成令牌"
|
||||
},
|
||||
"storage": {
|
||||
"title": "存储设置",
|
||||
"serverTitle": "服务器存储用量",
|
||||
"serverDescription": "这是你的账户在我们服务器上占用的存储空间(例如你上传的文件、图片和数据)。",
|
||||
"usagePlaceholder": "已用 -- MB / 共 -- MB(--)",
|
||||
"usage": "已用 {{used}} MB / 共 {{total}} MB({{percent}}%)",
|
||||
"basicPlanNotice": "基础套餐不支持服务器存储。升级到 Plus 即可查看服务器存储用量。",
|
||||
"localTitleApp": "应用本地存储与缓存",
|
||||
"localTitleBrowser": "浏览器本地存储与缓存",
|
||||
"localDescription": "这是为了加快访问速度而保存在浏览器本地的数据。清除不会影响服务器上的数据,但可能会退出登录。",
|
||||
"clearLocal": "清除全部本地存储与缓存",
|
||||
"clearLocalTitle": "清除全部本地存储",
|
||||
"clearLocalMessage": "确定要清除本地存储和缓存吗?这会移除此浏览器中的所有数据,并需要重新登录。",
|
||||
"clearAll": "全部清除",
|
||||
"appPreferences": "应用偏好设置",
|
||||
"deviceOnly": "仅本设备",
|
||||
"appPreferencesDescription": "这是应用保存在本设备上的偏好和设置。清除后应用相关设置会被重置,可能需要重新登录,但不会影响服务器上的数据。",
|
||||
"clearPreferences": "清除应用偏好设置",
|
||||
"clearPreferencesTitle": "清除应用偏好设置",
|
||||
"clearPreferencesMessage": "确定要清除全部应用偏好设置吗?这会重置你的应用设置,并可能需要重新登录。"
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "侧边栏自定义",
|
||||
"heading": "侧边栏设置",
|
||||
"description": "设置侧边栏显示哪些卡片以及它们的顺序。拖放可重新排序,也可切换每张卡片的显示状态。",
|
||||
"resetToDefaults": "恢复默认",
|
||||
"resetHelper": "这会将所有卡片的显示状态和顺序恢复为默认值。",
|
||||
"cards": {
|
||||
"welcome": {
|
||||
"name": "切换用户",
|
||||
"description": "让管理员/管理者以其他用户的视角查看任务"
|
||||
},
|
||||
"smartInsights": {
|
||||
"name": "智能洞察",
|
||||
"description": "基于你的任务提供快捷操作"
|
||||
},
|
||||
"assignees": {
|
||||
"name": "按负责人查看任务",
|
||||
"description": "按任务的负责人进行分组"
|
||||
},
|
||||
"calendar": {
|
||||
"name": "日历视图",
|
||||
"description": "以日历形式显示任务"
|
||||
},
|
||||
"activities": {
|
||||
"name": "近期动态",
|
||||
"description": "显示最近完成的任务和动态"
|
||||
},
|
||||
"weeklyGoals": {
|
||||
"name": "每周目标",
|
||||
"description": "显示每周进度和家庭完成情况统计"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题偏好",
|
||||
"description": "选择站点的外观。可以固定使用某个主题,也可以与系统同步,在日间和夜间主题之间自动切换。",
|
||||
"themeMode": "主题模式",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "跟随系统"
|
||||
},
|
||||
"localization": {
|
||||
"title": "本地化",
|
||||
"description": "自定义账户的语言、日期格式和区域偏好。",
|
||||
"language": "语言",
|
||||
"languageDescription": "选择你偏好的语言",
|
||||
"rtlNotice": "该语言使用从右到左(RTL)的书写方向",
|
||||
"dateFormat": "日期格式",
|
||||
"dateFormatDescription": "选择应用中日期的显示方式",
|
||||
"timeFormat": "时间格式",
|
||||
"timeFormatDescription": "选择 12 小时制或 24 小时制",
|
||||
"preview": "预览:{{value}}",
|
||||
"12hour": "12 小时制(AM/PM)",
|
||||
"24hour": "24 小时制",
|
||||
"firstDayOfWeek": "每周第一天",
|
||||
"firstDayOfWeekDescription": "选择一周从星期几开始",
|
||||
"sunday": "星期日",
|
||||
"monday": "星期一",
|
||||
"saturday": "星期六",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/YYYY(美国)",
|
||||
"dmy": "DD/MM/YYYY(欧洲)",
|
||||
"ymd": "YYYY-MM-DD(ISO)",
|
||||
"long": "长格式(例如:2024年1月1日)",
|
||||
"short": "短格式(例如:2024年1月1日)"
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高级设置",
|
||||
"description": "配置 Webhook、实时更新等高级功能,进一步提升效率。",
|
||||
"offlineTitle": "离线支持",
|
||||
"offlineDescription": "在此设备/浏览器上离线时也能继续使用 Donetick。你的更改会保存在本地,恢复联网后自动同步。",
|
||||
"offlineToggle": "启用离线支持",
|
||||
"offlineHelper": "关闭后,将移除此设备/浏览器上未同步的离线更改和已保存的离线数据。",
|
||||
"offlineEnabled": "已为此设备/浏览器启用离线模式",
|
||||
"offlineDisabled": "已关闭离线模式并清除本地数据",
|
||||
"offlineDisabledPartial": "离线模式已关闭,但可能仍有部分本地数据保留",
|
||||
"offlineDisableTitle": "关闭离线模式",
|
||||
"offlineDisableMessage": "关闭离线模式会移除此设备/浏览器上未同步的离线更改和已保存的离线数据。确定要继续吗?",
|
||||
"offlineDisableConfirm": "关闭并清除数据",
|
||||
"webhookTitle": "Webhook 集成",
|
||||
"webhookDescription": "Webhook 可以在你的圈子中发生事件时,向其他服务实时发送通知。配置 Webhook URL 即可接收实时更新。",
|
||||
"webhookPlusNotice": "基础套餐不支持 Webhook 通知。升级到 Plus 即可通过 Webhook 接收实时更新。",
|
||||
"webhookToggle": "启用 Webhook",
|
||||
"webhookHelper": "为任务和物件的更新启用 Webhook 通知。",
|
||||
"webhookURL": "Webhook URL",
|
||||
"webhookUpdated": "Webhook URL 更新成功",
|
||||
"webhookUpdateFailed": "更新 Webhook URL 失败",
|
||||
"realtimeTitle": "实时更新",
|
||||
"realtimeDescription": "设置当圈子中的任务和动态发生变化时,你如何接收实时更新。",
|
||||
"realtime": {
|
||||
"toggleLabel": "启用实时更新",
|
||||
"title": "实时更新",
|
||||
"subtitle": "任务更新时立即收到通知",
|
||||
"statusLabel": "状态:",
|
||||
"basicPlan": "基础套餐不支持实时更新。升级到 Plus 即可在任务更新时立即收到通知。",
|
||||
"disabled": "实时更新已关闭。开启后,当你或其他圈子成员完成、跳过或修改任务时,即可看到实时变化。",
|
||||
"connected": "实时更新运行正常。当你或其他圈子成员完成、跳过或修改任务时,你会看到实时变化。",
|
||||
"connecting": "正在连接实时更新…",
|
||||
"errored": "实时更新已开启但未正常工作:{{error}}",
|
||||
"notConnected": "实时更新已开启,但当前未连接。",
|
||||
"basicPlanNotice": "基础套餐不支持实时更新。升级到 Plus,当你或其他圈子成员完成、跳过或修改任务时即可立即收到通知。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
src/App.jsx
11
src/App.jsx
@@ -16,6 +16,7 @@ import useOnboardingGate from './hooks/useOnboardingGate'
|
||||
import useStatusBar from './hooks/useStatusBar'
|
||||
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import { GlobalSearchProvider } from './search/GlobalSearchContext'
|
||||
import { recordRoute } from './service/DiagnosticsSession'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
@@ -42,8 +43,8 @@ const AppContent = () => {
|
||||
recordRoute(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
// // First-launch native users see the onboarding flow before anything else.
|
||||
useOnboardingGate()
|
||||
// First-launch native users see the onboarding flow before anything else.
|
||||
const isRedirectingToOnboarding = useOnboardingGate()
|
||||
|
||||
// Initialize status bar with theme-aware configuration
|
||||
useStatusBar()
|
||||
@@ -95,6 +96,8 @@ const AppContent = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [needRefresh])
|
||||
|
||||
if (isRedirectingToOnboarding) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ImpersonateUserProvider>
|
||||
@@ -145,7 +148,9 @@ function App() {
|
||||
|
||||
<AuthProvider>
|
||||
<SSEProvider>
|
||||
<AppContent />
|
||||
<GlobalSearchProvider>
|
||||
<AppContent />
|
||||
</GlobalSearchProvider>
|
||||
</SSEProvider>
|
||||
</AuthProvider>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,11 @@ import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { PushNotifications } from '@capacitor/push-notifications'
|
||||
import { focusManager } from '@tanstack/react-query'
|
||||
|
||||
import { RegisterDeviceToken } from './utils/Fetcher'
|
||||
import { beginOAuthExchange } from './utils/OAuthExchangeState'
|
||||
import { hasSeenOnboarding } from './utils/Onboarding'
|
||||
import { setPendingInvite } from './utils/PendingInvite'
|
||||
|
||||
// React Router navigate(), injected by <App /> once the router is mounted.
|
||||
// Using client-side navigation (instead of window.location.href) avoids a full
|
||||
@@ -63,7 +66,27 @@ const handleNFCChoreDeepLink = (url, isColdStart) => {
|
||||
|
||||
const handleUrlOpen = (url, isColdStart = false) => {
|
||||
console.log('[NFC] handleUrlOpen:', url)
|
||||
if (url.startsWith('donetick://chores/add')) {
|
||||
let parsedUrl
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const isCircleInvite =
|
||||
(parsedUrl.protocol === 'donetick:' &&
|
||||
parsedUrl.host === 'circle' &&
|
||||
parsedUrl.pathname === '/join') ||
|
||||
(parsedUrl.protocol === 'https:' && parsedUrl.pathname === '/circle/join')
|
||||
|
||||
if (isCircleInvite) {
|
||||
setPendingInvite(parsedUrl.searchParams.get('code'))
|
||||
const needsOnboarding =
|
||||
!hasSeenOnboarding() && !localStorage.getItem('token')
|
||||
routerNavigate(
|
||||
needsOnboarding ? '/onboarding' : `/circle/join${parsedUrl.search}`,
|
||||
)
|
||||
} else if (url.startsWith('donetick://chores/add')) {
|
||||
// Widget "+" / quick-capture buttons: land on the chore list with the
|
||||
// quick-add modal open (MyChores watches for the add_task param and
|
||||
// consumes it). ?mode=scan|voice opens straight into that capture panel.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Sync, SyncDisabled } from '@mui/icons-material'
|
||||
import { Box, Card, Chip, FormHelperText, Switch, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useSSEContext } from '../hooks/useSSEContext'
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { isPlusAccount } from '../utils/Helpers'
|
||||
@@ -12,6 +14,7 @@ const REALTIME_TYPES = {
|
||||
}
|
||||
|
||||
const RealTimeSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
// SSE context
|
||||
@@ -63,26 +66,26 @@ const RealTimeSettings = () => {
|
||||
|
||||
const getStatusDescription = () => {
|
||||
if (!isPlusAccount(userProfile)) {
|
||||
return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when tasks are updated.'
|
||||
return t('advanced.realtime.basicPlan')
|
||||
}
|
||||
|
||||
if (realtimeType === REALTIME_TYPES.DISABLED) {
|
||||
return 'Real-time updates are disabled. Enable them to see live changes when you or other circle members complete, skip, or modify tasks.'
|
||||
return t('advanced.realtime.disabled')
|
||||
}
|
||||
|
||||
if (context.isConnected) {
|
||||
return "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify tasks."
|
||||
return t('advanced.realtime.connected')
|
||||
}
|
||||
|
||||
if (context.isConnecting) {
|
||||
return 'Connecting to real-time updates...'
|
||||
return t('advanced.realtime.connecting')
|
||||
}
|
||||
|
||||
if (context.error) {
|
||||
return `Real-time updates are enabled but not working: ${context.error}`
|
||||
return t('advanced.realtime.errored', { error: context.error })
|
||||
}
|
||||
|
||||
return 'Real-time updates are enabled but not currently connected.'
|
||||
return t('advanced.realtime.notConnected')
|
||||
}
|
||||
|
||||
const getConnectionStatusComponent = () => {
|
||||
@@ -109,7 +112,7 @@ const RealTimeSettings = () => {
|
||||
realtimeType !== REALTIME_TYPES.DISABLED ? 'success' : 'neutral'
|
||||
}
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
inputProps={{ 'aria-label': 'Enable Real-time Updates' }}
|
||||
inputProps={{ 'aria-label': t('advanced.realtime.toggleLabel') }}
|
||||
/>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
@@ -121,10 +124,10 @@ const RealTimeSettings = () => {
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md'>
|
||||
Real-time Updates
|
||||
{t('advanced.realtime.title')}
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Plus Feature
|
||||
{t('common.plusFeature')}
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
@@ -137,7 +140,7 @@ const RealTimeSettings = () => {
|
||||
)}
|
||||
</Box>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Get instant notifications when tasks are updated
|
||||
{t('advanced.realtime.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -167,7 +170,7 @@ const RealTimeSettings = () => {
|
||||
isPlusAccount(userProfile) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Status:
|
||||
{t('advanced.realtime.statusLabel')}
|
||||
</Typography>
|
||||
{getConnectionStatusComponent()}
|
||||
{context.error && (
|
||||
@@ -180,9 +183,7 @@ const RealTimeSettings = () => {
|
||||
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||
Real-time updates are not available in the Basic plan. Upgrade to Plus
|
||||
to receive instant notifications when you or other circle members
|
||||
complete, skip, or modify tasks.
|
||||
{t('advanced.realtime.basicPlanNotice')}
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -22,6 +22,8 @@ export const Z_INDEX = {
|
||||
MODAL_BACKDROP: 2000,
|
||||
MODAL_CONTENT: 2001,
|
||||
MODAL_CLOSE_BUTTON: 2002,
|
||||
// Popups that must float above open modals (portaled to document.body)
|
||||
MODAL_POPOVER: 2100,
|
||||
TOAST: 3000,
|
||||
|
||||
// Critical System UI (9000-9999)
|
||||
|
||||
@@ -8,8 +8,10 @@ import ThemeContext from './ThemeContext'
|
||||
const Contexts = ({ children }) => {
|
||||
const contexts = [
|
||||
AlertsProvider,
|
||||
ThemeContext,
|
||||
// Above ThemeContext: the theme reads the active language to pick the
|
||||
// text direction and the matching emotion cache.
|
||||
LocalizationProvider,
|
||||
ThemeContext,
|
||||
QueryContext,
|
||||
NotificationProvider,
|
||||
RouterContext,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import useStickyState from '@/hooks/useStickyState'
|
||||
import moment from 'moment'
|
||||
import { createContext, useContext, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import useStickyState from '@/hooks/useStickyState'
|
||||
|
||||
const LocalizationContext = createContext()
|
||||
|
||||
export const DATE_FORMATS = {
|
||||
@@ -21,10 +22,12 @@ export const TIME_FORMATS = {
|
||||
export const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
|
||||
|
||||
export const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'ar', name: 'Arabic', nativeName: 'العربية' },
|
||||
{ code: 'de', name: 'German', nativeName: 'Deutsch' },
|
||||
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français' },
|
||||
{ code: 'he', name: 'Hebrew', nativeName: 'עברית' },
|
||||
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
@@ -53,7 +56,10 @@ export const LocalizationProvider = ({ children }) => {
|
||||
}, [language, i18n])
|
||||
|
||||
useEffect(() => {
|
||||
const isRTL = RTL_LANGUAGES.includes(language)
|
||||
const isRTL = RTL_LANGUAGES.some(
|
||||
rtlLanguage =>
|
||||
language === rtlLanguage || language.startsWith(`${rtlLanguage}-`),
|
||||
)
|
||||
document.documentElement.dir = isRTL ? 'rtl' : 'ltr'
|
||||
document.documentElement.lang = language
|
||||
}, [language])
|
||||
@@ -91,7 +97,10 @@ export const LocalizationProvider = ({ children }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const isRTL = RTL_LANGUAGES.includes(language)
|
||||
const isRTL = RTL_LANGUAGES.some(
|
||||
rtlLanguage =>
|
||||
language === rtlLanguage || language.startsWith(`${rtlLanguage}-`),
|
||||
)
|
||||
|
||||
const fmt = {
|
||||
date: formatDate,
|
||||
|
||||
@@ -13,6 +13,7 @@ import SettingsOverview from '@/views/Settings/SettingsOverview'
|
||||
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
|
||||
import ThemeSettings from '@/views/Settings/ThemeSettings'
|
||||
|
||||
import GlobalSearchPage from '../search/GlobalSearchPage'
|
||||
import AuthenticationLoading from '../views/Authorization/Authenticating'
|
||||
import ForgotPasswordView from '../views/Authorization/ForgotPasswordView'
|
||||
import LoginSettings from '../views/Authorization/LoginSettings'
|
||||
@@ -131,6 +132,10 @@ const Router = createBrowserRouter([
|
||||
path: '/chores',
|
||||
element: <MyChores />,
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
element: <GlobalSearchPage />,
|
||||
},
|
||||
{
|
||||
path: '/archived',
|
||||
element: <ArchivedTasks />,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import createCache from '@emotion/cache'
|
||||
import { CacheProvider } from '@emotion/react'
|
||||
import { CssBaseline } from '@mui/joy'
|
||||
import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
|
||||
import PropType from 'prop-types'
|
||||
import { useMemo } from 'react'
|
||||
import { prefixer } from 'stylis'
|
||||
import rtlPlugin from 'stylis-plugin-rtl'
|
||||
|
||||
import { COLORS, THEME_BACKGROUND } from '@/constants/theme'
|
||||
|
||||
import { useLocalization } from './LocalizationContext'
|
||||
|
||||
const primaryColor = 'cyan'
|
||||
const shades = [
|
||||
'50',
|
||||
@@ -24,7 +31,7 @@ const primaryPalette = getPalette(primaryColor)
|
||||
const CONTROL_RADIUS = '12px'
|
||||
const ICON_BUTTON_RADIUS = '10px'
|
||||
|
||||
const theme = extendTheme({
|
||||
const themeConfig = {
|
||||
radius: {
|
||||
xs: '6px',
|
||||
sm: '8px',
|
||||
@@ -140,20 +147,93 @@ const theme = extendTheme({
|
||||
},
|
||||
JoyButtonGroup: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
root: ({ ownerState, theme }) => ({
|
||||
'--ButtonGroup-radius': CONTROL_RADIUS,
|
||||
},
|
||||
...(theme.direction === 'rtl' && buttonGroupRtlGeometry(ownerState)),
|
||||
}),
|
||||
},
|
||||
},
|
||||
// ToggleButtonGroup is styled(StyledButtonGroup) under its own slot name, so
|
||||
// it inherits the same geometry — and the same RTL problem — but not the
|
||||
// JoyButtonGroup override. Its radius is left at Joy's default on purpose;
|
||||
// only the direction-sensitive geometry needs correcting.
|
||||
JoyToggleButtonGroup: {
|
||||
styleOverrides: {
|
||||
root: ({ ownerState, theme }) =>
|
||||
theme.direction === 'rtl' ? buttonGroupRtlGeometry(ownerState) : {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const ThemeContext = ({ children }) => (
|
||||
<CssVarsProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</CssVarsProvider>
|
||||
)
|
||||
// Joy packs a ButtonGroup's direction-sensitive geometry into CSS custom
|
||||
// properties: --Button-radius as a four-corner shorthand, --Button-margin as the
|
||||
// negative overlap that collapses the seam between siblings. stylis-plugin-rtl
|
||||
// mirrors real properties only — it cannot know what a custom property will end
|
||||
// up feeding — so those two survive into RTL still in LTR order, while the
|
||||
// separator borders declared alongside them *do* flip. The result is rounded
|
||||
// corners on the wrong ends and the overlap pulling the wrong way. Re-mirror
|
||||
// them here. Vertical groups have no horizontal geometry, so they are left be.
|
||||
const GROUP_RADIUS = 'var(--ButtonGroup-radius)'
|
||||
const CHILD_RADIUS = 'var(--unstable_childRadius)'
|
||||
const OVERLAP = 'calc(var(--ButtonGroup-separatorSize) * -1)'
|
||||
|
||||
// Corners read clockwise from top-left. [data-first-child] is the DOM-first
|
||||
// button, which in RTL renders at the *right* end of the group, so it is the one
|
||||
// that needs its right corners rounded — and vice versa for [data-last-child].
|
||||
const ROUNDED_RIGHT = `${CHILD_RADIUS} ${GROUP_RADIUS} ${GROUP_RADIUS} ${CHILD_RADIUS}`
|
||||
const ROUNDED_LEFT = `${GROUP_RADIUS} ${CHILD_RADIUS} ${CHILD_RADIUS} ${GROUP_RADIUS}`
|
||||
|
||||
const buttonGroupRtlGeometry = ownerState => {
|
||||
if (ownerState.orientation === 'vertical') return {}
|
||||
return {
|
||||
'& > [data-first-child]': {
|
||||
'--Button-radius': ROUNDED_RIGHT,
|
||||
'--IconButton-radius': ROUNDED_RIGHT,
|
||||
},
|
||||
'& > [data-last-child]': {
|
||||
'--Button-radius': ROUNDED_LEFT,
|
||||
'--IconButton-radius': ROUNDED_LEFT,
|
||||
},
|
||||
// Each non-first button overlaps the sibling to its right in RTL.
|
||||
'& > :not([data-first-child]):not(:only-child)': {
|
||||
'--Button-margin': `0 ${OVERLAP} 0 0`,
|
||||
'--IconButton-margin': `0 ${OVERLAP} 0 0`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// One theme and one emotion cache per direction, built once and reused. The RTL
|
||||
// cache runs every rule through stylis-plugin-rtl, which mirrors the physical
|
||||
// properties (margin-left, left, text-align, translateX, …) that `sx` emits, so
|
||||
// components written for LTR lay out correctly without per-component overrides.
|
||||
const byDirection = {
|
||||
ltr: {
|
||||
theme: extendTheme({ ...themeConfig, direction: 'ltr' }),
|
||||
cache: createCache({ key: 'dt', stylisPlugins: [prefixer] }),
|
||||
},
|
||||
rtl: {
|
||||
theme: extendTheme({ ...themeConfig, direction: 'rtl' }),
|
||||
cache: createCache({ key: 'dt-rtl', stylisPlugins: [prefixer, rtlPlugin] }),
|
||||
},
|
||||
}
|
||||
|
||||
const ThemeContext = ({ children }) => {
|
||||
const { isRTL } = useLocalization()
|
||||
const { cache, theme } = useMemo(
|
||||
() => byDirection[isRTL ? 'rtl' : 'ltr'],
|
||||
[isRTL],
|
||||
)
|
||||
|
||||
return (
|
||||
<CacheProvider value={cache}>
|
||||
<CssVarsProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</CssVarsProvider>
|
||||
</CacheProvider>
|
||||
)
|
||||
}
|
||||
|
||||
ThemeContext.propTypes = {
|
||||
children: PropType.node,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding'
|
||||
import { setPendingInvite } from '../utils/PendingInvite'
|
||||
|
||||
// Routes a first-run user may legitimately be on without having gone through
|
||||
// onboarding: the flow itself, deep-link auth callbacks, and the legal pages
|
||||
@@ -27,16 +28,24 @@ const isAllowed = pathname =>
|
||||
*/
|
||||
const useOnboardingGate = () => {
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const { pathname, search } = useLocation()
|
||||
const isRedirecting =
|
||||
isNativeApp() &&
|
||||
!hasSeenOnboarding() &&
|
||||
!localStorage.getItem('token') &&
|
||||
!isAllowed(pathname)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNativeApp() || hasSeenOnboarding()) return
|
||||
// A signed-in user upgrading from an older build has nothing to onboard to.
|
||||
if (localStorage.getItem('token')) return
|
||||
if (isAllowed(pathname)) return
|
||||
if (!isRedirecting) return
|
||||
|
||||
if (pathname === '/circle/join') {
|
||||
setPendingInvite(new URLSearchParams(search).get('code'))
|
||||
}
|
||||
|
||||
navigate('/onboarding', { replace: true })
|
||||
}, [pathname, navigate])
|
||||
}, [isRedirecting, pathname, search, navigate])
|
||||
|
||||
return isRedirecting
|
||||
}
|
||||
|
||||
export default useOnboardingGate
|
||||
|
||||
@@ -2,9 +2,10 @@ import { App as capacitorApp } from '@capacitor/app'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { commandQueue } from '../utils/CommandQueue'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
import { networkManager } from './NetworkManager'
|
||||
@@ -91,6 +92,10 @@ export function useSyncOnReconnect() {
|
||||
|
||||
const runSync = async () => {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
// Public routes (onboarding, login, signup) have no session to sync.
|
||||
// Calling /sync/changes here returns 401 and the global auth handler
|
||||
// hard-navigates to /login, which reloads the WebView mid-onboarding.
|
||||
if (!localStorage.getItem('token')) return
|
||||
// Skip while the OAuth code exchange is in flight — there's no session
|
||||
// yet, so a sync here just 401s. Note the app-resume listener fires in
|
||||
// the same tick as the deep link, before the route changes, so this has
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'moment/locale/he'
|
||||
import 'moment/locale/ja'
|
||||
|
||||
import i18n from 'i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import HttpBackend from 'i18next-http-backend'
|
||||
import 'moment/locale/ja'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
i18n
|
||||
@@ -11,7 +13,7 @@ i18n
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
debug: import.meta.env.DEV,
|
||||
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
@@ -55,69 +55,17 @@ html {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* RTL Support */
|
||||
[dir='rtl'] {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[dir='rtl'] .rtl-mirror {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* Handle margins and paddings for RTL */
|
||||
[dir='rtl'] .ml-auto {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
[dir='rtl'] .mr-auto {
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Flip icons and arrows in RTL */
|
||||
/*
|
||||
* RTL Support
|
||||
*
|
||||
* Component styling is mirrored by stylis-plugin-rtl (see ThemeContext), which
|
||||
* flips the physical properties emitted by `sx`. Do not add per-component
|
||||
* `[dir='rtl']` overrides here — they fight the plugin and double-flip.
|
||||
*
|
||||
* Only opt-in utilities belong in this block:
|
||||
* .rtl-flip — mirror an icon that encodes a direction (arrows, chevrons).
|
||||
* Do NOT use it on symbols that read the same either way.
|
||||
*/
|
||||
[dir='rtl'] .rtl-flip {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* Ensure proper text alignment in RTL */
|
||||
[dir='rtl'] input,
|
||||
[dir='rtl'] textarea {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Handle border radius for RTL */
|
||||
[dir='rtl'] .rounded-l-none {
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
}
|
||||
|
||||
[dir='rtl'] .rounded-r-none {
|
||||
border-radius: 0.375rem 0 0 0.375rem;
|
||||
}
|
||||
|
||||
/* Fix flex alignment for RTL */
|
||||
[dir='rtl'] .flex {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/* Ensure cards and containers align properly in RTL */
|
||||
[dir='rtl'] .MuiCard-root,
|
||||
[dir='rtl'] .MuiBox-root,
|
||||
[dir='rtl'] .MuiStack-root {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Fix list item alignment in RTL */
|
||||
[dir='rtl'] .MuiListItem-root,
|
||||
[dir='rtl'] .MuiListItemButton-root {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
/* Fix gap alignment in RTL */
|
||||
[dir='rtl'] .gap-1,
|
||||
[dir='rtl'] .gap-2,
|
||||
[dir='rtl'] .gap-3,
|
||||
[dir='rtl'] .gap-4 {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
192
src/search/GlobalSearchContext.jsx
Normal file
192
src/search/GlobalSearchContext.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isParentUser } from '../utils/UserHelpers'
|
||||
import GlobalSearchPalette from './GlobalSearchPalette'
|
||||
import { getSearchProviders } from './searchProviders'
|
||||
|
||||
const GlobalSearchContext = createContext(null)
|
||||
const BLOCKED_ROUTES = [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/welcome',
|
||||
'/onboarding',
|
||||
'/get-started',
|
||||
'/ready',
|
||||
]
|
||||
|
||||
const unwrap = value => (Array.isArray(value) ? value : value?.res || [])
|
||||
const uniqueBy = (items, getId) => [
|
||||
...new Map(
|
||||
items.filter(Boolean).map(item => [String(getId(item)), item]),
|
||||
).values(),
|
||||
]
|
||||
|
||||
export const GlobalSearchProvider = ({ children }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery('(max-width:768px)')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [initialQuery, setInitialQuery] = useState('')
|
||||
const [documents, setDocuments] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const cachedChores = queryClient
|
||||
.getQueriesData({ queryKey: ['chores'] })
|
||||
.flatMap(([, data]) => unwrap(data))
|
||||
const cachedHistory = [
|
||||
...queryClient.getQueriesData({ queryKey: ['choresHistory'] }),
|
||||
...queryClient.getQueriesData({ queryKey: ['choreHistory'] }),
|
||||
].flatMap(([, data]) => unwrap(data))
|
||||
|
||||
const [
|
||||
offlineChores,
|
||||
offlineHistory,
|
||||
offlineProjects,
|
||||
offlineLabels,
|
||||
offlineMembers,
|
||||
offlineProfile,
|
||||
] = await Promise.all([
|
||||
offlineDB.getChores(true).catch(() => []),
|
||||
offlineDB.getHistoryByDays(365).catch(() => []),
|
||||
offlineDB.getKV('projects').catch(() => []),
|
||||
offlineDB.getKV('labels').catch(() => []),
|
||||
offlineDB.getKV('circle_members').catch(() => []),
|
||||
offlineDB.getKV('user_profile').catch(() => null),
|
||||
])
|
||||
|
||||
const projects = uniqueBy(
|
||||
[
|
||||
...unwrap(queryClient.getQueryData(['projects'])),
|
||||
...unwrap(offlineProjects),
|
||||
],
|
||||
item => item.id,
|
||||
)
|
||||
const labels = uniqueBy(
|
||||
[
|
||||
...unwrap(queryClient.getQueryData(['labels'])),
|
||||
...unwrap(offlineLabels),
|
||||
],
|
||||
item => item.id,
|
||||
)
|
||||
const members = uniqueBy(
|
||||
[
|
||||
...unwrap(queryClient.getQueryData(['allCircleMembers'])),
|
||||
...unwrap(offlineMembers),
|
||||
],
|
||||
item => item.userId,
|
||||
)
|
||||
const chores = uniqueBy(
|
||||
[...cachedChores, ...unwrap(offlineChores)],
|
||||
item => item.id,
|
||||
)
|
||||
const history = uniqueBy(
|
||||
[...cachedHistory, ...unwrap(offlineHistory)],
|
||||
item => item.id,
|
||||
)
|
||||
const profile =
|
||||
queryClient
|
||||
.getQueriesData({ queryKey: ['userProfile'] })
|
||||
.find(([, data]) => data)?.[1] || offlineProfile
|
||||
|
||||
const sources = {
|
||||
chores,
|
||||
history,
|
||||
projects,
|
||||
labels,
|
||||
members,
|
||||
isParent: isParentUser(profile),
|
||||
choresById: new Map(chores.map(item => [String(item.id), item])),
|
||||
projectsById: new Map(projects.map(item => [String(item.id), item])),
|
||||
membersById: new Map(members.map(item => [String(item.userId), item])),
|
||||
}
|
||||
|
||||
const nextDocuments = getSearchProviders().flatMap(provider => {
|
||||
try {
|
||||
return provider.getDocuments(sources) || []
|
||||
} catch (error) {
|
||||
console.warn(`Search provider ${provider.id} failed`, error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
setDocuments(nextDocuments)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [queryClient])
|
||||
|
||||
const openSearch = useCallback(
|
||||
(query = '') => {
|
||||
if (BLOCKED_ROUTES.some(route => location.pathname.startsWith(route)))
|
||||
return
|
||||
if (isMobile) {
|
||||
loadDocuments()
|
||||
navigate('/search', { state: { initialQuery: query } })
|
||||
return
|
||||
}
|
||||
setInitialQuery(query)
|
||||
setIsOpen(true)
|
||||
loadDocuments()
|
||||
},
|
||||
[isMobile, loadDocuments, location.pathname, navigate],
|
||||
)
|
||||
|
||||
const closeSearch = useCallback(() => setIsOpen(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = event => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault()
|
||||
isOpen ? closeSearch() : openSearch()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [closeSearch, isOpen, openSearch])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
closeSearch,
|
||||
documents,
|
||||
isLoading,
|
||||
loadDocuments,
|
||||
openSearch,
|
||||
}),
|
||||
[closeSearch, documents, isLoading, loadDocuments, openSearch],
|
||||
)
|
||||
|
||||
return (
|
||||
<GlobalSearchContext.Provider value={value}>
|
||||
{children}
|
||||
{isOpen && (
|
||||
<GlobalSearchPalette
|
||||
documents={documents}
|
||||
initialQuery={initialQuery}
|
||||
isLoading={isLoading}
|
||||
onClose={closeSearch}
|
||||
/>
|
||||
)}
|
||||
</GlobalSearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useGlobalSearch = () => {
|
||||
const context = useContext(GlobalSearchContext)
|
||||
if (!context)
|
||||
throw new Error('useGlobalSearch must be used inside GlobalSearchProvider')
|
||||
return context
|
||||
}
|
||||
32
src/search/GlobalSearchPage.jsx
Normal file
32
src/search/GlobalSearchPage.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useGlobalSearch } from './GlobalSearchContext'
|
||||
import GlobalSearchPalette from './GlobalSearchPalette'
|
||||
|
||||
const GlobalSearchPage = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { documents, isLoading, loadDocuments } = useGlobalSearch()
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments()
|
||||
}, [loadDocuments])
|
||||
|
||||
const handleClose = () => {
|
||||
if (window.history.state?.idx > 0) navigate(-1)
|
||||
else navigate('/chores', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<GlobalSearchPalette
|
||||
documents={documents}
|
||||
initialQuery={location.state?.initialQuery || ''}
|
||||
isLoading={isLoading}
|
||||
onClose={handleClose}
|
||||
presentation='page'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default GlobalSearchPage
|
||||
466
src/search/GlobalSearchPalette.jsx
Normal file
466
src/search/GlobalSearchPalette.jsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import {
|
||||
AddRounded,
|
||||
CheckCircleOutline,
|
||||
FolderOutlined,
|
||||
HistoryRounded,
|
||||
InboxOutlined,
|
||||
LabelOutlined,
|
||||
PersonOutline,
|
||||
SearchRounded,
|
||||
SettingsOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Input,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import AppModal from '../components/common/AppModal'
|
||||
|
||||
const RECENTS_KEY = 'donetick.globalSearch.recents'
|
||||
const GROUPS = [
|
||||
'tasks',
|
||||
'history',
|
||||
'projects',
|
||||
'labels',
|
||||
'people',
|
||||
'settings',
|
||||
'actions',
|
||||
]
|
||||
|
||||
const ICONS = {
|
||||
tasks: <CheckCircleOutline />,
|
||||
history: <HistoryRounded />,
|
||||
projects: <FolderOutlined />,
|
||||
labels: <LabelOutlined />,
|
||||
people: <PersonOutline />,
|
||||
settings: <SettingsOutlined />,
|
||||
actions: <AddRounded />,
|
||||
}
|
||||
|
||||
const buildQuickActions = t => [
|
||||
{
|
||||
id: 'action:create',
|
||||
provider: 'actions',
|
||||
title: t('search.actions.createTask'),
|
||||
subtitle: t('search.actions.quickAction'),
|
||||
route: '/chores/create',
|
||||
},
|
||||
{
|
||||
id: 'action:tasks',
|
||||
provider: 'actions',
|
||||
title: t('search.actions.viewAllTasks'),
|
||||
subtitle: t('search.actions.navigation'),
|
||||
route: '/chores',
|
||||
},
|
||||
{
|
||||
id: 'action:archived',
|
||||
provider: 'actions',
|
||||
title: t('search.actions.viewArchivedTasks'),
|
||||
subtitle: t('search.actions.navigation'),
|
||||
route: '/archived',
|
||||
},
|
||||
{
|
||||
id: 'action:settings',
|
||||
provider: 'actions',
|
||||
title: t('search.actions.openSettings'),
|
||||
subtitle: t('search.actions.navigation'),
|
||||
route: '/settings',
|
||||
},
|
||||
]
|
||||
|
||||
const readRecents = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const saveRecent = result => {
|
||||
if (result.provider === 'actions') return
|
||||
const recent = {
|
||||
id: result.id,
|
||||
provider: result.provider,
|
||||
route: result.route,
|
||||
title: result.title,
|
||||
subtitle: result.subtitle,
|
||||
}
|
||||
localStorage.setItem(
|
||||
RECENTS_KEY,
|
||||
JSON.stringify(
|
||||
[recent, ...readRecents().filter(item => item.id !== result.id)].slice(
|
||||
0,
|
||||
6,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const Highlight = ({ query, text }) => {
|
||||
if (!text || !query.trim()) return text || null
|
||||
const words = query.trim().split(/\s+/).filter(Boolean)
|
||||
const escaped = words.map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
if (!escaped.length) return text
|
||||
const pattern = new RegExp(`(${escaped.join('|')})`, 'ig')
|
||||
const isMatch = new RegExp(`^(${escaped.join('|')})$`, 'i')
|
||||
return String(text)
|
||||
.split(pattern)
|
||||
.map((part, index) =>
|
||||
isMatch.test(part) ? (
|
||||
<Box
|
||||
component='mark'
|
||||
key={index}
|
||||
sx={{ bgcolor: 'warning.softBg', color: 'inherit', borderRadius: 2 }}
|
||||
>
|
||||
{part}
|
||||
</Box>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const SearchContainer = ({ children, onClose, presentation }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (presentation === 'page') {
|
||||
return (
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100dvh - 56px)',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.body',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
open
|
||||
onClose={onClose}
|
||||
disableRestoreFocus
|
||||
title={t('search.title')}
|
||||
size='lg'
|
||||
maxHeight='min(720px, calc(100dvh - 48px))'
|
||||
contentSx={{
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
sx={{ height: 'min(720px, calc(100dvh - 48px))' }}
|
||||
>
|
||||
{children}
|
||||
</AppModal>
|
||||
)
|
||||
}
|
||||
|
||||
const GlobalSearchPalette = ({
|
||||
documents,
|
||||
initialQuery,
|
||||
isLoading,
|
||||
onClose,
|
||||
presentation = 'modal',
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const focusInputRef = useCallback(node => {
|
||||
if (node) requestAnimationFrame(() => node.focus())
|
||||
}, [])
|
||||
const [query, setQuery] = useState(initialQuery || '')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [recents] = useState(readRecents)
|
||||
const selectedResultRef = useRef(null)
|
||||
|
||||
const searchIndexes = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
GROUPS.filter(group => group !== 'actions').map(group => [
|
||||
group,
|
||||
new Fuse(
|
||||
documents.filter(item => item.provider === group),
|
||||
{
|
||||
threshold: 0.38,
|
||||
distance: 120,
|
||||
ignoreLocation: true,
|
||||
includeScore: true,
|
||||
keys:
|
||||
group === 'history'
|
||||
? [{ name: 'body', weight: 1 }]
|
||||
: [
|
||||
{ name: 'title', weight: 0.5 },
|
||||
{ name: 'keywords', weight: 0.25 },
|
||||
{ name: 'body', weight: 0.17 },
|
||||
{ name: 'subtitle', weight: 0.08 },
|
||||
],
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
[documents],
|
||||
)
|
||||
|
||||
const results = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase()
|
||||
if (!normalized) {
|
||||
const currentById = new Map(documents.map(item => [item.id, item]))
|
||||
const recentResults = recents
|
||||
.map(item => currentById.get(item.id) || item)
|
||||
.filter(item => item.provider !== 'history' || currentById.has(item.id))
|
||||
return [...recentResults, ...buildQuickActions(t)]
|
||||
}
|
||||
|
||||
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
|
||||
(searchIndexes.get(group)?.search(normalized, { limit: 7 }) || [])
|
||||
.map(match => {
|
||||
const title = match.item.title?.toLocaleLowerCase() ?? ''
|
||||
let score = match.score ?? 1
|
||||
|
||||
if (group !== 'history') {
|
||||
if (title === normalized) {
|
||||
score -= 1
|
||||
} else if (title.startsWith(normalized)) {
|
||||
score -= 0.15
|
||||
} else if (title.includes(normalized)) {
|
||||
score -= 0.08
|
||||
}
|
||||
}
|
||||
|
||||
return { ...match.item, score }
|
||||
})
|
||||
.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])
|
||||
|
||||
useEffect(() => {
|
||||
selectedResultRef.current?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}, [selectedIndex, results])
|
||||
|
||||
const selectResult = result => {
|
||||
saveRecent(result)
|
||||
navigate(result.route)
|
||||
if (presentation === 'modal') onClose()
|
||||
}
|
||||
|
||||
const onInputKeyDown = event => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex(index => Math.min(index + 1, results.length - 1))
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex(index => Math.max(index - 1, 0))
|
||||
} else if (event.key === 'Enter' && results[selectedIndex]) {
|
||||
event.preventDefault()
|
||||
selectResult(results[selectedIndex])
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchContainer onClose={onClose} presentation={presentation}>
|
||||
<Box sx={{ p: { xs: 1.5, sm: 2 } }}>
|
||||
<Input
|
||||
autoFocus
|
||||
slotProps={{
|
||||
input: {
|
||||
ref: focusInputRef,
|
||||
'aria-label': t('search.inputAriaLabel'),
|
||||
},
|
||||
}}
|
||||
value={query}
|
||||
onChange={event => {
|
||||
setQuery(event.target.value)
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder={t('search.placeholder')}
|
||||
startDecorator={<SearchRounded />}
|
||||
endDecorator={
|
||||
isLoading ? (
|
||||
<CircularProgress size='sm' />
|
||||
) : presentation === 'modal' ? (
|
||||
<Chip size='sm' variant='outlined'>
|
||||
{t('search.escape')}
|
||||
</Chip>
|
||||
) : null
|
||||
}
|
||||
sx={{
|
||||
'--Input-minHeight': '48px',
|
||||
fontSize: 'md',
|
||||
borderRadius: 'lg',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary', mt: 1, px: 0.5 }}
|
||||
>
|
||||
{t('search.deviceNote')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
pb: 'var(--safe-area-inset-bottom, 0px)',
|
||||
}}
|
||||
>
|
||||
{!isLoading && query.trim() && results.length === 1 && (
|
||||
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
|
||||
<InboxOutlined
|
||||
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
|
||||
/>
|
||||
<Typography level='title-md'>{t('search.empty.title')}</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{t('search.empty.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<List aria-live='polite' sx={{ px: 1, py: 1 }}>
|
||||
{results.map((result, index) => {
|
||||
const hasQuery = Boolean(query.trim())
|
||||
const showHeading = hasQuery
|
||||
? index === 0 || result.provider !== results[index - 1].provider
|
||||
: index === 0 ||
|
||||
(result.provider === 'actions' &&
|
||||
results[index - 1].provider !== 'actions')
|
||||
return (
|
||||
<Box key={result.id}>
|
||||
{showHeading && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
fontWeight: 'lg',
|
||||
px: 1.5,
|
||||
pt: index ? 2 : 0.5,
|
||||
pb: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{!query.trim() && result.provider !== 'actions'
|
||||
? t('search.recent')
|
||||
: t(`search.groups.${result.provider}`)}
|
||||
</Typography>
|
||||
)}
|
||||
<ListItemButton
|
||||
ref={index === selectedIndex ? selectedResultRef : null}
|
||||
selected={index === selectedIndex}
|
||||
onMouseMove={() => setSelectedIndex(index)}
|
||||
onClick={() => selectResult(result)}
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
py: 1.1,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator
|
||||
sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
|
||||
>
|
||||
{ICONS[result.provider]}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{ overflowWrap: 'anywhere' }}
|
||||
>
|
||||
<Highlight query={query} text={result.title} />
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.secondary' }}
|
||||
noWrap
|
||||
>
|
||||
{[result.subtitle, result.body]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
gap: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs'>
|
||||
↑↓ {t('search.footer.navigate')}
|
||||
</Typography>
|
||||
<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.typeToSearch')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SearchContainer>
|
||||
)
|
||||
}
|
||||
|
||||
SearchContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
presentation: PropTypes.oneOf(['modal', 'page']).isRequired,
|
||||
}
|
||||
|
||||
Highlight.propTypes = {
|
||||
query: PropTypes.string.isRequired,
|
||||
text: PropTypes.string,
|
||||
}
|
||||
|
||||
GlobalSearchPalette.propTypes = {
|
||||
documents: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
initialQuery: PropTypes.string,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
presentation: PropTypes.oneOf(['modal', 'page']),
|
||||
}
|
||||
|
||||
export default GlobalSearchPalette
|
||||
182
src/search/searchProviders.js
Normal file
182
src/search/searchProviders.js
Normal file
@@ -0,0 +1,182 @@
|
||||
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 HISTORY_STATUS = {
|
||||
0: 'in progress',
|
||||
1: 'completed',
|
||||
2: 'skipped',
|
||||
3: 'pending approval',
|
||||
4: 'rejected',
|
||||
5: 'missed',
|
||||
6: 'rescheduled',
|
||||
}
|
||||
|
||||
const SETTINGS = [
|
||||
['profile', 'Profile', 'Name, avatar and personal details'],
|
||||
['circle', 'Circle', 'Members and household settings', true],
|
||||
['account', 'Account', 'Subscription and account management', true],
|
||||
['subaccounts', 'Subaccounts', 'Manage child accounts'],
|
||||
['notifications', 'Notifications', 'Reminders and notification preferences'],
|
||||
['mfa', 'Multi-factor authentication', 'Secure your account', true],
|
||||
['apitokens', 'API tokens', 'Manage integrations and access tokens', true],
|
||||
['storage', 'Storage', 'Files, backups and device storage'],
|
||||
['sidepanel', 'Side panel', 'Customize navigation'],
|
||||
['theme', 'Appearance', 'Theme, dark mode and colors'],
|
||||
['localization', 'Language and region', 'Language, dates and time formats'],
|
||||
[
|
||||
'advanced',
|
||||
'Advanced settings',
|
||||
'Offline support, webhooks and application behavior',
|
||||
],
|
||||
['developer', 'Developer settings', 'Diagnostics and experimental tools'],
|
||||
]
|
||||
|
||||
const providers = []
|
||||
|
||||
export const registerSearchProvider = provider => {
|
||||
if (!provider?.id || typeof provider.getDocuments !== 'function') {
|
||||
throw new Error('A search provider needs an id and getDocuments function')
|
||||
}
|
||||
const existing = providers.findIndex(item => item.id === provider.id)
|
||||
if (existing >= 0) providers.splice(existing, 1, provider)
|
||||
else providers.push(provider)
|
||||
return () => {
|
||||
const index = providers.indexOf(provider)
|
||||
if (index >= 0) providers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export const getSearchProviders = () => [...providers]
|
||||
|
||||
const document = (provider, item) => ({ provider, ...item })
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'tasks',
|
||||
getDocuments: ({ chores, membersById, projectsById }) =>
|
||||
chores.map(chore => {
|
||||
const labels =
|
||||
chore.labelsV2?.map(label => label.name).filter(Boolean) || []
|
||||
const project = projectsById.get(String(chore.projectId))
|
||||
const assignees = (chore.assignees || [])
|
||||
.map(assignee => membersById.get(String(assignee.userId))?.displayName)
|
||||
.filter(Boolean)
|
||||
const description = stripHtml(chore.description)
|
||||
return document('tasks', {
|
||||
id: `task:${chore.id}`,
|
||||
entityId: chore.id,
|
||||
title: chore.name || 'Untitled task',
|
||||
subtitle:
|
||||
[project?.name, ...labels].filter(Boolean).join(' · ') || 'Task',
|
||||
body: description,
|
||||
keywords: [...labels, project?.name, ...assignees]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
route: `/chores/${chore.id}`,
|
||||
updatedAt: chore.updatedAt || chore.createdAt,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'history',
|
||||
getDocuments: ({ choresById, history, membersById }) =>
|
||||
history.flatMap(entry => {
|
||||
const note = stripHtml(entry.notes).trim()
|
||||
if (!note) return []
|
||||
|
||||
const chore = choresById.get(String(entry.choreId))
|
||||
const member = membersById.get(String(entry.completedBy))
|
||||
return [
|
||||
document('history', {
|
||||
id: `history:${entry.id}`,
|
||||
entityId: entry.id,
|
||||
title: chore?.name || entry.choreName || 'Task note',
|
||||
subtitle: [
|
||||
member?.displayName,
|
||||
entry.performedAt
|
||||
? new Date(entry.performedAt).toLocaleDateString()
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
body: note,
|
||||
keywords: `${HISTORY_STATUS[entry.status] || 'activity'} ${member?.displayName || ''}`,
|
||||
route: entry.choreId
|
||||
? `/chores/${entry.choreId}/history`
|
||||
: '/activities',
|
||||
updatedAt: entry.performedAt || entry.updatedAt,
|
||||
}),
|
||||
]
|
||||
}),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'projects',
|
||||
getDocuments: ({ projects }) =>
|
||||
projects.map(project =>
|
||||
document('projects', {
|
||||
id: `project:${project.id}`,
|
||||
entityId: project.id,
|
||||
title: project.name || 'Untitled project',
|
||||
subtitle: 'Project',
|
||||
body: stripHtml(project.description),
|
||||
keywords: 'folder project',
|
||||
route: `/chores?project=${encodeURIComponent(project.id)}`,
|
||||
updatedAt: project.updatedAt,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'labels',
|
||||
getDocuments: ({ labels }) =>
|
||||
labels.map(label =>
|
||||
document('labels', {
|
||||
id: `label:${label.id}`,
|
||||
entityId: label.id,
|
||||
title: label.name || 'Untitled label',
|
||||
subtitle: 'Label',
|
||||
keywords: 'tag label',
|
||||
route: '/labels',
|
||||
color: label.color,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'people',
|
||||
getDocuments: ({ members }) =>
|
||||
members.map(member =>
|
||||
document('people', {
|
||||
id: `person:${member.userId}`,
|
||||
entityId: member.userId,
|
||||
title: member.displayName || member.username || 'Circle member',
|
||||
subtitle: 'Circle member',
|
||||
keywords: `${member.username || ''} person member assignee`,
|
||||
route: '/chores',
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'settings',
|
||||
getDocuments: ({ isParent }) =>
|
||||
SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map(
|
||||
([id, title, description]) =>
|
||||
document('settings', {
|
||||
id: `setting:${id}`,
|
||||
entityId: id,
|
||||
title,
|
||||
subtitle: 'Settings',
|
||||
body: description,
|
||||
keywords: `preferences configuration ${id}`,
|
||||
route: `/settings/${id}`,
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -13,7 +13,12 @@ export const joinCirclePath = code =>
|
||||
|
||||
export const setPendingInvite = code => {
|
||||
if (!code) return
|
||||
localStorage.setItem(INVITE_KEY, code)
|
||||
|
||||
try {
|
||||
localStorage.setItem(INVITE_KEY, code)
|
||||
} catch {
|
||||
// The redirect cookie still preserves the invite through authentication.
|
||||
}
|
||||
// Every post-auth landing point (password login, OAuth callback, MFA) already
|
||||
// consumes `ca_redirect`, so reusing it is all the routing this needs.
|
||||
Cookies.set(REDIRECT_COOKIE, joinCirclePath(code), { expires: 1 })
|
||||
@@ -31,10 +36,11 @@ export const clearPendingInvite = () => {
|
||||
try {
|
||||
localStorage.removeItem(INVITE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
// Ignore unavailable storage during cleanup.
|
||||
}
|
||||
|
||||
const redirect = Cookies.get(REDIRECT_COOKIE)
|
||||
if (redirect && redirect.startsWith('/circle/join')) {
|
||||
if (redirect?.startsWith('/circle/join')) {
|
||||
Cookies.remove(REDIRECT_COOKIE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const AuthField = ({ label, error, helper, children, ...formProps }) => (
|
||||
)
|
||||
|
||||
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
|
||||
<AuthField label={label} error={error} helper={helper}>
|
||||
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
|
||||
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
|
||||
</AuthField>
|
||||
)
|
||||
@@ -49,7 +49,7 @@ export const AuthPasswordField = ({
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<AuthField label={label} error={error} helper={helper}>
|
||||
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
|
||||
<Input
|
||||
size='lg'
|
||||
type={visible ? 'text' : 'password'}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
@@ -38,9 +39,9 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import ChoreCard from './ChoreCard'
|
||||
@@ -93,7 +94,7 @@ const applyPendingArchivedState = async chores => {
|
||||
const ArchivedTasks = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { showError, showSuccess } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const queryClient = useQueryClient()
|
||||
const unArchiveChore = useUnArchiveChore()
|
||||
@@ -200,11 +201,11 @@ const ArchivedTasks = () => {
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: finalChores,
|
||||
activeFilters,
|
||||
setFilter,
|
||||
clearAll,
|
||||
filteredData: finalChores,
|
||||
hasActiveFilters,
|
||||
setFilter,
|
||||
} = useFilter(filteredChores, filterDefs)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -253,13 +254,6 @@ const ArchivedTasks = () => {
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + F to focus search input
|
||||
if (isHoldingCmdOrCtrl && event.key === 'f') {
|
||||
event.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + S Toggle Multi-select mode
|
||||
if (isHoldingCmdOrCtrl && event.key === 's') {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -411,14 +411,29 @@ const MyChores = () => {
|
||||
}
|
||||
}, [searchInputFocus])
|
||||
|
||||
// A global-search result can hand a query back to the task list as a scoped filter.
|
||||
useEffect(() => {
|
||||
const query = searchParams.get('search')
|
||||
if (query !== null) {
|
||||
setSearchTerm(query.toLowerCase())
|
||||
setSelectedCalendarDate(null)
|
||||
clearActiveFilter()
|
||||
clearQuickFilters()
|
||||
}
|
||||
// The setters above are intentionally applied only when URL search params change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams])
|
||||
|
||||
// Read and apply project from URL parameters
|
||||
useEffect(() => {
|
||||
if (!projects.length) return
|
||||
|
||||
const projectIdFromUrl = searchParams.get('project')
|
||||
|
||||
if (projectIdFromUrl && projectIdFromUrl !== selectedProject?.id) {
|
||||
const project = projectsWithDefault.find(p => p.id === projectIdFromUrl)
|
||||
if (projectIdFromUrl && projectIdFromUrl !== String(selectedProject?.id)) {
|
||||
const project = projectsWithDefault.find(
|
||||
p => String(p.id) === projectIdFromUrl,
|
||||
)
|
||||
if (project) {
|
||||
setSelectedProjectWithCache(project)
|
||||
}
|
||||
@@ -759,6 +774,11 @@ const MyChores = () => {
|
||||
setFilteredChores(selectedProject ? projectFilteredChores : chores)
|
||||
setSearchInputFocus(0)
|
||||
setSelectedCalendarDate(null)
|
||||
if (searchParams.has('search')) {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('search')
|
||||
setSearchParams(params, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedChoreSectionWithCache = value => {
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { CancelRounded } from '@mui/icons-material'
|
||||
import { CancelRounded, SearchRounded } from '@mui/icons-material'
|
||||
import { Box, Input } from '@mui/joy'
|
||||
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import { useGlobalSearch } from '../../../search/GlobalSearchContext'
|
||||
|
||||
const SearchBar = ({
|
||||
value,
|
||||
inputRef,
|
||||
onChange,
|
||||
onClose,
|
||||
onFocus,
|
||||
showKeyboardShortcuts,
|
||||
inputRef,
|
||||
value,
|
||||
}) => {
|
||||
const { openSearch } = useGlobalSearch()
|
||||
const handleOpen = () => {
|
||||
onFocus?.()
|
||||
openSearch(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
slotProps={{ input: { ref: inputRef } }}
|
||||
placeholder='Search'
|
||||
slotProps={{ input: { ref: inputRef, readOnly: true } }}
|
||||
placeholder='Search Donetick'
|
||||
value={value}
|
||||
onFocus={onFocus}
|
||||
onFocus={handleOpen}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
handleOpen()
|
||||
}}
|
||||
fullWidth
|
||||
sx={{
|
||||
mt: 1,
|
||||
@@ -24,17 +36,29 @@ const SearchBar = ({
|
||||
height: 24,
|
||||
borderColor: 'text.disabled',
|
||||
padding: 1,
|
||||
cursor: 'pointer',
|
||||
'& input': { cursor: 'pointer' },
|
||||
}}
|
||||
onChange={onChange}
|
||||
startDecorator={
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<SearchRounded sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
</Box>
|
||||
}
|
||||
endDecorator={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{value && (
|
||||
<>
|
||||
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
|
||||
<CancelRounded onClick={onClose} />
|
||||
<CancelRounded
|
||||
aria-label='Clear task search'
|
||||
onMouseDown={event => event.stopPropagation()}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const useKeyboardShortcuts = ({
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
addTaskModalOpen,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
filteredChores,
|
||||
handlers,
|
||||
isMultiSelectMode,
|
||||
openChoreSections,
|
||||
searchFilter,
|
||||
searchTerm,
|
||||
selectedChores,
|
||||
}) => {
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
|
||||
@@ -35,10 +35,6 @@ export const useKeyboardShortcuts = ({
|
||||
event.preventDefault()
|
||||
handlers.onNavigateToCreate()
|
||||
return
|
||||
} else if (isHoldingCmdOrCtrl && event.key === 'f') {
|
||||
event.preventDefault()
|
||||
handlers.onFocusSearch()
|
||||
return
|
||||
} else if (isHoldingCmdOrCtrl && event.key === 'x') {
|
||||
event.preventDefault()
|
||||
if (searchTerm?.length > 0) {
|
||||
|
||||
@@ -576,6 +576,7 @@ const FilterView = () => {
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
data-testid='open-add-filter-modal'
|
||||
color='primary'
|
||||
variant='solid'
|
||||
sx={{
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { GetUserCircle, JoinCircle } from '../../utils/Fetcher'
|
||||
import { haptic } from '../../utils/Onboarding'
|
||||
import { clearPendingInvite, getPendingInvite } from '../../utils/PendingInvite'
|
||||
import { authButtonSx } from '../Authorization/authStyles'
|
||||
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
|
||||
import { CircleVignette } from './OnboardingVignettes'
|
||||
@@ -81,10 +83,11 @@ const CircleSetupView = () => {
|
||||
const navigate = useNavigate()
|
||||
const { showNotification } = useNotification()
|
||||
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
||||
const pendingInvite = getPendingInvite()
|
||||
|
||||
const [mode, setMode] = useState('invite')
|
||||
const [mode, setMode] = useState(pendingInvite ? 'join' : 'invite')
|
||||
const [inviteCode, setInviteCode] = useState(null)
|
||||
const [joinCode, setJoinCode] = useState('')
|
||||
const [joinCode, setJoinCode] = useState(pendingInvite ?? '')
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,6 +118,7 @@ const CircleSetupView = () => {
|
||||
try {
|
||||
const resp = await JoinCircle(joinCode.trim())
|
||||
if (resp.ok) {
|
||||
clearPendingInvite()
|
||||
showAcknowledgment(
|
||||
"Your join request has been sent! The circle owner will need to approve it before you can see their chores. You'll get a notification once you're in.",
|
||||
'Request Sent',
|
||||
@@ -308,7 +312,11 @@ const CircleSetupView = () => {
|
||||
level='body-sm'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
onClick={() => setMode('invite')}
|
||||
onClick={() => {
|
||||
clearPendingInvite()
|
||||
setJoinCode('')
|
||||
setMode('invite')
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
|
||||
@@ -495,6 +495,7 @@ const ProjectView = () => {
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
data-testid='open-add-project-modal'
|
||||
color='primary'
|
||||
variant='solid'
|
||||
sx={{
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
@@ -26,6 +27,7 @@ import TextModal from '../Modals/Inputs/TextModal'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const APITokenSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { showNotification } = useNotification()
|
||||
const { fmt } = useLocalization()
|
||||
@@ -38,8 +40,8 @@ const APITokenSettings = () => {
|
||||
message,
|
||||
title,
|
||||
onConfirm,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText = t('common.confirm'),
|
||||
cancelText = t('common.cancel'),
|
||||
color = 'primary',
|
||||
) => {
|
||||
setConfirmModalConfig({
|
||||
@@ -80,23 +82,18 @@ const APITokenSettings = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout title='API Tokens'>
|
||||
<SettingsLayout title={t('apiTokens.title')}>
|
||||
<div className='grid gap-4 py-4' id='apitokens'>
|
||||
<Typography level='h3'>Access Token</Typography>
|
||||
<Typography level='h3'>{t('apiTokens.accessToken')}</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-sm'>
|
||||
Create token to use with the API to update things that trigger task or
|
||||
chores
|
||||
</Typography>
|
||||
<Typography level='body-sm'>{t('apiTokens.description')}</Typography>
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<>
|
||||
<Chip variant='soft' color='warning'>
|
||||
Plus Feature
|
||||
{t('common.plusFeature')}
|
||||
</Chip>
|
||||
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||
API tokens are not available in the Basic plan. Upgrade to Plus to
|
||||
generate API tokens for integrating with external systems and
|
||||
automating your tasks.
|
||||
{t('apiTokens.plusNotice')}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
@@ -125,7 +122,9 @@ const APITokenSettings = () => {
|
||||
setShowTokenId(token.id)
|
||||
}}
|
||||
>
|
||||
{showTokenId === token?.id ? 'Hide' : 'Show'} Token
|
||||
{showTokenId === token?.id
|
||||
? t('apiTokens.hideToken')
|
||||
: t('apiTokens.showToken')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -133,15 +132,15 @@ const APITokenSettings = () => {
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
`Are you sure you want to remove ${token.name}?`,
|
||||
'Remove Token',
|
||||
t('apiTokens.removeMessage', { name: token.name }),
|
||||
t('apiTokens.removeTitle'),
|
||||
() => {
|
||||
DeleteLongLiveToken(token.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Removed',
|
||||
message: 'API token has been removed',
|
||||
title: t('apiTokens.removedTitle'),
|
||||
message: t('apiTokens.removedMessage'),
|
||||
})
|
||||
const newTokens = tokens.filter(
|
||||
t => t.id !== token.id,
|
||||
@@ -150,13 +149,13 @@ const APITokenSettings = () => {
|
||||
}
|
||||
})
|
||||
},
|
||||
'Remove',
|
||||
'Cancel',
|
||||
t('common.remove'),
|
||||
t('common.cancel'),
|
||||
'danger',
|
||||
)
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
{t('common.remove')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -174,7 +173,7 @@ const APITokenSettings = () => {
|
||||
navigator.clipboard.writeText(token.token)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Token copied to clipboard',
|
||||
message: t('apiTokens.tokenCopied'),
|
||||
})
|
||||
setShowTokenId(null)
|
||||
}}
|
||||
@@ -200,15 +199,15 @@ const APITokenSettings = () => {
|
||||
setIsGetTokenNameModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Generate New Token
|
||||
{t('apiTokens.generateNew')}
|
||||
</Button>
|
||||
<TextModal
|
||||
isOpen={isGetTokenNameModalOpen}
|
||||
title='Give a name for your new token, something to remember it by.'
|
||||
title={t('apiTokens.nameModalTitle')}
|
||||
onClose={() => {
|
||||
setIsGetTokenNameModalOpen(false)
|
||||
}}
|
||||
okText={'Generate Token'}
|
||||
okText={t('apiTokens.generateToken')}
|
||||
onSave={handleSaveToken}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Purchases } from '@revenuecat/purchases-capacitor'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import SubscriptionModal from '../../components/SubscriptionModal'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
@@ -15,6 +17,7 @@ import UserDeletionModal from '../Modals/Inputs/UserDeletionModal'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const AccountSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const queryClient = useQueryClient()
|
||||
const { showNotification } = useNotification()
|
||||
@@ -42,43 +45,49 @@ const AccountSettings = () => {
|
||||
|
||||
const getSubscriptionDetails = () => {
|
||||
if (userProfile?.subscription === 'active') {
|
||||
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${fmt.date(userProfile?.expiration)}.`
|
||||
return t('accountSettings.activeDescription', {
|
||||
date: fmt.date(userProfile?.expiration),
|
||||
})
|
||||
} else if (userProfile?.subscription === 'cancelled') {
|
||||
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${fmt.date(userProfile?.expiration)}.`
|
||||
return t('accountSettings.cancelledDescription', {
|
||||
date: fmt.date(userProfile?.expiration),
|
||||
})
|
||||
} else {
|
||||
return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.`
|
||||
return t('accountSettings.freeDescription')
|
||||
}
|
||||
}
|
||||
|
||||
const getSubscriptionStatus = () => {
|
||||
if (userProfile?.subscription === 'active') {
|
||||
return `Plus`
|
||||
return t('accountSettings.plus')
|
||||
} else if (userProfile?.subscription === 'cancelled') {
|
||||
if (moment().isBefore(userProfile?.expiration)) {
|
||||
return `Plus(until ${fmt.date(userProfile?.expiration)})`
|
||||
return t('accountSettings.plusUntil', {
|
||||
date: fmt.date(userProfile?.expiration),
|
||||
})
|
||||
}
|
||||
return `Free`
|
||||
return t('accountSettings.free')
|
||||
} else {
|
||||
return `Free`
|
||||
return t('accountSettings.free')
|
||||
}
|
||||
}
|
||||
|
||||
if (!userProfile) {
|
||||
return (
|
||||
<SettingsLayout title='Account Settings'>
|
||||
<div>Loading...</div>
|
||||
<SettingsLayout title={t('accountSettings.title')}>
|
||||
<div>{t('common.loading')}</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout title='Account Settings'>
|
||||
<SettingsLayout title={t('accountSettings.title')}>
|
||||
<div className='grid gap-4'>
|
||||
<Typography level='body-md'>
|
||||
Change your account settings, type or update your password
|
||||
{t('accountSettings.description')}
|
||||
</Typography>
|
||||
<Typography level='title-md' mb={-1}>
|
||||
Account Type : {getSubscriptionStatus()}
|
||||
{t('accountSettings.accountType', { type: getSubscriptionStatus() })}
|
||||
</Typography>
|
||||
<Typography level='body-sm'>{getSubscriptionDetails()}</Typography>
|
||||
<Box>
|
||||
@@ -95,9 +104,8 @@ const AccountSettings = () => {
|
||||
onClick={async () => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
const { RevenueCatUI } = await import(
|
||||
'@revenuecat/purchases-capacitor-ui'
|
||||
)
|
||||
const { RevenueCatUI } =
|
||||
await import('@revenuecat/purchases-capacitor-ui')
|
||||
|
||||
const offering = await Purchases.getOfferings()
|
||||
await RevenueCatUI.presentPaywall({
|
||||
@@ -110,8 +118,7 @@ const AccountSettings = () => {
|
||||
queryClient.refetchQueries(['userProfile'])
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message:
|
||||
'Purchase successful! Please restart the app to access Plus features.',
|
||||
message: t('accountSettings.purchase.success'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -122,57 +129,53 @@ const AccountSettings = () => {
|
||||
} else if (error.code === '2') {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message:
|
||||
'Store connection issue. Please check your network and try again.',
|
||||
message: t('accountSettings.purchase.storeConnection'),
|
||||
})
|
||||
} else if (error.code === '3') {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message:
|
||||
'Purchases are not allowed on this device. Please check your device restrictions.',
|
||||
message: t('accountSettings.purchase.notAllowed'),
|
||||
})
|
||||
} else if (error.code === '4') {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message:
|
||||
'This subscription is not available. Please try again later.',
|
||||
message: t('accountSettings.purchase.unavailable'),
|
||||
})
|
||||
} else if (error.code === '5') {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message:
|
||||
'This purchase has already been processed. If you believe this is an error, please contact support.',
|
||||
message: t('accountSettings.purchase.alreadyProcessed'),
|
||||
})
|
||||
} else if (error.code === '6') {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message:
|
||||
'Purchase receipt missing. Please try purchasing again.',
|
||||
message: t('accountSettings.purchase.receiptMissing'),
|
||||
})
|
||||
} else if (error.code === '7') {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message:
|
||||
'Network error. Please check your connection and try again.',
|
||||
message: t('accountSettings.purchase.networkError'),
|
||||
})
|
||||
} else if (error.code === '8') {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message:
|
||||
'Invalid purchase receipt. Please contact support if this persists.',
|
||||
message: t('accountSettings.purchase.invalidReceipt'),
|
||||
})
|
||||
} else if (error.code === '9') {
|
||||
showNotification({
|
||||
type: 'warning',
|
||||
message:
|
||||
'Payment is pending approval. You will receive access once approved.',
|
||||
message: t('accountSettings.purchase.pending'),
|
||||
})
|
||||
} else {
|
||||
console.error('Unexpected purchase error:', error)
|
||||
console.error('Error occurred in purchase flow')
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Purchase failed: ${error.message || 'Unknown error'}. Please try again or contact support.`,
|
||||
message: t('accountSettings.purchase.failed', {
|
||||
error:
|
||||
error.message ||
|
||||
t('accountSettings.purchase.unknownError'),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -181,7 +184,7 @@ const AccountSettings = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upgrade
|
||||
{t('accountSettings.upgrade')}
|
||||
</Button>
|
||||
|
||||
{userProfile?.subscription === 'active' && (
|
||||
@@ -197,14 +200,14 @@ const AccountSettings = () => {
|
||||
setNativeCancelModal(true)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('accountSettings.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{import.meta.env.VITE_IS_SELF_HOSTED === 'true' && (
|
||||
<Box>
|
||||
<Typography level='title-md' mb={1}>
|
||||
Password :
|
||||
{t('accountSettings.password')}
|
||||
</Typography>
|
||||
<Typography mb={1} level='body-sm'></Typography>
|
||||
<Button
|
||||
@@ -213,7 +216,7 @@ const AccountSettings = () => {
|
||||
setChangePasswordModal(true)
|
||||
}}
|
||||
>
|
||||
Change Password
|
||||
{t('accountSettings.changePassword')}
|
||||
</Button>
|
||||
{changePasswordModal ? (
|
||||
<PassowrdChangeModal
|
||||
@@ -224,12 +227,12 @@ const AccountSettings = () => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Password changed successfully',
|
||||
message: t('accountSettings.passwordChanged'),
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Password change failed',
|
||||
message: t('accountSettings.passwordChangeFailed'),
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -243,18 +246,17 @@ const AccountSettings = () => {
|
||||
|
||||
<Box>
|
||||
<Typography level='title-md' mb={1} color='danger'>
|
||||
Danger Zone
|
||||
{t('accountSettings.dangerZone')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={2} color='neutral'>
|
||||
Once you delete your account, there is no going back. Please be
|
||||
certain.
|
||||
{t('accountSettings.dangerZoneDescription')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => setUserDeletionModal(true)}
|
||||
>
|
||||
Delete Account
|
||||
{t('accountSettings.deleteAccount')}
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
@@ -271,7 +273,7 @@ const AccountSettings = () => {
|
||||
if (success) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Account deleted successfully',
|
||||
message: t('accountSettings.accountDeleted'),
|
||||
})
|
||||
}
|
||||
}}
|
||||
@@ -287,13 +289,13 @@ const AccountSettings = () => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Subscription cancelled',
|
||||
message: t('accountSettings.subscriptionCancelled'),
|
||||
})
|
||||
window.location.reload()
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to cancel subscription',
|
||||
message: t('accountSettings.subscriptionCancelFailed'),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
@@ -27,6 +29,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const AdvancedSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const queryClient = useQueryClient()
|
||||
const { showNotification } = useNotification()
|
||||
@@ -73,7 +76,7 @@ const AdvancedSettings = () => {
|
||||
queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Offline mode turned off and local data was cleared',
|
||||
message: t('advanced.offlineDisabled'),
|
||||
})
|
||||
} catch {
|
||||
setOfflineFeatureEnabled(false)
|
||||
@@ -82,8 +85,7 @@ const AdvancedSettings = () => {
|
||||
queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'warning',
|
||||
message:
|
||||
'Offline mode was turned off, but some local data may still be stored',
|
||||
message: t('advanced.offlineDisabledPartial'),
|
||||
})
|
||||
} finally {
|
||||
setOfflineLoading(false)
|
||||
@@ -93,11 +95,10 @@ const AdvancedSettings = () => {
|
||||
const showDisableOfflineConfirmation = () => {
|
||||
setConfirmModalConfig({
|
||||
isOpen: true,
|
||||
title: 'Turn Off Offline Mode',
|
||||
message:
|
||||
'Turning off offline mode will remove unsynced offline changes and saved offline data on this device/browser. Do you want to continue?',
|
||||
confirmText: 'Turn Off & Clear Data',
|
||||
cancelText: 'Cancel',
|
||||
title: t('advanced.offlineDisableTitle'),
|
||||
message: t('advanced.offlineDisableMessage'),
|
||||
confirmText: t('advanced.offlineDisableConfirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
color: 'danger',
|
||||
onClose: isConfirmed => {
|
||||
setConfirmModalConfig({})
|
||||
@@ -117,7 +118,7 @@ const AdvancedSettings = () => {
|
||||
queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Offline mode turned on for this device/browser',
|
||||
message: t('advanced.offlineEnabled'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -134,15 +135,12 @@ const AdvancedSettings = () => {
|
||||
// }
|
||||
|
||||
return (
|
||||
<SettingsLayout title='Advanced Settings'>
|
||||
<SettingsLayout title={t('advanced.title')}>
|
||||
<div className='grid gap-4'>
|
||||
<Typography level='body-md'>
|
||||
Configure advanced features like webhooks and real-time updates for
|
||||
enhanced productivity.
|
||||
</Typography>
|
||||
<Typography level='body-md'>{t('advanced.description')}</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
||||
<Typography level='title-lg'>Offline Support</Typography>
|
||||
<Typography level='title-lg'>{t('advanced.offlineTitle')}</Typography>
|
||||
<Chip
|
||||
variant='outlined'
|
||||
size='sm'
|
||||
@@ -154,43 +152,36 @@ const AdvancedSettings = () => {
|
||||
borderColor: 'warning.main',
|
||||
}}
|
||||
>
|
||||
Early Access
|
||||
{t('common.earlyAccess')}
|
||||
</Chip>
|
||||
</Box>
|
||||
<Typography level='body-md' mt={-1}>
|
||||
Keep using Donetick when you're offline on this device/browser. Your
|
||||
changes are saved locally and synced when you're back online.
|
||||
{t('advanced.offlineDescription')}
|
||||
</Typography>
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
<Checkbox
|
||||
checked={offlineEnabled}
|
||||
onChange={handleOfflineToggle}
|
||||
variant='soft'
|
||||
label='Enable Offline Support'
|
||||
label={t('advanced.offlineToggle')}
|
||||
disabled={offlineLoading}
|
||||
overlay
|
||||
/>
|
||||
<FormHelperText>
|
||||
Turning this off removes unsynced offline changes and saved offline
|
||||
data from this device/browser.
|
||||
</FormHelperText>
|
||||
<FormHelperText>{t('advanced.offlineHelper')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Webhook Settings - Only show for admins */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Typography level='title-lg' mt={2}>
|
||||
Webhook Integration
|
||||
{t('advanced.webhookTitle')}
|
||||
</Typography>
|
||||
<Typography level='body-md' mt={-1}>
|
||||
Webhooks allow you to send real-time notifications to other
|
||||
services when events happen in your Circle. Configure a webhook
|
||||
URL to receive real-time updates.
|
||||
{t('advanced.webhookDescription')}
|
||||
</Typography>
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||
Webhook notifications are not available in the Basic plan.
|
||||
Upgrade to Plus to receive real-time updates via webhooks.
|
||||
{t('advanced.webhookPlusNotice')}
|
||||
</Typography>
|
||||
)}
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
@@ -204,7 +195,7 @@ const AdvancedSettings = () => {
|
||||
}
|
||||
}}
|
||||
variant='soft'
|
||||
label='Enable Webhook'
|
||||
label={t('advanced.webhookToggle')}
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
overlay
|
||||
/>
|
||||
@@ -213,10 +204,10 @@ const AdvancedSettings = () => {
|
||||
opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Enable webhook notifications for tasks and things updates.{' '}
|
||||
{t('advanced.webhookHelper')}{' '}
|
||||
{userProfile && !isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning'>
|
||||
Plus Feature
|
||||
{t('common.plusFeature')}
|
||||
</Chip>
|
||||
)}
|
||||
</FormHelperText>
|
||||
@@ -224,7 +215,9 @@ const AdvancedSettings = () => {
|
||||
|
||||
{webhookURL !== null && (
|
||||
<Box>
|
||||
<Typography level='title-sm'>Webhook URL</Typography>
|
||||
<Typography level='title-sm'>
|
||||
{t('advanced.webhookURL')}
|
||||
</Typography>
|
||||
<Input
|
||||
value={webhookURL ? webhookURL : ''}
|
||||
onChange={e => setWebhookURL(e.target.value)}
|
||||
@@ -247,19 +240,19 @@ const AdvancedSettings = () => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Webhook URL updated successfully',
|
||||
message: t('advanced.webhookUpdated'),
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to update webhook URL',
|
||||
message: t('advanced.webhookUpdateFailed'),
|
||||
})
|
||||
}
|
||||
})
|
||||
}}
|
||||
disabled={!isPlusAccount(userProfile)}
|
||||
>
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -268,11 +261,10 @@ const AdvancedSettings = () => {
|
||||
|
||||
{/* Real-time Settings */}
|
||||
<Typography level='title-lg' mt={2}>
|
||||
Real-time Updates
|
||||
{t('advanced.realtimeTitle')}
|
||||
</Typography>
|
||||
<Typography level='body-md' mt={-1}>
|
||||
Configure how you receive live updates when tasks and activities
|
||||
change in your circle.
|
||||
{t('advanced.realtimeDescription')}
|
||||
</Typography>
|
||||
<RealTimeSettings />
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { useChildUsers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
@@ -28,6 +31,8 @@ import PasswordChangeModal from '../Modals/Inputs/PasswordChangeModal'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const ChildUserSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { fmt } = useLocalization()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: childUsers, isLoading, refetch } = useChildUsers()
|
||||
const { showNotification } = useNotification()
|
||||
@@ -54,18 +59,20 @@ const ChildUserSettings = () => {
|
||||
const result = await response.json()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: `Child account "${result.res.displayName}" created successfully!`,
|
||||
message: t('subaccounts.createdSuccess', {
|
||||
name: result.res.displayName,
|
||||
}),
|
||||
})
|
||||
refetch()
|
||||
queryClient.invalidateQueries(['childUsers'])
|
||||
} else {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to create child user')
|
||||
throw new Error(error.error || t('subaccounts.createFailedGeneric'))
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Failed to create child account: ${error.message}`,
|
||||
message: t('subaccounts.createFailed', { error: error.message }),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
@@ -80,24 +87,28 @@ const ChildUserSettings = () => {
|
||||
if (response.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Child password updated successfully',
|
||||
message: t('subaccounts.passwordUpdated'),
|
||||
})
|
||||
} else {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to update password')
|
||||
throw new Error(
|
||||
error.error || t('subaccounts.passwordUpdateFailedGeneric'),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Failed to update password: ${error.message}`,
|
||||
message: t('subaccounts.passwordUpdateFailed', {
|
||||
error: error.message,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteChild = async (childId, childName) => {
|
||||
showConfirmation(
|
||||
`Are you sure you want to delete the child account "${childName}"? This action cannot be undone.`,
|
||||
'Delete Sub Account',
|
||||
t('subaccounts.deleteConfirmMessage', { name: childName }),
|
||||
t('subaccounts.deleteConfirmTitle'),
|
||||
async () => {
|
||||
setDeletingChildId(childId)
|
||||
try {
|
||||
@@ -106,50 +117,46 @@ const ChildUserSettings = () => {
|
||||
if (response.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: `Sub account "${childName}" deleted successfully`,
|
||||
message: t('subaccounts.deleted', { name: childName }),
|
||||
})
|
||||
refetch()
|
||||
queryClient.invalidateQueries(['childUsers'])
|
||||
} else {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to delete Sub user')
|
||||
throw new Error(error.error || t('subaccounts.deleteFailedGeneric'))
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Failed to delete Sub account: ${error.message}`,
|
||||
message: t('subaccounts.deleteFailed', { error: error.message }),
|
||||
})
|
||||
} finally {
|
||||
setDeletingChildId(null)
|
||||
}
|
||||
},
|
||||
'Delete',
|
||||
'Cancel',
|
||||
t('common.delete'),
|
||||
t('common.cancel'),
|
||||
'danger',
|
||||
)
|
||||
}
|
||||
|
||||
if (!isParentUser) {
|
||||
return (
|
||||
<SettingsLayout title='Sub Account Management'>
|
||||
<SettingsLayout title={t('subaccounts.notParentTitle')}>
|
||||
<Typography level='body-md' color='warning'>
|
||||
Only primary users can manage sub accounts.
|
||||
{t('subaccounts.notParentMessage')}
|
||||
</Typography>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout title='Managed Accounts'>
|
||||
<SettingsLayout title={t('subaccounts.title')}>
|
||||
<div className='grid gap-4'>
|
||||
<Typography level='body-md'>
|
||||
Manage sub accounts. Sub account users can log in and complete
|
||||
assigned tasks.
|
||||
</Typography>
|
||||
<Typography level='body-md'>{t('subaccounts.description')}</Typography>
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
|
||||
Sub account limited to 1 on Free plan. Upgrade to Plus to have up to
|
||||
5 sub accounts.
|
||||
{t('subaccounts.freePlanNotice')}
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
@@ -160,33 +167,32 @@ const ChildUserSettings = () => {
|
||||
}}
|
||||
>
|
||||
<Typography level='title-lg'>
|
||||
Sub Accounts ({childUsers?.length || 0})
|
||||
{t('subaccounts.count', { count: childUsers?.length || 0 })}
|
||||
</Typography>
|
||||
<Button
|
||||
startDecorator={<PersonAddIcon />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
Add Sub Account
|
||||
{t('subaccounts.add')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Typography>Loading sub accounts...</Typography>
|
||||
<Typography>{t('subaccounts.loading')}</Typography>
|
||||
) : childUsers?.length === 0 ? (
|
||||
<Card variant='soft' sx={{ textAlign: 'center', py: 4 }}>
|
||||
<CardContent>
|
||||
<Typography level='title-md' mb={1}>
|
||||
No Sub Accounts
|
||||
{t('subaccounts.emptyTitle')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={3}>
|
||||
Create sub accounts so team members can log in and complete
|
||||
their assigned tasks.
|
||||
{t('subaccounts.emptyDescription')}
|
||||
</Typography>
|
||||
<Button
|
||||
startDecorator={<PersonAddIcon />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
Add Your First Sub Account
|
||||
{t('subaccounts.addFirst')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -206,11 +212,14 @@ const ChildUserSettings = () => {
|
||||
{child.displayName || child.username}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Username: {child.username}
|
||||
{t('subaccounts.username', {
|
||||
username: child.username,
|
||||
})}
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Created:{' '}
|
||||
{new Date(child.createdAt).toLocaleDateString()}
|
||||
{t('subaccounts.created', {
|
||||
date: fmt.date(child.createdAt),
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -222,7 +231,7 @@ const ChildUserSettings = () => {
|
||||
setSelectedChildId(child.id)
|
||||
setPasswordModalOpen(true)
|
||||
}}
|
||||
title='Change Password'
|
||||
title={t('subaccounts.changePassword')}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
@@ -237,7 +246,7 @@ const ChildUserSettings = () => {
|
||||
)
|
||||
}
|
||||
loading={deletingChildId === child.id}
|
||||
title='Delete Account'
|
||||
title={t('subaccounts.deleteAccount')}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
@@ -253,21 +262,19 @@ const ChildUserSettings = () => {
|
||||
|
||||
<Box>
|
||||
<Typography level='title-md' mb={2}>
|
||||
How Managed Accounts Work
|
||||
{t('subaccounts.howItWorksTitle')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Managed accounts created by the primary user, these specific for
|
||||
user you want to have ability to delete and reset password.
|
||||
• {t('subaccounts.howItWorks1')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Sub accounts can log in with their own username and password.
|
||||
• {t('subaccounts.howItWorks2')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' mb={1}>
|
||||
• Managed accounts can complete tasks but have limited
|
||||
administrative permissions
|
||||
• {t('subaccounts.howItWorks3')}
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
• Managed accounts automatically added to your circle
|
||||
• {t('subaccounts.howItWorks4')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Delete, Refresh } from '@mui/icons-material'
|
||||
import { Share } from '@capacitor/share'
|
||||
import { CopyAll, Delete, IosShare, Refresh } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -9,15 +10,18 @@ import {
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Typography
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import {
|
||||
AcceptCircleMemberRequest,
|
||||
DeleteCircleMember,
|
||||
@@ -33,6 +37,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const CircleSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const queryClient = useQueryClient()
|
||||
const { showNotification } = useNotification()
|
||||
@@ -52,8 +57,8 @@ const CircleSettings = () => {
|
||||
message,
|
||||
title,
|
||||
onConfirm,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText = t('common.confirm'),
|
||||
cancelText = t('common.cancel'),
|
||||
color = 'primary',
|
||||
) => {
|
||||
setConfirmModalConfig({
|
||||
@@ -72,6 +77,29 @@ const CircleSettings = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
value: 'member',
|
||||
label: t('circleSettings.roles.member'),
|
||||
description: t('circleSettings.roles.memberDescription'),
|
||||
},
|
||||
{
|
||||
value: 'manager',
|
||||
label: t('circleSettings.roles.manager'),
|
||||
description: t('circleSettings.roles.managerDescription'),
|
||||
},
|
||||
{
|
||||
value: 'admin',
|
||||
label: t('circleSettings.roles.admin'),
|
||||
description: t('circleSettings.roles.adminDescription'),
|
||||
},
|
||||
]
|
||||
|
||||
// Roles come back from the API as lowercase identifiers, so fall back to the
|
||||
// raw value for anything the translations don't cover yet.
|
||||
const roleLabel = role =>
|
||||
roleOptions.find(option => option.value === role)?.label ?? role
|
||||
|
||||
const refreshMemberRequests = async () => {
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
@@ -82,7 +110,7 @@ const CircleSettings = () => {
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to refresh member requests',
|
||||
message: t('circleSettings.refreshFailed'),
|
||||
})
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
@@ -115,99 +143,129 @@ const CircleSettings = () => {
|
||||
}
|
||||
}, [circleMembers, userProfile])
|
||||
|
||||
const inviteCode = userCircles[0]?.invite_code
|
||||
const apiURL = new URL(apiClient.getApiURL(), window.location.origin)
|
||||
const inviteOrigin =
|
||||
apiURL.hostname === 'api.donetick.com'
|
||||
? 'https://app.donetick.com'
|
||||
: `${apiURL.origin}${apiURL.pathname.replace(/\/api\/v1\/?$/, '')}`
|
||||
const inviteLink = inviteCode
|
||||
? `${inviteOrigin.replace(/\/$/, '')}/circle/join?code=${encodeURIComponent(inviteCode)}`
|
||||
: ''
|
||||
|
||||
const shareInvite = async () => {
|
||||
const circleName = userCircles[0]?.name || t('circleSettings.myCircle')
|
||||
|
||||
try {
|
||||
await Share.share({
|
||||
title: t('circleSettings.shareTitle', { name: circleName }),
|
||||
text: t('circleSettings.shareText', { name: circleName }),
|
||||
url: inviteLink,
|
||||
dialogTitle: t('circleSettings.shareDialogTitle'),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.message?.toLowerCase().includes('cancel')) return
|
||||
await navigator.clipboard.writeText(inviteLink)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: t('circleSettings.linkCopied'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!userProfile) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout title='Circle Settings'>
|
||||
<SettingsLayout title={t('circleSettings.title')}>
|
||||
<div className='grid gap-4'>
|
||||
<Typography level='body-md'>
|
||||
Your account is automatically connected to a Circle when you create or
|
||||
join one. Easily invite friends by sharing the unique Circle code or
|
||||
link below. You'll receive a notification below when someone requests
|
||||
to join your Circle.
|
||||
{t('circleSettings.description')}
|
||||
</Typography>
|
||||
<Typography level='title-sm' mb={-1}>
|
||||
{userCircles[0]?.userRole === 'member'
|
||||
? `You part of ${userCircles[0]?.name} `
|
||||
: `You circle code is:`}
|
||||
|
||||
<Box>
|
||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||
{userCircles[0]?.userRole === 'member'
|
||||
? t('circleSettings.memberOf', { name: userCircles[0]?.name })
|
||||
: t('circleSettings.yourCircleCode')}
|
||||
</Typography>
|
||||
<Input
|
||||
value={userCircles[0]?.invite_code}
|
||||
value={inviteCode}
|
||||
disabled
|
||||
size='lg'
|
||||
sx={{
|
||||
width: '220px',
|
||||
width: { xs: '100%', sm: '220px' },
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Code copied to clipboard',
|
||||
})
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
Copy Code
|
||||
</Button>
|
||||
<Button
|
||||
variant='soft'
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
window.location.protocol +
|
||||
'//' +
|
||||
window.location.host +
|
||||
`/circle/join?code=${userCircles[0]?.invite_code}`,
|
||||
)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Link copied to clipboard',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
|
||||
<Button
|
||||
color='danger'
|
||||
variant='outlined'
|
||||
sx={{ ml: 1 }}
|
||||
variant='soft'
|
||||
startDecorator={<CopyAll />}
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
'Are you sure you want to leave your circle?',
|
||||
'Leave Circle',
|
||||
() => {
|
||||
LeaveCircle(userCircles[0]?.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Left circle successfully',
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to leave circle',
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
'Leave',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: t('circleSettings.codeCopied'),
|
||||
})
|
||||
}}
|
||||
>
|
||||
Leave Circle
|
||||
{t('circleSettings.copyCode')}
|
||||
</Button>
|
||||
)}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
disabled={!inviteLink}
|
||||
startDecorator={<IosShare />}
|
||||
onClick={shareInvite}
|
||||
>
|
||||
{t('circleSettings.shareInvite')}
|
||||
</Button>
|
||||
{userCircles.length > 0 &&
|
||||
userCircles[0]?.userRole === 'member' && (
|
||||
<Button
|
||||
color='danger'
|
||||
variant='outlined'
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
t('circleSettings.leaveConfirmMessage'),
|
||||
t('circleSettings.leaveConfirmTitle'),
|
||||
() => {
|
||||
LeaveCircle(userCircles[0]?.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: t('circleSettings.leftCircle'),
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: t('circleSettings.leaveFailed'),
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
t('circleSettings.leaveConfirmButton'),
|
||||
t('common.cancel'),
|
||||
'danger',
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t('circleSettings.leave')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography level='title-md'>Circle Members</Typography>
|
||||
<Typography level='title-md'>
|
||||
{t('circleSettings.circleMembers')}
|
||||
</Typography>
|
||||
{circleMembers.map(member => (
|
||||
<Card key={member.id} className='p-4'>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
@@ -215,20 +273,27 @@ const CircleSettings = () => {
|
||||
<Typography level='body-md'>
|
||||
{member.displayName.charAt(0).toUpperCase() +
|
||||
member.displayName.slice(1)}
|
||||
{member.userId === userProfile.id ? '(You)' : ''}{' '}
|
||||
{member.userId === userProfile.id
|
||||
? t('circleSettings.you')
|
||||
: ''}{' '}
|
||||
<Chip>
|
||||
{' '}
|
||||
{member.isActive ? member.role : 'Pending Approval'}
|
||||
{member.isActive
|
||||
? roleLabel(member.role)
|
||||
: t('circleSettings.pendingApproval')}
|
||||
</Chip>
|
||||
</Typography>
|
||||
{member.isActive ? (
|
||||
<Typography level='body-sm'>
|
||||
Joined on {fmt.date(member.createdAt)}
|
||||
{t('circleSettings.joinedOn', {
|
||||
date: fmt.date(member.createdAt),
|
||||
})}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography level='body-sm' color='danger'>
|
||||
Request to join{' '}
|
||||
{fmt.date(member.updatedAt)}
|
||||
{t('circleSettings.requestedToJoin', {
|
||||
date: fmt.date(member.updatedAt),
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -240,10 +305,7 @@ const CircleSettings = () => {
|
||||
sx={{ mr: 1 }}
|
||||
value={member.role}
|
||||
renderValue={() => (
|
||||
<Typography>
|
||||
{member.role.charAt(0).toUpperCase() +
|
||||
member.role.slice(1)}
|
||||
</Typography>
|
||||
<Typography>{roleLabel(member.role)}</Typography>
|
||||
)}
|
||||
onChange={(e, value) => {
|
||||
UpdateMemberRole(member.userId, value).then(resp => {
|
||||
@@ -258,27 +320,13 @@ const CircleSettings = () => {
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to update role',
|
||||
message: t('circleSettings.roleUpdateFailed'),
|
||||
})
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'member',
|
||||
description: 'Just a regular member of the circle',
|
||||
},
|
||||
{
|
||||
value: 'manager',
|
||||
description:
|
||||
'Can impersonate users and perform actions on their behalf',
|
||||
},
|
||||
{
|
||||
value: 'admin',
|
||||
description: 'Full access to the circle',
|
||||
},
|
||||
].map((option, index) => (
|
||||
{roleOptions.map((option, index) => (
|
||||
<Option value={option.value} key={index}>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -294,8 +342,7 @@ const CircleSettings = () => {
|
||||
level='title-sm'
|
||||
sx={{ mb: 0, mt: 0, lineHeight: 1.1 }}
|
||||
>
|
||||
{option.value.charAt(0).toUpperCase() +
|
||||
option.value.slice(1)}
|
||||
{option.label}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
@@ -317,8 +364,10 @@ const CircleSettings = () => {
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
`Are you sure you want to remove ${member.displayName} from your circle?`,
|
||||
'Remove Member',
|
||||
t('circleSettings.removeMemberMessage', {
|
||||
name: member.displayName,
|
||||
}),
|
||||
t('circleSettings.removeMemberTitle'),
|
||||
() => {
|
||||
DeleteCircleMember(
|
||||
member.circleId,
|
||||
@@ -327,7 +376,7 @@ const CircleSettings = () => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Removed member successfully',
|
||||
message: t('circleSettings.memberRemoved'),
|
||||
})
|
||||
queryClient.invalidateQueries(['circleMembers'])
|
||||
queryClient.invalidateQueries(['userCircle'])
|
||||
@@ -341,8 +390,8 @@ const CircleSettings = () => {
|
||||
}
|
||||
})
|
||||
},
|
||||
'Remove',
|
||||
'Cancel',
|
||||
t('common.remove'),
|
||||
t('common.cancel'),
|
||||
'danger',
|
||||
)
|
||||
}}
|
||||
@@ -363,11 +412,15 @@ const CircleSettings = () => {
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md'>Circle Member Requests</Typography>
|
||||
<Typography level='title-md'>
|
||||
{t('circleSettings.circleMemberRequests')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{lastRefresh && (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Last updated: {fmt.dateTime(lastRefresh)}
|
||||
{t('circleSettings.lastUpdated', {
|
||||
time: fmt.dateTime(lastRefresh),
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
@@ -379,7 +432,9 @@ const CircleSettings = () => {
|
||||
isRefreshing ? <CircularProgress size='sm' /> : <Refresh />
|
||||
}
|
||||
>
|
||||
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
{isRefreshing
|
||||
? t('circleSettings.refreshing')
|
||||
: t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -387,21 +442,24 @@ const CircleSettings = () => {
|
||||
{circleMemberRequests.map(request => (
|
||||
<Card key={request.id} className='p-4'>
|
||||
<Typography level='body-md'>
|
||||
{request.displayName} wants to join your circle.
|
||||
{t('circleSettings.wantsToJoin', { name: request.displayName })}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='success'
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
`Are you sure you want to accept ${request.displayName} (username: ${request.username}) to join your circle?`,
|
||||
'Accept Member Request',
|
||||
t('circleSettings.acceptRequestMessage', {
|
||||
name: request.displayName,
|
||||
username: request.username,
|
||||
}),
|
||||
t('circleSettings.acceptRequestTitle'),
|
||||
() => {
|
||||
AcceptCircleMemberRequest(request.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Accepted request successfully',
|
||||
message: t('circleSettings.requestAccepted'),
|
||||
})
|
||||
queryClient.invalidateQueries(['circleMembers'])
|
||||
queryClient.invalidateQueries(['circleMemberRequests'])
|
||||
@@ -416,26 +474,25 @@ const CircleSettings = () => {
|
||||
}
|
||||
})
|
||||
},
|
||||
'Accept',
|
||||
'Cancel',
|
||||
t('circleSettings.accept'),
|
||||
t('common.cancel'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
{t('circleSettings.accept')}
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
<Divider> or </Divider>
|
||||
<Divider> {t('circleSettings.or')} </Divider>
|
||||
|
||||
<Typography level='body-md'>
|
||||
if want to join someone else's Circle? Ask them for their unique
|
||||
Circle code or join link. Enter the code below to join their Circle.
|
||||
{t('circleSettings.joinOtherDescription')}
|
||||
</Typography>
|
||||
|
||||
<Typography level='title-sm' mb={-1}>
|
||||
Enter Circle code:
|
||||
{t('circleSettings.enterCircleCode')}
|
||||
<Input
|
||||
placeholder='Enter code'
|
||||
placeholder={t('circleSettings.enterCodePlaceholder')}
|
||||
value={circleInviteCode}
|
||||
onChange={e => setCircleInviteCode(e.target.value)}
|
||||
size='lg'
|
||||
@@ -451,20 +508,19 @@ const CircleSettings = () => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message:
|
||||
'Joined circle successfully, wait for the circle owner to accept your request.',
|
||||
message: t('circleSettings.joinedPending'),
|
||||
})
|
||||
setTimeout(() => navigate('/'), 3000)
|
||||
} else {
|
||||
if (resp.status === 409) {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'You are already a member of this circle',
|
||||
message: t('circleSettings.alreadyMember'),
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to join circle',
|
||||
message: t('circleSettings.joinFailed'),
|
||||
})
|
||||
}
|
||||
setTimeout(() => navigate('/'), 3000)
|
||||
@@ -472,7 +528,7 @@ const CircleSettings = () => {
|
||||
})
|
||||
}}
|
||||
>
|
||||
Join Circle
|
||||
{t('circleSettings.joinCircle')}
|
||||
</Button>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ const LocalizationSettings = () => {
|
||||
]
|
||||
|
||||
return (
|
||||
<SettingsLayout title='Localization'>
|
||||
<SettingsLayout title={t('localization.title')}>
|
||||
<div className='grid gap-4 py-4'>
|
||||
<Typography level='body-md'>{t('localization.description')}</Typography>
|
||||
|
||||
@@ -71,12 +71,7 @@ const LocalizationSettings = () => {
|
||||
))}
|
||||
</Select>
|
||||
{isRTL && (
|
||||
<FormHelperText>
|
||||
{t(
|
||||
'localization.rtlNotice',
|
||||
'This language uses right-to-left (RTL) text direction',
|
||||
)}
|
||||
</FormHelperText>
|
||||
<FormHelperText>{t('localization.rtlNotice')}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
@@ -110,7 +105,9 @@ const LocalizationSettings = () => {
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Preview: {sampleDate.format(dateFormat)}
|
||||
{t('localization.preview', {
|
||||
value: sampleDate.format(dateFormat),
|
||||
})}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
@@ -157,7 +154,9 @@ const LocalizationSettings = () => {
|
||||
</Option>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Preview: {sampleDate.format(timeFormat)}
|
||||
{t('localization.preview', {
|
||||
value: sampleDate.format(timeFormat),
|
||||
})}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { CheckCircle, Security, Smartphone } from '@mui/icons-material'
|
||||
import { Alert, Box, Button, Card, Input, Stack, Typography } from '@mui/joy'
|
||||
import QRCode from 'qrcode'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import AppModal from '../../components/common/AppModal'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import LoadingComponent from '../components/Loading'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const MFASettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false)
|
||||
@@ -56,7 +59,7 @@ const MFASettings = () => {
|
||||
setQrCodeDataUrl(qrCodeDataUrl)
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error)
|
||||
setError('Failed to generate QR code')
|
||||
setError(t('mfa.errors.qrGenerationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +82,7 @@ const MFASettings = () => {
|
||||
hasQrCodeUrl: !!data.qrCodeUrl,
|
||||
hasSecret: !!data.secret,
|
||||
})
|
||||
setError('Invalid response from server. Missing QR code or secret.')
|
||||
setError(t('mfa.errors.invalidResponse'))
|
||||
return
|
||||
}
|
||||
if (data.backupCodes) {
|
||||
@@ -98,24 +101,22 @@ const MFASettings = () => {
|
||||
} else {
|
||||
// Handle different error status codes
|
||||
if (response.status === 404) {
|
||||
setError(
|
||||
'MFA setup endpoint not found. This feature may not be available yet.',
|
||||
)
|
||||
setError(t('mfa.errors.notFound'))
|
||||
} else if (response.status === 401) {
|
||||
setError('Unauthorized. Please login again.')
|
||||
setError(t('mfa.errors.unauthorized'))
|
||||
} else if (response.status === 500) {
|
||||
setError('Server error. Please try again later.')
|
||||
setError(t('mfa.errors.serverError'))
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
setError(
|
||||
errorData.message ||
|
||||
`Failed to setup MFA (${response.status}). Please try again.`,
|
||||
t('mfa.errors.setupFailed', { status: response.status }),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting up MFA:', error)
|
||||
setError('Network error. Please check your connection and try again.')
|
||||
setError(t('mfa.errors.networkError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,12 +131,12 @@ const MFASettings = () => {
|
||||
if (response.ok) {
|
||||
setSetupStep(3)
|
||||
setMfaEnabled(true)
|
||||
setSuccess('MFA has been successfully enabled!')
|
||||
setSuccess(t('mfa.enabledSuccess'))
|
||||
} else {
|
||||
setError('Invalid verification code. Please try again.')
|
||||
setError(t('mfa.errors.invalidCode'))
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to confirm MFA. Please try again.')
|
||||
setError(t('mfa.errors.confirmFailed'))
|
||||
console.error('Error confirming MFA:', error)
|
||||
}
|
||||
}
|
||||
@@ -148,12 +149,12 @@ const MFASettings = () => {
|
||||
setMfaEnabled(false)
|
||||
setDisableModalOpen(false)
|
||||
setDisableCode('')
|
||||
setSuccess('MFA has been disabled successfully!')
|
||||
setSuccess(t('mfa.disabledSuccess'))
|
||||
} else {
|
||||
setError('Invalid verification code. Please try again.')
|
||||
setError(t('mfa.errors.invalidCode'))
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to disable MFA. Please try again.')
|
||||
setError(t('mfa.errors.disableFailed'))
|
||||
console.error('Error disabling MFA:', error)
|
||||
}
|
||||
}
|
||||
@@ -178,14 +179,9 @@ const MFASettings = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout title='Multi-Factor Authentication'>
|
||||
<SettingsLayout title={t('mfa.title')}>
|
||||
<div className='grid gap-4 py-4' id='mfa'>
|
||||
<Typography level='body-md'>
|
||||
Add an extra layer of security to your account with multi-factor
|
||||
authentication (MFA). When enabled, you'll need to provide a
|
||||
verification code from your authenticator app in addition to your
|
||||
password when signing in.
|
||||
</Typography>
|
||||
<Typography level='body-md'>{t('mfa.description')}</Typography>
|
||||
|
||||
{success && (
|
||||
<Alert color='success' onClose={() => setSuccess('')}>
|
||||
@@ -204,13 +200,11 @@ const MFASettings = () => {
|
||||
<Box className='flex items-center gap-3'>
|
||||
<Security color='primary' />
|
||||
<Box>
|
||||
<Typography level='title-md'>
|
||||
Two-Factor Authentication
|
||||
</Typography>
|
||||
<Typography level='title-md'>{t('mfa.twoFactor')}</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{mfaEnabled
|
||||
? 'Your account is protected with 2FA'
|
||||
: 'Secure your account with an authenticator app'}
|
||||
? t('mfa.enabledSubtitle')
|
||||
: t('mfa.disabledSubtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -221,7 +215,7 @@ const MFASettings = () => {
|
||||
variant='outlined'
|
||||
onClick={() => setDisableModalOpen(true)}
|
||||
>
|
||||
Disable
|
||||
{t('mfa.disable')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -229,7 +223,7 @@ const MFASettings = () => {
|
||||
variant='solid'
|
||||
onClick={handleSetupMFA}
|
||||
>
|
||||
Enable
|
||||
{t('mfa.enable')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
@@ -265,23 +259,29 @@ const MFASettings = () => {
|
||||
<AppModal
|
||||
open={setupModalOpen}
|
||||
onClose={closeSetupModal}
|
||||
title='Set up Multi-Factor Authentication'
|
||||
title={t('mfa.setup.title')}
|
||||
size='md'
|
||||
footer={
|
||||
setupStep === 1 ? (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: closeSetupModal }}
|
||||
secondary={{
|
||||
label: t('common.cancel'),
|
||||
onClick: closeSetupModal,
|
||||
}}
|
||||
primary={{
|
||||
label: "I've added the account",
|
||||
label: t('mfa.setup.addedAccount'),
|
||||
onClick: () => setSetupStep(2),
|
||||
startDecorator: <Smartphone />,
|
||||
}}
|
||||
/>
|
||||
) : setupStep === 2 ? (
|
||||
<ModalActions
|
||||
secondary={{ label: 'Back', onClick: () => setSetupStep(1) }}
|
||||
secondary={{
|
||||
label: t('mfa.setup.back'),
|
||||
onClick: () => setSetupStep(1),
|
||||
}}
|
||||
primary={{
|
||||
label: 'Verify & Enable',
|
||||
label: t('mfa.setup.verifyAndEnable'),
|
||||
onClick: handleConfirmMFA,
|
||||
disabled: verificationCode.length !== 6,
|
||||
}}
|
||||
@@ -289,7 +289,7 @@ const MFASettings = () => {
|
||||
) : (
|
||||
<ModalActions
|
||||
primary={{
|
||||
label: "I've saved my backup codes",
|
||||
label: t('mfa.setup.savedBackupCodes'),
|
||||
onClick: closeSetupModal,
|
||||
}}
|
||||
/>
|
||||
@@ -299,8 +299,8 @@ const MFASettings = () => {
|
||||
{setupStep === 1 && setupData && (
|
||||
<Stack spacing={3}>
|
||||
<Typography level='body-md'>
|
||||
<strong>Step 1:</strong> Scan the QR code below with your
|
||||
authenticator app (Google Authenticator, Authy, etc.)
|
||||
<strong>{t('mfa.setup.step1Label')}</strong>{' '}
|
||||
{t('mfa.setup.step1')}
|
||||
</Typography>
|
||||
|
||||
<Box className='flex justify-center rounded bg-white p-4'>
|
||||
@@ -310,14 +310,11 @@ const MFASettings = () => {
|
||||
qrCodeDataUrl ||
|
||||
`data:image/png;base64,${setupData.qrCode}`
|
||||
}
|
||||
alt='MFA QR Code'
|
||||
alt={t('mfa.setup.qrAlt')}
|
||||
style={{ maxWidth: '200px', maxHeight: '200px' }}
|
||||
/>
|
||||
) : (
|
||||
<Alert color='danger'>
|
||||
QR code could not be generated. Please try again or use the
|
||||
manual entry key below.
|
||||
</Alert>
|
||||
<Alert color='danger'>{t('mfa.setup.qrFailed')}</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -331,7 +328,7 @@ const MFASettings = () => {
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm'>
|
||||
<strong>Manual entry key:</strong>
|
||||
<strong>{t('mfa.setup.manualKey')}</strong>
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
@@ -346,12 +343,12 @@ const MFASettings = () => {
|
||||
{setupStep === 2 && (
|
||||
<Stack spacing={3}>
|
||||
<Typography level='body-md'>
|
||||
<strong>Step 2:</strong> Enter the 6-digit verification code
|
||||
from your authenticator app
|
||||
<strong>{t('mfa.setup.step2Label')}</strong>{' '}
|
||||
{t('mfa.setup.step2')}
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
placeholder='Enter 6-digit code'
|
||||
placeholder={t('mfa.setup.codePlaceholder')}
|
||||
value={verificationCode}
|
||||
size='lg'
|
||||
// send on enter:
|
||||
@@ -383,17 +380,16 @@ const MFASettings = () => {
|
||||
<Box className='text-center'>
|
||||
<CheckCircle color='success' sx={{ fontSize: 48, mb: 2 }} />
|
||||
<Typography level='h4' color='success'>
|
||||
MFA Successfully Enabled!
|
||||
{t('mfa.setup.successTitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert color='warning'>
|
||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||
Save these backup codes in a safe place
|
||||
{t('mfa.setup.backupCodesTitle')}
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
You can use these codes to access your account if you lose
|
||||
your authenticator device. Each code can only be used once.
|
||||
{t('mfa.setup.backupCodesDescription')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
@@ -418,15 +414,18 @@ const MFASettings = () => {
|
||||
<AppModal
|
||||
open={disableModalOpen}
|
||||
onClose={closeDisableModal}
|
||||
title='Disable Multi-Factor Authentication'
|
||||
title={t('mfa.disableModal.title')}
|
||||
size='sm'
|
||||
role='alertdialog'
|
||||
closeOnBackdrop={false}
|
||||
footer={
|
||||
<ModalActions
|
||||
secondary={{ label: 'Cancel', onClick: closeDisableModal }}
|
||||
secondary={{
|
||||
label: t('common.cancel'),
|
||||
onClick: closeDisableModal,
|
||||
}}
|
||||
primary={{
|
||||
label: 'Disable MFA',
|
||||
label: t('mfa.disableModal.confirm'),
|
||||
color: 'danger',
|
||||
onClick: handleDisableMFA,
|
||||
disabled: disableCode.length !== 6,
|
||||
@@ -437,17 +436,16 @@ const MFASettings = () => {
|
||||
<Stack spacing={3}>
|
||||
<Alert color='warning'>
|
||||
<Typography level='body-sm'>
|
||||
Disabling MFA will make your account less secure. Are you sure
|
||||
you want to continue?
|
||||
{t('mfa.disableModal.warning')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Typography level='body-md'>
|
||||
Enter a verification code from your authenticator app to confirm:
|
||||
{t('mfa.disableModal.prompt')}
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
placeholder='Enter 6-digit code'
|
||||
placeholder={t('mfa.setup.codePlaceholder')}
|
||||
value={disableCode}
|
||||
size='lg'
|
||||
onKeyDown={e => {
|
||||
@@ -477,12 +475,12 @@ const MFASettings = () => {
|
||||
<AppModal
|
||||
open={backupCodesModalOpen}
|
||||
onClose={() => setBackupCodesModalOpen(false)}
|
||||
title='New Backup Codes'
|
||||
title={t('mfa.backupCodesModal.title')}
|
||||
size='sm'
|
||||
footer={
|
||||
<ModalActions
|
||||
primary={{
|
||||
label: "I've saved my backup codes",
|
||||
label: t('mfa.setup.savedBackupCodes'),
|
||||
onClick: () => setBackupCodesModalOpen(false),
|
||||
}}
|
||||
/>
|
||||
@@ -491,8 +489,7 @@ const MFASettings = () => {
|
||||
<Stack spacing={3}>
|
||||
<Alert color='warning'>
|
||||
<Typography level='body-sm'>
|
||||
Your previous backup codes are now invalid. Save these new codes
|
||||
in a safe place. Each code can only be used once.
|
||||
{t('mfa.backupCodesModal.warning')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Capacitor } from '@capacitor/core'
|
||||
import { Device } from '@capacitor/device'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { PushNotifications } from '@capacitor/push-notifications'
|
||||
import { Android, Apple } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
@@ -18,9 +19,10 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { PushNotifications } from '@capacitor/push-notifications'
|
||||
import { registerPushNotifications } from '../../CapacitorListener'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useDeviceTokens, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle'
|
||||
@@ -31,6 +33,8 @@ import {
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const NotificationSetting = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { fmt } = useLocalization()
|
||||
const { showWarning } = useNotification()
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
const { data: deviceTokens, refetch: refetchDevices } = useDeviceTokens()
|
||||
@@ -149,26 +153,23 @@ const NotificationSetting = () => {
|
||||
const handleDeviceRegistered = () => {
|
||||
refetchDevices()
|
||||
showWarning({
|
||||
title: 'Success',
|
||||
message: 'Device registered successfully for push notifications.',
|
||||
title: t('common.success'),
|
||||
message: t('notifications.deviceRegistered'),
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeviceRegistrationFailed = event => {
|
||||
const { status, error } = event.detail || {}
|
||||
const { error, status } = event.detail || {}
|
||||
|
||||
if (status === 409) {
|
||||
showWarning({
|
||||
title: 'Device Limit Reached',
|
||||
message:
|
||||
'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.',
|
||||
title: t('notifications.deviceLimitTitle'),
|
||||
message: t('notifications.deviceLimitMessage'),
|
||||
})
|
||||
} else {
|
||||
showWarning({
|
||||
title: 'Registration Failed',
|
||||
message:
|
||||
error ||
|
||||
'Failed to register device automatically. Please try again.',
|
||||
title: t('notifications.registrationFailedTitle'),
|
||||
message: error || t('notifications.registrationFailedMessage'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -195,16 +196,16 @@ const NotificationSetting = () => {
|
||||
switch (notificationTarget) {
|
||||
case '1':
|
||||
if (chatID === '') {
|
||||
setError('Chat ID is required')
|
||||
setError(t('notifications.chatIdRequired'))
|
||||
return false
|
||||
} else if (isNaN(chatID) || chatID === '0') {
|
||||
setError('Invalid Chat ID')
|
||||
setError(t('notifications.chatIdInvalid'))
|
||||
return false
|
||||
}
|
||||
break
|
||||
case '2':
|
||||
if (chatID === '') {
|
||||
setError('User key is required')
|
||||
setError(t('notifications.userKeyRequired'))
|
||||
return false
|
||||
}
|
||||
break
|
||||
@@ -222,12 +223,12 @@ const NotificationSetting = () => {
|
||||
type: Number(notificationTarget),
|
||||
}).then(resp => {
|
||||
if (resp.status != 200) {
|
||||
alert(`Error while updating notification target: ${resp.statusText}`)
|
||||
alert(t('notifications.targetUpdateFailed', { error: resp.statusText }))
|
||||
return
|
||||
}
|
||||
|
||||
refetchUserProfile()
|
||||
alert('Notification target updated')
|
||||
alert(t('notifications.targetUpdated'))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,9 +239,8 @@ const NotificationSetting = () => {
|
||||
const currentDeviceCount = deviceTokens ? deviceTokens.length : 0
|
||||
if (currentDeviceCount >= 5) {
|
||||
showWarning({
|
||||
title: 'Device Limit Reached',
|
||||
message:
|
||||
'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.',
|
||||
title: t('notifications.deviceLimitTitle'),
|
||||
message: t('notifications.deviceLimitMessage'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -251,9 +251,8 @@ const NotificationSetting = () => {
|
||||
|
||||
if (permStatus.receive !== 'granted') {
|
||||
showWarning({
|
||||
title: 'Permission Required',
|
||||
message:
|
||||
'Push notification permission is required to register this device.',
|
||||
title: t('notifications.permissionRequiredTitle'),
|
||||
message: t('notifications.permissionRequiredMessage'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -267,25 +266,25 @@ const NotificationSetting = () => {
|
||||
setPushNotification(true)
|
||||
|
||||
showWarning({
|
||||
title: 'Registration Initiated',
|
||||
message:
|
||||
'Push notification registration has been initiated. The device will be registered automatically.',
|
||||
title: t('notifications.registrationInitiatedTitle'),
|
||||
message: t('notifications.registrationInitiatedMessage'),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error registering device:', error)
|
||||
showWarning({
|
||||
title: 'Error',
|
||||
message: 'Failed to register device. Please try again.',
|
||||
title: t('common.error'),
|
||||
message: t('notifications.registerDeviceFailed'),
|
||||
})
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
<SettingsLayout title='Notification Settings'>
|
||||
<SettingsLayout title={t('notifications.title')}>
|
||||
<div className='grid gap-4 py-4' id='notifications'>
|
||||
<Typography level='h3'>Device Notification</Typography>
|
||||
<Typography level='h3'>{t('notifications.deviceSection')}</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>Manage your Device Notification</Typography>
|
||||
<Typography level='body-md'>
|
||||
{t('notifications.deviceSectionDescription')}
|
||||
</Typography>
|
||||
|
||||
<FormControl orientation='horizontal'>
|
||||
<Switch
|
||||
@@ -300,9 +299,8 @@ const NotificationSetting = () => {
|
||||
setNotificationPreferences({ granted: true })
|
||||
} else if (resp.display === 'denied') {
|
||||
showWarning({
|
||||
title: 'Notification Permission Denied',
|
||||
message:
|
||||
'You have denied notification permissions. You can enable them later in your device settings.',
|
||||
title: t('notifications.permissionDeniedTitle'),
|
||||
message: t('notifications.permissionDeniedMessage'),
|
||||
})
|
||||
setDeviceNotification(false)
|
||||
setNotificationPreferences({ granted: false })
|
||||
@@ -324,11 +322,11 @@ const NotificationSetting = () => {
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel>Device Notification</FormLabel>
|
||||
<FormLabel>{t('notifications.deviceLabel')}</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>
|
||||
{Capacitor.isNativePlatform()
|
||||
? 'Receive notification on your device when a task is due'
|
||||
: 'This feature is only available on mobile devices'}{' '}
|
||||
? t('notifications.deviceHelper')
|
||||
: t('notifications.mobileOnly')}{' '}
|
||||
</FormHelperText>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -345,8 +343,8 @@ const NotificationSetting = () => {
|
||||
LocalNotifications.schedule({
|
||||
notifications: [
|
||||
{
|
||||
title: 'Test Notification',
|
||||
body: 'You have a task due soon',
|
||||
title: t('notifications.testNotification'),
|
||||
body: t('notifications.testNotificationBody'),
|
||||
id: 1,
|
||||
schedule: { at: new Date(Date.now() + 2000) },
|
||||
sound: null,
|
||||
@@ -358,32 +356,32 @@ const NotificationSetting = () => {
|
||||
})
|
||||
}}
|
||||
>
|
||||
Test Notification{' '}
|
||||
{t('notifications.testNotification')}{' '}
|
||||
</Button>
|
||||
{deviceNotification && (
|
||||
<Card>
|
||||
{[
|
||||
{
|
||||
title: 'Due Date Notification',
|
||||
title: t('notifications.dueTitle'),
|
||||
checked: dueNotification,
|
||||
set: setDueNotification,
|
||||
label: 'Notification when the task is due',
|
||||
label: t('notifications.dueLabel'),
|
||||
property: 'dueNotification',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
title: 'Pre-Due Date Notification',
|
||||
title: t('notifications.preDueTitle'),
|
||||
checked: preDueNotification,
|
||||
set: setPreDueNotification,
|
||||
label: 'Notification a few hours before the task is due',
|
||||
label: t('notifications.preDueLabel'),
|
||||
property: 'preDueNotification',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
title: 'Overdue Notification',
|
||||
title: t('notifications.overdueTitle'),
|
||||
checked: naggingNotification,
|
||||
set: setNaggingNotification,
|
||||
label: 'Notification when the task is overdue',
|
||||
label: t('notifications.overdueLabel'),
|
||||
property: 'naggingNotification',
|
||||
disabled: false,
|
||||
},
|
||||
@@ -409,7 +407,7 @@ const NotificationSetting = () => {
|
||||
}}
|
||||
color={item.checked ? 'success' : ''}
|
||||
variant='solid'
|
||||
endDecorator={item.checked ? 'On' : 'Off'}
|
||||
endDecorator={item.checked ? t('common.on') : t('common.off')}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -422,11 +420,11 @@ const NotificationSetting = () => {
|
||||
sx={{ width: 400, justifyContent: 'space-between' }}
|
||||
>
|
||||
<div>
|
||||
<FormLabel>Push Notifications</FormLabel>
|
||||
<FormLabel>{t('notifications.pushLabel')}</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>
|
||||
{Capacitor.isNativePlatform()
|
||||
? 'Receive Nudges, Announcements, and Chore Assignments via Push Notifications'
|
||||
: 'This feature is only available on mobile devices'}{' '}
|
||||
? t('notifications.pushHelper')
|
||||
: t('notifications.mobileOnly')}{' '}
|
||||
</FormHelperText>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -446,9 +444,8 @@ const NotificationSetting = () => {
|
||||
}
|
||||
if (resp.receive !== 'granted') {
|
||||
showWarning({
|
||||
title: 'Push Notification Permission Denied',
|
||||
message:
|
||||
'Push notifications have been disabled. You can enable them in your device settings if needed.',
|
||||
title: t('notifications.pushPermissionDeniedTitle'),
|
||||
message: t('notifications.pushPermissionDeniedMessage'),
|
||||
})
|
||||
setPushNotification(false)
|
||||
setPushNotificationPreferences({ granted: false })
|
||||
@@ -463,7 +460,7 @@ const NotificationSetting = () => {
|
||||
}}
|
||||
color={pushNotification ? 'success' : 'neutral'}
|
||||
variant={pushNotification ? 'solid' : 'outlined'}
|
||||
endDecorator={pushNotification ? 'On' : 'Off'}
|
||||
endDecorator={pushNotification ? t('common.on') : t('common.off')}
|
||||
slotProps={{
|
||||
endDecorator: {
|
||||
sx: {
|
||||
@@ -478,11 +475,13 @@ const NotificationSetting = () => {
|
||||
{isOfficialInstance && (
|
||||
<>
|
||||
<Typography level='h4' sx={{ mt: 2 }}>
|
||||
Registered Devices ({deviceTokens ? deviceTokens.length : 0}/5)
|
||||
{t('notifications.registeredDevices', {
|
||||
count: deviceTokens ? deviceTokens.length : 0,
|
||||
})}
|
||||
</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md' sx={{ mb: 2 }}>
|
||||
Devices registered to receive push notifications for your account
|
||||
{t('notifications.registeredDevicesDescription')}
|
||||
</Typography>
|
||||
|
||||
{/* Show register current device option if not registered */}
|
||||
@@ -508,12 +507,16 @@ const NotificationSetting = () => {
|
||||
)}
|
||||
<Box>
|
||||
<Typography level='body-md' sx={{ fontWeight: 'bold' }}>
|
||||
Current Device:{' '}
|
||||
{currentDevice.platform === 'ios' ? 'iOS' : 'Android'}{' '}
|
||||
{currentDevice.model}
|
||||
{t('notifications.currentDevice', {
|
||||
platform:
|
||||
currentDevice.platform === 'ios'
|
||||
? 'iOS'
|
||||
: 'Android',
|
||||
model: currentDevice.model,
|
||||
})}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
This device is not registered for push notifications
|
||||
{t('notifications.currentDeviceNotRegistered')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -525,8 +528,8 @@ const NotificationSetting = () => {
|
||||
onClick={handleRegisterCurrentDevice}
|
||||
>
|
||||
{deviceTokens && deviceTokens.length >= 5
|
||||
? 'Limit Reached'
|
||||
: 'Register Device'}
|
||||
? t('notifications.limitReached')
|
||||
: t('notifications.registerDevice')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
@@ -557,13 +560,15 @@ const NotificationSetting = () => {
|
||||
sx={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{device.platform === 'ios' ? 'iOS' : 'Android'}{' '}
|
||||
{device.deviceModel || 'Unknown Device'}
|
||||
{device.deviceModel ||
|
||||
t('notifications.unknownDevice')}
|
||||
</Typography>
|
||||
|
||||
{device.createdAt && (
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Created At:{' '}
|
||||
{new Date(device.createdAt).toLocaleDateString()}
|
||||
{t('notifications.deviceCreatedAt', {
|
||||
date: fmt.date(device.createdAt),
|
||||
})}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@@ -582,19 +587,19 @@ const NotificationSetting = () => {
|
||||
refetchDevices()
|
||||
} else {
|
||||
showWarning({
|
||||
title: 'Error',
|
||||
message: 'Failed to unregister device',
|
||||
title: t('common.error'),
|
||||
message: t('notifications.unregisterFailed'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showWarning({
|
||||
title: 'Error',
|
||||
message: 'Failed to unregister device',
|
||||
title: t('common.error'),
|
||||
message: t('notifications.unregisterFailed'),
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
{t('common.remove')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
@@ -602,16 +607,16 @@ const NotificationSetting = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<Typography level='body-md' color='neutral'>
|
||||
No devices registered for push notifications
|
||||
{t('notifications.noDevices')}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography level='h3'>Custom Notification</Typography>
|
||||
<Typography level='h3'>{t('notifications.customSection')}</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
Notification through other platform like Telegram or Pushover
|
||||
{t('notifications.customSectionDescription')}
|
||||
</Typography>
|
||||
|
||||
<FormControl orientation='horizontal'>
|
||||
@@ -649,9 +654,9 @@ const NotificationSetting = () => {
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<div>
|
||||
<FormLabel>Custom Notification</FormLabel>
|
||||
<FormLabel>{t('notifications.customLabel')}</FormLabel>
|
||||
<FormHelperText sx={{ mt: 0 }}>
|
||||
Receive notification on other platform
|
||||
{t('notifications.customHelper')}
|
||||
</FormHelperText>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -668,16 +673,15 @@ const NotificationSetting = () => {
|
||||
sx={{ maxWidth: '200px' }}
|
||||
onChange={(e, selected) => setNotificationTarget(selected)}
|
||||
>
|
||||
<Option value='0'>None</Option>
|
||||
<Option value='1'>Telegram</Option>
|
||||
<Option value='2'>Pushover</Option>
|
||||
<Option value='3'>Webhooks</Option>
|
||||
<Option value='0'>{t('notifications.targetNone')}</Option>
|
||||
<Option value='1'>{t('notifications.targetTelegram')}</Option>
|
||||
<Option value='2'>{t('notifications.targetPushover')}</Option>
|
||||
<Option value='3'>{t('notifications.targetWebhooks')}</Option>
|
||||
</Select>
|
||||
{notificationTarget === '1' && (
|
||||
<>
|
||||
<Typography level='body-xs'>
|
||||
You need to initiate a message to the bot in order for the
|
||||
Telegram notification to work{' '}
|
||||
{t('notifications.telegramBotHelpBefore')}{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
@@ -685,24 +689,25 @@ const NotificationSetting = () => {
|
||||
}}
|
||||
href='https://t.me/DonetickBot'
|
||||
>
|
||||
Click here
|
||||
{t('notifications.clickHere')}
|
||||
</a>{' '}
|
||||
to start a chat
|
||||
{t('notifications.telegramBotHelpAfter')}
|
||||
</Typography>
|
||||
|
||||
<Typography level='body-sm'>Chat ID</Typography>
|
||||
<Typography level='body-sm'>
|
||||
{t('notifications.chatId')}
|
||||
</Typography>
|
||||
|
||||
<Input
|
||||
value={chatID}
|
||||
onChange={e => setChatID(e.target.value)}
|
||||
placeholder='User ID / Chat ID'
|
||||
placeholder={t('notifications.chatIdPlaceholder')}
|
||||
sx={{
|
||||
width: '200px',
|
||||
}}
|
||||
/>
|
||||
<Typography mt={0} level='body-xs'>
|
||||
If you don't know your Chat ID, start chat with userinfobot
|
||||
and it will send you your Chat ID.{' '}
|
||||
{t('notifications.telegramChatIdHelpBefore')}{' '}
|
||||
<a
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
@@ -710,19 +715,21 @@ const NotificationSetting = () => {
|
||||
}}
|
||||
href='https://t.me/userinfobot'
|
||||
>
|
||||
Click here
|
||||
{t('notifications.clickHere')}
|
||||
</a>{' '}
|
||||
to start chat with userinfobot{' '}
|
||||
{t('notifications.telegramChatIdHelpAfter')}{' '}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{notificationTarget === '2' && (
|
||||
<>
|
||||
<Typography level='body-sm'>User key</Typography>
|
||||
<Typography level='body-sm'>
|
||||
{t('notifications.userKey')}
|
||||
</Typography>
|
||||
<Input
|
||||
value={chatID}
|
||||
onChange={e => setChatID(e.target.value)}
|
||||
placeholder='User ID'
|
||||
placeholder={t('notifications.userKeyPlaceholder')}
|
||||
sx={{
|
||||
width: '200px',
|
||||
}}
|
||||
@@ -742,7 +749,7 @@ const NotificationSetting = () => {
|
||||
}}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -180,7 +180,7 @@ const ProfileSettings = () => {
|
||||
setShowCropper(false)
|
||||
setSelectedFile(null)
|
||||
}}
|
||||
title={t('profile.editPhoto', { defaultValue: 'Edit profile photo' })}
|
||||
title={t('profile.editPhoto')}
|
||||
size='sm'
|
||||
closeOnBackdrop={!isUploading}
|
||||
closeOnEscape={!isUploading}
|
||||
|
||||
@@ -373,7 +373,7 @@ const SettingsOverview = () => {
|
||||
borderColor: 'warning.main',
|
||||
}}
|
||||
>
|
||||
Early Access
|
||||
{t('common.earlyAccess')}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
DEFAULT_SIDEPANEL_CONFIG,
|
||||
getSidepanelConfig,
|
||||
@@ -34,8 +36,15 @@ import {
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const SidepanelSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const [config, setConfig] = useState(getSidepanelConfig())
|
||||
|
||||
// Card names/descriptions live in the config so they can be persisted, but the
|
||||
// stored copy is English. Prefer the translated string and fall back to it.
|
||||
const cardName = item => t(`sidepanel.cards.${item.id}.name`, item.name)
|
||||
const cardDescription = item =>
|
||||
t(`sidepanel.cards.${item.id}.description`, item.description)
|
||||
|
||||
const getIcon = iconName => {
|
||||
switch (iconName) {
|
||||
case 'SupervisorAccount':
|
||||
@@ -93,15 +102,14 @@ const SidepanelSettings = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout title='Sidepanel Customization'>
|
||||
<SettingsLayout title={t('sidepanel.title')}>
|
||||
<div className='grid gap-4'>
|
||||
<Box>
|
||||
<Typography level='h4' sx={{ mb: 2 }}>
|
||||
Sidepanel Settings
|
||||
{t('sidepanel.heading')}
|
||||
</Typography>
|
||||
<Typography level='body-md' sx={{ mb: 3 }}>
|
||||
Customize which cards appear in the sidepanel and their order. Drag
|
||||
and drop to reorder, or toggle visibility for each card.
|
||||
{t('sidepanel.description')}
|
||||
</Typography>
|
||||
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
@@ -176,7 +184,7 @@ const SidepanelSettings = () => {
|
||||
level='title-sm'
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
{item.name}
|
||||
{cardName(item)}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
@@ -184,7 +192,7 @@ const SidepanelSettings = () => {
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
- {item.description}
|
||||
- {cardDescription(item)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemContent>
|
||||
@@ -226,10 +234,10 @@ const SidepanelSettings = () => {
|
||||
onClick={resetToDefaults}
|
||||
size='sm'
|
||||
>
|
||||
Reset to Defaults
|
||||
{t('sidepanel.resetToDefaults')}
|
||||
</Button>
|
||||
<FormHelperText sx={{ mt: 1 }}>
|
||||
This will restore all cards to their default visibility and order.
|
||||
{t('sidepanel.resetHelper')}
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Button, Card, Chip, LinearProgress, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { GetStorageUsage } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
@@ -15,6 +11,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const StorageSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const Navigate = useNavigate()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [usage, setUsage] = useState({ used: 0, total: 0 })
|
||||
@@ -25,8 +22,8 @@ const StorageSettings = () => {
|
||||
message,
|
||||
title,
|
||||
onConfirm,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmText = t('common.confirm'),
|
||||
cancelText = t('common.cancel'),
|
||||
color = 'primary',
|
||||
) => {
|
||||
setConfirmModalConfig({
|
||||
@@ -62,20 +59,19 @@ const StorageSettings = () => {
|
||||
const totalMB = (usage.total / (1024 * 1024)).toFixed(2)
|
||||
|
||||
return (
|
||||
<SettingsLayout title='Storage Settings'>
|
||||
<SettingsLayout title={t('storage.title')}>
|
||||
<div className='grid gap-4 py-4' id='storage'>
|
||||
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
Server Storage Usage
|
||||
{t('storage.serverTitle')}
|
||||
{!isPlusAccount(userProfile) && (
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Plus Feature
|
||||
{t('common.plusFeature')}
|
||||
</Chip>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This is the storage used by your account on our servers (e.g. files,
|
||||
images, and data you have uploaded).
|
||||
{t('storage.serverDescription')}
|
||||
</Typography>
|
||||
{!isPlusAccount(userProfile) ? (
|
||||
<>
|
||||
@@ -91,23 +87,26 @@ const StorageSettings = () => {
|
||||
}}
|
||||
/>
|
||||
<Typography level='body-xs' sx={{ opacity: 0.6, mb: 1 }}>
|
||||
-- MB used / -- MB total (--)
|
||||
{t('storage.usagePlaceholder')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='warning'>
|
||||
Server storage is not available in the Basic plan. Upgrade to
|
||||
Plus to track your server storage usage.
|
||||
{t('storage.basicPlanNotice')}
|
||||
</Typography>
|
||||
</>
|
||||
) : loading ? (
|
||||
<>
|
||||
<LinearProgress sx={{ mb: 1 }} />
|
||||
<Typography level='body-xs'>Loading...</Typography>
|
||||
<Typography level='body-xs'>{t('common.loading')}</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LinearProgress determinate value={percent} sx={{ mb: 1 }} />
|
||||
<Typography level='body-xs'>
|
||||
{usedMB} MB used / {totalMB} MB total ({percent}%)
|
||||
{t('storage.usage', {
|
||||
used: usedMB,
|
||||
total: totalMB,
|
||||
percent,
|
||||
})}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
@@ -115,72 +114,69 @@ const StorageSettings = () => {
|
||||
|
||||
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
|
||||
Cache
|
||||
{Capacitor.isNativePlatform()
|
||||
? t('storage.localTitleApp')
|
||||
: t('storage.localTitleBrowser')}
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This is data stored locally in your browser for faster access.
|
||||
Clearing this will not affect your server data, but may log you out.
|
||||
{t('storage.localDescription')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
'Are you sure you want to clear your local storage and cache? This will remove all your data from this browser and require login.',
|
||||
'Clear All Local Storage',
|
||||
t('storage.clearLocalMessage'),
|
||||
t('storage.clearLocalTitle'),
|
||||
() => {
|
||||
localStorage.clear()
|
||||
Navigate('/login')
|
||||
},
|
||||
'Clear All',
|
||||
'Cancel',
|
||||
t('storage.clearAll'),
|
||||
t('common.cancel'),
|
||||
'danger',
|
||||
)
|
||||
}}
|
||||
>
|
||||
Clear All Local Storage and Cache
|
||||
{t('storage.clearLocal')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{Capacitor.isNativePlatform() && (
|
||||
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
App Preferences
|
||||
{t('storage.appPreferences')}
|
||||
<Chip variant='soft' color='info' sx={{ ml: 1 }}>
|
||||
Device Only
|
||||
{t('storage.deviceOnly')}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
These are preferences and settings stored locally on your device
|
||||
by the app. Clearing them will reset app-specific settings and may
|
||||
log you out, but will not affect your server data.
|
||||
{t('storage.appPreferencesDescription')}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
'Are you sure you want to clear all app preferences? This will reset your app settings and may require you to log in again.',
|
||||
'Clear App Preferences',
|
||||
t('storage.clearPreferencesMessage'),
|
||||
t('storage.clearPreferencesTitle'),
|
||||
async () => {
|
||||
try {
|
||||
const { Preferences } = await import(
|
||||
'@capacitor/preferences'
|
||||
)
|
||||
const { Preferences } =
|
||||
await import('@capacitor/preferences')
|
||||
await Preferences.clear()
|
||||
Navigate('/login')
|
||||
} catch (e) {
|
||||
// Optionally show error feedback
|
||||
}
|
||||
},
|
||||
'Clear Preferences',
|
||||
'Cancel',
|
||||
t('storage.clearPreferences'),
|
||||
t('common.cancel'),
|
||||
'danger',
|
||||
)
|
||||
}}
|
||||
>
|
||||
Clear App Preferences
|
||||
{t('storage.clearPreferences')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { Typography } from '@mui/joy'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
const ThemeSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
|
||||
return (
|
||||
<SettingsLayout title="Theme Preferences">
|
||||
<SettingsLayout title={t('theme.title')}>
|
||||
<div className='grid gap-4'>
|
||||
<Typography level='body-md'>
|
||||
Choose how the site looks to you. Select a single theme, or sync with
|
||||
your system and automatically switch between day and night themes.
|
||||
</Typography>
|
||||
<Typography level='body-md'>{t('theme.description')}</Typography>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSettings
|
||||
export default ThemeSettings
|
||||
|
||||
@@ -3,14 +3,16 @@ import { Add } from '@mui/icons-material'
|
||||
import { Divider, Menu, MenuItem } from '@mui/joy'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const AutocompleteDropdown = ({
|
||||
currentValue,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onSelectSuggestion,
|
||||
onMouseEnterSuggestion, // Added for hover selection
|
||||
onCreateSuggestion, // Called when the "Create new" row is chosen
|
||||
onMouseEnterSuggestion, // Added for hover selection
|
||||
onSelectSuggestion,
|
||||
parentRefer, // Ref to the dropdown element
|
||||
selectedIndex,
|
||||
suggestions,
|
||||
}) => {
|
||||
// Scroll selected item into view
|
||||
const dropdownMenuRef = React.useRef(null)
|
||||
@@ -60,7 +62,7 @@ const AutocompleteDropdown = ({
|
||||
position: 'relative',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 1300,
|
||||
zIndex: Z_INDEX.MODAL_POPOVER,
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
} from './CustomParsers'
|
||||
import DueDatePickerField from './DueDatePickerField'
|
||||
import LabelsPickerField from './LabelsPickerField'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
import NotificationPickerField from './NotificationPickerField'
|
||||
import PriorityPickerField from './PriorityPickerField'
|
||||
import RepeatPickerField from './RepeatPickerField'
|
||||
@@ -108,6 +108,66 @@ const getDefaultNotification = () => {
|
||||
return DEFAULT_NOTIFICATION_TEMPLATES
|
||||
}
|
||||
|
||||
// Get initial project from localStorage (current active project)
|
||||
const getInitialProject = () => {
|
||||
const saved = localStorage.getItem('selectedProject')
|
||||
if (saved) {
|
||||
try {
|
||||
const project = JSON.parse(saved)
|
||||
return project?.id || 'default'
|
||||
} catch {
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
0: TASK_COLOR.NO_PRIORITY,
|
||||
1: TASK_COLOR.PRIORITY_1,
|
||||
2: TASK_COLOR.PRIORITY_2,
|
||||
3: TASK_COLOR.PRIORITY_3,
|
||||
4: TASK_COLOR.PRIORITY_4,
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS = {
|
||||
0: '--',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
// Static option sets for the smart input's trigger suggestions
|
||||
const PRIORITY_SUGGESTIONS = {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: 'P1' },
|
||||
{ id: '2', name: 'P2' },
|
||||
{ id: '3', name: 'P3' },
|
||||
{ id: '4', name: 'P4' },
|
||||
],
|
||||
}
|
||||
|
||||
const POINTS_SUGGESTIONS = {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: '1 point' },
|
||||
{ id: '5', name: '5 points' },
|
||||
{ id: '10', name: '10 points' },
|
||||
{ id: '25', name: '25 points' },
|
||||
{ id: '50', name: '50 points' },
|
||||
{ id: '100', name: '100 points' },
|
||||
],
|
||||
}
|
||||
|
||||
// Delay between the last keystroke and the smart-input parse. Parsing (chrono
|
||||
// especially) is too heavy to run per keystroke; submitChore flushes a pending
|
||||
// parse so a fast type-then-Enter never creates from stale parsed state.
|
||||
const PARSE_DEBOUNCE_MS = 150
|
||||
|
||||
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||
@@ -138,38 +198,85 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
// Get initial project from localStorage (current active project)
|
||||
const getInitialProject = () => {
|
||||
const saved = localStorage.getItem('selectedProject')
|
||||
if (saved) {
|
||||
try {
|
||||
const project = JSON.parse(saved)
|
||||
return project?.id || 'default'
|
||||
} catch {
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
const smartInputSuggestions = useMemo(
|
||||
() => ({
|
||||
'#': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: userLabels || [],
|
||||
creatable: true,
|
||||
onCreate: handleCreateLabel,
|
||||
},
|
||||
'!': PRIORITY_SUGGESTIONS,
|
||||
'@': {
|
||||
value: 'userId',
|
||||
display: 'displayName',
|
||||
options: [
|
||||
{ userId: 'anyone', displayName: 'Anyone' },
|
||||
...(circleMembers?.res || []),
|
||||
],
|
||||
},
|
||||
'*': POINTS_SUGGESTIONS,
|
||||
}),
|
||||
[userLabels, circleMembers, handleCreateLabel],
|
||||
)
|
||||
|
||||
const [taskText, setTaskText] = useState('')
|
||||
const [taskTitle, setTaskTitle] = useState('')
|
||||
const [renderedParts, setRenderedParts] = useState([])
|
||||
// Highlight spans paired with the text they were computed from: the parse
|
||||
// is debounced, so while typing these lag behind taskText
|
||||
const [renderedParts, setRenderedParts] = useState({ text: '', parts: [] })
|
||||
|
||||
// What the smart input overlay shows. While a parse is pending, keep every
|
||||
// highlight span that precedes the edit point and render the rest as plain
|
||||
// text — existing token styles must not flicker away on each keystroke.
|
||||
const displayedParts = useMemo(() => {
|
||||
const { parts, text } = renderedParts
|
||||
if (text === taskText) return parts
|
||||
|
||||
let prefixLen = 0
|
||||
const max = Math.min(text.length, taskText.length)
|
||||
while (prefixLen < max && text[prefixLen] === taskText[prefixLen]) {
|
||||
prefixLen++
|
||||
}
|
||||
|
||||
const kept = []
|
||||
let consumed = 0
|
||||
for (const part of parts) {
|
||||
const partText = typeof part === 'string' ? part : part.props.children
|
||||
if (consumed + partText.length > prefixLen) break
|
||||
kept.push(part)
|
||||
consumed += partText.length
|
||||
}
|
||||
kept.push(taskText.slice(consumed))
|
||||
return kept
|
||||
}, [renderedParts, taskText])
|
||||
|
||||
const richTextEditorRef = useRef(null)
|
||||
const latestRef = useRef({})
|
||||
// Picker edits made on a voice task card, applied once after the reparse
|
||||
// that follows landing the spoken text in the smart input
|
||||
const pendingVoiceOverridesRef = useRef(null)
|
||||
// True while the current assignees came from an @mention in the text, so a
|
||||
// reparse without mentions only resets what a mention set — never a
|
||||
// selection made directly in the assignee picker
|
||||
const assigneesFromMentionRef = useRef(false)
|
||||
// Pending debounced parse of the smart input text, if any
|
||||
const parseTimerRef = useRef(null)
|
||||
// Identities (type + text) of the highlights from the previous parse, so
|
||||
// the appear animation only plays for tokens detected just now
|
||||
const prevHighlightKeysRef = useRef(new Set())
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [description, setDescription] = useState(null)
|
||||
const [assignees, setAssignees] = useState([])
|
||||
const [labelsV2, setLabelsV2] = useState([])
|
||||
const [frequency, setFrequency] = useState(null)
|
||||
const [notificationMetadata, setNotificationMetadata] = useState({
|
||||
// Lazy initializers: these read localStorage, which must not happen on
|
||||
// every render
|
||||
const [notificationMetadata, setNotificationMetadata] = useState(() => ({
|
||||
templates: getDefaultNotification(),
|
||||
})
|
||||
}))
|
||||
const [subTasks, setSubTasks] = useState(null)
|
||||
const [points, setPoints] = useState(-1)
|
||||
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
|
||||
@@ -185,7 +292,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
const [dueTime, setDueTime] = useState(null)
|
||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject())
|
||||
const [projectId, setProjectId] = useState(getInitialProject)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
|
||||
const [draftId, setDraftId] = useState(() => generateUUID())
|
||||
@@ -251,23 +358,6 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
}
|
||||
}, [isModalOpen, initialMode, voiceAvailable, llmAvailable])
|
||||
|
||||
// Priority colors
|
||||
const priorityColors = {
|
||||
0: TASK_COLOR.NO_PRIORITY,
|
||||
1: TASK_COLOR.PRIORITY_1,
|
||||
2: TASK_COLOR.PRIORITY_2,
|
||||
3: TASK_COLOR.PRIORITY_3,
|
||||
4: TASK_COLOR.PRIORITY_4,
|
||||
}
|
||||
|
||||
const priorityLabels = {
|
||||
0: '--',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||
useEffect(() => {
|
||||
if (hasDescription && richTextEditorRef.current) {
|
||||
@@ -281,11 +371,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
const {
|
||||
createChore,
|
||||
dueDate,
|
||||
handleCloseModal,
|
||||
hasDescription,
|
||||
isModalOpen,
|
||||
submitChore,
|
||||
} = latestRef.current
|
||||
const isHoldingCmd = event.ctrlKey || event.metaKey
|
||||
if (isHoldingCmd) {
|
||||
@@ -323,7 +413,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
isModalOpen
|
||||
) {
|
||||
event.preventDefault()
|
||||
createChore()
|
||||
submitChore()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && isModalOpen) {
|
||||
@@ -411,6 +501,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const seenHighlightKeys = new Set()
|
||||
for (const highlight of resolvedHighlights) {
|
||||
if (highlight.start > lastIndex) {
|
||||
const textBefore = sentence.substring(lastIndex, highlight.start)
|
||||
@@ -446,10 +537,13 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
highlight.start,
|
||||
highlight.end,
|
||||
)
|
||||
const highlightKey = `${highlight.type}:${highlightedText.toLowerCase()}`
|
||||
const isNewHighlight = !prevHighlightKeysRef.current.has(highlightKey)
|
||||
seenHighlightKeys.add(highlightKey)
|
||||
parts.push(
|
||||
<span
|
||||
key={highlight.start}
|
||||
className={className}
|
||||
className={`${className}${isNewHighlight ? ' highlight-appear' : ''}`}
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
textDecorationThickness: '2px',
|
||||
@@ -462,6 +556,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
prevHighlightKeysRef.current = seenHighlightKeys
|
||||
|
||||
if (lastIndex < sentence.length) {
|
||||
const remainingText = sentence.substring(lastIndex)
|
||||
@@ -477,14 +572,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
[],
|
||||
)
|
||||
|
||||
const processText = useCallback(
|
||||
sentence => {
|
||||
const priority = parsePriority(sentence)
|
||||
const pointsParsed = parsePoints(sentence)
|
||||
const labels = parseLabels(sentence, userLabels || [])
|
||||
|
||||
const circleMembersList = circleMembers?.res || []
|
||||
const assigneesForParsing = circleMembersList.map(member => ({
|
||||
// Rebuilt only when the member list actually changes, so a query refetch
|
||||
// with identical data doesn't re-trigger the parse effect below
|
||||
const assigneesForParsing = useMemo(
|
||||
() =>
|
||||
(circleMembers?.res || []).map(member => ({
|
||||
userId: member.userId,
|
||||
username:
|
||||
member.username ||
|
||||
@@ -492,7 +584,15 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
displayName: member.displayName,
|
||||
name: member.displayName,
|
||||
id: member.userId,
|
||||
}))
|
||||
})),
|
||||
[circleMembers],
|
||||
)
|
||||
|
||||
const processText = useCallback(
|
||||
sentence => {
|
||||
const priority = parsePriority(sentence)
|
||||
const pointsParsed = parsePoints(sentence)
|
||||
const labels = parseLabels(sentence, userLabels || [])
|
||||
|
||||
const assigneesResult = parseAssignees(sentence, assigneesForParsing)
|
||||
const repeat = parseRepeatV2(sentence)
|
||||
@@ -510,14 +610,18 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
// @Anyone was used - set empty assignees (anyone can do the task)
|
||||
setIsAnyoneTask(true)
|
||||
setAssignees([])
|
||||
assigneesFromMentionRef.current = true
|
||||
} else if (assigneesResult.result && assigneesResult.result.length > 0) {
|
||||
setIsAnyoneTask(false)
|
||||
const parsedAssignees = assigneesResult.result.map(assignee => ({
|
||||
userId: assignee.userId,
|
||||
}))
|
||||
setAssignees(parsedAssignees)
|
||||
} else {
|
||||
// Only assign to current user if no @ mentions found and userProfile exists
|
||||
assigneesFromMentionRef.current = true
|
||||
} else if (assigneesFromMentionRef.current) {
|
||||
// The @mention that set the current assignees was deleted — fall back
|
||||
// to the implicit self default. Picker selections stay untouched.
|
||||
assigneesFromMentionRef.current = false
|
||||
setIsAnyoneTask(false)
|
||||
if (userProfile?.id) {
|
||||
setAssignees([
|
||||
@@ -555,39 +659,47 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
syncDueDateStates(repeat.dueDate)
|
||||
}
|
||||
|
||||
// Create the cleaned sentence by sequentially applying all cleanups
|
||||
// Create the cleaned sentence by sequentially applying all cleanups.
|
||||
// Each stage only needs a reparse when an earlier cleanup actually
|
||||
// changed the sentence; otherwise the first-pass result (computed on the
|
||||
// identical string) is reused as-is.
|
||||
let cleanedSentence = sentence
|
||||
if (priority.result) cleanedSentence = priority.cleanedSentence
|
||||
if (pointsParsed.result) {
|
||||
// Apply points cleaning to the current cleaned sentence
|
||||
const pointsReparse = parsePoints(cleanedSentence)
|
||||
const pointsReparse =
|
||||
cleanedSentence === sentence
|
||||
? pointsParsed
|
||||
: parsePoints(cleanedSentence)
|
||||
if (pointsReparse.result)
|
||||
cleanedSentence = pointsReparse.cleanedSentence
|
||||
}
|
||||
if (labels.result) {
|
||||
// Apply labels cleaning to the current cleaned sentence
|
||||
const labelsReparse = parseLabels(cleanedSentence, userLabels || [])
|
||||
const labelsReparse =
|
||||
cleanedSentence === sentence
|
||||
? labels
|
||||
: parseLabels(cleanedSentence, userLabels || [])
|
||||
if (labelsReparse.result)
|
||||
cleanedSentence = labelsReparse.cleanedSentence
|
||||
}
|
||||
if (assigneesResult.result) {
|
||||
// Apply assignees cleaning to the current cleaned sentence
|
||||
const assigneesReparse = parseAssignees(
|
||||
cleanedSentence,
|
||||
assigneesForParsing,
|
||||
)
|
||||
const assigneesReparse =
|
||||
cleanedSentence === sentence
|
||||
? assigneesResult
|
||||
: parseAssignees(cleanedSentence, assigneesForParsing)
|
||||
if (assigneesReparse.result)
|
||||
cleanedSentence = assigneesReparse.cleanedSentence
|
||||
}
|
||||
if (repeat.result) {
|
||||
// Apply repeat cleaning to the current cleaned sentence
|
||||
const repeatReparse = parseRepeatV2(cleanedSentence)
|
||||
const repeatReparse =
|
||||
cleanedSentence === sentence ? repeat : parseRepeatV2(cleanedSentence)
|
||||
if (repeatReparse.result)
|
||||
cleanedSentence = repeatReparse.cleanedSentence
|
||||
}
|
||||
if (dueDateParsed.result) {
|
||||
// Apply date cleaning to the current cleaned sentence
|
||||
const dueDateReparse = parseDueDate(cleanedSentence, chrono)
|
||||
const dueDateReparse =
|
||||
cleanedSentence === sentence
|
||||
? dueDateParsed
|
||||
: parseDueDate(cleanedSentence, chrono)
|
||||
if (dueDateReparse.result)
|
||||
cleanedSentence = dueDateReparse.cleanedSentence
|
||||
}
|
||||
@@ -606,7 +718,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
assigneesResult.highlight,
|
||||
)
|
||||
|
||||
setRenderedParts(parts)
|
||||
setRenderedParts({ text: sentence, parts })
|
||||
|
||||
const overrides = pendingVoiceOverridesRef.current
|
||||
if (overrides) {
|
||||
@@ -622,6 +734,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
if ('assignees' in overrides || 'isAnyone' in overrides) {
|
||||
setIsAnyoneTask(!!overrides.isAnyone)
|
||||
setAssignees(overrides.assignees || [])
|
||||
assigneesFromMentionRef.current = false
|
||||
}
|
||||
if ('dueDate' in overrides) {
|
||||
if (overrides.dueDate) {
|
||||
@@ -635,7 +748,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[userLabels, renderHighlightedSentence, circleMembers, userProfile],
|
||||
[userLabels, renderHighlightedSentence, assigneesForParsing, userProfile],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -648,7 +761,16 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
return
|
||||
}
|
||||
|
||||
processText(taskText)
|
||||
// Debounced so fast typing doesn't run the full parse pipeline per
|
||||
// keystroke; submitChore flushes a pending parse before creating.
|
||||
parseTimerRef.current = setTimeout(() => {
|
||||
parseTimerRef.current = null
|
||||
processText(taskText)
|
||||
}, PARSE_DEBOUNCE_MS)
|
||||
return () => {
|
||||
clearTimeout(parseTimerRef.current)
|
||||
parseTimerRef.current = null
|
||||
}
|
||||
}, [
|
||||
taskText,
|
||||
userLabelsLoading,
|
||||
@@ -713,7 +835,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
}
|
||||
|
||||
const handleEnterPressed = () => {
|
||||
createChore()
|
||||
submitChore()
|
||||
}
|
||||
|
||||
// The scan keeps its source image when asked: upload it against the draft so
|
||||
@@ -849,6 +971,10 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
setHasSubTasks(false)
|
||||
setLabelsV2([])
|
||||
setAssignees([])
|
||||
assigneesFromMentionRef.current = false
|
||||
// The modal closes without a final parse, so drop the highlight identities
|
||||
// here or nothing would animate on the next open
|
||||
prevHighlightKeysRef.current = new Set()
|
||||
setProjectId(getInitialProject())
|
||||
setDeadlineOffset(-1)
|
||||
setRequireApproval(false)
|
||||
@@ -921,8 +1047,12 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
chore.frequencyMetadata = frequency.frequencyMetadata
|
||||
chore.frequency = frequency.frequency
|
||||
}
|
||||
if (!frequency && dueDate) {
|
||||
// Use RFC3339/ISO-8601 format expected by backend.
|
||||
if (dueDate) {
|
||||
// Use RFC3339/ISO-8601 format expected by backend. The backend only
|
||||
// derives NextDueDate from what's sent on create (handler.go never
|
||||
// computes it from frequencyType), so this must be sent whether or
|
||||
// not the task also repeats — otherwise a recurring task created with
|
||||
// a due date lands with nextDueDate: null.
|
||||
chore.nextDueDate = new Date(dueDate).toISOString()
|
||||
}
|
||||
if (hasReminders && (frequency || dueDate)) {
|
||||
@@ -954,11 +1084,26 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
handleCloseModal(false)
|
||||
}
|
||||
|
||||
// All submit paths (Enter, Cmd+Enter, footer button) go through here: a
|
||||
// debounce may still be holding the parse of the latest text, and creating
|
||||
// from pre-parse state would drop the tail of what the user typed.
|
||||
const submitChore = () => {
|
||||
if (parseTimerRef.current) {
|
||||
clearTimeout(parseTimerRef.current)
|
||||
parseTimerRef.current = null
|
||||
flushSync(() => processText(taskText))
|
||||
}
|
||||
// Read through latestRef: after the flush, this render's createChore
|
||||
// closure is stale
|
||||
latestRef.current.createChore()
|
||||
}
|
||||
|
||||
latestRef.current = {
|
||||
isModalOpen,
|
||||
hasDescription,
|
||||
dueDate,
|
||||
createChore,
|
||||
submitChore,
|
||||
handleCloseModal,
|
||||
}
|
||||
|
||||
@@ -1028,7 +1173,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
color='primary'
|
||||
loading={isAttachingScan}
|
||||
disabled={!taskTitle.trim() || isAttachingScan}
|
||||
onClick={createChore}
|
||||
onClick={submitChore}
|
||||
>
|
||||
Create
|
||||
{showKeyboardShortcuts && (
|
||||
@@ -1041,8 +1186,8 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
>
|
||||
{!showScan && !showVoice && (
|
||||
<>
|
||||
<Box>
|
||||
<Box
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{/* <Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
@@ -1093,7 +1238,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box> */}
|
||||
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
@@ -1124,7 +1269,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
setTaskText(text)
|
||||
if (!text) setTaskTitle('')
|
||||
}}
|
||||
customRenderer={renderedParts}
|
||||
customRenderer={displayedParts}
|
||||
onEnterPressed={handleEnterPressed}
|
||||
onShiftEnterPressed={() => {
|
||||
if (!hasDescription) {
|
||||
@@ -1132,45 +1277,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
}
|
||||
setTimeout(() => richTextEditorRef.current?.focus(), 50)
|
||||
}}
|
||||
suggestions={{
|
||||
'#': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: userLabels ? userLabels : [],
|
||||
creatable: true,
|
||||
onCreate: handleCreateLabel,
|
||||
},
|
||||
'!': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: 'P1' },
|
||||
{ id: '2', name: 'P2' },
|
||||
{ id: '3', name: 'P3' },
|
||||
{ id: '4', name: 'P4' },
|
||||
],
|
||||
},
|
||||
'@': {
|
||||
value: 'userId',
|
||||
display: 'displayName',
|
||||
options: [
|
||||
{ userId: 'anyone', displayName: 'Anyone' },
|
||||
...(circleMembers?.res || []),
|
||||
],
|
||||
},
|
||||
'*': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: '1 point' },
|
||||
{ id: '5', name: '5 points' },
|
||||
{ id: '10', name: '10 points' },
|
||||
{ id: '25', name: '25 points' },
|
||||
{ id: '50', name: '50 points' },
|
||||
{ id: '100', name: '100 points' },
|
||||
],
|
||||
},
|
||||
}}
|
||||
suggestions={smartInputSuggestions}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1212,8 +1319,8 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
priorityColors={PRIORITY_COLORS}
|
||||
priorityLabels={PRIORITY_LABELS}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
@@ -1292,9 +1399,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-sm' sx={{ color: 'inherit' }}>
|
||||
Description
|
||||
</Typography>
|
||||
<Typography level='body-sm'>Description</Typography>
|
||||
</Button>
|
||||
)}
|
||||
{!hasSubTasks && (
|
||||
@@ -1317,9 +1422,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-sm' sx={{ color: 'inherit' }}>
|
||||
Subtasks
|
||||
</Typography>
|
||||
<Typography level='body-sm'>Subtasks</Typography>
|
||||
</Button>
|
||||
)}
|
||||
<AdvancedOptionsTrigger
|
||||
@@ -1351,7 +1454,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
onAssignStrategyChange={setAssignStrategy}
|
||||
hasDueDate={!!dueDate}
|
||||
hasMultipleAssignees={assignees.length > 1}
|
||||
hasAssignees={assignees.length > 0}
|
||||
// Empty assignees still implicitly assigns the current user at
|
||||
// create time; only an "Anyone" task truly has no assignee
|
||||
hasAssignees={!isAnyoneTask}
|
||||
isPrivate={isPrivate}
|
||||
onIsPrivateChange={setIsPrivate}
|
||||
/>
|
||||
|
||||
@@ -90,7 +90,6 @@ export const AdvancedOptionsTrigger = ({
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ListAlt,
|
||||
Logout,
|
||||
MenuRounded,
|
||||
SearchRounded,
|
||||
SettingsOutlined,
|
||||
Toll,
|
||||
Widgets,
|
||||
@@ -23,30 +24,34 @@ import {
|
||||
ListItemDecorator,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { version } from '../../../package.json'
|
||||
import UserProfileAvatar from '../../components/UserProfileAvatar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import NavBarLink from './NavBarLink'
|
||||
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useGlobalSearch } from '../../search/GlobalSearchContext'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import NavBarLink from './NavBarLink'
|
||||
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||
|
||||
const publicPages = ['/landing', '/privacy', '/terms']
|
||||
const NavBar = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const { isRTL } = useLocalization()
|
||||
const { data: resource } = useResource()
|
||||
const { openSearch } = useGlobalSearch()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: t('navigation.search'),
|
||||
icon: <SearchRounded />,
|
||||
onClick: () => openSearch(),
|
||||
},
|
||||
{
|
||||
to: '/chores',
|
||||
label: t('navigation.allTasks'),
|
||||
@@ -105,6 +110,22 @@ const NavBar = () => {
|
||||
<MenuRounded />
|
||||
</IconButton>
|
||||
)
|
||||
if (location.pathname === '/search') {
|
||||
return (
|
||||
<IconButton
|
||||
size='md'
|
||||
variant='plain'
|
||||
onClick={() => {
|
||||
if (window.history.state?.idx > 0) navigate(-1)
|
||||
else navigate('/chores', { replace: true })
|
||||
}}
|
||||
aria-label='Back from search'
|
||||
title={t('back')}
|
||||
>
|
||||
<ArrowBack className='rtl-flip' />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return menuRounded
|
||||
}
|
||||
@@ -133,7 +154,7 @@ const NavBar = () => {
|
||||
: t('back')
|
||||
}
|
||||
>
|
||||
<ArrowBack />
|
||||
<ArrowBack className='rtl-flip' />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
@@ -201,14 +222,19 @@ const NavBar = () => {
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={closeDrawer}
|
||||
anchor={isRTL ? 'right' : 'left'}
|
||||
// Always 'left'. Joy bakes the anchor into emotion CSS (`left: 0` plus a
|
||||
// translateX for the slide), so stylis-plugin-rtl already mirrors it to
|
||||
// the right edge under RTL. Branching on isRTL here would flip it twice
|
||||
// and land the drawer back on the left, half off-screen.
|
||||
anchor='left'
|
||||
size='sm'
|
||||
onClick={closeDrawer}
|
||||
sx={{
|
||||
'& .MuiDrawer-content': {
|
||||
position: 'fixed',
|
||||
// pt: 'calc(var(--safe-area-inset-top, 0px))',
|
||||
...(isRTL ? { right: 0 } : { left: 0 }),
|
||||
// Physical on purpose, so it is mirrored in step with the anchor.
|
||||
left: 0,
|
||||
// pb: 'calc(var(--safe-area-inset-bottom, 0px))',
|
||||
// height:
|
||||
// 'calc(100vh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))',
|
||||
|
||||
@@ -7,13 +7,12 @@ import {
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const NavBarLink = ({ link }) => {
|
||||
const { to, icon, label } = link
|
||||
const { to, icon, label, onClick } = link
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
key={to}
|
||||
component={Link}
|
||||
to={to}
|
||||
{...(onClick ? { onClick } : { component: Link, to })}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={{
|
||||
|
||||
@@ -57,6 +57,34 @@
|
||||
color: var(--highlight-label-color);
|
||||
}
|
||||
|
||||
/* Played once when a token is first detected: the dashed underline fades in
|
||||
while a soft tint of the token's own color flashes and clears. Keyframe
|
||||
values override the span's inline text-decoration while running. */
|
||||
@keyframes smart-highlight-in {
|
||||
from {
|
||||
text-decoration-color: transparent;
|
||||
background-color: color-mix(in srgb, currentColor 22%, transparent);
|
||||
}
|
||||
60% {
|
||||
background-color: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
to {
|
||||
text-decoration-color: currentColor;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-appear {
|
||||
border-radius: 4px;
|
||||
animation: smart-highlight-in 450ms ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.highlight-appear {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.task-input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -89,17 +89,14 @@ const SmartTaskTitleInput = ({
|
||||
}
|
||||
}, [])
|
||||
const handleSuggestionChange = text => {
|
||||
// if the last word start with '@' or '#' or 'P':
|
||||
const lastWord = text.split(' ').pop()
|
||||
if (
|
||||
lastWord.startsWith('@') ||
|
||||
lastWord.startsWith('#') ||
|
||||
lastWord.startsWith('!')
|
||||
) {
|
||||
// show the menu when the last word starts with a configured trigger
|
||||
// character (e.g. '@', '#', '!', '*')
|
||||
const lastWord = text.split(/\s+/).pop()
|
||||
if (lastWord && suggestions?.[lastWord[0]]) {
|
||||
setSuggestionTrigger(lastWord[0])
|
||||
// last word without the first character:
|
||||
setLastWord(lastWord.slice(1))
|
||||
|
||||
setSelectedSuggestionIndex(0)
|
||||
setShowSuggestions(true)
|
||||
} else {
|
||||
setShowSuggestions(false)
|
||||
@@ -121,6 +118,7 @@ const SmartTaskTitleInput = ({
|
||||
|
||||
const newCursorPosition =
|
||||
cursorPosition - lastWord.length + suggestionValue.length + 1
|
||||
setCursorPosition(newCursorPosition)
|
||||
titleInputRef.current.setSelectionRange(
|
||||
newCursorPosition,
|
||||
newCursorPosition,
|
||||
@@ -376,18 +374,10 @@ const SmartTaskTitleInput = ({
|
||||
const suggestionValue = suggestions[suggestionTrigger].display
|
||||
? suggestion[suggestions[suggestionTrigger].display]
|
||||
: suggestion
|
||||
const newValue = `${value.slice(0, cursorPosition)}${suggestionValue}${value.slice(cursorPosition)}`
|
||||
|
||||
onChange(newValue)
|
||||
// Same insertion path as keyboard selection: replace the partial
|
||||
// word typed after the trigger instead of inserting alongside it
|
||||
titleInputRef?.current?.focus()
|
||||
|
||||
setCursorPosition(cursorPosition + suggestion.length)
|
||||
titleInputRef.current.value = newValue
|
||||
titleInputRef.current.setSelectionRange(
|
||||
cursorPosition + suggestionValue.length,
|
||||
cursorPosition + suggestionValue.length,
|
||||
)
|
||||
setShowSuggestions(false)
|
||||
selectSuggestionText(suggestionValue)
|
||||
}}
|
||||
onCreateSuggestion={name => {
|
||||
selectSuggestionText(name)
|
||||
|
||||
Reference in New Issue
Block a user