@@ -6,7 +6,9 @@ const browser = await chromium.launch()
|
|||||||
const context = await browser.newContext({ storageState: state })
|
const context = await browser.newContext({ storageState: state })
|
||||||
const page = await context.newPage()
|
const page = await context.newPage()
|
||||||
page.on('console', msg => console.log('[console]', msg.type(), msg.text()))
|
page.on('console', msg => console.log('[console]', msg.type(), msg.text()))
|
||||||
page.on('pageerror', err => console.log('[pageerror]', err.message, '\n', err.stack))
|
page.on('pageerror', err =>
|
||||||
|
console.log('[pageerror]', err.message, '\n', err.stack),
|
||||||
|
)
|
||||||
|
|
||||||
await page.goto('http://localhost:5173/chores/create')
|
await page.goto('http://localhost:5173/chores/create')
|
||||||
await page.getByTestId('chore-name-input').fill('Debug Chore ' + Date.now())
|
await page.getByTestId('chore-name-input').fill('Debug Chore ' + Date.now())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test as base, expect } from '@playwright/test'
|
import { expect, test as base } from '@playwright/test'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
@@ -9,7 +9,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|||||||
* After successful signup the app auto-logs in and walks through the
|
* After successful signup the app auto-logs in and walks through the
|
||||||
* onboarding flow (/circle-setup, then /ready) before landing on /chores.
|
* onboarding flow (/circle-setup, then /ready) before landing on /chores.
|
||||||
*/
|
*/
|
||||||
export async function signUpViaUI(page, { username, email, password, displayName }) {
|
export async function signUpViaUI(
|
||||||
|
page,
|
||||||
|
{ displayName, email, password, username },
|
||||||
|
) {
|
||||||
await page.goto('/signup')
|
await page.goto('/signup')
|
||||||
await page.locator('#username').fill(username)
|
await page.locator('#username').fill(username)
|
||||||
await page.locator('#email').fill(email)
|
await page.locator('#email').fill(email)
|
||||||
@@ -30,7 +33,7 @@ export async function signUpViaUI(page, { username, email, password, displayName
|
|||||||
* Fill and submit the login form through the UI.
|
* Fill and submit the login form through the UI.
|
||||||
* After successful login the app redirects to /chores.
|
* After successful login the app redirects to /chores.
|
||||||
*/
|
*/
|
||||||
export async function loginViaUI(page, { username, password }) {
|
export async function loginViaUI(page, { password, username }) {
|
||||||
await page.goto('/login')
|
await page.goto('/login')
|
||||||
await page.locator('#username').fill(username)
|
await page.locator('#username').fill(username)
|
||||||
await page.locator('#password').fill(password)
|
await page.locator('#password').fill(password)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { writeFile, mkdir } from 'fs/promises'
|
import { mkdir, writeFile } from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export default async function globalSetup() {
|
|||||||
throw new Error(`Login failed (${loginRes.status}): ${body}`)
|
throw new Error(`Login failed (${loginRes.status}): ${body}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token, expire } = await loginRes.json()
|
const { expire, token } = await loginRes.json()
|
||||||
|
|
||||||
// Write a Playwright storage-state file containing the token in localStorage
|
// Write a Playwright storage-state file containing the token in localStorage
|
||||||
const stateDir = path.join(__dirname, '.auth')
|
const stateDir = path.join(__dirname, '.auth')
|
||||||
@@ -84,7 +84,11 @@ export default async function globalSetup() {
|
|||||||
async function waitForServer(url, retries = 20, delayMs = 1000) {
|
async function waitForServer(url, retries = 20, delayMs = 1000) {
|
||||||
for (let i = 0; i < retries; i++) {
|
for (let i = 0; i < retries; i++) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${url}/api/v1/auth/login`, { method: 'POST', body: '{}', headers: { 'Content-Type': 'application/json' } })
|
const res = await fetch(`${url}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: '{}',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
if (res.status < 500) return
|
if (res.status < 500) return
|
||||||
} catch {
|
} catch {
|
||||||
// server not up yet
|
// server not up yet
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { signUpViaUI, loginViaUI } from '../fixtures/auth.js'
|
|
||||||
|
import { loginViaUI, signUpViaUI } from '../fixtures/auth.js'
|
||||||
|
|
||||||
// Username must match /^[a-z.-]+$/ — no digits allowed.
|
// Username must match /^[a-z.-]+$/ — no digits allowed.
|
||||||
// Generate a random lowercase-only suffix for uniqueness across runs.
|
// Generate a random lowercase-only suffix for uniqueness across runs.
|
||||||
function randomSuffix(len = 8) {
|
function randomSuffix(len = 8) {
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyz'
|
const chars = 'abcdefghijklmnopqrstuvwxyz'
|
||||||
return Array.from({ length: len }, () =>
|
return Array.from(
|
||||||
chars[Math.floor(Math.random() * 26)],
|
{ length: len },
|
||||||
|
() => chars[Math.floor(Math.random() * 26)],
|
||||||
).join('')
|
).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +43,9 @@ test.describe('Auth – Sign Up', () => {
|
|||||||
|
|
||||||
test.describe('Auth – Login', () => {
|
test.describe('Auth – Login', () => {
|
||||||
// Re-use the shared E2E user that global-setup already created
|
// Re-use the shared E2E user that global-setup already created
|
||||||
test('logs in with valid credentials and lands on /chores', async ({ page }) => {
|
test('logs in with valid credentials and lands on /chores', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await loginViaUI(page, {
|
await loginViaUI(page, {
|
||||||
username: 'e2e.user',
|
username: 'e2e.user',
|
||||||
password: 'E2ePassword123!',
|
password: 'E2ePassword123!',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Select from '@mui/joy/Select'
|
|||||||
import Typography from '@mui/joy/Typography'
|
import Typography from '@mui/joy/Typography'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
|
||||||
import { TIME_UNITS } from '../utils/DurationUtils'
|
import { TIME_UNITS } from '../utils/DurationUtils'
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ const timingOptions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function getRelativeLabel(notification, t) {
|
function getRelativeLabel(notification, t) {
|
||||||
const { value, unit } = notification
|
const { unit, value } = notification
|
||||||
const numericValue = Number(value)
|
const numericValue = Number(value)
|
||||||
if (numericValue === 0) {
|
if (numericValue === 0) {
|
||||||
return t('notifTemplate.onDueDate')
|
return t('notifTemplate.onDueDate')
|
||||||
@@ -71,8 +72,8 @@ const NotificationTemplate = ({
|
|||||||
// Consumers that own an empty state themselves pass 0.
|
// Consumers that own an empty state themselves pass 0.
|
||||||
minNotifications = 1,
|
minNotifications = 1,
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
|
||||||
showTimeline = true,
|
showTimeline = true,
|
||||||
|
value,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const [notifications, setNotifications] = useState(
|
const [notifications, setNotifications] = useState(
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
|
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
|
||||||
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
|
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
|
||||||
|
|
||||||
import { useSSEContext } from '../hooks/useSSEContext'
|
import { useSSEContext } from '../hooks/useSSEContext'
|
||||||
|
|
||||||
const SSEConnectionStatus = ({
|
const SSEConnectionStatus = ({
|
||||||
variant = 'minimal',
|
|
||||||
showError = false,
|
showError = false,
|
||||||
sx = {},
|
sx = {},
|
||||||
|
variant = 'minimal',
|
||||||
}) => {
|
}) => {
|
||||||
const { isConnected, isConnecting, error, getConnectionStatus } =
|
const { error, getConnectionStatus, isConnected, isConnecting } =
|
||||||
useSSEContext()
|
useSSEContext()
|
||||||
|
|
||||||
const getStatusColor = () => {
|
const getStatusColor = () => {
|
||||||
|
|||||||
@@ -9,22 +9,23 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { useSSEContext } from '../hooks/useSSEContext'
|
import { useSSEContext } from '../hooks/useSSEContext'
|
||||||
import { useUserProfile } from '../queries/UserQueries'
|
import { useUserProfile } from '../queries/UserQueries'
|
||||||
import { isPlusAccount } from '../utils/Helpers'
|
import { isPlusAccount } from '../utils/Helpers'
|
||||||
import SSEConnectionStatus from './SSEConnectionStatus'
|
import SSEConnectionStatus from './SSEConnectionStatus'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const SSESettings = () => {
|
const SSESettings = () => {
|
||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const {
|
const {
|
||||||
isConnected,
|
|
||||||
isConnecting,
|
|
||||||
error,
|
error,
|
||||||
getConnectionStatus,
|
getConnectionStatus,
|
||||||
toggleSSEEnabled,
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
isSSEEnabled,
|
isSSEEnabled,
|
||||||
|
toggleSSEEnabled,
|
||||||
} = useSSEContext()
|
} = useSSEContext()
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Check, Star } from '@mui/icons-material'
|
import { Check, Star } from '@mui/icons-material'
|
||||||
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
|
import { Box, Card, Chip, Divider, Radio, Typography } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import AppModal from './common/AppModal'
|
|
||||||
import ModalActions from './common/ModalActions'
|
|
||||||
import { useNotification } from '../service/NotificationProvider'
|
|
||||||
import { GetSubscriptionSession } from '../utils/Fetcher'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const SubscriptionModal = ({ open, onClose }) => {
|
import { useNotification } from '../service/NotificationProvider'
|
||||||
|
import { GetSubscriptionSession } from '../utils/Fetcher'
|
||||||
|
import AppModal from './common/AppModal'
|
||||||
|
import ModalActions from './common/ModalActions'
|
||||||
|
|
||||||
|
const SubscriptionModal = ({ onClose, open }) => {
|
||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
const [selectedPlan, setSelectedPlan] = useState('yearly')
|
const [selectedPlan, setSelectedPlan] = useState('yearly')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -81,7 +82,11 @@ const SubscriptionModal = ({ open, onClose }) => {
|
|||||||
footer={
|
footer={
|
||||||
<ModalActions
|
<ModalActions
|
||||||
stackOnMobile
|
stackOnMobile
|
||||||
secondary={{ label: t('accountSettings.cancel'), onClick: onClose, disabled: isLoading }}
|
secondary={{
|
||||||
|
label: t('accountSettings.cancel'),
|
||||||
|
onClick: onClose,
|
||||||
|
disabled: isLoading,
|
||||||
|
}}
|
||||||
primary={{
|
primary={{
|
||||||
label: t('subscription.subscribe'),
|
label: t('subscription.subscribe'),
|
||||||
onClick: handleSubscribe,
|
onClick: handleSubscribe,
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ import {
|
|||||||
import { useMediaQuery } from '@mui/material'
|
import { useMediaQuery } from '@mui/material'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
|
||||||
import useStickyState from '../hooks/useStickyState'
|
import useStickyState from '../hooks/useStickyState'
|
||||||
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
|
||||||
@@ -35,7 +37,6 @@ import { apiClient } from '../utils/ApiClient'
|
|||||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||||
import UserModal from '../views/Modals/Inputs/UserModal'
|
import UserModal from '../views/Modals/Inputs/UserModal'
|
||||||
import SubscriptionModal from './SubscriptionModal'
|
import SubscriptionModal from './SubscriptionModal'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const UserProfileAvatar = () => {
|
const UserProfileAvatar = () => {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
@@ -43,11 +44,11 @@ const UserProfileAvatar = () => {
|
|||||||
const { mode, setMode } = useColorScheme()
|
const { mode, setMode } = useColorScheme()
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const {
|
const {
|
||||||
|
canImpersonate,
|
||||||
|
getEffectiveUser,
|
||||||
isImpersonating,
|
isImpersonating,
|
||||||
startImpersonation,
|
startImpersonation,
|
||||||
stopImpersonation,
|
stopImpersonation,
|
||||||
canImpersonate,
|
|
||||||
getEffectiveUser,
|
|
||||||
} = useImpersonateUser()
|
} = useImpersonateUser()
|
||||||
const { data: circleMembersData } = useCircleMembers()
|
const { data: circleMembersData } = useCircleMembers()
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
@@ -129,7 +130,9 @@ const UserProfileAvatar = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={resolvePhotoURL(userProfile?.image || userProfile?.avatar)}
|
src={resolvePhotoURL(
|
||||||
|
userProfile?.image || userProfile?.avatar,
|
||||||
|
)}
|
||||||
alt={userProfile?.displayName || userProfile?.name}
|
alt={userProfile?.displayName || userProfile?.name}
|
||||||
size='sm'
|
size='sm'
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -1,40 +1,43 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Box } from '@mui/joy'
|
|
||||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
|
||||||
import { useStaggeredAnimation, useReducedMotion } from '../../hooks/useAnimations'
|
|
||||||
import './PageTransition.css'
|
import './PageTransition.css'
|
||||||
|
|
||||||
const AnimatedList = ({
|
import { Box } from '@mui/joy'
|
||||||
children,
|
import React from 'react'
|
||||||
staggerDelay = 50,
|
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||||
animationType = 'stagger', // 'stagger', 'fade', 'slide'
|
|
||||||
direction = 'up', // 'up', 'down', 'left', 'right'
|
import {
|
||||||
renderItem,
|
useReducedMotion,
|
||||||
|
useStaggeredAnimation,
|
||||||
|
} from '../../hooks/useAnimations'
|
||||||
|
|
||||||
|
const AnimatedList = ({
|
||||||
|
animationType = 'stagger',
|
||||||
|
children,
|
||||||
|
direction = 'up', // 'stagger', 'fade', 'slide'
|
||||||
|
items, // 'up', 'down', 'left', 'right'
|
||||||
keyExtractor,
|
keyExtractor,
|
||||||
items,
|
renderItem,
|
||||||
...boxProps
|
staggerDelay = 50,
|
||||||
|
...boxProps
|
||||||
}) => {
|
}) => {
|
||||||
// Handle both children and items patterns
|
// Handle both children and items patterns
|
||||||
let childrenArray
|
let childrenArray
|
||||||
if (items && renderItem) {
|
if (items && renderItem) {
|
||||||
childrenArray = items.map((item, index) =>
|
childrenArray = items.map((item, index) =>
|
||||||
React.cloneElement(renderItem(item, index), {
|
React.cloneElement(renderItem(item, index), {
|
||||||
key: keyExtractor ? keyExtractor(item, index) : index
|
key: keyExtractor ? keyExtractor(item, index) : index,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
childrenArray = React.Children.toArray(children)
|
childrenArray = React.Children.toArray(children)
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay)
|
const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay)
|
||||||
const prefersReducedMotion = useReducedMotion()
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
|
||||||
// If user prefers reduced motion, render without animations
|
// If user prefers reduced motion, render without animations
|
||||||
if (prefersReducedMotion) {
|
if (prefersReducedMotion) {
|
||||||
return (
|
return (
|
||||||
<Box {...boxProps}>
|
<Box {...boxProps}>{items && renderItem ? childrenArray : children}</Box>
|
||||||
{items && renderItem ? childrenArray : children}
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +58,7 @@ const AnimatedList = ({
|
|||||||
<TransitionGroup component={null}>
|
<TransitionGroup component={null}>
|
||||||
{childrenArray.map((child, index) => {
|
{childrenArray.map((child, index) => {
|
||||||
const isVisible = visibleItems.has(index)
|
const isVisible = visibleItems.has(index)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CSSTransition
|
<CSSTransition
|
||||||
key={child.key || index}
|
key={child.key || index}
|
||||||
|
|||||||
@@ -65,11 +65,7 @@ const LogoContainer = styled(Box)({
|
|||||||
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const LoadingScreen = ({
|
const LoadingScreen = ({ message = null, showLogo = true, size = 'lg' }) => {
|
||||||
message = null,
|
|
||||||
showLogo = true,
|
|
||||||
size = 'lg',
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
return (
|
return (
|
||||||
<LoadingContainer>
|
<LoadingContainer>
|
||||||
@@ -90,7 +86,7 @@ const LoadingScreen = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</LogoContainer>
|
</LogoContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CircularProgress
|
<CircularProgress
|
||||||
size={size}
|
size={size}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -98,7 +94,7 @@ const LoadingScreen = ({
|
|||||||
mb: 2,
|
mb: 2,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PulsingText level='body-md'>{message ?? t('loading')}</PulsingText>
|
<PulsingText level='body-md'>{message ?? t('loading')}</PulsingText>
|
||||||
</LoadingContent>
|
</LoadingContent>
|
||||||
</LoadingContainer>
|
</LoadingContainer>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import './PageTransition.css'
|
||||||
|
|
||||||
import { useLayoutEffect, useRef, useState } from 'react'
|
import { useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import './PageTransition.css'
|
|
||||||
|
|
||||||
// Route hierarchy for determining navigation direction
|
// Route hierarchy for determining navigation direction
|
||||||
const routeHierarchy = {
|
const routeHierarchy = {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Box, Skeleton } from '@mui/joy'
|
import { Box, Skeleton } from '@mui/joy'
|
||||||
|
|
||||||
const SkeletonLoader = ({
|
const SkeletonLoader = ({
|
||||||
type = 'card',
|
|
||||||
count = 1,
|
count = 1,
|
||||||
height = 100,
|
height = 100,
|
||||||
width = '100%',
|
type = 'card',
|
||||||
variant = 'rectangular',
|
variant = 'rectangular',
|
||||||
|
width = '100%',
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const renderSkeleton = () => {
|
const renderSkeleton = () => {
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Card } from '@mui/joy'
|
import { Card } from '@mui/joy'
|
||||||
import { styled } from '@mui/joy/styles'
|
import { styled } from '@mui/joy/styles'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
const AnimatedCard = styled(Card)(({ theme }) => ({
|
const AnimatedCard = styled(Card)(({ theme }) => ({
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
transform: 'translateZ(0)', // Enable GPU acceleration
|
transform: 'translateZ(0)', // Enable GPU acceleration
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: 'translateY(-4px) translateZ(0)',
|
transform: 'translateY(-4px) translateZ(0)',
|
||||||
boxShadow: theme.shadow.lg,
|
boxShadow: theme.shadow.lg,
|
||||||
},
|
},
|
||||||
|
|
||||||
'&:active': {
|
'&:active': {
|
||||||
transform: 'translateY(-2px) translateZ(0)',
|
transform: 'translateY(-2px) translateZ(0)',
|
||||||
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Subtle background animation on hover
|
// Subtle background animation on hover
|
||||||
'&::before': {
|
'&::before': {
|
||||||
content: '""',
|
content: '""',
|
||||||
@@ -26,49 +26,50 @@ const AnimatedCard = styled(Card)(({ theme }) => ({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
background: 'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
|
background:
|
||||||
|
'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transition: 'opacity 0.3s ease',
|
transition: 'opacity 0.3s ease',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
borderRadius: 'inherit',
|
borderRadius: 'inherit',
|
||||||
},
|
},
|
||||||
|
|
||||||
'&:hover::before': {
|
'&:hover::before': {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Focus states for accessibility
|
// Focus states for accessibility
|
||||||
'&:focus-visible': {
|
'&:focus-visible': {
|
||||||
outline: '2px solid',
|
outline: '2px solid',
|
||||||
outlineColor: theme.palette.primary[500],
|
outlineColor: theme.palette.primary[500],
|
||||||
outlineOffset: '2px',
|
outlineOffset: '2px',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reduced motion support
|
// Reduced motion support
|
||||||
'@media (prefers-reduced-motion: reduce)': {
|
'@media (prefers-reduced-motion: reduce)': {
|
||||||
transition: 'none',
|
transition: 'none',
|
||||||
transform: 'none !important',
|
transform: 'none !important',
|
||||||
|
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: 'none',
|
transform: 'none',
|
||||||
boxShadow: theme.shadow.md, // Still provide visual feedback
|
boxShadow: theme.shadow.md, // Still provide visual feedback
|
||||||
},
|
},
|
||||||
|
|
||||||
'&:active': {
|
'&:active': {
|
||||||
transform: 'none',
|
transform: 'none',
|
||||||
},
|
},
|
||||||
|
|
||||||
'&::before': {
|
'&::before': {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const SmoothCard = ({
|
const SmoothCard = ({
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
animationDisabled = false,
|
animationDisabled = false,
|
||||||
...props
|
children,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
if (animationDisabled) {
|
if (animationDisabled) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import './PageTransition.css'
|
||||||
|
|
||||||
import { Box } from '@mui/joy'
|
import { Box } from '@mui/joy'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||||
import './PageTransition.css'
|
|
||||||
|
|
||||||
const StaggeredList = ({
|
const StaggeredList = ({
|
||||||
children,
|
|
||||||
staggerDelay = 50,
|
|
||||||
initialDelay = 0,
|
|
||||||
animate = true,
|
animate = true,
|
||||||
|
children,
|
||||||
|
initialDelay = 0,
|
||||||
|
staggerDelay = 50,
|
||||||
}) => {
|
}) => {
|
||||||
const [isVisible, setIsVisible] = useState(!animate)
|
const [isVisible, setIsVisible] = useState(!animate)
|
||||||
|
|
||||||
|
|||||||
@@ -17,27 +17,27 @@ const WIDTH_BY_SIZE = {
|
|||||||
const AppModal = forwardRef(
|
const AppModal = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
open,
|
backdropBlur = true,
|
||||||
onClose,
|
|
||||||
children,
|
children,
|
||||||
title,
|
closeOnBackdrop = true,
|
||||||
|
closeOnEscape = true,
|
||||||
|
contentSx,
|
||||||
description,
|
description,
|
||||||
footer,
|
footer,
|
||||||
size = 'md',
|
footerSx,
|
||||||
fullWidth = true,
|
fullWidth = true,
|
||||||
isMobile: isMobileProp,
|
isMobile: isMobileProp,
|
||||||
keepMounted = false,
|
keepMounted = false,
|
||||||
|
maxHeight = '90dvh',
|
||||||
mobilePresentation = 'sheet',
|
mobilePresentation = 'sheet',
|
||||||
|
onClose,
|
||||||
|
open,
|
||||||
role = 'dialog',
|
role = 'dialog',
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
showHandle = false,
|
showHandle = false,
|
||||||
closeOnBackdrop = true,
|
size = 'md',
|
||||||
closeOnEscape = true,
|
|
||||||
backdropBlur = true,
|
|
||||||
maxHeight = '90dvh',
|
|
||||||
contentSx,
|
|
||||||
footerSx,
|
|
||||||
sx,
|
sx,
|
||||||
|
title,
|
||||||
unmountDelay = 180,
|
unmountDelay = 180,
|
||||||
...modalProps
|
...modalProps
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Add, Remove } from '@mui/icons-material'
|
import { Add, Remove } from '@mui/icons-material'
|
||||||
import { Box, IconButton, Input, Option, Select } from '@mui/joy'
|
import { Box, IconButton, Input, Option, Select } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
secondsToValueAndUnit,
|
secondsToValueAndUnit,
|
||||||
TIME_UNITS,
|
TIME_UNITS,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
* size – Joy UI size ('sm' | 'md')
|
* size – Joy UI size ('sm' | 'md')
|
||||||
* minValue – minimum numeric value (default 1)
|
* minValue – minimum numeric value (default 1)
|
||||||
*/
|
*/
|
||||||
const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => {
|
const DurationInput = ({ minValue = 1, onChange, size = 'md', value }) => {
|
||||||
const derived =
|
const derived =
|
||||||
value != null && value >= 0
|
value != null && value >= 0
|
||||||
? secondsToValueAndUnit(value)
|
? secondsToValueAndUnit(value)
|
||||||
@@ -26,7 +27,7 @@ const DurationInput = ({ value, onChange, size = 'md', minValue = 1 }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value != null && value >= 0) {
|
if (value != null && value >= 0) {
|
||||||
const { value: v, unit: u } = secondsToValueAndUnit(value)
|
const { unit: u, value: v } = secondsToValueAndUnit(value)
|
||||||
setDisplayValue(v)
|
setDisplayValue(v)
|
||||||
setUnit(u)
|
setUnit(u)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const SIZES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ActionButton = ({ action, ...buttonProps }) => {
|
const ActionButton = ({ action, ...buttonProps }) => {
|
||||||
const { label, to, onClick, ...rest } = action
|
const { label, onClick, to, ...rest } = action
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
@@ -73,15 +73,15 @@ ActionButton.propTypes = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EmptyState = ({
|
const EmptyState = ({
|
||||||
variant = 'empty',
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
description,
|
||||||
|
fullHeight = false,
|
||||||
|
icon,
|
||||||
primaryAction,
|
primaryAction,
|
||||||
secondaryAction,
|
secondaryAction,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
fullHeight = false,
|
|
||||||
sx,
|
sx,
|
||||||
|
title,
|
||||||
|
variant = 'empty',
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const tone = TONES[variant] || TONES.empty
|
const tone = TONES[variant] || TONES.empty
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import AppModal from './AppModal'
|
import AppModal from './AppModal'
|
||||||
import ModalActions from './ModalActions'
|
|
||||||
import ActiveFilterChips from './filter/ActiveFilterChips'
|
import ActiveFilterChips from './filter/ActiveFilterChips'
|
||||||
|
import ModalActions from './ModalActions'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable filter bar component.
|
* Reusable filter bar component.
|
||||||
@@ -141,17 +142,17 @@ const fmtDisplayDate = iso => {
|
|||||||
// ── Component ────────────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const FilterBar = ({
|
const FilterBar = ({
|
||||||
filterDefs,
|
|
||||||
activeFilters,
|
activeFilters,
|
||||||
onSetFilter,
|
filterDefs,
|
||||||
onClearAll,
|
onClearAll,
|
||||||
resultCount,
|
onOpenChange,
|
||||||
totalCount,
|
onSetFilter,
|
||||||
|
open,
|
||||||
// When the host renders its own trigger (e.g. an icon button in a toolbar
|
// When the host renders its own trigger (e.g. an icon button in a toolbar
|
||||||
// row), it drives the sheet through `open`/`onOpenChange` and hides ours.
|
// row), it drives the sheet through `open`/`onOpenChange` and hides ours.
|
||||||
open,
|
resultCount,
|
||||||
onOpenChange,
|
|
||||||
showTrigger = true,
|
showTrigger = true,
|
||||||
|
totalCount,
|
||||||
}) => {
|
}) => {
|
||||||
const [internalOpen, setInternalOpen] = useState(false)
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
const isControlled = open !== undefined
|
const isControlled = open !== undefined
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import PropTypes from 'prop-types'
|
|||||||
function KeyboardShortcutHint({
|
function KeyboardShortcutHint({
|
||||||
shortcut,
|
shortcut,
|
||||||
show = true,
|
show = true,
|
||||||
withCmd = true,
|
|
||||||
withCtrl, // Legacy prop for backward compatibility
|
|
||||||
withShift = false,
|
|
||||||
sx = {},
|
sx = {},
|
||||||
|
withCmd = true, // Legacy prop for backward compatibility
|
||||||
|
withCtrl,
|
||||||
|
withShift = false,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
if (!show) return null
|
if (!show) return null
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ const ActionButton = ({ action, defaults, sx }) => {
|
|||||||
* primary action is always the final, highest-emphasis control.
|
* primary action is always the final, highest-emphasis control.
|
||||||
*/
|
*/
|
||||||
const ModalActions = ({
|
const ModalActions = ({
|
||||||
|
children,
|
||||||
primary,
|
primary,
|
||||||
secondary,
|
secondary,
|
||||||
tertiary,
|
|
||||||
children,
|
|
||||||
stackOnMobile = false,
|
stackOnMobile = false,
|
||||||
sx,
|
sx,
|
||||||
|
tertiary,
|
||||||
}) => {
|
}) => {
|
||||||
const responsiveButtonStyles = stackOnMobile
|
const responsiveButtonStyles = stackOnMobile
|
||||||
? { '& > button': { width: { xs: '100%', sm: 'auto' } } }
|
? { '& > button': { width: { xs: '100%', sm: 'auto' } } }
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Add, Close } from '@mui/icons-material'
|
import { Add, Close } from '@mui/icons-material'
|
||||||
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
|
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const ActiveFilterChips = ({
|
const ActiveFilterChips = ({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { initWidgetSync } from '../service/WidgetService'
|
import { initWidgetSync } from '../service/WidgetService'
|
||||||
|
|
||||||
const QueryContext = ({ children }) => {
|
const QueryContext = ({ children }) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createContext, useContext } from 'react'
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
import { useSSE } from '../hooks/useSSE'
|
import { useSSE } from '../hooks/useSSE'
|
||||||
|
|
||||||
export const SSEContext = createContext({
|
export const SSEContext = createContext({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Network } from '@capacitor/network'
|
import { Network } from '@capacitor/network'
|
||||||
|
|
||||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||||
|
|
||||||
class NetworkManager {
|
class NetworkManager {
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ const useAcknowledgmentModal = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useAcknowledgmentModal
|
export default useAcknowledgmentModal
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const useReducedMotion = () => {
|
|||||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
setPrefersReducedMotion(mediaQuery.matches)
|
setPrefersReducedMotion(mediaQuery.matches)
|
||||||
|
|
||||||
const handleChange = (event) => {
|
const handleChange = event => {
|
||||||
setPrefersReducedMotion(event.matches)
|
setPrefersReducedMotion(event.matches)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,13 +32,13 @@ export const useStaggeredAnimation = (itemCount, delay = 50) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timeouts = []
|
const timeouts = []
|
||||||
|
|
||||||
// Stagger the appearance of items
|
// Stagger the appearance of items
|
||||||
for (let i = 0; i < itemCount; i++) {
|
for (let i = 0; i < itemCount; i++) {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
setVisibleItems(prev => new Set([...prev, i]))
|
setVisibleItems(prev => new Set([...prev, i]))
|
||||||
}, i * delay)
|
}, i * delay)
|
||||||
|
|
||||||
timeouts.push(timeout)
|
timeouts.push(timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export const useInViewAnimation = (threshold = 0.1) => {
|
|||||||
([entry]) => {
|
([entry]) => {
|
||||||
setIsInView(entry.isIntersecting)
|
setIsInView(entry.isIntersecting)
|
||||||
},
|
},
|
||||||
{ threshold }
|
{ threshold },
|
||||||
)
|
)
|
||||||
|
|
||||||
observer.observe(element)
|
observer.observe(element)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { apiClient } from '../utils/ApiClient'
|
import { apiClient } from '../utils/ApiClient'
|
||||||
import { offlineDB } from '../utils/OfflineDB'
|
import { offlineDB } from '../utils/OfflineDB'
|
||||||
import { clearAllTokens, saveTokens } from '../utils/TokenStorage'
|
import { clearAllTokens, saveTokens } from '../utils/TokenStorage'
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ const useConfirmationModal = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useConfirmationModal
|
export default useConfirmationModal
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { patchDescriptionHtml } from '../utils/ImageCache'
|
import { patchDescriptionHtml } from '../utils/ImageCache'
|
||||||
|
|
||||||
// Returns description HTML safe to render: embedded images with an expired
|
// Returns description HTML safe to render: embedded images with an expired
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ import { Capacitor } from '@capacitor/core'
|
|||||||
function normalizeScannedImage(raw) {
|
function normalizeScannedImage(raw) {
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
if (raw.startsWith('data:')) return raw
|
if (raw.startsWith('data:')) return raw
|
||||||
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('content://')) return raw
|
if (
|
||||||
if (raw.startsWith('/') || raw.startsWith('file://')) return Capacitor.convertFileSrc(raw)
|
raw.startsWith('http://') ||
|
||||||
|
raw.startsWith('https://') ||
|
||||||
|
raw.startsWith('content://')
|
||||||
|
)
|
||||||
|
return raw
|
||||||
|
if (raw.startsWith('/') || raw.startsWith('file://'))
|
||||||
|
return Capacitor.convertFileSrc(raw)
|
||||||
// iOS base64 without prefix
|
// iOS base64 without prefix
|
||||||
return `data:image/jpeg;base64,${raw}`
|
return `data:image/jpeg;base64,${raw}`
|
||||||
}
|
}
|
||||||
@@ -25,11 +31,16 @@ function normalizeScannedImage(raw) {
|
|||||||
export function useDocumentScanner() {
|
export function useDocumentScanner() {
|
||||||
const isNativeScanner = Capacitor.isNativePlatform()
|
const isNativeScanner = Capacitor.isNativePlatform()
|
||||||
|
|
||||||
const scanDocument = async ({ maxDocuments = 1, quality = 90, letUserAdjustCrop = false } = {}) => {
|
const scanDocument = async ({
|
||||||
|
letUserAdjustCrop = false,
|
||||||
|
maxDocuments = 1,
|
||||||
|
quality = 90,
|
||||||
|
} = {}) => {
|
||||||
if (!isNativeScanner) return { image: null, cancelled: false }
|
if (!isNativeScanner) return { image: null, cancelled: false }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { DocumentScanner } = await import('@capgo/capacitor-document-scanner')
|
const { DocumentScanner } =
|
||||||
|
await import('@capgo/capacitor-document-scanner')
|
||||||
const { scannedImages } = await DocumentScanner.scanDocument({
|
const { scannedImages } = await DocumentScanner.scanDocument({
|
||||||
croppedImageQuality: quality,
|
croppedImageQuality: quality,
|
||||||
maxNumDocuments: maxDocuments,
|
maxNumDocuments: maxDocuments,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import imageCompression from 'browser-image-compression'
|
import imageCompression from 'browser-image-compression'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { useUserProfile } from '../queries/UserQueries'
|
import { useUserProfile } from '../queries/UserQueries'
|
||||||
import { useNotification } from '../service/NotificationProvider'
|
import { useNotification } from '../service/NotificationProvider'
|
||||||
import { apiClient } from '../utils/ApiClient'
|
import { apiClient } from '../utils/ApiClient'
|
||||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export const useFileUpload = ({
|
export const useFileUpload = ({
|
||||||
draftId,
|
draftId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { commandQueue } from '../utils/CommandQueue'
|
import { commandQueue } from '../utils/CommandQueue'
|
||||||
|
|
||||||
// Hook to get pending commands for a specific chore (for showing pending badges/undo)
|
// Hook to get pending commands for a specific chore (for showing pending badges/undo)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
|
|
||||||
import AppModal from '../components/common/AppModal'
|
import AppModal from '../components/common/AppModal'
|
||||||
|
|
||||||
const MobileAppModal = props =>
|
const MobileAppModal = props =>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Capacitor } from '@capacitor/core'
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { EventSourcePolyfill } from 'event-source-polyfill'
|
import { EventSourcePolyfill } from 'event-source-polyfill'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { useUserProfile } from '../queries/UserQueries'
|
import { useUserProfile } from '../queries/UserQueries'
|
||||||
import { useAlerts } from '../service/AlertsProvider'
|
import { useAlerts } from '../service/AlertsProvider'
|
||||||
import { useNotification } from '../service/NotificationProvider'
|
import { useNotification } from '../service/NotificationProvider'
|
||||||
import { apiClient } from '../utils/ApiClient'
|
import { apiClient } from '../utils/ApiClient'
|
||||||
import { useAuth } from './useAuth.jsx'
|
import { useAuth } from './useAuth.jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const SSE_STATES = {
|
const SSE_STATES = {
|
||||||
CONNECTING: 0,
|
CONNECTING: 0,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useContext } from 'react'
|
import { useContext } from 'react'
|
||||||
|
|
||||||
import { SSEContext } from '../contexts/SSEContext'
|
import { SSEContext } from '../contexts/SSEContext'
|
||||||
|
|
||||||
export const useSSEContext = () => {
|
export const useSSEContext = () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { GetResource } from '../utils/Fetcher'
|
import { GetResource } from '../utils/Fetcher'
|
||||||
|
|
||||||
// Helper to check if we have a valid token
|
// Helper to check if we have a valid token
|
||||||
@@ -13,7 +14,7 @@ const isTokenValid = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useResource = () => {
|
export const useResource = () => {
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, error, isLoading, refetch } = useQuery({
|
||||||
queryKey: ['resource'],
|
queryKey: ['resource'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await GetResource()
|
const response = await GetResource()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { networkManager } from '../hooks/NetworkManager'
|
import { networkManager } from '../hooks/NetworkManager'
|
||||||
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
|
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { GetThingHistory } from '../utils/Fetcher'
|
import { GetThingHistory } from '../utils/Fetcher'
|
||||||
|
|
||||||
export const useThingHistory = (thingId, limit = 10) => {
|
export const useThingHistory = (thingId, limit = 10) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ClearChoreTimer,
|
ClearChoreTimer,
|
||||||
DeleteTimeSession,
|
DeleteTimeSession,
|
||||||
@@ -56,7 +57,7 @@ export const useUpdateTimeSession = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ choreId, sessionId, sessionData }) =>
|
mutationFn: ({ choreId, sessionData, sessionId }) =>
|
||||||
UpdateTimeSession(choreId, sessionId, sessionData),
|
UpdateTimeSession(choreId, sessionId, sessionData),
|
||||||
onSuccess: (_, { choreId }) => {
|
onSuccess: (_, { choreId }) => {
|
||||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export function isCacheEnabled() {
|
|||||||
export function setCacheEnabled(enabled) {
|
export function setCacheEnabled(enabled) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(ENABLED_KEY, String(enabled))
|
localStorage.setItem(ENABLED_KEY, String(enabled))
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hashContent(content) {
|
export function hashContent(content) {
|
||||||
@@ -56,7 +58,9 @@ export function setCached(hash, value) {
|
|||||||
index.push(hash)
|
index.push(hash)
|
||||||
localStorage.setItem(INDEX_KEY, JSON.stringify(index))
|
localStorage.setItem(INDEX_KEY, JSON.stringify(index))
|
||||||
}
|
}
|
||||||
} catch { /* storage full, ignore */ }
|
} catch {
|
||||||
|
/* storage full, ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCacheStats() {
|
export function getCacheStats() {
|
||||||
@@ -66,7 +70,15 @@ export function getCacheStats() {
|
|||||||
export function clearCache() {
|
export function clearCache() {
|
||||||
const index = getIndex()
|
const index = getIndex()
|
||||||
index.forEach(h => {
|
index.forEach(h => {
|
||||||
try { localStorage.removeItem(ENTRY_PREFIX + h) } catch { /* ignore */ }
|
try {
|
||||||
|
localStorage.removeItem(ENTRY_PREFIX + h)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
})
|
})
|
||||||
try { localStorage.removeItem(INDEX_KEY) } catch { /* ignore */ }
|
try {
|
||||||
|
localStorage.removeItem(INDEX_KEY)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
|
||||||
import { getCached, hashContent, setCached } from './AIPromptCache'
|
import { getCached, hashContent, setCached } from './AIPromptCache'
|
||||||
|
|
||||||
// Native-only local AI service using @capacitor/local-llm.
|
// Native-only local AI service using @capacitor/local-llm.
|
||||||
@@ -74,14 +75,19 @@ class LocalAIService {
|
|||||||
await this.warmup()
|
await this.warmup()
|
||||||
try {
|
try {
|
||||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||||
const { text: out } = await LocalLLM.prompt({ prompt: text, sessionId: this._sessionId })
|
const { text: out } = await LocalLLM.prompt({
|
||||||
|
prompt: text,
|
||||||
|
sessionId: this._sessionId,
|
||||||
|
})
|
||||||
return out?.trim() || null
|
return out?.trim() || null
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||||
this._warmedUp = false
|
this._warmedUp = false
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +105,9 @@ class LocalAIService {
|
|||||||
try {
|
try {
|
||||||
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
|
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
|
||||||
const userMsg = messages.find(m => m.role === 'user')?.content || ''
|
const userMsg = messages.find(m => m.role === 'user')?.content || ''
|
||||||
const result = await this._nativePrompt(`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`)
|
const result = await this._nativePrompt(
|
||||||
|
`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`,
|
||||||
|
)
|
||||||
if (result) setCached(cacheHash, result)
|
if (result) setCached(cacheHash, result)
|
||||||
return result
|
return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -122,7 +130,10 @@ class LocalAIService {
|
|||||||
try {
|
try {
|
||||||
await this.warmup()
|
await this.warmup()
|
||||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||||
const { text } = await LocalLLM.prompt({ prompt, sessionId: this._sessionId })
|
const { text } = await LocalLLM.prompt({
|
||||||
|
prompt,
|
||||||
|
sessionId: this._sessionId,
|
||||||
|
})
|
||||||
const result = text?.trim() || null
|
const result = text?.trim() || null
|
||||||
if (result) setCached(cacheHash, result)
|
if (result) setCached(cacheHash, result)
|
||||||
return result
|
return result
|
||||||
@@ -133,7 +144,9 @@ class LocalAIService {
|
|||||||
const { LocalLLM } = await import('@capacitor/local-llm')
|
const { LocalLLM } = await import('@capacitor/local-llm')
|
||||||
await LocalLLM.endSession({ sessionId: this._sessionId })
|
await LocalLLM.endSession({ sessionId: this._sessionId })
|
||||||
this._warmedUp = false
|
this._warmedUp = false
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ export const decodeNdefUrl = record => {
|
|||||||
|
|
||||||
// Starts a native NFC write session. Calls onWaiting once scanning is active,
|
// Starts a native NFC write session. Calls onWaiting once scanning is active,
|
||||||
// then onSuccess or onError when the write completes. Returns a cancel function.
|
// then onSuccess or onError when the write completes. Returns a cancel function.
|
||||||
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => {
|
export const startNativeNFCWrite = async (
|
||||||
|
url,
|
||||||
|
{ onError, onSuccess, onWaiting },
|
||||||
|
) => {
|
||||||
let listener = null
|
let listener = null
|
||||||
let done = false
|
let done = false
|
||||||
|
|
||||||
@@ -86,7 +89,7 @@ export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }
|
|||||||
|
|
||||||
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
|
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
|
||||||
// NDEF record is found, or onError on failure. Returns a cancel function.
|
// NDEF record is found, or onError on failure. Returns a cancel function.
|
||||||
export const startNativeScan = async ({ onTag, onError }) => {
|
export const startNativeScan = async ({ onError, onTag }) => {
|
||||||
let listener = null
|
let listener = null
|
||||||
let done = false
|
let done = false
|
||||||
|
|
||||||
|
|||||||
@@ -113,9 +113,8 @@ class VoiceInputService {
|
|||||||
async isSupported() {
|
async isSupported() {
|
||||||
if (this.isNative) {
|
if (this.isNative) {
|
||||||
try {
|
try {
|
||||||
const { SpeechRecognition } = await import(
|
const { SpeechRecognition } =
|
||||||
'@capacitor-community/speech-recognition'
|
await import('@capacitor-community/speech-recognition')
|
||||||
)
|
|
||||||
const { available } = await SpeechRecognition.available()
|
const { available } = await SpeechRecognition.available()
|
||||||
return !!available
|
return !!available
|
||||||
} catch {
|
} catch {
|
||||||
@@ -134,9 +133,8 @@ class VoiceInputService {
|
|||||||
return 'granted'
|
return 'granted'
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { SpeechRecognition } = await import(
|
const { SpeechRecognition } =
|
||||||
'@capacitor-community/speech-recognition'
|
await import('@capacitor-community/speech-recognition')
|
||||||
)
|
|
||||||
const current = await SpeechRecognition.checkPermissions()
|
const current = await SpeechRecognition.checkPermissions()
|
||||||
if (current.speechRecognition === 'granted') return 'granted'
|
if (current.speechRecognition === 'granted') return 'granted'
|
||||||
const res = await SpeechRecognition.requestPermissions()
|
const res = await SpeechRecognition.requestPermissions()
|
||||||
@@ -189,9 +187,8 @@ class VoiceInputService {
|
|||||||
if (this.isNative) {
|
if (this.isNative) {
|
||||||
let SpeechRecognition
|
let SpeechRecognition
|
||||||
try {
|
try {
|
||||||
;({ SpeechRecognition } = await import(
|
;({ SpeechRecognition } =
|
||||||
'@capacitor-community/speech-recognition'
|
await import('@capacitor-community/speech-recognition'))
|
||||||
))
|
|
||||||
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
|
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
|
||||||
} catch {
|
} catch {
|
||||||
// recognizer may already be stopped
|
// recognizer may already be stopped
|
||||||
@@ -282,9 +279,8 @@ class VoiceInputService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _startNative() {
|
async _startNative() {
|
||||||
const { SpeechRecognition } = await import(
|
const { SpeechRecognition } =
|
||||||
'@capacitor-community/speech-recognition'
|
await import('@capacitor-community/speech-recognition')
|
||||||
)
|
|
||||||
await SpeechRecognition.removeAllListeners()
|
await SpeechRecognition.removeAllListeners()
|
||||||
|
|
||||||
await SpeechRecognition.addListener('partialResults', ({ matches }) => {
|
await SpeechRecognition.addListener('partialResults', ({ matches }) => {
|
||||||
@@ -342,9 +338,8 @@ class VoiceInputService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _doRestartNative() {
|
async _doRestartNative() {
|
||||||
const { SpeechRecognition } = await import(
|
const { SpeechRecognition } =
|
||||||
'@capacitor-community/speech-recognition'
|
await import('@capacitor-community/speech-recognition')
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
|
await withTimeout(SpeechRecognition.stop(), NATIVE_CALL_TIMEOUT_MS)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Capacitor, registerPlugin } from '@capacitor/core'
|
import { Capacitor, registerPlugin } from '@capacitor/core'
|
||||||
|
|
||||||
import { apiClient } from '../utils/ApiClient'
|
import { apiClient } from '../utils/ApiClient'
|
||||||
|
|
||||||
// Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and
|
// Native bridge implemented in ios/App/App/WidgetBridgePlugin.swift and
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ const allMonths = [
|
|||||||
* @param {Object} chore - The chore object (needed for nextDueDate null check)
|
* @param {Object} chore - The chore object (needed for nextDueDate null check)
|
||||||
* @returns {string} The formatted due date text
|
* @returns {string} The formatted due date text
|
||||||
*/
|
*/
|
||||||
export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') => {
|
export const getDueDateChipText = (
|
||||||
|
nextDueDate,
|
||||||
|
chore,
|
||||||
|
timeFormat = 'h:mm A',
|
||||||
|
) => {
|
||||||
if (chore?.nextDueDate === null || nextDueDate === null) return 'No Due Date'
|
if (chore?.nextDueDate === null || nextDueDate === null) return 'No Due Date'
|
||||||
|
|
||||||
const dueDate = moment(nextDueDate)
|
const dueDate = moment(nextDueDate)
|
||||||
@@ -34,16 +38,22 @@ export const getDueDateChipText = (nextDueDate, chore, timeFormat = 'h:mm A') =>
|
|||||||
sameElse: `MMM D ${timeFormat}`,
|
sameElse: `MMM D ${timeFormat}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// if time is 23:59:59, treat as end-of-day (date only, no specific time)
|
// if time is 23:59:59, treat as end-of-day (date only, no specific time)
|
||||||
if (dueDate.hours() === 23 && dueDate.minutes() === 59 && dueDate.seconds() === 59) {
|
if (
|
||||||
|
dueDate.hours() === 23 &&
|
||||||
|
dueDate.minutes() === 59 &&
|
||||||
|
dueDate.seconds() === 59
|
||||||
|
) {
|
||||||
if (diff < 0) {
|
if (diff < 0) {
|
||||||
// For overdue dates, show calendar format for recent dates
|
// For overdue dates, show calendar format for recent dates
|
||||||
const absDiff = Math.abs(diff)
|
const absDiff = Math.abs(diff)
|
||||||
if (absDiff <= 48) {
|
if (absDiff <= 48) {
|
||||||
return (
|
return (
|
||||||
'Overdue ' +
|
'Overdue ' +
|
||||||
moment(nextDueDate).calendar(null, calendarFormat).split(' ')[0].toLowerCase()
|
moment(nextDueDate)
|
||||||
|
.calendar(null, calendarFormat)
|
||||||
|
.split(' ')[0]
|
||||||
|
.toLowerCase()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return 'Overdue ' + dueDate.fromNow()
|
return 'Overdue ' + dueDate.fromNow()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
import { TASK_COLOR } from './Colors.jsx'
|
import { TASK_COLOR } from './Colors.jsx'
|
||||||
|
|
||||||
const priorityOrder = [1, 2, 3, 4, 0]
|
const priorityOrder = [1, 2, 3, 4, 0]
|
||||||
@@ -213,7 +214,7 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'due_date': {
|
case 'due_date': {
|
||||||
var { dateGroups: dueDateGroups, anytime: dueAnytime } =
|
var { anytime: dueAnytime, dateGroups: dueDateGroups } =
|
||||||
buildActualDateGroups(chores)
|
buildActualDateGroups(chores)
|
||||||
groups = [...dueDateGroups]
|
groups = [...dueDateGroups]
|
||||||
if (dueAnytime.length > 0) {
|
if (dueAnytime.length > 0) {
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
export const createDateFormatter = (
|
export const createDateFormatter = (dateFormat, timeFormat, firstDayOfWeek) => {
|
||||||
dateFormat,
|
|
||||||
timeFormat,
|
|
||||||
firstDayOfWeek,
|
|
||||||
) => {
|
|
||||||
moment.updateLocale('en', {
|
moment.updateLocale('en', {
|
||||||
week: {
|
week: {
|
||||||
dow: firstDayOfWeek,
|
dow: firstDayOfWeek,
|
||||||
|
|||||||
@@ -740,7 +740,7 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
|
|||||||
const UploadChoreAttachment = (
|
const UploadChoreAttachment = (
|
||||||
file,
|
file,
|
||||||
entityType,
|
entityType,
|
||||||
{ entityId, draftId } = {},
|
{ draftId, entityId } = {},
|
||||||
) => {
|
) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
@@ -986,11 +986,6 @@ const TrackFilterUsage = id => {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
AcceptCircleMemberRequest,
|
AcceptCircleMemberRequest,
|
||||||
DeleteChoreAttachment,
|
|
||||||
DeleteDraftAttachment,
|
|
||||||
GetChoreAttachments,
|
|
||||||
SignAssetURL,
|
|
||||||
UploadChoreAttachment,
|
|
||||||
ApproveChore,
|
ApproveChore,
|
||||||
ArchiveChore,
|
ArchiveChore,
|
||||||
CancelSubscription,
|
CancelSubscription,
|
||||||
@@ -1010,8 +1005,10 @@ export {
|
|||||||
CreateThing,
|
CreateThing,
|
||||||
DeleteChildUser,
|
DeleteChildUser,
|
||||||
DeleteChore,
|
DeleteChore,
|
||||||
|
DeleteChoreAttachment,
|
||||||
DeleteChoreHistory,
|
DeleteChoreHistory,
|
||||||
DeleteCircleMember,
|
DeleteCircleMember,
|
||||||
|
DeleteDraftAttachment,
|
||||||
DeleteFilter,
|
DeleteFilter,
|
||||||
DeleteLabel,
|
DeleteLabel,
|
||||||
DeleteLongLiveToken,
|
DeleteLongLiveToken,
|
||||||
@@ -1024,6 +1021,7 @@ export {
|
|||||||
GetAllUsers,
|
GetAllUsers,
|
||||||
GetArchivedChores,
|
GetArchivedChores,
|
||||||
GetChildUsers,
|
GetChildUsers,
|
||||||
|
GetChoreAttachments,
|
||||||
GetChoreByID,
|
GetChoreByID,
|
||||||
GetChoreDetailById,
|
GetChoreDetailById,
|
||||||
GetChoreHistory,
|
GetChoreHistory,
|
||||||
@@ -1069,6 +1067,7 @@ export {
|
|||||||
SaveChore,
|
SaveChore,
|
||||||
SaveThing,
|
SaveThing,
|
||||||
SetupMFA,
|
SetupMFA,
|
||||||
|
SignAssetURL,
|
||||||
signUp,
|
signUp,
|
||||||
SkipChore,
|
SkipChore,
|
||||||
StartChore,
|
StartChore,
|
||||||
@@ -1091,5 +1090,6 @@ export {
|
|||||||
UpdateThingState,
|
UpdateThingState,
|
||||||
UpdateTimeSession,
|
UpdateTimeSession,
|
||||||
UpdateUserDetails,
|
UpdateUserDetails,
|
||||||
|
UploadChoreAttachment,
|
||||||
VerifyMFA,
|
VerifyMFA,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* @returns {boolean} - Whether the chore matches the condition
|
* @returns {boolean} - Whether the chore matches the condition
|
||||||
*/
|
*/
|
||||||
export const evaluateCondition = (chore, condition, context = {}) => {
|
export const evaluateCondition = (chore, condition, context = {}) => {
|
||||||
const { type, operator, value } = condition
|
const { operator, type, value } = condition
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'assignee':
|
case 'assignee':
|
||||||
@@ -343,7 +343,7 @@ export const getFilterOverdueCount = (chores, filter, context = {}) => {
|
|||||||
* @returns {Object} - { isValid: boolean, issues: Array }
|
* @returns {Object} - { isValid: boolean, issues: Array }
|
||||||
*/
|
*/
|
||||||
export const validateFilter = (filter, context = {}) => {
|
export const validateFilter = (filter, context = {}) => {
|
||||||
const { members = [], labels = [], projects = [] } = context
|
const { labels = [], members = [], projects = [] } = context
|
||||||
const issues = []
|
const issues = []
|
||||||
|
|
||||||
if (!filter.conditions || filter.conditions.length === 0) {
|
if (!filter.conditions || filter.conditions.length === 0) {
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ const patchDescriptionHtml = async (html, meta = {}) => {
|
|||||||
const images = extractDescriptionImages(html)
|
const images = extractDescriptionImages(html)
|
||||||
if (images.length === 0) return html
|
if (images.length === 0) return html
|
||||||
let patched = html
|
let patched = html
|
||||||
for (const { path, src, rawSrc } of images) {
|
for (const { path, rawSrc, src } of images) {
|
||||||
try {
|
try {
|
||||||
const nextSrc = await getImageSrc(path, src, {
|
const nextSrc = await getImageSrc(path, src, {
|
||||||
kind: 'description',
|
kind: 'description',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CapacitorSQLite } from '@capacitor-community/sqlite'
|
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import { CapacitorSQLite } from '@capacitor-community/sqlite'
|
||||||
|
|
||||||
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||||
|
|
||||||
const DB_NAME = 'donetick_offline'
|
const DB_NAME = 'donetick_offline'
|
||||||
@@ -630,7 +631,7 @@ class IndexedDBBackend {
|
|||||||
async saveChores(chores) {
|
async saveChores(chores) {
|
||||||
if (!chores.length) return
|
if (!chores.length) return
|
||||||
|
|
||||||
const { tx, store } = await this._tx('cached_chores', 'readwrite')
|
const { store, tx } = await this._tx('cached_chores', 'readwrite')
|
||||||
|
|
||||||
for (const chore of chores) {
|
for (const chore of chores) {
|
||||||
store.put({
|
store.put({
|
||||||
@@ -670,7 +671,7 @@ class IndexedDBBackend {
|
|||||||
|
|
||||||
async deleteChores(ids) {
|
async deleteChores(ids) {
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
const { tx, store } = await this._tx('cached_chores', 'readwrite')
|
const { store, tx } = await this._tx('cached_chores', 'readwrite')
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
store.delete(id)
|
store.delete(id)
|
||||||
}
|
}
|
||||||
@@ -693,7 +694,7 @@ class IndexedDBBackend {
|
|||||||
const choreIds = [...new Set(entries.map(e => Number(e.choreId)))]
|
const choreIds = [...new Set(entries.map(e => Number(e.choreId)))]
|
||||||
await this._deletePendingHistoryByChoreIds(choreIds)
|
await this._deletePendingHistoryByChoreIds(choreIds)
|
||||||
// Upsert real entries
|
// Upsert real entries
|
||||||
const { tx, store } = await this._tx('cached_history', 'readwrite')
|
const { store, tx } = await this._tx('cached_history', 'readwrite')
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
store.put({
|
store.put({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
@@ -736,7 +737,7 @@ class IndexedDBBackend {
|
|||||||
.map(row => row.id)
|
.map(row => row.id)
|
||||||
if (!toDelete.length) return
|
if (!toDelete.length) return
|
||||||
// Tx 2: delete them
|
// Tx 2: delete them
|
||||||
const { tx, store: writeStore } = await this._tx(
|
const { store: writeStore, tx } = await this._tx(
|
||||||
'cached_history',
|
'cached_history',
|
||||||
'readwrite',
|
'readwrite',
|
||||||
)
|
)
|
||||||
@@ -772,7 +773,7 @@ class IndexedDBBackend {
|
|||||||
|
|
||||||
async deleteHistory(ids) {
|
async deleteHistory(ids) {
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
const { tx, store } = await this._tx('cached_history', 'readwrite')
|
const { store, tx } = await this._tx('cached_history', 'readwrite')
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
store.delete(id)
|
store.delete(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ class SyncEngine {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case CommandType.COMPLETE_CHORE: {
|
case CommandType.COMPLETE_CHORE: {
|
||||||
const { id, body, completedDate, performer } = cmd.payload
|
const { body, completedDate, id, performer } = cmd.payload
|
||||||
response = await MarkChoreComplete(
|
response = await MarkChoreComplete(
|
||||||
id,
|
id,
|
||||||
body || {},
|
body || {},
|
||||||
@@ -221,7 +221,7 @@ class SyncEngine {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case CommandType.UPDATE_CHORE_HISTORY: {
|
case CommandType.UPDATE_CHORE_HISTORY: {
|
||||||
const { choreId, historyId, historyData } = cmd.payload
|
const { choreId, historyData, historyId } = cmd.payload
|
||||||
response = await UpdateChoreHistory(choreId, historyId, historyData)
|
response = await UpdateChoreHistory(choreId, historyId, historyData)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case CommandType.RESCHEDULE_CHORE: {
|
case CommandType.RESCHEDULE_CHORE: {
|
||||||
const { id, dueDate } = cmd.payload
|
const { dueDate, id } = cmd.payload
|
||||||
response = await UpdateDueDate(id, dueDate)
|
response = await UpdateDueDate(id, dueDate)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,48 +5,52 @@ export const USER_TYPES = {
|
|||||||
CHILD: 1,
|
CHILD: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isParentUser = (user) => {
|
export const isParentUser = user => {
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
return user.userType === USER_TYPES.PARENT && !user.parentUserId
|
return user.userType === USER_TYPES.PARENT && !user.parentUserId
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isChildUser = (user) => {
|
export const isChildUser = user => {
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
return user.userType === USER_TYPES.CHILD && user.parentUserId !== null
|
return user.userType === USER_TYPES.CHILD && user.parentUserId !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const canManageChildUsers = (user) => {
|
export const canManageChildUsers = user => {
|
||||||
return isParentUser(user)
|
return isParentUser(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const canCreateChores = (user) => {
|
export const canCreateChores = user => {
|
||||||
// Both parent and child users can create chores
|
// Both parent and child users can create chores
|
||||||
return user && (isParentUser(user) || isChildUser(user))
|
return user && (isParentUser(user) || isChildUser(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const canManageCircle = (user) => {
|
export const canManageCircle = user => {
|
||||||
// Only parent users can manage circle settings
|
// Only parent users can manage circle settings
|
||||||
return isParentUser(user)
|
return isParentUser(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const canAccessAdminSettings = (user) => {
|
export const canAccessAdminSettings = user => {
|
||||||
// Only parent users can access admin settings like API tokens, MFA, etc.
|
// Only parent users can access admin settings like API tokens, MFA, etc.
|
||||||
return isParentUser(user)
|
return isParentUser(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUserDisplayInfo = (user) => {
|
export const getUserDisplayInfo = user => {
|
||||||
if (!user) return { displayName: '', username: '', userType: 'unknown' }
|
if (!user) return { displayName: '', username: '', userType: 'unknown' }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
displayName: user.displayName || user.username,
|
displayName: user.displayName || user.username,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
userType: isParentUser(user) ? 'parent' : isChildUser(user) ? 'child' : 'unknown',
|
userType: isParentUser(user)
|
||||||
|
? 'parent'
|
||||||
|
: isChildUser(user)
|
||||||
|
? 'child'
|
||||||
|
: 'unknown',
|
||||||
parentUserId: user.parentUserId,
|
parentUserId: user.parentUserId,
|
||||||
circleID: user.circleID,
|
circleID: user.circleID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getChildUsernameFromCombined = (combinedUsername) => {
|
export const getChildUsernameFromCombined = combinedUsername => {
|
||||||
// Extract child name from format: parent_child
|
// Extract child name from format: parent_child
|
||||||
const parts = combinedUsername.split('_')
|
const parts = combinedUsername.split('_')
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
@@ -55,7 +59,7 @@ export const getChildUsernameFromCombined = (combinedUsername) => {
|
|||||||
return combinedUsername
|
return combinedUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getParentUsernameFromCombined = (combinedUsername) => {
|
export const getParentUsernameFromCombined = combinedUsername => {
|
||||||
// Extract parent name from format: parent_child
|
// Extract parent name from format: parent_child
|
||||||
const parts = combinedUsername.split('_')
|
const parts = combinedUsername.split('_')
|
||||||
return parts[0] || combinedUsername
|
return parts[0] || combinedUsername
|
||||||
@@ -63,4 +67,4 @@ export const getParentUsernameFromCombined = (combinedUsername) => {
|
|||||||
|
|
||||||
export const buildChildUsername = (parentUsername, childName) => {
|
export const buildChildUsername = (parentUsername, childName) => {
|
||||||
return `${parentUsername}_${childName}`
|
return `${parentUsername}_${childName}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { authButtonSx, authInputSx } from './authStyles'
|
import { authButtonSx, authInputSx } from './authStyles'
|
||||||
|
|
||||||
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
|
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
|
||||||
|
|
||||||
export const AuthField = ({ label, error, helper, children, ...formProps }) => (
|
export const AuthField = ({ children, error, helper, label, ...formProps }) => (
|
||||||
<FormControl error={Boolean(error)} {...formProps}>
|
<FormControl error={Boolean(error)} {...formProps}>
|
||||||
<FormLabel sx={labelSx}>{label}</FormLabel>
|
<FormLabel sx={labelSx}>{label}</FormLabel>
|
||||||
{children}
|
{children}
|
||||||
@@ -34,16 +35,16 @@ export const AuthField = ({ label, error, helper, children, ...formProps }) => (
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
|
export const AuthTextField = ({ error, helper, label, sx, ...inputProps }) => (
|
||||||
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
|
<AuthField label={label} error={error} helper={helper} id={inputProps.id}>
|
||||||
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
|
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
|
||||||
</AuthField>
|
</AuthField>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const AuthPasswordField = ({
|
export const AuthPasswordField = ({
|
||||||
label,
|
|
||||||
error,
|
error,
|
||||||
helper,
|
helper,
|
||||||
|
label,
|
||||||
sx,
|
sx,
|
||||||
...inputProps
|
...inputProps
|
||||||
}) => {
|
}) => {
|
||||||
@@ -92,7 +93,7 @@ export const AuthSubmitButton = ({ children, sx, ...props }) => (
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
export const SocialButton = ({ icon, children, sx, ...props }) => (
|
export const SocialButton = ({ children, icon, sx, ...props }) => (
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
size='lg'
|
size='lg'
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { Box, Sheet, Typography } from '@mui/joy'
|
import { Box, Sheet, Typography } from '@mui/joy'
|
||||||
|
|
||||||
import Logo from '../../Logo'
|
import Logo from '../../Logo'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,18 +9,18 @@ import Logo from '../../Logo'
|
|||||||
* its own safe-area padding (the top inset is already reserved by NavBar).
|
* its own safe-area padding (the top inset is already reserved by NavBar).
|
||||||
*/
|
*/
|
||||||
const AuthShell = ({
|
const AuthShell = ({
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
action,
|
action,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
logoSize = 48,
|
logoSize = 48,
|
||||||
|
showLogo = !Capacitor.isNativePlatform(),
|
||||||
|
subtitle,
|
||||||
// In the app the user already came through the app icon and the Get Started
|
// In the app the user already came through the app icon and the Get Started
|
||||||
// mark, so repeating it here is noise. On the web these routes are the first
|
// mark, so repeating it here is noise. On the web these routes are the first
|
||||||
// thing a visitor sees — often on a self-hosted domain, and with no navbar —
|
// thing a visitor sees — often on a self-hosted domain, and with no navbar —
|
||||||
// so the mark is the only thing identifying the app. Views reached from an
|
// so the mark is the only thing identifying the app. Views reached from an
|
||||||
// emailed link override this to always show it.
|
// emailed link override this to always show it.
|
||||||
showLogo = !Capacitor.isNativePlatform(),
|
title,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Box, Button, LinearProgress } from '@mui/joy'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import { Box, Button, LinearProgress } from '@mui/joy'
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { useUserProfile } from '../../queries/UserQueries'
|
import { useUserProfile } from '../../queries/UserQueries'
|
||||||
import { apiClient } from '../../utils/ApiClient'
|
import { apiClient } from '../../utils/ApiClient'
|
||||||
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
|
|
||||||
import { GetUserProfile } from '../../utils/Fetcher'
|
import { GetUserProfile } from '../../utils/Fetcher'
|
||||||
|
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
|
||||||
import { saveTokens } from '../../utils/TokenStorage'
|
import { saveTokens } from '../../utils/TokenStorage'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import AuthShell from './AuthShell'
|
import AuthShell from './AuthShell'
|
||||||
import { authButtonSx } from './authStyles'
|
import { authButtonSx } from './authStyles'
|
||||||
import MFAVerificationModal from './MFAVerificationModal'
|
import MFAVerificationModal from './MFAVerificationModal'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Box, Button, Link, Typography } from '@mui/joy'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { useNotification } from '../../service/NotificationProvider'
|
import { useNotification } from '../../service/NotificationProvider'
|
||||||
import { ResetPassword } from '../../utils/Fetcher'
|
import { ResetPassword } from '../../utils/Fetcher'
|
||||||
import { AuthSubmitButton, AuthTextField, LegalLinks } from './AuthFields'
|
import { AuthSubmitButton, AuthTextField, LegalLinks } from './AuthFields'
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
|||||||
import WifiIcon from '@mui/icons-material/Wifi'
|
import WifiIcon from '@mui/icons-material/Wifi'
|
||||||
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy'
|
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { API_URL } from '../../Config'
|
import { API_URL } from '../../Config'
|
||||||
import { useResource } from '../../queries/ResourceQueries'
|
import { useResource } from '../../queries/ResourceQueries'
|
||||||
import { apiClient } from '../../utils/ApiClient'
|
import { apiClient } from '../../utils/ApiClient'
|
||||||
@@ -12,7 +14,6 @@ import { offlineDB } from '../../utils/OfflineDB'
|
|||||||
import { AuthSubmitButton, AuthTextField } from './AuthFields'
|
import { AuthSubmitButton, AuthTextField } from './AuthFields'
|
||||||
import AuthShell from './AuthShell'
|
import AuthShell from './AuthShell'
|
||||||
import { authButtonSx } from './authStyles'
|
import { authButtonSx } from './authStyles'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const CONNECTION_TIMEOUT_MS = 8000
|
const CONNECTION_TIMEOUT_MS = 8000
|
||||||
|
|
||||||
@@ -106,8 +107,7 @@ const LoginSettings = () => {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message:
|
message: t('server.dnsFailed'),
|
||||||
t('server.dnsFailed'),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -119,16 +119,14 @@ const LoginSettings = () => {
|
|||||||
// no-cors also timed out → server/host truly unreachable
|
// no-cors also timed out → server/host truly unreachable
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message:
|
message: t('server.unreachable'),
|
||||||
t('server.unreachable'),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback (should rarely hit)
|
// Fallback (should rarely hit)
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message:
|
message: t('server.unreachable'),
|
||||||
t('server.unreachable'),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,9 +143,7 @@ const LoginSettings = () => {
|
|||||||
|
|
||||||
if (!isValidURL(trimmedURL)) {
|
if (!isValidURL(trimmedURL)) {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
setErrorMessage(
|
setErrorMessage(t('server.invalidUrl'))
|
||||||
t('server.invalidUrl'),
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import AuthShell from './AuthShell'
|
|||||||
import { authButtonSx } from './authStyles'
|
import { authButtonSx } from './authStyles'
|
||||||
import MFAVerificationModal from './MFAVerificationModal'
|
import MFAVerificationModal from './MFAVerificationModal'
|
||||||
|
|
||||||
const SegmentedControl = ({ value, onChange, options }) => (
|
const SegmentedControl = ({ onChange, options, value }) => (
|
||||||
<Box
|
<Box
|
||||||
role='tablist'
|
role='tablist'
|
||||||
sx={{
|
sx={{
|
||||||
@@ -582,7 +582,7 @@ const LoginView = () => {
|
|||||||
discoveryDocs='claims_supported'
|
discoveryDocs='claims_supported'
|
||||||
access_type='online'
|
access_type='online'
|
||||||
isOnlyGetToken={true}
|
isOnlyGetToken={true}
|
||||||
onResolve={({ provider, data }) => {
|
onResolve={({ data, provider }) => {
|
||||||
loggedWithProvider(provider, data)
|
loggedWithProvider(provider, data)
|
||||||
}}
|
}}
|
||||||
onReject={() => {
|
onReject={() => {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
|
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import ModalActions from '../../components/common/ModalActions'
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { VerifyMFA } from '../../utils/Fetcher'
|
import { VerifyMFA } from '../../utils/Fetcher'
|
||||||
import { authInputSx } from './authStyles'
|
import { authInputSx } from './authStyles'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const MFAVerificationModal = ({
|
const MFAVerificationModal = ({
|
||||||
open,
|
|
||||||
onClose,
|
onClose,
|
||||||
sessionToken,
|
|
||||||
onSuccess,
|
|
||||||
onError,
|
onError,
|
||||||
|
onSuccess,
|
||||||
|
open,
|
||||||
|
sessionToken,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('auth')
|
const { t } = useTranslation('auth')
|
||||||
const [verificationCode, setVerificationCode] = useState('')
|
const [verificationCode, setVerificationCode] = useState('')
|
||||||
@@ -38,8 +38,7 @@ const MFAVerificationModal = ({
|
|||||||
onSuccess(data)
|
onSuccess(data)
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
const message =
|
const message = errorData.message || t('mfaModal.invalidCode')
|
||||||
errorData.message || t('mfaModal.invalidCode')
|
|
||||||
setError(message)
|
setError(message)
|
||||||
onError?.(message)
|
onError?.(message)
|
||||||
}
|
}
|
||||||
@@ -77,9 +76,7 @@ const MFAVerificationModal = ({
|
|||||||
size='md'
|
size='md'
|
||||||
title={t('mfaModal.title')}
|
title={t('mfaModal.title')}
|
||||||
description={
|
description={
|
||||||
isBackupCode
|
isBackupCode ? t('mfaModal.backupHint') : t('mfaModal.codeHint')
|
||||||
? t('mfaModal.backupHint')
|
|
||||||
: t('mfaModal.codeHint')
|
|
||||||
}
|
}
|
||||||
closeOnBackdrop={!loading}
|
closeOnBackdrop={!loading}
|
||||||
closeOnEscape={!loading}
|
closeOnEscape={!loading}
|
||||||
@@ -112,7 +109,9 @@ const MFAVerificationModal = ({
|
|||||||
<Input
|
<Input
|
||||||
id='mfa-code'
|
id='mfa-code'
|
||||||
size='lg'
|
size='lg'
|
||||||
placeholder={isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'}
|
placeholder={
|
||||||
|
isBackupCode ? t('mfaModal.backupPlaceholder') : '000000'
|
||||||
|
}
|
||||||
value={verificationCode}
|
value={verificationCode}
|
||||||
onChange={e => setVerificationCode(e.target.value)}
|
onChange={e => setVerificationCode(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|||||||
@@ -39,8 +39,7 @@ const SignupView = () => {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
showError({
|
showError({
|
||||||
title: 'Almost there',
|
title: 'Almost there',
|
||||||
message:
|
message: t('signupSignInFailed'),
|
||||||
t('signupSignInFailed'),
|
|
||||||
})
|
})
|
||||||
Navigate('/login')
|
Navigate('/login')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
|||||||
|
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
|
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
|
||||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||||
import {
|
import {
|
||||||
useChoreDetails,
|
useChoreDetails,
|
||||||
@@ -80,12 +81,6 @@ import {
|
|||||||
} from '../../utils/Fetcher'
|
} from '../../utils/Fetcher'
|
||||||
import { offlineDB } from '../../utils/OfflineDB'
|
import { offlineDB } from '../../utils/OfflineDB'
|
||||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||||
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
|
||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
|
||||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
|
||||||
import NudgeModal from '../Modals/Inputs/NudgeModal'
|
|
||||||
import SelectModal from '../Modals/Inputs/SelectModal'
|
|
||||||
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
|
|
||||||
import ChoreActionMenu from '../components/ChoreActionMenu'
|
import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||||
import DueDatePickerModal, {
|
import DueDatePickerModal, {
|
||||||
combineDueDate,
|
combineDueDate,
|
||||||
@@ -95,9 +90,14 @@ import LoadingComponent from '../components/Loading.jsx'
|
|||||||
import PendingBadge from '../components/PendingBadge'
|
import PendingBadge from '../components/PendingBadge'
|
||||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||||
import SubTasks from '../components/SubTask.jsx'
|
import SubTasks from '../components/SubTask.jsx'
|
||||||
|
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
|
||||||
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
|
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||||
|
import NudgeModal from '../Modals/Inputs/NudgeModal'
|
||||||
|
import SelectModal from '../Modals/Inputs/SelectModal'
|
||||||
|
import WriteNFCModal from '../Modals/Inputs/WriteNFCModal'
|
||||||
import TimePassedCard from './TimePassedCard.jsx'
|
import TimePassedCard from './TimePassedCard.jsx'
|
||||||
import TimerSplitButton from './TimerSplitButton.jsx'
|
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||||
import { useDescriptionHtml } from '../../hooks/useDescriptionHtml'
|
|
||||||
|
|
||||||
const isNetworkError = err =>
|
const isNetworkError = err =>
|
||||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||||
@@ -130,7 +130,7 @@ const ChoreView = () => {
|
|||||||
const { choreId } = useParams()
|
const { choreId } = useParams()
|
||||||
const [note, setNote] = useState(null)
|
const [note, setNote] = useState(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { showSuccess, showError, showUndo } = useNotification()
|
const { showError, showSuccess, showUndo } = useNotification()
|
||||||
|
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
const isValidTrigger = (thing, condition, triggerState) => {
|
const isValidTrigger = (thing, condition, triggerState) => {
|
||||||
const newErrors = {}
|
const newErrors = {}
|
||||||
if (!thing || !triggerState) {
|
if (!thing || !triggerState) {
|
||||||
@@ -49,11 +49,11 @@ const isValidTrigger = (thing, condition, triggerState) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ThingTriggerSection = ({
|
const ThingTriggerSection = ({
|
||||||
things,
|
isAttepmtingToSave,
|
||||||
onTriggerUpdate,
|
onTriggerUpdate,
|
||||||
onValidate,
|
onValidate,
|
||||||
selected,
|
selected,
|
||||||
isAttepmtingToSave,
|
things,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const [selectedThing, setSelectedThing] = useState(null)
|
const [selectedThing, setSelectedThing] = useState(null)
|
||||||
@@ -86,9 +86,7 @@ const ThingTriggerSection = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card sx={{ mt: 1 }}>
|
<Card sx={{ mt: 1 }}>
|
||||||
<Typography level='h5'>
|
<Typography level='h5'>{t('thing.triggerHint')}</Typography>
|
||||||
{t('thing.triggerHint')}
|
|
||||||
</Typography>
|
|
||||||
{things?.length === 0 && (
|
{things?.length === 0 && (
|
||||||
<Typography level='body-sm'>
|
<Typography level='body-sm'>
|
||||||
it's look like you don't have any things yet, create a thing to
|
it's look like you don't have any things yet, create a thing to
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { Box, Card, Chip, Typography } from '@mui/joy'
|
import { Box, Card, Chip, Typography } from '@mui/joy'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
|
|
||||||
const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {
|
const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
|
|
||||||
const TimerSplitButton = ({
|
const TimerSplitButton = ({
|
||||||
chore,
|
chore,
|
||||||
onAction,
|
|
||||||
onShowDetails,
|
|
||||||
onResetTimer,
|
|
||||||
onClearAllTime,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
|
onAction,
|
||||||
|
onClearAllTime,
|
||||||
|
onResetTimer,
|
||||||
|
onShowDetails,
|
||||||
}) => {
|
}) => {
|
||||||
const [anchorEl, setAnchorEl] = useState(null)
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
const isMenuOpen = Boolean(anchorEl)
|
const isMenuOpen = Boolean(anchorEl)
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
|
||||||
import { useCircleMembers } from '../../queries/UserQueries'
|
import { useCircleMembers } from '../../queries/UserQueries'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const ActivityItem = ({ activity, members, onViewNote }) => {
|
const ActivityItem = ({ activity, members, onViewNote }) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
|
|||||||
@@ -1109,7 +1109,10 @@ const ArchivedTasks = () => {
|
|||||||
}
|
}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
searchTerm
|
searchTerm
|
||||||
? { label: t('archived.clearSearch'), onClick: handleSearchClose }
|
? {
|
||||||
|
label: t('archived.clearSearch'),
|
||||||
|
onClick: handleSearchClose,
|
||||||
|
}
|
||||||
: { label: t('archived.clearFilters'), onClick: clearAll }
|
: { label: t('archived.clearFilters'), onClick: clearAll }
|
||||||
}
|
}
|
||||||
secondaryAction={
|
secondaryAction={
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Type as ListType,
|
|
||||||
SwipeableList,
|
SwipeableList,
|
||||||
SwipeableListItem,
|
SwipeableListItem,
|
||||||
SwipeAction,
|
SwipeAction,
|
||||||
TrailingActions,
|
TrailingActions,
|
||||||
|
Type as ListType,
|
||||||
} from '@meauxt/react-swipeable-list'
|
} from '@meauxt/react-swipeable-list'
|
||||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
import { Box, Typography } from '@mui/joy'
|
import { Box, Typography } from '@mui/joy'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLongPress } from '../../hooks/useLongPress'
|
import { useLongPress } from '../../hooks/useLongPress'
|
||||||
import ChoreCard from './ChoreCard'
|
import ChoreCard from './ChoreCard'
|
||||||
import CompactChoreCard from './CompactChoreCard'
|
import CompactChoreCard from './CompactChoreCard'
|
||||||
@@ -28,16 +30,16 @@ import CompactChoreCard from './CompactChoreCard'
|
|||||||
* can't live in the render loop because it needs a hook.
|
* can't live in the render loop because it needs a hook.
|
||||||
*/
|
*/
|
||||||
const ChoreSwipeableItem = ({
|
const ChoreSwipeableItem = ({
|
||||||
trailingActions,
|
children,
|
||||||
|
longPressEnabled,
|
||||||
onClick,
|
onClick,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
longPressEnabled,
|
trailingActions,
|
||||||
children,
|
|
||||||
// SwipeableList clones its children to inject list-level config
|
// SwipeableList clones its children to inject list-level config
|
||||||
// (listType, fullSwipe, thresholds…), so it has to be passed through.
|
// (listType, fullSwipe, thresholds…), so it has to be passed through.
|
||||||
...listProps
|
...listProps
|
||||||
}) => {
|
}) => {
|
||||||
const { handlers: longPressHandlers, cancel: cancelLongPress } = useLongPress(
|
const { cancel: cancelLongPress, handlers: longPressHandlers } = useLongPress(
|
||||||
onLongPress,
|
onLongPress,
|
||||||
{ enabled: longPressEnabled },
|
{ enabled: longPressEnabled },
|
||||||
)
|
)
|
||||||
@@ -75,19 +77,19 @@ const ChoreSwipeableItem = ({
|
|||||||
|
|
||||||
const ChoreListView = ({
|
const ChoreListView = ({
|
||||||
chores,
|
chores,
|
||||||
viewMode,
|
|
||||||
membersData,
|
|
||||||
userLabels,
|
|
||||||
handleLabelFiltering,
|
|
||||||
handleChoreAction,
|
handleChoreAction,
|
||||||
|
handleLabelFiltering,
|
||||||
isMultiSelectMode,
|
isMultiSelectMode,
|
||||||
selectedChores,
|
|
||||||
toggleChoreSelection,
|
|
||||||
userProfile,
|
|
||||||
isOfficialInstance,
|
isOfficialInstance,
|
||||||
toggleMultiSelectMode,
|
membersData,
|
||||||
onLongPressChore,
|
onLongPressChore,
|
||||||
|
selectedChores,
|
||||||
showActions = true,
|
showActions = true,
|
||||||
|
toggleChoreSelection,
|
||||||
|
toggleMultiSelectMode,
|
||||||
|
userLabels,
|
||||||
|
userProfile,
|
||||||
|
viewMode,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
@@ -204,7 +206,9 @@ const ChoreListView = ({
|
|||||||
<Check sx={{ fontSize: 20 }} />
|
<Check sx={{ fontSize: 20 }} />
|
||||||
)}
|
)}
|
||||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||||
{chore.status !== 1 ? t('choreView.start') : t('list.complete')}
|
{chore.status !== 1
|
||||||
|
? t('choreView.start')
|
||||||
|
: t('list.complete')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</SwipeAction>
|
</SwipeAction>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
|
import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||||
@@ -30,17 +31,17 @@ import PendingBadge from '../components/PendingBadge'
|
|||||||
|
|
||||||
const CompactChoreCard = ({
|
const CompactChoreCard = ({
|
||||||
chore,
|
chore,
|
||||||
performers,
|
|
||||||
sx,
|
|
||||||
viewOnly,
|
|
||||||
showActions = true,
|
|
||||||
onChipClick,
|
|
||||||
onAction,
|
|
||||||
// Multi-select props
|
|
||||||
isMultiSelectMode = false,
|
isMultiSelectMode = false,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
|
onAction,
|
||||||
|
onChipClick,
|
||||||
onSelectionToggle,
|
onSelectionToggle,
|
||||||
onlyClickable = false,
|
onlyClickable = false,
|
||||||
|
// Multi-select props
|
||||||
|
performers,
|
||||||
|
showActions = true,
|
||||||
|
sx,
|
||||||
|
viewOnly,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
|
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import IconButton from '@mui/joy/IconButton'
|
import IconButton from '@mui/joy/IconButton'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||||
|
|
||||||
const IconButtonWithMenu = ({
|
const IconButtonWithMenu = ({
|
||||||
label,
|
|
||||||
k,
|
|
||||||
icon,
|
icon,
|
||||||
options,
|
isActive,
|
||||||
|
k,
|
||||||
|
label,
|
||||||
onItemSelect,
|
onItemSelect,
|
||||||
|
options,
|
||||||
selectedItem,
|
selectedItem,
|
||||||
setSelectedItem,
|
setSelectedItem,
|
||||||
isActive,
|
|
||||||
useChips,
|
|
||||||
title,
|
title,
|
||||||
|
useChips,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const [anchorEl, setAnchorEl] = useState(null)
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const scheduleNotificationFromTemplate = (
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const time = getTimeFromTemplate(template, dueDate)
|
const time = getTimeFromTemplate(template, dueDate)
|
||||||
const notificationId = getIdFromTemplate(chore.id, template)
|
const notificationId = getIdFromTemplate(chore.id, template)
|
||||||
const { title, body } = getNotificationText(
|
const { body, title } = getNotificationText(
|
||||||
chore.name,
|
chore.name,
|
||||||
template,
|
template,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { HelpOutline } from '@mui/icons-material'
|
import { HelpOutline } from '@mui/icons-material'
|
||||||
import { Box, Card, IconButton, Typography } from '@mui/joy'
|
import { Box, Card, IconButton, Typography } from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import ModalActions from '../../components/common/ModalActions'
|
import ModalActions from '../../components/common/ModalActions'
|
||||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const MultiSelectHelp = ({ isVisible = true }) => {
|
const MultiSelectHelp = ({ isVisible = true }) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
@@ -102,7 +103,7 @@ const MultiSelectHelp = ({ isVisible = true }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortcutItem = ({ keys, description }) => (
|
const ShortcutItem = ({ description, keys }) => (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { useMediaQuery } from '@mui/material'
|
import { useMediaQuery } from '@mui/material'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import EmptyState from '../../components/common/EmptyState'
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
@@ -73,7 +74,6 @@ import {
|
|||||||
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
|
||||||
import Sidepanel from './Sidepanel'
|
import Sidepanel from './Sidepanel'
|
||||||
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
// Mirrors the assignee options in the toolbar, phrased to drop into a
|
// Mirrors the assignee options in the toolbar, phrased to drop into a
|
||||||
// sentence ("none of them are assigned to you").
|
// sentence ("none of them are assigned to you").
|
||||||
@@ -1667,7 +1667,9 @@ const MyChores = () => {
|
|||||||
saveFilter(filter)
|
saveFilter(filter)
|
||||||
showSuccess({
|
showSuccess({
|
||||||
title: t('list.advancedFilterCreated'),
|
title: t('list.advancedFilterCreated'),
|
||||||
message: t('list.advancedFilterCreatedMsg', { name: filter.name }),
|
message: t('list.advancedFilterCreatedMsg', {
|
||||||
|
name: filter.name,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setShowAdvancedFilterBuilder(false)
|
setShowAdvancedFilterBuilder(false)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Preferences } from '@capacitor/preferences'
|
|||||||
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
|
import { Button, Snackbar, Stack, Typography } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { registerPushNotifications } from '../../CapacitorListener'
|
import { registerPushNotifications } from '../../CapacitorListener'
|
||||||
|
|
||||||
const NotificationAccessSnackbar = () => {
|
const NotificationAccessSnackbar = () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Sheet } from '@mui/joy'
|
import { Box, Sheet } from '@mui/joy'
|
||||||
import { useMediaQuery } from '@mui/material'
|
import { useMediaQuery } from '@mui/material'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { useChoresHistory } from '../../queries/ChoreQueries'
|
import { useChoresHistory } from '../../queries/ChoreQueries'
|
||||||
import { ChoresGrouper } from '../../utils/Chores'
|
import { ChoresGrouper } from '../../utils/Chores'
|
||||||
import { getSidepanelConfig } from '../../utils/SidepanelConfig'
|
import { getSidepanelConfig } from '../../utils/SidepanelConfig'
|
||||||
@@ -11,9 +12,9 @@ import TasksByAssigneeCard from './TasksByAssigneeCard'
|
|||||||
import UserSwitcher from './UserSwitcher'
|
import UserSwitcher from './UserSwitcher'
|
||||||
|
|
||||||
const Sidepanel = ({
|
const Sidepanel = ({
|
||||||
chores,
|
|
||||||
allChores,
|
allChores,
|
||||||
applyTempFilter,
|
applyTempFilter,
|
||||||
|
chores,
|
||||||
clearTempFilter,
|
clearTempFilter,
|
||||||
tempFilter,
|
tempFilter,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -22,8 +23,8 @@ const Sidepanel = ({
|
|||||||
const [sidepanelConfig, setSidepanelConfig] = useState([])
|
const [sidepanelConfig, setSidepanelConfig] = useState([])
|
||||||
const {
|
const {
|
||||||
data: choresHistory,
|
data: choresHistory,
|
||||||
isChoresHistoryLoading,
|
|
||||||
handleLimitChange: refetchHistory,
|
handleLimitChange: refetchHistory,
|
||||||
|
isChoresHistoryLoading,
|
||||||
} = useChoresHistory(7, true)
|
} = useChoresHistory(7, true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Box, Button, Chip, Sheet, Typography } from '@mui/joy'
|
import { Box, Button, Chip, Sheet, Typography } from '@mui/joy'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { TASK_COLOR } from '../../utils/Colors'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import { TASK_COLOR } from '../../utils/Colors'
|
||||||
|
|
||||||
// Static insight filter definitions – used for URL restoration
|
// Static insight filter definitions – used for URL restoration
|
||||||
export const INSIGHT_FILTER_DEFS = {
|
export const INSIGHT_FILTER_DEFS = {
|
||||||
overdue: {
|
overdue: {
|
||||||
@@ -58,8 +59,8 @@ export const INSIGHT_FILTER_DEFS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SmartInsightsCard = ({
|
const SmartInsightsCard = ({
|
||||||
chores,
|
|
||||||
applyTempFilter,
|
applyTempFilter,
|
||||||
|
chores,
|
||||||
clearTempFilter,
|
clearTempFilter,
|
||||||
tempFilter,
|
tempFilter,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -13,21 +13,22 @@ import {
|
|||||||
import IconButton from '@mui/joy/IconButton'
|
import IconButton from '@mui/joy/IconButton'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||||
|
|
||||||
const SortAndGrouping = ({
|
const SortAndGrouping = ({
|
||||||
label,
|
|
||||||
k,
|
|
||||||
icon,
|
icon,
|
||||||
onItemSelect,
|
|
||||||
selectedItem,
|
|
||||||
setSelectedItem,
|
|
||||||
selectedFilter,
|
|
||||||
setFilter,
|
|
||||||
isActive,
|
isActive,
|
||||||
useChips,
|
k,
|
||||||
title,
|
label,
|
||||||
onCreateNewFilter,
|
onCreateNewFilter,
|
||||||
|
onItemSelect,
|
||||||
|
selectedFilter,
|
||||||
|
selectedItem,
|
||||||
|
setFilter,
|
||||||
|
setSelectedItem,
|
||||||
|
title,
|
||||||
|
useChips,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const [anchorEl, setAnchorEl] = useState(null)
|
const [anchorEl, setAnchorEl] = useState(null)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { BarChart, Person } from '@mui/icons-material'
|
import { BarChart, Person } from '@mui/icons-material'
|
||||||
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
|
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import EmptyState from '../../components/common/EmptyState'
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import { useCircleMembers } from '../../queries/UserQueries'
|
import { useCircleMembers } from '../../queries/UserQueries'
|
||||||
import { TASK_COLOR } from '../../utils/Colors'
|
import { TASK_COLOR } from '../../utils/Colors'
|
||||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const TasksByAssigneeCard = ({ chores = [] }) => {
|
const TasksByAssigneeCard = ({ chores = [] }) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { SupervisorAccount } from '@mui/icons-material'
|
import { SupervisorAccount } from '@mui/icons-material'
|
||||||
import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy'
|
import { Avatar, Box, Button, Sheet, Typography } from '@mui/joy'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
|
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
|
||||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||||
import UserModal from '../Modals/Inputs/UserModal'
|
import UserModal from '../Modals/Inputs/UserModal'
|
||||||
const UserSwitcher = () => {
|
const UserSwitcher = () => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const {
|
const {
|
||||||
impersonatedUser,
|
canImpersonate,
|
||||||
|
impersonatedUser,
|
||||||
isImpersonating,
|
isImpersonating,
|
||||||
startImpersonation,
|
startImpersonation,
|
||||||
stopImpersonation,
|
stopImpersonation,
|
||||||
canImpersonate
|
|
||||||
} = useImpersonateUser()
|
} = useImpersonateUser()
|
||||||
const { data: userProfile } = useUserProfile()
|
const { data: userProfile } = useUserProfile()
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
@@ -54,7 +54,9 @@ const UserSwitcher = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SupervisorAccount color='' />
|
<SupervisorAccount color='' />
|
||||||
<Typography level='title-md'>{t('impersonate.viewAs')}</Typography>
|
<Typography level='title-md'>
|
||||||
|
{t('impersonate.viewAs')}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import DateModal from '../../Modals/Inputs/DateModal'
|
|
||||||
import DueDatePickerModal, {
|
import DueDatePickerModal, {
|
||||||
combineDueDate,
|
combineDueDate,
|
||||||
splitDueDate,
|
splitDueDate,
|
||||||
} from '../../components/DueDatePickerModal'
|
} from '../../components/DueDatePickerModal'
|
||||||
|
import DateModal from '../../Modals/Inputs/DateModal'
|
||||||
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
import NudgeModal from '../../Modals/Inputs/NudgeModal'
|
||||||
import SelectModal from '../../Modals/Inputs/SelectModal'
|
import SelectModal from '../../Modals/Inputs/SelectModal'
|
||||||
import TextModal from '../../Modals/Inputs/TextModal'
|
import TextModal from '../../Modals/Inputs/TextModal'
|
||||||
@@ -17,14 +18,14 @@ const getNFCUrl = choreId =>
|
|||||||
|
|
||||||
const ChoreModals = ({
|
const ChoreModals = ({
|
||||||
activeModal,
|
activeModal,
|
||||||
modalChore,
|
|
||||||
membersData,
|
membersData,
|
||||||
onChangeDueDate,
|
modalChore,
|
||||||
onCompleteWithPastDate,
|
|
||||||
onAssigneeChange,
|
onAssigneeChange,
|
||||||
onCompleteWithNote,
|
onChangeDueDate,
|
||||||
onNudge,
|
|
||||||
onClose,
|
onClose,
|
||||||
|
onCompleteWithNote,
|
||||||
|
onCompleteWithPastDate,
|
||||||
|
onNudge,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -47,27 +47,28 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import AppModal from '../../../components/common/AppModal'
|
import AppModal from '../../../components/common/AppModal'
|
||||||
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
import { FILTER_COLORS } from '../../../utils/Colors'
|
import { FILTER_COLORS } from '../../../utils/Colors'
|
||||||
import Priorities from '../../../utils/Priorities'
|
import Priorities from '../../../utils/Priorities'
|
||||||
|
import ProjectSelector from '../../components/ProjectSelector'
|
||||||
|
import CustomFilterChips from './CustomFilterChips'
|
||||||
import FilterBuilderContent, {
|
import FilterBuilderContent, {
|
||||||
CHORE_STATUSES,
|
CHORE_STATUSES,
|
||||||
DUE_DATE_OPTIONS,
|
|
||||||
POINTS_OPERATORS,
|
|
||||||
conditionsToSelections,
|
conditionsToSelections,
|
||||||
defaultSelections,
|
defaultSelections,
|
||||||
|
DUE_DATE_OPTIONS,
|
||||||
|
POINTS_OPERATORS,
|
||||||
selectionsToConditions,
|
selectionsToConditions,
|
||||||
} from './FilterBuilderContent'
|
} from './FilterBuilderContent'
|
||||||
import SearchBar from './SearchBar'
|
import SearchBar from './SearchBar'
|
||||||
import ProjectSelector from '../../components/ProjectSelector'
|
|
||||||
import CustomFilterChips from './CustomFilterChips'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
// ─── sub-components for the Display sheet ────────────────────────────────────
|
// ─── sub-components for the Display sheet ────────────────────────────────────
|
||||||
|
|
||||||
const SectionHeader = ({ icon, label, badge }) => (
|
const SectionHeader = ({ badge, icon, label }) => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
{icon && (
|
{icon && (
|
||||||
<Box
|
<Box
|
||||||
@@ -97,7 +98,7 @@ const SectionHeader = ({ icon, label, badge }) => (
|
|||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
||||||
const OptionChips = ({ options, selected, multi, onToggle }) => (
|
const OptionChips = ({ multi, onToggle, options, selected }) => (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{options.map(opt => {
|
{options.map(opt => {
|
||||||
const isSelected = multi
|
const isSelected = multi
|
||||||
@@ -183,48 +184,48 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
|
|||||||
*/
|
*/
|
||||||
const ChoreToolbar = ({
|
const ChoreToolbar = ({
|
||||||
// advanced filter
|
// advanced filter
|
||||||
members = [],
|
activeFilterId,
|
||||||
labels = [],
|
|
||||||
projects = [],
|
|
||||||
tempFilter,
|
|
||||||
tempFilterMeta,
|
|
||||||
applyTempFilter,
|
applyTempFilter,
|
||||||
clearTempFilter,
|
clearTempFilter,
|
||||||
saveFilter,
|
|
||||||
updateFilter,
|
|
||||||
onFilterSaved,
|
|
||||||
// result counts
|
|
||||||
resultCount,
|
|
||||||
totalCount,
|
|
||||||
// clear all
|
|
||||||
onClearAllFilters,
|
|
||||||
// project (for Display sheet)
|
|
||||||
selectedProject,
|
|
||||||
onProjectSelect,
|
|
||||||
// assignee (for Display sheet)
|
|
||||||
selectedAssigneeFilter = 'anyone',
|
|
||||||
onAssigneeFilterChange,
|
|
||||||
// saved / custom
|
|
||||||
savedFilters = [],
|
|
||||||
activeFilterId,
|
|
||||||
onSavedFilterClick,
|
|
||||||
onSavedFilterEdit,
|
|
||||||
onSavedFilterDelete,
|
|
||||||
onSavedFilterPin,
|
|
||||||
// grouping
|
|
||||||
selectedGroupBy = 'default',
|
|
||||||
onGroupBySelect,
|
|
||||||
// view + multiselect
|
|
||||||
viewMode = 'default',
|
|
||||||
onToggleViewMode,
|
|
||||||
isMultiSelectMode,
|
isMultiSelectMode,
|
||||||
onToggleMultiSelect,
|
labels = [],
|
||||||
// search
|
members = [],
|
||||||
searchTerm,
|
onAssigneeFilterChange,
|
||||||
|
onClearAllFilters,
|
||||||
|
onFilterSaved,
|
||||||
|
onGroupBySelect,
|
||||||
|
// result counts
|
||||||
|
onProjectSelect,
|
||||||
|
onSavedFilterClick,
|
||||||
|
// clear all
|
||||||
|
onSavedFilterDelete,
|
||||||
|
// project (for Display sheet)
|
||||||
|
onSavedFilterEdit,
|
||||||
|
onSavedFilterPin,
|
||||||
|
// assignee (for Display sheet)
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSearchClose,
|
onSearchClose,
|
||||||
|
// saved / custom
|
||||||
|
onToggleMultiSelect,
|
||||||
|
onToggleViewMode,
|
||||||
|
projects = [],
|
||||||
|
resultCount,
|
||||||
|
saveFilter,
|
||||||
|
savedFilters = [],
|
||||||
|
// grouping
|
||||||
searchInputRef,
|
searchInputRef,
|
||||||
|
searchTerm,
|
||||||
|
// view + multiselect
|
||||||
|
selectedAssigneeFilter = 'anyone',
|
||||||
|
selectedGroupBy = 'default',
|
||||||
|
selectedProject,
|
||||||
showKeyboardShortcuts,
|
showKeyboardShortcuts,
|
||||||
|
// search
|
||||||
|
tempFilter,
|
||||||
|
tempFilterMeta,
|
||||||
|
totalCount,
|
||||||
|
updateFilter,
|
||||||
|
viewMode = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
|
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
StarBorder,
|
StarBorder,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -18,16 +17,18 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
|
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
|
||||||
|
|
||||||
const CustomFilterChips = ({
|
const CustomFilterChips = ({
|
||||||
filters = [],
|
|
||||||
activeFilterId,
|
activeFilterId,
|
||||||
|
filters = [],
|
||||||
onFilterClick,
|
onFilterClick,
|
||||||
onFilterDelete,
|
onFilterDelete,
|
||||||
onFilterPin,
|
|
||||||
onFilterEdit,
|
onFilterEdit,
|
||||||
|
onFilterPin,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -98,8 +99,7 @@ const CustomFilterChips = ({
|
|||||||
const badgeTextColor = filter.color
|
const badgeTextColor = filter.color
|
||||||
? getTextColorFromBackgroundColor(filter.color)
|
? getTextColorFromBackgroundColor(filter.color)
|
||||||
: '#ffffff'
|
: '#ffffff'
|
||||||
const displayCount =
|
const displayCount = filter.count > 99 ? '99+' : (filter.count ?? 0)
|
||||||
filter.count > 99 ? '99+' : (filter.count ?? 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -113,7 +113,9 @@ const CustomFilterChips = ({
|
|||||||
>
|
>
|
||||||
<Chip
|
<Chip
|
||||||
variant={isActive ? 'solid' : 'outlined'}
|
variant={isActive ? 'solid' : 'outlined'}
|
||||||
color={hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'}
|
color={
|
||||||
|
hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'
|
||||||
|
}
|
||||||
size='md'
|
size='md'
|
||||||
onClick={() => !hasWarning && onFilterClick(filter.id)}
|
onClick={() => !hasWarning && onFilterClick(filter.id)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -124,7 +126,9 @@ const CustomFilterChips = ({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontWeight: isActive ? 600 : 500,
|
fontWeight: isActive ? 600 : 500,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: isActive ? undefined : 'neutral.softHoverBg',
|
backgroundColor: isActive
|
||||||
|
? undefined
|
||||||
|
: 'neutral.softHoverBg',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TaskAlt,
|
TaskAlt,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
|
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
|
||||||
|
|
||||||
import Priorities from '../../../utils/Priorities'
|
import Priorities from '../../../utils/Priorities'
|
||||||
|
|
||||||
export const DUE_DATE_OPTIONS = [
|
export const DUE_DATE_OPTIONS = [
|
||||||
@@ -99,7 +100,7 @@ export const selectionsToConditions = selections => {
|
|||||||
return conditions
|
return conditions
|
||||||
}
|
}
|
||||||
|
|
||||||
const SectionHeader = ({ icon, label, children }) => (
|
const SectionHeader = ({ children, icon, label }) => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -119,9 +120,9 @@ const SectionHeader = ({ icon, label, children }) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const IncludeExcludeToggle = ({
|
const IncludeExcludeToggle = ({
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
labels = ['Include', 'Exclude'],
|
labels = ['Include', 'Exclude'],
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
}) => (
|
}) => (
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
|
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
|
||||||
{[
|
{[
|
||||||
@@ -136,7 +137,11 @@ const IncludeExcludeToggle = ({
|
|||||||
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
|
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
|
||||||
}
|
}
|
||||||
onClick={() => onChange(o.op)}
|
onClick={() => onChange(o.op)}
|
||||||
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -152,11 +157,11 @@ const IncludeExcludeToggle = ({
|
|||||||
* functional updater `prev => next` (same contract as React's setState setter).
|
* functional updater `prev => next` (same contract as React's setState setter).
|
||||||
*/
|
*/
|
||||||
const FilterBuilderContent = ({
|
const FilterBuilderContent = ({
|
||||||
selections,
|
|
||||||
onSelectionsChange,
|
|
||||||
members = [],
|
|
||||||
labels = [],
|
labels = [],
|
||||||
|
members = [],
|
||||||
|
onSelectionsChange,
|
||||||
projects = [],
|
projects = [],
|
||||||
|
selections,
|
||||||
}) => {
|
}) => {
|
||||||
const toggleValue = (type, value) =>
|
const toggleValue = (type, value) =>
|
||||||
onSelectionsChange(prev => {
|
onSelectionsChange(prev => {
|
||||||
@@ -204,9 +209,11 @@ const FilterBuilderContent = ({
|
|||||||
variant={isSelected ? 'solid' : 'soft'}
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
|
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
|
||||||
startDecorator={
|
startDecorator={
|
||||||
isSelected
|
isSelected ? (
|
||||||
? <Check sx={{ fontSize: 14 }} />
|
<Check sx={{ fontSize: 14 }} />
|
||||||
: (extra.startDecorator ?? null)
|
) : (
|
||||||
|
(extra.startDecorator ?? null)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onClick={() => toggleValue(type, opt.value)}
|
onClick={() => toggleValue(type, opt.value)}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -312,7 +319,7 @@ const FilterBuilderContent = ({
|
|||||||
Priorities.map(p => ({ value: p.value, label: p.name })),
|
Priorities.map(p => ({ value: p.value, label: p.name })),
|
||||||
(opt, isSelected) => ({
|
(opt, isSelected) => ({
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? (Priorities.find(p => p.value === opt.value)?.color || 'primary')
|
? Priorities.find(p => p.value === opt.value)?.color || 'primary'
|
||||||
: 'neutral',
|
: 'neutral',
|
||||||
startDecorator: !isSelected
|
startDecorator: !isSelected
|
||||||
? Priorities.find(p => p.value === opt.value)?.icon
|
? Priorities.find(p => p.value === opt.value)?.icon
|
||||||
@@ -331,7 +338,9 @@ const FilterBuilderContent = ({
|
|||||||
key={opt.value}
|
key={opt.value}
|
||||||
variant={isSelected ? 'solid' : 'soft'}
|
variant={isSelected ? 'solid' : 'soft'}
|
||||||
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
|
||||||
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
|
startDecorator={
|
||||||
|
isSelected ? <Check sx={{ fontSize: 14 }} /> : null
|
||||||
|
}
|
||||||
onClick={() => toggleDueDate(opt.value)}
|
onClick={() => toggleDueDate(opt.value)}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -375,7 +384,9 @@ const FilterBuilderContent = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null}
|
endDecorator={
|
||||||
|
isSelected ? <Check sx={{ fontSize: 12 }} /> : null
|
||||||
|
}
|
||||||
onClick={() => toggleValue('label', lbl.id)}
|
onClick={() => toggleValue('label', lbl.id)}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -425,12 +436,14 @@ const FilterBuilderContent = ({
|
|||||||
key={op.value}
|
key={op.value}
|
||||||
size='sm'
|
size='sm'
|
||||||
variant={
|
variant={
|
||||||
selections.points.operator === op.value && selections.points.active
|
selections.points.operator === op.value &&
|
||||||
|
selections.points.active
|
||||||
? 'solid'
|
? 'solid'
|
||||||
: 'soft'
|
: 'soft'
|
||||||
}
|
}
|
||||||
color={
|
color={
|
||||||
selections.points.operator === op.value && selections.points.active
|
selections.points.operator === op.value &&
|
||||||
|
selections.points.active
|
||||||
? 'primary'
|
? 'primary'
|
||||||
: 'neutral'
|
: 'neutral'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Box } from '@mui/joy'
|
import { Box } from '@mui/joy'
|
||||||
|
|
||||||
import CustomFilterChips from './CustomFilterChips'
|
import CustomFilterChips from './CustomFilterChips'
|
||||||
|
|
||||||
const FilterSection = ({
|
const FilterSection = ({
|
||||||
savedFilters,
|
|
||||||
activeFilterId,
|
activeFilterId,
|
||||||
|
|
||||||
onFilterClick,
|
onFilterClick,
|
||||||
|
|
||||||
onFilterDelete,
|
onFilterDelete,
|
||||||
onFilterPin,
|
|
||||||
onFilterEdit,
|
onFilterEdit,
|
||||||
|
onFilterPin,
|
||||||
|
savedFilters,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import AppModal from '../../../components/common/AppModal'
|
import AppModal from '../../../components/common/AppModal'
|
||||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||||
import LABEL_COLORS, {
|
import LABEL_COLORS, {
|
||||||
@@ -134,26 +135,26 @@ const selectableChipSx = {
|
|||||||
|
|
||||||
const MultiSelectToolbar = ({
|
const MultiSelectToolbar = ({
|
||||||
isVisible,
|
isVisible,
|
||||||
selectedCount,
|
labels = [],
|
||||||
onSelectAll,
|
members = [],
|
||||||
|
onArchive,
|
||||||
onClear,
|
onClear,
|
||||||
onComplete,
|
onComplete,
|
||||||
onSkip,
|
|
||||||
onArchive,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
onMoveToProject,
|
onMoveToProject,
|
||||||
onSetDueDate,
|
onSelectAll,
|
||||||
onSetAssignee,
|
onSetAssignee,
|
||||||
|
onSetDueDate,
|
||||||
onSetPriority,
|
onSetPriority,
|
||||||
onToggleLabel,
|
onSkip,
|
||||||
// Shape produced by useMultiSelect.getSelectionSummary — drives which value
|
// Shape produced by useMultiSelect.getSelectionSummary — drives which value
|
||||||
// each control shows as current, and which labels can be added vs removed.
|
// each control shows as current, and which labels can be added vs removed.
|
||||||
selectionSummary,
|
onToggleLabel,
|
||||||
members = [],
|
|
||||||
labels = [],
|
|
||||||
projects = [],
|
projects = [],
|
||||||
showKeyboardShortcuts,
|
|
||||||
selectAllDisabled,
|
selectAllDisabled,
|
||||||
|
selectedCount,
|
||||||
|
selectionSummary,
|
||||||
|
showKeyboardShortcuts,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FilterAlt } from '@mui/icons-material'
|
import { FilterAlt } from '@mui/icons-material'
|
||||||
import { Box, Stack, Typography } from '@mui/joy'
|
import { Box, Stack, Typography } from '@mui/joy'
|
||||||
|
|
||||||
import { getIconComponent } from '../../../utils/ProjectIcons.jsx'
|
import { getIconComponent } from '../../../utils/ProjectIcons.jsx'
|
||||||
|
|
||||||
const MyChoreHeader = ({
|
const MyChoreHeader = ({
|
||||||
activeFilterId,
|
|
||||||
activeFilter,
|
activeFilter,
|
||||||
|
activeFilterId,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
tempFilter,
|
tempFilter,
|
||||||
tempFilterMeta,
|
tempFilterMeta,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useArchiveChore,
|
useArchiveChore,
|
||||||
useUnArchiveChore,
|
useUnArchiveChore,
|
||||||
@@ -22,7 +24,6 @@ import {
|
|||||||
} from '../../../utils/Fetcher'
|
} from '../../../utils/Fetcher'
|
||||||
import { offlineDB } from '../../../utils/OfflineDB'
|
import { offlineDB } from '../../../utils/OfflineDB'
|
||||||
import { isOfflineFeatureEnabled } from '../../../utils/OfflineFeatureToggle'
|
import { isOfflineFeatureEnabled } from '../../../utils/OfflineFeatureToggle'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
// Effectively "can this action be queued offline?" — requires the offline
|
// Effectively "can this action be queued offline?" — requires the offline
|
||||||
// feature, otherwise there is no command queue to replay it later.
|
// feature, otherwise there is no command queue to replay it later.
|
||||||
@@ -55,22 +56,22 @@ const expectOk = async request => {
|
|||||||
|
|
||||||
export const useChoreActions = ({
|
export const useChoreActions = ({
|
||||||
chores,
|
chores,
|
||||||
filteredChores,
|
|
||||||
setChores,
|
|
||||||
setFilteredChores,
|
|
||||||
userProfile,
|
|
||||||
impersonatedUser,
|
|
||||||
showSuccess,
|
|
||||||
showError,
|
|
||||||
showWarning,
|
|
||||||
showUndo,
|
|
||||||
refetchChores,
|
|
||||||
setConfirmModelConfig,
|
|
||||||
openModal,
|
|
||||||
closeModal,
|
|
||||||
modalChore,
|
|
||||||
getSelectedChoresData,
|
|
||||||
clearSelection,
|
clearSelection,
|
||||||
|
closeModal,
|
||||||
|
filteredChores,
|
||||||
|
getSelectedChoresData,
|
||||||
|
impersonatedUser,
|
||||||
|
modalChore,
|
||||||
|
openModal,
|
||||||
|
refetchChores,
|
||||||
|
setChores,
|
||||||
|
setConfirmModelConfig,
|
||||||
|
setFilteredChores,
|
||||||
|
showError,
|
||||||
|
showSuccess,
|
||||||
|
showUndo,
|
||||||
|
showWarning,
|
||||||
|
userProfile,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('chores')
|
const { t } = useTranslation('chores')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
export const useChoreModals = () => {
|
export const useChoreModals = () => {
|
||||||
const [activeModal, setActiveModal] = useState(null)
|
const [activeModal, setActiveModal] = useState(null)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
SearchRounded,
|
SearchRounded,
|
||||||
Warning,
|
Warning,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
@@ -25,6 +24,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { GetAllUsers, GetChores, MarkChoreComplete } from '../utils/Fetcher'
|
import { GetAllUsers, GetChores, MarkChoreComplete } from '../utils/Fetcher'
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
|
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link, useRouteError } from 'react-router-dom'
|
import { Link, useRouteError } from 'react-router-dom'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
formatErrorReport,
|
formatErrorReport,
|
||||||
} from '../service/ErrorReportService'
|
} from '../service/ErrorReportService'
|
||||||
import ErrorReportModal from './Modals/ErrorReportModal'
|
import ErrorReportModal from './Modals/ErrorReportModal'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const getErrorKind = error => {
|
const getErrorKind = error => {
|
||||||
if (!error)
|
if (!error)
|
||||||
@@ -274,7 +274,8 @@ const Error = () => {
|
|||||||
textAlign='center'
|
textAlign='center'
|
||||||
sx={{ color: 'text.tertiary', mb: 1.5 }}
|
sx={{ color: 'text.tertiary', mb: 1.5 }}
|
||||||
>
|
>
|
||||||
If this keeps happening, send us a report please consider sending us report so we can take a look.
|
If this keeps happening, send us a report please consider sending us
|
||||||
|
report so we can take a look.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{(error?.stack || message) && (
|
{(error?.stack || message) && (
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import EmptyState from '../../components/common/EmptyState'
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
@@ -52,7 +53,6 @@ import {
|
|||||||
useToggleFilterPin,
|
useToggleFilterPin,
|
||||||
useUpdateFilter,
|
useUpdateFilter,
|
||||||
} from './FilterQueries'
|
} from './FilterQueries'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const FilterCardContent = ({
|
const FilterCardContent = ({
|
||||||
filter,
|
filter,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Type as ListType,
|
|
||||||
SwipeableList,
|
SwipeableList,
|
||||||
SwipeableListItem,
|
SwipeableListItem,
|
||||||
SwipeAction,
|
SwipeAction,
|
||||||
TrailingActions,
|
TrailingActions,
|
||||||
|
Type as ListType,
|
||||||
} from '@meauxt/react-swipeable-list'
|
} from '@meauxt/react-swipeable-list'
|
||||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
|
||||||
import {
|
import {
|
||||||
Analytics,
|
Analytics,
|
||||||
CalendarMonth,
|
CalendarMonth,
|
||||||
@@ -31,7 +32,9 @@ import EditIcon from '@mui/icons-material/Edit'
|
|||||||
import { Box, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
import { Box, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import EmptyState from '../../components/common/EmptyState'
|
import EmptyState from '../../components/common/EmptyState'
|
||||||
import FilterBar from '../../components/common/FilterBar'
|
import FilterBar from '../../components/common/FilterBar'
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
@@ -52,7 +55,6 @@ import HistoryDetailModal from '../Modals/HistoryDetailModal'
|
|||||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||||
import HistoryCard from './HistoryCard'
|
import HistoryCard from './HistoryCard'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const ChoreHistory = () => {
|
const ChoreHistory = () => {
|
||||||
const { t } = useTranslation('history')
|
const { t } = useTranslation('history')
|
||||||
@@ -66,7 +68,7 @@ const ChoreHistory = () => {
|
|||||||
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
|
||||||
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
|
||||||
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
|
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
|
||||||
const { showSuccess, showError } = useNotification()
|
const { showError, showSuccess } = useNotification()
|
||||||
// React Query hooks
|
// React Query hooks
|
||||||
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
|
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
|
||||||
const { data: circleMembersData } = useCircleMembers()
|
const { data: circleMembersData } = useCircleMembers()
|
||||||
@@ -177,11 +179,11 @@ const ChoreHistory = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
filteredData: filteredHistory,
|
|
||||||
activeFilters,
|
|
||||||
setFilter,
|
|
||||||
clearAll,
|
|
||||||
activeFilterCount,
|
activeFilterCount,
|
||||||
|
activeFilters,
|
||||||
|
clearAll,
|
||||||
|
filteredData: filteredHistory,
|
||||||
|
setFilter,
|
||||||
} = useFilter(choreHistory, filterDefs)
|
} = useFilter(choreHistory, filterDefs)
|
||||||
|
|
||||||
const sortedHistory = useMemo(
|
const sortedHistory = useMemo(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
|
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||||
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
import { TASK_COLOR } from '../../utils/Colors.jsx'
|
||||||
import PendingBadge from '../components/PendingBadge'
|
import PendingBadge from '../components/PendingBadge'
|
||||||
@@ -34,24 +35,32 @@ const stripHtmlTags = html => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
0: { labelKey: 'status.inProgress', color: 'primary', icon: <AccessTime /> },
|
0: { labelKey: 'status.inProgress', color: 'primary', icon: <AccessTime /> },
|
||||||
1: { labelKey: 'status.completed', color: 'success', icon: <Check /> },
|
1: { labelKey: 'status.completed', color: 'success', icon: <Check /> },
|
||||||
2: { labelKey: 'status.skipped', color: 'warning', icon: <Redo /> },
|
2: { labelKey: 'status.skipped', color: 'warning', icon: <Redo /> },
|
||||||
3: { labelKey: 'status.pendingApproval', color: 'neutral', icon: <HourglassEmpty /> },
|
3: {
|
||||||
4: { labelKey: 'status.rejected', color: 'danger', icon: <ThumbDown /> },
|
labelKey: 'status.pendingApproval',
|
||||||
5: { labelKey: 'status.missed', color: 'danger', icon: <RunningWithErrors /> },
|
color: 'neutral',
|
||||||
6: { labelKey: 'status.rescheduled', color: 'warning', icon: <Schedule /> },
|
icon: <HourglassEmpty />,
|
||||||
|
},
|
||||||
|
4: { labelKey: 'status.rejected', color: 'danger', icon: <ThumbDown /> },
|
||||||
|
5: {
|
||||||
|
labelKey: 'status.missed',
|
||||||
|
color: 'danger',
|
||||||
|
icon: <RunningWithErrors />,
|
||||||
|
},
|
||||||
|
6: { labelKey: 'status.rescheduled', color: 'warning', icon: <Schedule /> },
|
||||||
}
|
}
|
||||||
|
|
||||||
const HistoryCard = ({
|
const HistoryCard = ({
|
||||||
allHistory,
|
allHistory,
|
||||||
performers,
|
|
||||||
historyEntry,
|
historyEntry,
|
||||||
index,
|
index,
|
||||||
pendingCommands,
|
|
||||||
onToggleActions,
|
onToggleActions,
|
||||||
onViewNote,
|
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
|
onViewNote,
|
||||||
|
pendingCommands,
|
||||||
|
performers,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('history')
|
const { t } = useTranslation('history')
|
||||||
const { fmt } = useLocalization()
|
const { fmt } = useLocalization()
|
||||||
@@ -65,7 +74,7 @@ const HistoryCard = ({
|
|||||||
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
|
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
|
||||||
|
|
||||||
const getTimingLine = () => {
|
const getTimingLine = () => {
|
||||||
const { status, performedAt, dueDate } = historyEntry
|
const { dueDate, performedAt, status } = historyEntry
|
||||||
if (!dueDate) return null
|
if (!dueDate) return null
|
||||||
|
|
||||||
if (status === 6 || status === 5) {
|
if (status === 6 || status === 5) {
|
||||||
@@ -90,14 +99,18 @@ const HistoryCard = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timingLine = getTimingLine()
|
const timingLine = getTimingLine()
|
||||||
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : ''
|
const plainTextNotes = historyEntry.notes
|
||||||
|
? stripHtmlTags(historyEntry.notes)
|
||||||
|
: ''
|
||||||
|
|
||||||
const metaTextParts = [
|
const metaTextParts = [
|
||||||
fmt.dateTime(actionDate),
|
fmt.dateTime(actionDate),
|
||||||
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
|
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
|
||||||
? t('card.assignedTo', { name: assignedTo.displayName })
|
? t('card.assignedTo', { name: assignedTo.displayName })
|
||||||
: null,
|
: null,
|
||||||
historyEntry?.duration > 0 ? `⏱ ${formatTime(historyEntry.duration)}` : null,
|
historyEntry?.duration > 0
|
||||||
|
? `⏱ ${formatTime(historyEntry.duration)}`
|
||||||
|
: null,
|
||||||
historyEntry?.points > 0
|
historyEntry?.points > 0
|
||||||
? t('card.points', { count: historyEntry.points })
|
? t('card.points', { count: historyEntry.points })
|
||||||
: null,
|
: null,
|
||||||
@@ -120,7 +133,14 @@ const HistoryCard = ({
|
|||||||
>
|
>
|
||||||
<Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}>
|
<Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}>
|
||||||
{/* Status + timing chip */}
|
{/* Status + timing chip */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -130,7 +150,11 @@ const HistoryCard = ({
|
|||||||
>
|
>
|
||||||
{config.icon}
|
{config.icon}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Typography level='title-sm' fontWeight='lg' sx={{ color: `${config.color}.plainColor` }}>
|
<Typography
|
||||||
|
level='title-sm'
|
||||||
|
fontWeight='lg'
|
||||||
|
sx={{ color: `${config.color}.plainColor` }}
|
||||||
|
>
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -146,31 +170,58 @@ const HistoryCard = ({
|
|||||||
{/* Notes inline */}
|
{/* Notes inline */}
|
||||||
|
|
||||||
{plainTextNotes && (
|
{plainTextNotes && (
|
||||||
<Card
|
<Card
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
size='sm'
|
size='sm'
|
||||||
sx={{ mt: 0.5, whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
sx={{
|
||||||
>
|
mt: 0.5,
|
||||||
<Typography
|
whiteSpace: 'pre-wrap',
|
||||||
level='body-xs'
|
overflow: 'hidden',
|
||||||
sx={{ color: 'text.secondary', fontStyle: 'italic', mb: 0.25, cursor: 'pointer' }}
|
textOverflow: 'ellipsis',
|
||||||
onClick={e => { e.stopPropagation(); onViewNote?.(historyEntry.notes) }}
|
}}
|
||||||
>
|
>
|
||||||
{plainTextNotes.length > 80 ? `${plainTextNotes.slice(0, 80)}…` : plainTextNotes}
|
<Typography
|
||||||
</Typography>
|
level='body-xs'
|
||||||
</Card>
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
mb: 0.25,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onViewNote?.(historyEntry.notes)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{plainTextNotes.length > 80
|
||||||
|
? `${plainTextNotes.slice(0, 80)}…`
|
||||||
|
: plainTextNotes}
|
||||||
|
</Typography>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Metadata strip: performer chip + date + extras */}
|
{/* Metadata strip: performer chip + date + extras */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
mt: 0.5,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{performer && (
|
{performer && (
|
||||||
<Chip
|
<Chip
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='soft'
|
variant='soft'
|
||||||
color='neutral'
|
color='neutral'
|
||||||
startDecorator={
|
startDecorator={
|
||||||
<Avatar src={performer.image} alt={performer.displayName} sx={{ width: 14, height: 14 }} />
|
<Avatar
|
||||||
|
src={performer.image}
|
||||||
|
alt={performer.displayName}
|
||||||
|
sx={{ width: 14, height: 14 }}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{performer.displayName}
|
{performer.displayName}
|
||||||
@@ -184,13 +235,19 @@ const HistoryCard = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }} onClick={e => e.stopPropagation()}>
|
<Box
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
{onToggleActions && (
|
{onToggleActions && (
|
||||||
<IconButton
|
<IconButton
|
||||||
color='neutral'
|
color='neutral'
|
||||||
variant='plain'
|
variant='plain'
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={e => { e.stopPropagation(); onToggleActions() }}
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleActions()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MoreVert sx={{ fontSize: 18 }} />
|
<MoreVert sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Box, Button, Container, Typography } from '@mui/joy'
|
import { Box, Button, Container, Typography } from '@mui/joy'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import Logo from '../Logo'
|
import Logo from '../Logo'
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const { t } = useTranslation('common')
|
const { t } = useTranslation('common')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { CreateLabel, GetLabels } from '../../utils/Fetcher'
|
import { CreateLabel, GetLabels } from '../../utils/Fetcher'
|
||||||
import { offlineDB } from '../../utils/OfflineDB'
|
import { offlineDB } from '../../utils/OfflineDB'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Card, Grid, Typography } from '@mui/joy'
|
import { Card, Grid, Typography } from '@mui/joy'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
|
|
||||||
import CalendarMonthly from '../components/CalendarMonthly'
|
import CalendarMonthly from '../components/CalendarMonthly'
|
||||||
|
|
||||||
const DemoCalendar = () => {
|
const DemoCalendar = () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Card, Grid, Typography } from '@mui/joy'
|
import { Card, Grid, Typography } from '@mui/joy'
|
||||||
|
|
||||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||||
|
|
||||||
const DemoNotificationTemplate = () => {
|
const DemoNotificationTemplate = () => {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import LogoSVG from '@/assets/logo.svg'
|
|
||||||
import { Email, GitHub } from '@mui/icons-material'
|
import { Email, GitHub } from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -9,6 +8,9 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/joy'
|
} from '@mui/joy'
|
||||||
|
|
||||||
|
import LogoSVG from '@/assets/logo.svg'
|
||||||
|
|
||||||
import { version } from '../../../package.json'
|
import { version } from '../../../package.json'
|
||||||
import DiscordIcon from '../../components/icons/DiscordIcon'
|
import DiscordIcon from '../../components/icons/DiscordIcon'
|
||||||
import RedditIcon from '../../components/icons/RedditIcon'
|
import RedditIcon from '../../components/icons/RedditIcon'
|
||||||
@@ -333,7 +335,6 @@ const Footer = () => {
|
|||||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||||
Version {version}
|
Version {version}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { Box, Button, Card, Container, Grid, Typography } from '@mui/joy'
|
import { Box, Button, Card, Container, Grid, Typography } from '@mui/joy'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
function StartOptionCard({ icon: Icon, title, description, button, index }) {
|
function StartOptionCard({ button, description, icon: Icon, index, title }) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
data-aos='fade-up'
|
data-aos='fade-up'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user