@@ -171,7 +171,11 @@
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
"description": "Tell us how Donetick is working for you or request a feature."
|
||||
},
|
||||
"bugReport": {
|
||||
"title": "Report a Bug",
|
||||
"description": "Something not working right? Send us the details along with a technical snapshot."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,8 @@ import tailwindConfig from '/tailwind.config.mjs'
|
||||
export const { theme: THEME } = resolveConfig(tailwindConfig)
|
||||
|
||||
export const COLORS = THEME.colors
|
||||
|
||||
export const THEME_BACKGROUND = {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { COLORS } from '@/constants/theme'
|
||||
import { CssBaseline } from '@mui/joy'
|
||||
import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
|
||||
import PropType from 'prop-types'
|
||||
|
||||
import { COLORS, THEME_BACKGROUND } from '@/constants/theme'
|
||||
|
||||
const primaryColor = 'cyan'
|
||||
const shades = [
|
||||
'50',
|
||||
@@ -34,6 +35,9 @@ const theme = extendTheme({
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
background: {
|
||||
body: THEME_BACKGROUND.light,
|
||||
},
|
||||
primary: primaryPalette,
|
||||
success: {
|
||||
50: '#f3faf7',
|
||||
@@ -75,6 +79,9 @@ const theme = extendTheme({
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
background: {
|
||||
body: THEME_BACKGROUND.dark,
|
||||
},
|
||||
primary: primaryPalette,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
|
||||
export const useFileUpload = ({
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
entityId,
|
||||
entityType = 'chore_attachment',
|
||||
} = {}) => {
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -19,28 +20,36 @@ export const useFileUpload = ({
|
||||
showError({
|
||||
title: 'Plus Feature',
|
||||
message:
|
||||
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
|
||||
'File uploads are not available in the Basic plan. Upgrade to Plus to add files to your content.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
// Only images go through compression — anything else (PDFs, docs)
|
||||
// would be destroyed by re-encoding it as a JPEG.
|
||||
let fileToUpload = file
|
||||
if (file.type?.startsWith('image/')) {
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(
|
||||
file,
|
||||
compressionOptions,
|
||||
)
|
||||
fileToUpload = new File(
|
||||
[compressedFile],
|
||||
`${file.name.split('.')[0]}.jpg`,
|
||||
{ type: 'image/jpeg' },
|
||||
)
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(file, compressionOptions)
|
||||
const compressedJpegFile = new File(
|
||||
[compressedFile],
|
||||
`${file.name.split('.')[0]}.jpg`,
|
||||
{ type: 'image/jpeg' },
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', compressedJpegFile)
|
||||
formData.append('file', fileToUpload)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId) formData.append('entityId', String(entityId))
|
||||
if (draftId) formData.append('draftId', draftId)
|
||||
@@ -62,7 +71,7 @@ export const useFileUpload = ({
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Upgrade Required',
|
||||
message: 'Image uploads are only available for Plus accounts.',
|
||||
message: 'File uploads are only available for Plus accounts.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403) {
|
||||
@@ -74,7 +83,7 @@ export const useFileUpload = ({
|
||||
} else if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload image.',
|
||||
message: 'Failed to upload file.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -91,7 +100,7 @@ export const useFileUpload = ({
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'An error occurred while processing the image.',
|
||||
message: 'An error occurred while processing the file.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding'
|
||||
|
||||
// Routes a first-run user may legitimately be on without having gone through
|
||||
@@ -11,6 +12,9 @@ const ALLOWED_PATHS = [
|
||||
'/login/settings',
|
||||
'/privacy',
|
||||
'/terms',
|
||||
// An invite link is a legitimate first launch: the join view explains itself
|
||||
// and routes to sign-in, so onboarding must not swallow the code.
|
||||
'/circle/join',
|
||||
]
|
||||
|
||||
const isAllowed = pathname =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useColorScheme } from '@mui/joy'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import statusBarManager from '../utils/StatusBarManager'
|
||||
|
||||
/**
|
||||
@@ -35,10 +36,7 @@ export const useStatusBar = () => {
|
||||
|
||||
// Update the status bar with the resolved theme
|
||||
await statusBarManager.updateResolvedTheme(resolvedTheme)
|
||||
|
||||
// Also update the base theme for future reference
|
||||
await statusBarManager.setTheme(mode)
|
||||
|
||||
|
||||
// Notify any custom listeners
|
||||
statusBarManager.notifyThemeChange(resolvedTheme)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ export function useSyncOnReconnect() {
|
||||
// the same tick as the deep link, before the route changes, so this has
|
||||
// to test the shared flag rather than the pathname.
|
||||
if (isOAuthExchangeInProgress()) return
|
||||
// No session, nothing to sync — and a 401 here would force a logout that
|
||||
// hard-navigates signed-out visitors (invite links) away to /login.
|
||||
if (!localStorage.getItem('token')) return
|
||||
const wasOffline = !networkManager.isOnline
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) {
|
||||
|
||||
@@ -29,6 +29,7 @@ export const useAllUsers = () => {
|
||||
|
||||
export const useCircleMembers = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['allCircleMembers'],
|
||||
@@ -46,6 +47,10 @@ export const useCircleMembers = () => {
|
||||
return { res: [] }
|
||||
}
|
||||
},
|
||||
// NavBar's avatar mounts this on every route, including the signed-out
|
||||
// ones. Without the gate the 401 tips ApiClient into a forced logout that
|
||||
// hard-navigates to /login — which is what used to eat circle invites.
|
||||
enabled: !!token,
|
||||
})
|
||||
|
||||
const handleRefetch = () => {
|
||||
|
||||
@@ -113,6 +113,9 @@ export const collectErrorReport = async ({ error, errorInfo, reportId }) => {
|
||||
return {
|
||||
reportId: reportId ?? newReportId(),
|
||||
occurredAt: new Date().toISOString(),
|
||||
// No error means the user came here deliberately from settings rather than
|
||||
// off the back of a crash — same diagnostics, different story to tell.
|
||||
kind: error ? 'crash' : 'bug',
|
||||
error: describeError(error, errorInfo),
|
||||
runtime: describeRuntime(),
|
||||
app: context,
|
||||
@@ -137,7 +140,11 @@ export const formatErrorReport = report => {
|
||||
`Report ID: ${report.reportId}`,
|
||||
`Time: ${report.occurredAt}`,
|
||||
'',
|
||||
`Error: ${error.name}${error.message ? `: ${error.message}` : ''}`,
|
||||
// A user-initiated report has no throw behind it; "Error: Unknown" would
|
||||
// only be noise in the panel the user is being asked to read.
|
||||
report.kind === 'bug'
|
||||
? 'Reported manually (no crash)'
|
||||
: `Error: ${error.name}${error.message ? `: ${error.message}` : ''}`,
|
||||
error.status
|
||||
? `HTTP: ${error.status} ${error.statusText ?? ''}`.trim()
|
||||
: null,
|
||||
@@ -210,11 +217,14 @@ export const formatErrorReport = report => {
|
||||
* leaves infrastructure they control, and they see it before it is published.
|
||||
*/
|
||||
export const buildErrorIssueUrl = ({ description, report }) => {
|
||||
const title = `[crash] ${
|
||||
report.error.message?.slice(0, 80) ||
|
||||
report.error.name ||
|
||||
'Unexpected error'
|
||||
}`
|
||||
const isBug = report.kind === 'bug'
|
||||
const title = isBug
|
||||
? `[bug] ${description?.trim().slice(0, 80) || 'Reported from the app'}`
|
||||
: `[crash] ${
|
||||
report.error.message?.slice(0, 80) ||
|
||||
report.error.name ||
|
||||
'Unexpected error'
|
||||
}`
|
||||
const body = [
|
||||
'### What happened',
|
||||
description?.trim() || '_no description provided_',
|
||||
@@ -241,7 +251,7 @@ export const submitErrorReport = async ({
|
||||
}) => {
|
||||
const payload = {
|
||||
source: 'donetick-app',
|
||||
kind: 'error-report',
|
||||
kind: report.kind === 'bug' ? 'bug-report' : 'error-report',
|
||||
reportId: report.reportId,
|
||||
description: description?.trim() || null,
|
||||
contactEmail: contactEmail?.trim() || null,
|
||||
|
||||
@@ -155,6 +155,21 @@ class ApiClient {
|
||||
return
|
||||
}
|
||||
|
||||
// An expired session on an invite link would otherwise drop the code on the
|
||||
// way to /login. Stash it first so sign-in returns to the join.
|
||||
try {
|
||||
const { pathname, search } = window.location
|
||||
if (pathname === '/circle/join') {
|
||||
const code = new URLSearchParams(search).get('code')
|
||||
if (code) {
|
||||
const { setPendingInvite } = await import('./PendingInvite')
|
||||
setPendingInvite(code)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error preserving pending invite on logout', e)
|
||||
}
|
||||
|
||||
await clearAllTokens()
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
|
||||
17
src/utils/FileConvert.js
Normal file
17
src/utils/FileConvert.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Turns an image source the scanners produce — a base64 data URI on iOS/web,
|
||||
* a Capacitor localhost URL on Android — into a File the upload endpoint
|
||||
* accepts. Both forms are fetchable, so one path covers them.
|
||||
*/
|
||||
export async function imageSourceToFile(source, fileName = 'scan.jpg') {
|
||||
if (!source) return null
|
||||
try {
|
||||
const response = await fetch(source)
|
||||
const blob = await response.blob()
|
||||
const type = blob.type && blob.type !== '' ? blob.type : 'image/jpeg'
|
||||
return new File([blob], fileName, { type })
|
||||
} catch (e) {
|
||||
console.error('[FileConvert] failed to convert image source:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
40
src/utils/PendingInvite.js
Normal file
40
src/utils/PendingInvite.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
// A circle invite link is often the very first thing a new user opens, so the
|
||||
// code has to survive the trip through login/signup (including OAuth, which
|
||||
// leaves and re-enters the app) and be replayed once a session exists.
|
||||
const INVITE_KEY = 'pending_circle_invite'
|
||||
const REDIRECT_COOKIE = 'ca_redirect'
|
||||
|
||||
// `auto=1` tells the join view this visit is the return leg of an auth
|
||||
// round-trip, so it can submit the request instead of asking a second time.
|
||||
export const joinCirclePath = code =>
|
||||
`/circle/join?code=${encodeURIComponent(code)}&auto=1`
|
||||
|
||||
export const setPendingInvite = code => {
|
||||
if (!code) return
|
||||
localStorage.setItem(INVITE_KEY, code)
|
||||
// Every post-auth landing point (password login, OAuth callback, MFA) already
|
||||
// consumes `ca_redirect`, so reusing it is all the routing this needs.
|
||||
Cookies.set(REDIRECT_COOKIE, joinCirclePath(code), { expires: 1 })
|
||||
}
|
||||
|
||||
export const getPendingInvite = () => {
|
||||
try {
|
||||
return localStorage.getItem(INVITE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const clearPendingInvite = () => {
|
||||
try {
|
||||
localStorage.removeItem(INVITE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const redirect = Cookies.get(REDIRECT_COOKIE)
|
||||
if (redirect && redirect.startsWith('/circle/join')) {
|
||||
Cookies.remove(REDIRECT_COOKIE)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { Capacitor } from '@capacitor/core'
|
||||
import { StatusBar, Style } from '@capacitor/status-bar'
|
||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||
|
||||
import { THEME_BACKGROUND } from '@/constants/theme'
|
||||
|
||||
/**
|
||||
* StatusBarManager - A utility class to handle status bar configuration
|
||||
* following Capacitor best practices and theme-aware styling
|
||||
@@ -52,17 +54,18 @@ class StatusBarManager {
|
||||
this.currentTheme = theme
|
||||
|
||||
try {
|
||||
let style = Style.Light // Default to light content (dark status bar)
|
||||
|
||||
if (theme === 'dark') {
|
||||
style = Style.Dark // Dark content (light status bar)
|
||||
} else if (theme === 'system') {
|
||||
// For system theme, we need to detect the actual system preference
|
||||
// Joy UI's useColorScheme will handle this, but we default to light
|
||||
style = Style.Light
|
||||
}
|
||||
const resolvedTheme =
|
||||
theme === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: theme
|
||||
const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
|
||||
|
||||
await StatusBar.setStyle({ style })
|
||||
await StatusBar.setBackgroundColor({
|
||||
color: THEME_BACKGROUND[resolvedTheme],
|
||||
})
|
||||
console.log(`StatusBarManager: Theme set to ${theme}, style: ${style}`)
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to set theme:', error)
|
||||
@@ -162,15 +165,7 @@ class StatusBarManager {
|
||||
async updateResolvedTheme(resolvedTheme) {
|
||||
if (!this.isNativePlatform) return
|
||||
|
||||
try {
|
||||
const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
|
||||
await StatusBar.setStyle({ style })
|
||||
console.log(
|
||||
`StatusBarManager: Resolved theme updated to ${resolvedTheme}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to update resolved theme:', error)
|
||||
}
|
||||
await this.setTheme(resolvedTheme)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,12 +12,14 @@ import Cookies from 'js-cookie'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||
|
||||
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { getPendingInvite } from '../../utils/PendingInvite'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
||||
import {
|
||||
@@ -138,7 +140,15 @@ const LoginView = () => {
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
Navigate('/chores')
|
||||
// An already-signed-in visitor who lands here from a deep link (a circle
|
||||
// invite, for example) still has to end up where they were headed.
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl && redirectUrl !== '/') {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/chores')
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user, Navigate])
|
||||
const handleSubmit = async e => {
|
||||
@@ -416,9 +426,11 @@ const LoginView = () => {
|
||||
<AuthShell
|
||||
title={userProfile ? 'Welcome back' : 'Sign in'}
|
||||
subtitle={
|
||||
userProfile
|
||||
? 'Pick up right where you left off.'
|
||||
: 'Sign in to your account to continue.'
|
||||
getPendingInvite()
|
||||
? 'Sign in and we’ll send your circle join request right after.'
|
||||
: userProfile
|
||||
? 'Pick up right where you left off.'
|
||||
: 'Sign in to your account to continue.'
|
||||
}
|
||||
logoSize={0}
|
||||
footer={<LegalLinks />}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Box, Link, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { login, signUp } from '../../utils/Fetcher'
|
||||
import { signUp } from '../../utils/Fetcher'
|
||||
import { getPendingInvite, joinCirclePath } from '../../utils/PendingInvite'
|
||||
import {
|
||||
AuthPasswordField,
|
||||
AuthSubmitButton,
|
||||
@@ -25,28 +28,39 @@ const SignupView = () => {
|
||||
const [displayNameError, setDisplayNameError] = React.useState('')
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const { showError } = useNotification()
|
||||
const handleLogin = (username, password) => {
|
||||
login(username, password).then(response => {
|
||||
if (response.status === 200) {
|
||||
response.json().then(res => {
|
||||
localStorage.setItem('token', res.token)
|
||||
localStorage.setItem('token_expiry', res.expire)
|
||||
const { login: authLogin } = useAuth()
|
||||
// Sign-in goes through the auth context, not a bare fetch: it stores the
|
||||
// refresh token and updates the provider's own state, so the rest of the app
|
||||
// sees the new session without a reload.
|
||||
const handleLogin = async (username, password) => {
|
||||
const result = await authLogin({ username, password })
|
||||
if (!result.success) {
|
||||
showError({
|
||||
title: 'Almost there',
|
||||
message:
|
||||
'Your account was created, but signing in failed. Please sign in.',
|
||||
})
|
||||
Navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
|
||||
// The "how did you hear about us" step (/heard-about) is
|
||||
// temporarily skipped; new accounts go straight to circle setup.
|
||||
// Re-enable by navigating to '/heard-about' again — that view
|
||||
// already forwards to '/circle-setup' when done.
|
||||
Navigate('/circle-setup', { replace: true })
|
||||
})
|
||||
} else {
|
||||
console.log('Login failed', response)
|
||||
// Someone who signed up from a circle invite is joining an existing
|
||||
// circle, so sending them through "name your circle" is both a dead
|
||||
// end for the invite and the wrong question.
|
||||
const pendingInvite = getPendingInvite()
|
||||
if (pendingInvite) {
|
||||
Navigate(joinCirclePath(pendingInvite), { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Navigate('/login')
|
||||
}
|
||||
})
|
||||
// The "how did you hear about us" step (/heard-about) is
|
||||
// temporarily skipped; new accounts go straight to circle setup.
|
||||
// Re-enable by navigating to '/heard-about' again — that view
|
||||
// already forwards to '/circle-setup' when done.
|
||||
Navigate('/circle-setup', { replace: true })
|
||||
}
|
||||
const handleSignUpValidation = () => {
|
||||
// Reset errors before validation
|
||||
@@ -132,7 +146,11 @@ const SignupView = () => {
|
||||
return (
|
||||
<AuthShell
|
||||
title='Create your account'
|
||||
subtitle='Track chores and tasks together, in one shared place.'
|
||||
subtitle={
|
||||
getPendingInvite()
|
||||
? 'Create an account and we’ll send your circle join request right after.'
|
||||
: 'Track chores and tasks together, in one shared place.'
|
||||
}
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowDropDown,
|
||||
AttachFile,
|
||||
Delete,
|
||||
DocumentScanner,
|
||||
HorizontalRule,
|
||||
Save,
|
||||
UploadFile,
|
||||
@@ -41,6 +42,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import DurationInput from '../../components/common/DurationInput'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import {
|
||||
useArchiveChore,
|
||||
useChore,
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
GetThings,
|
||||
UploadChoreAttachment,
|
||||
} from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getImageSrc, removeCachedImage } from '../../utils/ImageCache'
|
||||
import Priorities from '../../utils/Priorities.jsx'
|
||||
@@ -173,6 +176,7 @@ const ChoreEdit = () => {
|
||||
const { data: membersData, isLoading: isMemberDataLoading } =
|
||||
useCircleMembers()
|
||||
const { showError, showSuccess } = useNotification()
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
|
||||
const [userLabels, setUserLabels] = useState([])
|
||||
|
||||
@@ -671,6 +675,67 @@ const ChoreEdit = () => {
|
||||
}
|
||||
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
|
||||
|
||||
const uploadAttachmentFile = async file => {
|
||||
if (!file) return
|
||||
setIsUploadingAttachment(true)
|
||||
try {
|
||||
const response = choreId
|
||||
? await UploadChoreAttachment(file, 'chore_attachment', {
|
||||
entityId: choreId,
|
||||
})
|
||||
: await UploadChoreAttachment(file, 'chore_attachment_draft', {
|
||||
draftId,
|
||||
})
|
||||
if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
file_path: data.path,
|
||||
file_name: data.file_name,
|
||||
size_bytes: data.size_bytes,
|
||||
sign: data.sign,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploadingAttachment(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Native only: the OS scanner returns a cropped, deskewed page which is a
|
||||
// better attachment than a raw camera shot of the same document.
|
||||
const handleScanAttachment = async () => {
|
||||
const { cancelled, error, image } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: error || 'Could not scan the document.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
|
||||
if (!file) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: 'Could not read the scanned image.',
|
||||
})
|
||||
return
|
||||
}
|
||||
await uploadAttachmentFile(file)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
@@ -1109,62 +1174,39 @@ const ChoreEdit = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
component='label'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={isUploadingAttachment ? null : <UploadFile />}
|
||||
loading={isUploadingAttachment}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
Upload File
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
setIsUploadingAttachment(true)
|
||||
try {
|
||||
const response = choreId
|
||||
? await UploadChoreAttachment(file, 'chore_attachment', {
|
||||
entityId: choreId,
|
||||
})
|
||||
: await UploadChoreAttachment(
|
||||
file,
|
||||
'chore_attachment_draft',
|
||||
{ draftId },
|
||||
)
|
||||
if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
file_path: data.path,
|
||||
file_name: data.file_name,
|
||||
size_bytes: data.size_bytes,
|
||||
sign: data.sign,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploadingAttachment(false)
|
||||
<Box sx={{ display: 'flex', gap: 1, alignSelf: 'flex-start' }}>
|
||||
<Button
|
||||
component='label'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={isUploadingAttachment ? null : <UploadFile />}
|
||||
loading={isUploadingAttachment}
|
||||
>
|
||||
Upload File
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files[0]
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
await uploadAttachmentFile(file)
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{isNativeScanner && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<DocumentScanner />}
|
||||
disabled={isUploadingAttachment}
|
||||
onClick={handleScanAttachment}
|
||||
>
|
||||
Scan
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,162 +1,260 @@
|
||||
import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
import { Button } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { JoinCircle } from '../../utils/Fetcher'
|
||||
import { clearPendingInvite, setPendingInvite } from '../../utils/PendingInvite'
|
||||
import { authButtonSx } from '../Authorization/authStyles'
|
||||
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
|
||||
import { CircleVignette } from '../Onboarding/OnboardingVignettes'
|
||||
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
|
||||
const enter = (delay = 0) => ({
|
||||
animation: `joinCircleIn 520ms ${EASE} ${delay}ms both`,
|
||||
'@keyframes joinCircleIn': {
|
||||
from: { opacity: 0, transform: 'translateY(12px)' },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
})
|
||||
|
||||
const JoinCircleView = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: userProfile, isLoading: isProfileLoading } = useUserProfile()
|
||||
// Read the token rather than useAuth(): the provider's copy only updates
|
||||
// through its own login(), so signup and the OAuth callback — which save
|
||||
// tokens directly — would still look signed out here. The query hooks read
|
||||
// storage the same way.
|
||||
const isAuthenticated = !!localStorage.getItem('token')
|
||||
const { showError } = useNotification()
|
||||
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
|
||||
let [searchParams, setSearchParams] = useSearchParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const code = searchParams.get('code')
|
||||
// `auto=1` is on the link we send the user back to after they authenticate,
|
||||
// and only there — someone who opens an invite while already signed in gets
|
||||
// asked, not auto-joined.
|
||||
const isReturningFromAuth = searchParams.get('auto') === '1'
|
||||
const autoJoinAttempted = useRef(false)
|
||||
|
||||
const submitJoin = useCallback(() => {
|
||||
setIsJoining(true)
|
||||
JoinCircle(code)
|
||||
.then(resp => {
|
||||
clearPendingInvite()
|
||||
if (resp.ok) {
|
||||
showAcknowledgment(
|
||||
'Your request has been sent. A circle admin will need to approve ' +
|
||||
"it before you can access the circle and its chores. We'll " +
|
||||
"notify you when it's approved.",
|
||||
'Request sent',
|
||||
() => navigate('/chores'),
|
||||
'Got it',
|
||||
'success',
|
||||
)
|
||||
} else {
|
||||
setIsJoining(false)
|
||||
if (resp.status === 409) {
|
||||
showError('You are already a member of this circle')
|
||||
} else {
|
||||
showError('Failed to join circle')
|
||||
}
|
||||
navigate('/chores')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setIsJoining(false)
|
||||
clearPendingInvite()
|
||||
showError('Could not send your join request. Please try again.')
|
||||
})
|
||||
}, [code, navigate, showAcknowledgment, showError])
|
||||
|
||||
// Coming back from login/signup the user already said yes by opening the
|
||||
// link, so send the request instead of asking a second time. This step used
|
||||
// to be missing entirely: the login page was a dead end.
|
||||
useEffect(() => {
|
||||
if (autoJoinAttempted.current) return
|
||||
if (!code || !isReturningFromAuth) return
|
||||
if (!isAuthenticated || !userProfile) return
|
||||
|
||||
autoJoinAttempted.current = true
|
||||
submitJoin()
|
||||
}, [code, isReturningFromAuth, isAuthenticated, userProfile, submitJoin])
|
||||
|
||||
// Park the code so it survives the round-trip, including OAuth flows that
|
||||
// leave the app entirely.
|
||||
const goToAuth = destination => {
|
||||
setPendingInvite(code)
|
||||
navigate(destination)
|
||||
}
|
||||
|
||||
const inviteCodeField = (
|
||||
<Input
|
||||
value={code || ''}
|
||||
readOnly
|
||||
size='lg'
|
||||
slotProps={{ input: { style: { textAlign: 'center', fontWeight: 600 } } }}
|
||||
/>
|
||||
)
|
||||
|
||||
let title = "You're invited to join a circle"
|
||||
let subtitle = null
|
||||
let body = null
|
||||
|
||||
if (!code) {
|
||||
title = 'Invite link is incomplete'
|
||||
subtitle =
|
||||
'This invite link is missing a code. Ask the person who invited you to send a new link.'
|
||||
body = (
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/chores')}
|
||||
>
|
||||
Go to Donetick
|
||||
</Button>
|
||||
)
|
||||
// A token that no longer resolves to a profile is as good as signed out —
|
||||
// better to offer sign-in than to spin forever.
|
||||
} else if (!isAuthenticated || (!isProfileLoading && !userProfile)) {
|
||||
subtitle =
|
||||
"Sign in or create a Donetick account to continue. We'll send your join request once you're signed in."
|
||||
body = (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{inviteCodeField}
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={authButtonSx}
|
||||
onClick={() => goToAuth('/login')}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => goToAuth('/signup')}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
} else if (isProfileLoading || isJoining) {
|
||||
title = 'Sending your request'
|
||||
subtitle = 'Sending your request…'
|
||||
body = (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
subtitle =
|
||||
`Hi ${userProfile?.displayName || userProfile?.username}. ` +
|
||||
"Send a request to share this circle's chores with its members."
|
||||
body = (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
A circle admin will review your request before you get access.
|
||||
</Typography>
|
||||
<Button fullWidth size='lg' sx={authButtonSx} onClick={submitJoin}>
|
||||
Send join request
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => {
|
||||
clearPendingInvite()
|
||||
navigate('/chores')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
<Box
|
||||
component='main'
|
||||
maxWidth='xs'
|
||||
|
||||
// make content center in the middle of the page:
|
||||
sx={{
|
||||
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
px: 3,
|
||||
pb: 'calc(var(--safe-area-inset-bottom, 0px) + 24px)',
|
||||
bgcolor: 'background.body',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
my: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
<Box sx={{ mb: 2, ...enter(0) }}>
|
||||
<CircleVignette />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
textAlign: 'center',
|
||||
gap: 1.5,
|
||||
mb: 4,
|
||||
...enter(60),
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
<Typography
|
||||
level='h1'
|
||||
sx={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
maxWidth: '34ch',
|
||||
textWrap: 'pretty',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
</Typography>
|
||||
{code && userProfile && (
|
||||
<>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
Hi {userProfile?.displayName}, you have been invited to join the
|
||||
circle{' '}
|
||||
</Typography>
|
||||
<Input
|
||||
fullWidth
|
||||
placeholder='Enter code'
|
||||
value={code}
|
||||
disabled={!!code}
|
||||
size='lg'
|
||||
sx={{
|
||||
width: '220px',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
Joining will give you access to the circle's chores and members.
|
||||
</Typography>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
You can leave the circle later from you Settings page.
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={isJoining}
|
||||
onClick={() => {
|
||||
setIsJoining(true)
|
||||
JoinCircle(code).then(resp => {
|
||||
if (resp.ok) {
|
||||
showAcknowledgment(
|
||||
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
|
||||
'Join Request Sent!',
|
||||
() => navigate('/'),
|
||||
'Got it',
|
||||
'success',
|
||||
)
|
||||
} else {
|
||||
setIsJoining(false)
|
||||
if (resp.status === 409) {
|
||||
showError('You are already a member of this circle')
|
||||
} else {
|
||||
showError('Failed to join circle')
|
||||
}
|
||||
navigate('/')
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isJoining ? 'Joining...' : 'Join Circle'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
q
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/chores')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
{!code ||
|
||||
(!userProfile && (
|
||||
<>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
You need to be logged in to join a circle
|
||||
</Typography>
|
||||
<Typography level='body-md' alignSelf={'center'} sx={{ mb: 9 }}>
|
||||
Login or sign up to continue
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
</Sheet>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ ...enter(120) }}>{body}</Box>
|
||||
</Box>
|
||||
|
||||
<AcknowledgmentModal config={ackModalConfig} />
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,9 +100,13 @@ const IconHalo = ({ color = 'primary', icon }) => (
|
||||
* user, everything else gathered automatically. The diagnostics are shown
|
||||
* before sending rather than after — people are more willing to send a report
|
||||
* they can see, and this is the one moment they already distrust the app.
|
||||
*
|
||||
* Also reached deliberately from settings with no error attached, where the
|
||||
* same diagnostics back a bug the user noticed but the app never threw on.
|
||||
*/
|
||||
const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const isBugReport = !error
|
||||
|
||||
const [report, setReport] = useState(null)
|
||||
const [description, setDescription] = useState('')
|
||||
@@ -160,7 +164,10 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
||||
{step === STEP.FORM && (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ ...enter(0) }}>
|
||||
<IconHalo icon={<BugReportRounded />} color='danger' />
|
||||
<IconHalo
|
||||
icon={<BugReportRounded />}
|
||||
color={isBugReport ? 'warning' : 'danger'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center', ...enter(50) }}>
|
||||
@@ -168,26 +175,33 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
||||
level='h4'
|
||||
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
|
||||
>
|
||||
Report this problem
|
||||
{isBugReport ? 'Report a bug' : 'Report this problem'}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
|
||||
>
|
||||
A sentence about what you were doing turns this into something we
|
||||
can actually fix.
|
||||
{isBugReport
|
||||
? 'Tell us what went wrong and we’ll attach the technical details for you.'
|
||||
: 'A sentence about what you were doing turns this into something we can actually fix.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControl sx={{ ...enter(100) }}>
|
||||
<FormLabel sx={{ fontWeight: 600 }}>What were you doing?</FormLabel>
|
||||
<FormLabel sx={{ fontWeight: 600 }}>
|
||||
{isBugReport ? 'What went wrong?' : 'What were you doing?'}
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
autoFocus
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder='e.g. I tapped a chore in My Chores and the screen went blank'
|
||||
placeholder={
|
||||
isBugReport
|
||||
? 'e.g. Completing a chore from the list doesn’t update the due date'
|
||||
: 'e.g. I tapped a chore in My Chores and the screen went blank'
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -279,7 +293,9 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
||||
size='lg'
|
||||
fullWidth
|
||||
loading={submitting}
|
||||
disabled={!report}
|
||||
// A crash report stands on its own; a manual one is only the
|
||||
// description, so there's nothing to send without it.
|
||||
disabled={!report || (isBugReport && !description.trim())}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Send report
|
||||
@@ -293,7 +309,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
||||
underline='hover'
|
||||
onClick={onClose}
|
||||
>
|
||||
Not now
|
||||
{isBugReport ? 'Cancel' : 'Not now'}
|
||||
</Link>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AccountCircle,
|
||||
Api,
|
||||
BugReport,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Code,
|
||||
@@ -35,9 +36,11 @@ import {
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { isParentUser } from '../../utils/UserHelpers'
|
||||
import ErrorReportModal from '../Modals/ErrorReportModal'
|
||||
import FeedbackModal from '../Modals/FeedbackModal'
|
||||
|
||||
const SettingsOverview = () => {
|
||||
@@ -45,6 +48,7 @@ const SettingsOverview = () => {
|
||||
const navigate = useNavigate()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
||||
const [bugReportOpen, setBugReportOpen] = useState(false)
|
||||
|
||||
const settingsCards = [
|
||||
{
|
||||
@@ -133,6 +137,13 @@ const SettingsOverview = () => {
|
||||
icon: <Feedback />,
|
||||
onSelect: () => setFeedbackOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'bugreport',
|
||||
title: t('overview.sections.bugReport.title'),
|
||||
description: t('overview.sections.bugReport.description'),
|
||||
icon: <BugReport />,
|
||||
onSelect: () => setBugReportOpen(true),
|
||||
},
|
||||
]
|
||||
|
||||
const handleCardClick = setting => {
|
||||
@@ -387,6 +398,13 @@ const SettingsOverview = () => {
|
||||
open={feedbackOpen}
|
||||
onClose={() => setFeedbackOpen(false)}
|
||||
/>
|
||||
|
||||
{/* No error to pass: the report is about something the user saw, not
|
||||
something the app threw, so the modal collects diagnostics only. */}
|
||||
<ErrorReportModal
|
||||
open={bugReportOpen}
|
||||
onClose={() => setBugReportOpen(false)}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
|
||||
import { CreateLabel } from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { generateUUID } from '../../utils/UUID'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
import AdvancedOptionsSection, {
|
||||
AdvancedOptionsTrigger,
|
||||
} from './AdvancedOptionsSection'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import {
|
||||
parseAssignees,
|
||||
parseDueDate,
|
||||
@@ -21,19 +35,6 @@ import {
|
||||
parsePriority,
|
||||
parseRepeatV2,
|
||||
} from './CustomParsers'
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
|
||||
import AdvancedOptionsSection, {
|
||||
AdvancedOptionsTrigger,
|
||||
} from './AdvancedOptionsSection'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import DueDatePickerField from './DueDatePickerField'
|
||||
import LabelsPickerField from './LabelsPickerField'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
@@ -42,6 +43,7 @@ import PriorityPickerField from './PriorityPickerField'
|
||||
import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import ScanPanel from './ScanToTask/ScanPanel'
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
import SubTasks from './SubTask'
|
||||
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
|
||||
import VoicePanel from './VoiceToTask/VoicePanel'
|
||||
@@ -106,7 +108,7 @@ const getDefaultNotification = () => {
|
||||
return DEFAULT_NOTIFICATION_TEMPLATES
|
||||
}
|
||||
|
||||
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
|
||||
@@ -190,6 +192,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
const [showScan, setShowScan] = useState(false)
|
||||
const [scanAutoCapture, setScanAutoCapture] = useState(false)
|
||||
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
|
||||
const [isAttachingScan, setIsAttachingScan] = useState(false)
|
||||
const [llmAvailable, setLlmAvailable] = useState(false)
|
||||
const [showVoice, setShowVoice] = useState(false)
|
||||
const [voiceAvailable, setVoiceAvailable] = useState(false)
|
||||
@@ -213,6 +216,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
primaryAction: null,
|
||||
})
|
||||
const { isNativeScanner } = useDocumentScanner()
|
||||
const { uploadFile } = useFileUpload({
|
||||
entityType: 'chore_attachment_draft',
|
||||
draftId,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
localAIService.isAvailable().then(setLlmAvailable)
|
||||
@@ -274,11 +281,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
const {
|
||||
isModalOpen,
|
||||
hasDescription,
|
||||
dueDate,
|
||||
createChore,
|
||||
dueDate,
|
||||
handleCloseModal,
|
||||
hasDescription,
|
||||
isModalOpen,
|
||||
} = latestRef.current
|
||||
const isHoldingCmd = event.ctrlKey || event.metaKey
|
||||
if (isHoldingCmd) {
|
||||
@@ -709,11 +716,38 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
createChore()
|
||||
}
|
||||
|
||||
// The scan keeps its source image when asked: upload it against the draft so
|
||||
// the server promotes it onto the chore the same way manual uploads are.
|
||||
const attachScannedImage = async imageSource => {
|
||||
// Creating the chore promotes whatever draft attachments exist at that
|
||||
// moment, so Create waits on this upload rather than orphaning it.
|
||||
setIsAttachingScan(true)
|
||||
try {
|
||||
const file = await imageSourceToFile(
|
||||
imageSource,
|
||||
`scan-${Date.now()}.jpg`,
|
||||
)
|
||||
if (!file) return
|
||||
const uploaded = await uploadFile(file)
|
||||
if (!uploaded) return
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
} finally {
|
||||
setIsAttachingScan(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskExtracted = ({
|
||||
taskName,
|
||||
attachmentImage,
|
||||
description: extractedDesc,
|
||||
dueDate: extractedDue,
|
||||
taskName,
|
||||
}) => {
|
||||
if (attachmentImage) {
|
||||
attachScannedImage(attachmentImage)
|
||||
}
|
||||
if (taskName) {
|
||||
processText(taskName)
|
||||
}
|
||||
@@ -801,6 +835,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
setVoiceState({ segments: [], isListening: false })
|
||||
setScanState({ phase: 'idle', primaryAction: null })
|
||||
setCreatingVoiceTasks(false)
|
||||
setIsAttachingScan(false)
|
||||
setTaskText('')
|
||||
setTaskTitle('')
|
||||
setDueDate(null)
|
||||
@@ -829,6 +864,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}
|
||||
|
||||
const createChore = () => {
|
||||
// A scanned attachment still uploading would be orphaned by the create
|
||||
if (isAttachingScan) return
|
||||
|
||||
// Handle different assignee scenarios
|
||||
let finalAssignees = assignees
|
||||
let finalAssignedTo = null
|
||||
@@ -988,7 +1026,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!taskTitle.trim()}
|
||||
loading={isAttachingScan}
|
||||
disabled={!taskTitle.trim() || isAttachingScan}
|
||||
onClick={createChore}
|
||||
>
|
||||
Create
|
||||
@@ -1359,6 +1398,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
<ScanPanel
|
||||
open
|
||||
autoCapture={scanAutoCapture}
|
||||
canKeepImage={isPlusAccount(userProfile)}
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
initialImageUrl={pendingPhotoUrl}
|
||||
onStateChange={setScanState}
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
import { Person } from '@mui/icons-material'
|
||||
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const ANYONE = 'anyone'
|
||||
|
||||
const AssigneePickerField = ({
|
||||
value = null,
|
||||
currentUserId = null,
|
||||
emptyDisplay,
|
||||
includeAnyone = true,
|
||||
isAnyone = false,
|
||||
members = [],
|
||||
onChange,
|
||||
onClear,
|
||||
members = [],
|
||||
includeAnyone = true,
|
||||
emptyDisplay,
|
||||
currentUserId = null,
|
||||
values = [],
|
||||
}) => {
|
||||
const options = [
|
||||
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
|
||||
...(includeAnyone ? [{ userId: ANYONE, displayName: 'Anyone' }] : []),
|
||||
...members.map(member => ({
|
||||
userId: member.userId,
|
||||
displayName: member.displayName || member.username || 'Unknown',
|
||||
})),
|
||||
]
|
||||
|
||||
const displayValue = currentUserId && value === currentUserId ? null : value
|
||||
// An implicit self-assignment is shown as "unset" so the chip stays empty
|
||||
// until the user picks someone explicitly.
|
||||
const isImplicitSelf =
|
||||
!isAnyone &&
|
||||
currentUserId &&
|
||||
values.length === 1 &&
|
||||
values[0] === currentUserId
|
||||
|
||||
const displayValues = isAnyone ? [ANYONE] : isImplicitSelf ? [] : values
|
||||
|
||||
const handleValuesChange = nextValues => {
|
||||
const wasAnyone = displayValues.includes(ANYONE)
|
||||
const hasAnyone = nextValues.includes(ANYONE)
|
||||
|
||||
if (hasAnyone && !wasAnyone) {
|
||||
onChange?.([ANYONE])
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.(nextValues.filter(userId => userId !== ANYONE))
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={displayValue}
|
||||
onChange={onChange}
|
||||
multiple
|
||||
values={displayValues}
|
||||
onValuesChange={handleValuesChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Assignee'
|
||||
@@ -32,9 +57,11 @@ const AssigneePickerField = ({
|
||||
getItemLabel={item => item.displayName}
|
||||
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Assignee' : selectedItems[0].displayName
|
||||
}
|
||||
getTriggerText={({ isEmpty, selectedItems }) => {
|
||||
if (isEmpty) return 'Assignee'
|
||||
if (selectedItems.length === 1) return selectedItems[0].displayName
|
||||
return `${selectedItems.length} assignees`
|
||||
}}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
|
||||
import {
|
||||
AttachFile,
|
||||
Close,
|
||||
DeleteOutline,
|
||||
DocumentScanner,
|
||||
Image,
|
||||
InsertDriveFile,
|
||||
PhotoCamera,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -9,23 +17,41 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { DeleteDraftAttachment } from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
|
||||
|
||||
const isImageAttachment = attachment => {
|
||||
const ext = (attachment?.name || '').split('.').pop()?.toLowerCase()
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
const AttachmentPickerField = ({
|
||||
attachments = [],
|
||||
draftId,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityId,
|
||||
entityType = 'chore_attachment',
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
const { showError } = useNotification()
|
||||
|
||||
// Without a native scanner, `capture` asks a phone for its camera directly.
|
||||
// Desktop browsers ignore it and fall back to the file picker, which would
|
||||
// duplicate "Image", so the button only appears on touch devices.
|
||||
const canTakePhoto = isNativeScanner || navigator.maxTouchPoints > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -36,27 +62,60 @@ const AttachmentPickerField = ({
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen])
|
||||
|
||||
const handleAddFile = () => {
|
||||
const upload = async file => {
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const uploaded = await uploadFile(file)
|
||||
if (uploaded) {
|
||||
onChange([
|
||||
...attachments,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePickFile = ({ accept, capture } = {}) => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
input.click()
|
||||
input.onchange = async () => {
|
||||
if (accept) input.setAttribute('accept', accept)
|
||||
if (capture) input.setAttribute('capture', capture)
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const uploaded = await uploadFile(file)
|
||||
if (uploaded) {
|
||||
onChange([
|
||||
...attachments,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
if (file) upload(file)
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
// Native builds get the OS document scanner (edge detection + perspective
|
||||
// correction); everywhere else "take photo" is the camera roll shortcut.
|
||||
const handleScan = async () => {
|
||||
if (!isNativeScanner) {
|
||||
handlePickFile({ accept: 'image/*', capture: 'environment' })
|
||||
return
|
||||
}
|
||||
const { cancelled, error, image } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: error || 'Could not scan the document.',
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsUploading(true)
|
||||
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
|
||||
setIsUploading(false)
|
||||
if (!file) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: 'Could not read the scanned image.',
|
||||
})
|
||||
return
|
||||
}
|
||||
await upload(file)
|
||||
}
|
||||
|
||||
const handleRemove = async index => {
|
||||
@@ -199,26 +258,30 @@ const AttachmentPickerField = ({
|
||||
'&:hover': { bgcolor: 'background.level1' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
{isImageAttachment(attachment) && (
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'none',
|
||||
display: isImageAttachment(attachment)
|
||||
? 'none'
|
||||
: 'flex',
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
@@ -228,7 +291,15 @@ const AttachmentPickerField = ({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
|
||||
{isImageAttachment(attachment) ? (
|
||||
<Image
|
||||
sx={{ fontSize: 20, color: 'text.tertiary' }}
|
||||
/>
|
||||
) : (
|
||||
<InsertDriveFile
|
||||
sx={{ fontSize: 20, color: 'text.tertiary' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
@@ -255,26 +326,64 @@ const AttachmentPickerField = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isUploading ? (
|
||||
{isUploading ? (
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
disabled
|
||||
startDecorator={
|
||||
<CircularProgress
|
||||
size='sm'
|
||||
sx={{ '--CircularProgress-size': '14px' }}
|
||||
/>
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleAddFile}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading…' : 'Add image'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Uploading…
|
||||
</Button>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{canTakePhoto && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
startDecorator={
|
||||
isNativeScanner ? (
|
||||
<DocumentScanner sx={{ fontSize: 16 }} />
|
||||
) : (
|
||||
<PhotoCamera sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleScan}
|
||||
>
|
||||
{isNativeScanner ? 'Scan' : 'Photo'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
startDecorator={<Image sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handlePickFile({ accept: 'image/*' })}
|
||||
>
|
||||
Image
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
startDecorator={<AttachFile sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handlePickFile()}
|
||||
>
|
||||
File
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
|
||||
@@ -150,6 +150,9 @@ const NavBar = () => {
|
||||
'/onboarding',
|
||||
'/get-started',
|
||||
'/ready',
|
||||
// Reached from an invite link, often signed out: it owns its own shell
|
||||
// and must not mount the avatar's authenticated queries.
|
||||
'/circle/join',
|
||||
].includes(location.pathname)
|
||||
) {
|
||||
return (
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useScanToTask } from './useScanToTask'
|
||||
|
||||
/**
|
||||
@@ -27,34 +29,39 @@ import { useScanToTask } from './useScanToTask'
|
||||
* belongs to the capture surface and drives a hidden input in this subtree.
|
||||
*/
|
||||
const ScanPanel = ({
|
||||
open,
|
||||
onTaskExtracted,
|
||||
autoCapture,
|
||||
canKeepImage = false,
|
||||
initialImageUrl,
|
||||
onClose,
|
||||
onStateChange,
|
||||
initialImageUrl,
|
||||
autoCapture,
|
||||
onTaskExtracted,
|
||||
open,
|
||||
}) => {
|
||||
const {
|
||||
isNativeScanner,
|
||||
phase,
|
||||
capturedImage,
|
||||
ocrProgress,
|
||||
taskResult,
|
||||
errorMsg,
|
||||
activate,
|
||||
cameraAvailable,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
fileInputRef,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
capture,
|
||||
capturedImage,
|
||||
errorMsg,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
handleNativeScan,
|
||||
retake,
|
||||
activate,
|
||||
isNativeScanner,
|
||||
ocrProgress,
|
||||
phase,
|
||||
reset,
|
||||
retake,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
taskResult,
|
||||
videoRef,
|
||||
} = useScanToTask()
|
||||
|
||||
// The scanned page is usually the task's source of truth (the bill, the
|
||||
// notice), so keeping it is the default — the OCR text alone loses it.
|
||||
const [keepImage, setKeepImage] = useState(false)
|
||||
|
||||
// Start/stop based on open state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -82,7 +89,10 @@ const ScanPanel = ({
|
||||
// Auto-close and populate when done
|
||||
useEffect(() => {
|
||||
if (phase === 'done' && taskResult) {
|
||||
onTaskExtracted(taskResult)
|
||||
onTaskExtracted({
|
||||
...taskResult,
|
||||
attachmentImage: canKeepImage && keepImage ? capturedImage : null,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -137,6 +147,18 @@ const ScanPanel = ({
|
||||
|
||||
const isProcessing = phase === 'processing'
|
||||
|
||||
// Attachments are a Plus feature; without it the upload would only ever
|
||||
// surface an upgrade error, so the choice isn't offered at all.
|
||||
const keepImageToggle = !canKeepImage ? null : (
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={keepImage}
|
||||
onChange={e => setKeepImage(e.target.checked)}
|
||||
label='Keep photo as attachment'
|
||||
sx={{ '--Checkbox-size': '18px' }}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* ── Capture phase ── */}
|
||||
@@ -201,16 +223,18 @@ const ScanPanel = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Hidden when Upload is already the footer's primary action */}
|
||||
{(isNativeScanner || cameraAvailable) && (
|
||||
<Box
|
||||
sx={{
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* Hidden when Upload is already the footer's primary action */}
|
||||
{(isNativeScanner || cameraAvailable) && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
@@ -220,8 +244,9 @@ const ScanPanel = ({
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
{keepImageToggle}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -285,6 +310,9 @@ const ScanPanel = ({
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Still editable here — the choice is only read once the task lands */}
|
||||
{keepImageToggle}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user