71
scripts/pull-secrets.sh
Executable file
71
scripts/pull-secrets.sh
Executable file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BW_SERVER="${BW_SERVER:-https://bitwarden.com}"
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
echo "→ Connecting to Vaultwarden at $BW_SERVER"
|
||||
CURRENT_SERVER=$(bw status | jq -r '.serverUrl // empty')
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$BW_SERVER" ]; then
|
||||
bw logout || true
|
||||
bw config server "$BW_SERVER"
|
||||
fi
|
||||
|
||||
if [ -z "${BW_SESSION:-}" ]; then
|
||||
BW_LOGIN_STATUS=$(bw status | jq -r '.status')
|
||||
|
||||
if [ "$BW_LOGIN_STATUS" = "unauthenticated" ]; then
|
||||
if [ -n "${BW_CLIENTID:-}" ] && [ -n "${BW_CLIENTSECRET:-}" ]; then
|
||||
bw login --apikey
|
||||
else
|
||||
bw login
|
||||
fi
|
||||
fi
|
||||
|
||||
export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw)
|
||||
fi
|
||||
|
||||
bw sync --session "$BW_SESSION" > /dev/null
|
||||
|
||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||
get_note() {
|
||||
bw get item "$1" --session "$BW_SESSION" | jq -r '.notes'
|
||||
}
|
||||
|
||||
get_password() {
|
||||
bw get item "$1" --session "$BW_SESSION" | jq -r '.login.password // .notes'
|
||||
}
|
||||
|
||||
# ── Android ───────────────────────────────────────────────────────────────────
|
||||
echo "→ Writing android/app/google-services.json"
|
||||
get_note "Donetick Google Services Android" > "$REPO_ROOT/android/app/google-services.json"
|
||||
|
||||
echo "→ Writing android keystore"
|
||||
get_note "Donetick Android Keystore" | base64 --decode > "$REPO_ROOT/android/app/release/donetick.jks"
|
||||
|
||||
KEYSTORE_PASSWORD=$(get_password "Donetick Keystore Password")
|
||||
|
||||
cat > "$REPO_ROOT/android/keystore.properties" <<EOF
|
||||
storeFile=release/donetick.jks
|
||||
storePassword=$KEYSTORE_PASSWORD
|
||||
keyAlias=key0
|
||||
keyPassword=$KEYSTORE_PASSWORD
|
||||
EOF
|
||||
|
||||
echo "→ Writing android/play-service-account.json"
|
||||
get_note "Donetick Google Play Service Account" > "$REPO_ROOT/android/play-service-account.json"
|
||||
|
||||
# ── iOS ───────────────────────────────────────────────────────────────────────
|
||||
echo "→ Writing ios/App/App/GoogleService-Info.plist"
|
||||
get_note "Donetick Google Services iOS" > "$REPO_ROOT/ios/App/App/GoogleService-Info.plist"
|
||||
|
||||
echo "→ Writing App Store Connect key"
|
||||
get_note "Donetick App Store Connect Key" | base64 --decode > "$REPO_ROOT/ios/AuthKey_84F695CDQ3.p8"
|
||||
|
||||
# ── Env ───────────────────────────────────────────────────────────────────────
|
||||
echo "→ Writing .env.production"
|
||||
get_note "Donetick Env Production" > "$REPO_ROOT/.env.production"
|
||||
|
||||
echo "✓ All secrets pulled successfully"
|
||||
69
scripts/push-secrets.sh
Executable file
69
scripts/push-secrets.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time script to upload local secrets into Vaultwarden.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# BW_SERVER="${BW_SERVER:-https://www.bitwareden.com}"
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
# bw config server "$BW_SERVER"
|
||||
# bw login
|
||||
export BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw)
|
||||
|
||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||
upsert_secure_note() {
|
||||
local name="$1"
|
||||
local content="$2"
|
||||
local existing_id
|
||||
existing_id=$(bw list items --session "$BW_SESSION" | jq -r --arg n "$name" '.[] | select(.name == $n) | .id' | head -1)
|
||||
|
||||
if [[ -n "$existing_id" ]]; then
|
||||
bw get item "$existing_id" --session "$BW_SESSION" \
|
||||
| jq --arg c "$content" '.notes = $c' \
|
||||
| bw encode \
|
||||
| bw edit item "$existing_id" --session "$BW_SESSION" > /dev/null
|
||||
echo " ✓ Updated: $name"
|
||||
else
|
||||
bw get template item --session "$BW_SESSION" \
|
||||
| jq --arg n "$name" --arg c "$content" \
|
||||
'.name = $n | .type = 2 | .secureNote = {"type":0} | .notes = $c' \
|
||||
| bw encode \
|
||||
| bw create item --session "$BW_SESSION" > /dev/null
|
||||
echo " ✓ Created: $name"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Upload ────────────────────────────────────────────────────────────────────
|
||||
echo "→ Uploading Android google-services.json"
|
||||
upsert_secure_note \
|
||||
"Donetick Google Services Android" \
|
||||
"$(cat "$REPO_ROOT/android/app/google-services.json")"
|
||||
|
||||
echo "→ Uploading Fastline google-services.json"
|
||||
upsert_secure_note \
|
||||
"Donetick Google Play Service Account" \
|
||||
"$(cat "$REPO_ROOT/donetick-5f910-5688a280a65a--fastline.json")"
|
||||
|
||||
echo "→ Uploading Android keystore (base64)"
|
||||
upsert_secure_note \
|
||||
"Donetick Android Keystore" \
|
||||
"$(base64 < /Users/mohamad-macbook-air/donetick-android-ley)"
|
||||
|
||||
echo "→ Uploading iOS GoogleService-Info.plist"
|
||||
upsert_secure_note \
|
||||
"Donetick Google Services iOS" \
|
||||
"$(cat "$REPO_ROOT/ios/App/App/GoogleService-Info.plist")"
|
||||
|
||||
echo "→ Uploading App Store Connect key (base64)"
|
||||
upsert_secure_note \
|
||||
"Donetick App Store Connect Key" \
|
||||
"$(base64 < /Users/mohamad-macbook-air/Downloads/AuthKey_84F695CDQ3.p8)"
|
||||
|
||||
echo "→ Uploading .env.production"
|
||||
upsert_secure_note \
|
||||
"Donetick Env Production" \
|
||||
"$(cat "$REPO_ROOT/.env.production")"
|
||||
|
||||
echo ""
|
||||
echo "✓ All secrets uploaded. Verify in Vaultwarden, then you can safely delete local copies outside the repo."
|
||||
echo " NOTE: 'Donetick Keystore Password' should already exist — if not, create it manually as a Login item."
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { clearAllTokens, saveTokens } from '../utils/TokenStorage'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
@@ -67,6 +68,12 @@ export const AuthProvider = ({ children }) => {
|
||||
if (userToken) {
|
||||
setToken(userToken)
|
||||
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
} catch (e) {
|
||||
console.error('Error clearing offline data on login', e)
|
||||
}
|
||||
|
||||
// Use centralized token storage
|
||||
await saveTokens({
|
||||
accessToken: userToken,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Preferences } from '@capacitor/preferences'
|
||||
import { API_URL } from '../Config'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { logout, RefreshToken } from './Fetcher'
|
||||
import { offlineDB } from './OfflineDB'
|
||||
import {
|
||||
clearAllTokens,
|
||||
isRefreshTokenExpired,
|
||||
@@ -49,10 +50,7 @@ class ApiClient {
|
||||
const refreshExpired = await isRefreshTokenExpired()
|
||||
if (refreshExpired) {
|
||||
console.log('Refresh token expired, forcing logout')
|
||||
await clearAllTokens()
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
await this.handleLogout()
|
||||
return { success: false, error: 'Refresh token expired' }
|
||||
}
|
||||
|
||||
@@ -135,6 +133,11 @@ class ApiClient {
|
||||
// Helper to avoid repeating cleanup code
|
||||
async handleLogout() {
|
||||
await clearAllTokens()
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
} catch (e) {
|
||||
console.error('Error clearing offline data on logout', e)
|
||||
}
|
||||
try {
|
||||
await logout()
|
||||
} catch (e) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { API_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 8000
|
||||
|
||||
@@ -166,6 +167,11 @@ const LoginSettings = () => {
|
||||
}
|
||||
|
||||
await Preferences.set({ key: 'customServerUrl', value: trimmedURL })
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
} catch (e) {
|
||||
console.error('Error clearing offline data on server change', e)
|
||||
}
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
setStatus('success')
|
||||
|
||||
@@ -355,6 +355,31 @@ const ThingsView = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSetThingState = thing => {
|
||||
UpdateThingState(thing)
|
||||
.then(result => {
|
||||
result.json().then(data => {
|
||||
const currentThings = [...things]
|
||||
const thingIndex = currentThings.findIndex(
|
||||
currentThing => currentThing.id === thing.id,
|
||||
)
|
||||
currentThings[thingIndex] = data.res
|
||||
setThings(currentThings)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Updated',
|
||||
message: 'Thing state updated successfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
showError({
|
||||
title: 'Unable to update thing state',
|
||||
message: 'An error occurred while updating the thing state',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
|
||||
@@ -550,7 +575,7 @@ const ThingsView = () => {
|
||||
setIsShowEditStateModal(false)
|
||||
setCreateModalThing(null)
|
||||
}}
|
||||
onSave={handleStateChangeRequest}
|
||||
onSave={handleSetThingState}
|
||||
currentThing={createModalThing}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,9 @@ import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import AdvancedOptionsSection, {
|
||||
AdvancedOptionsTrigger,
|
||||
} from './AdvancedOptionsSection'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import DueDatePickerField from './DueDatePickerField'
|
||||
@@ -101,6 +104,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [hasDescription, setHasDescription] = useState(false)
|
||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||
const [requireApproval, setRequireApproval] = useState(false)
|
||||
const [completionWindow, setCompletionWindow] = useState(-1)
|
||||
const [assignStrategy, setAssignStrategy] = useState('keep_last_assigned')
|
||||
const [isPrivate, setIsPrivate] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [dueDateOnly, setDueDateOnly] = useState(null)
|
||||
const [dueTime, setDueTime] = useState(null)
|
||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||
@@ -422,6 +430,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
if (dueDateParsed.result) {
|
||||
syncDueDateStates(dueDateParsed.result)
|
||||
dueDateHighlight = dueDateParsed.highlight[0]
|
||||
} else if (repeat.dueDate) {
|
||||
syncDueDateStates(repeat.dueDate)
|
||||
}
|
||||
|
||||
// Create the cleaned sentence by sequentially applying all cleanups
|
||||
@@ -597,6 +607,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setAssignees([])
|
||||
setProjectId(getInitialProject())
|
||||
setDeadlineOffset(-1)
|
||||
setRequireApproval(false)
|
||||
setCompletionWindow(-1)
|
||||
setAssignStrategy('keep_last_assigned')
|
||||
setIsPrivate(false)
|
||||
setShowAdvanced(false)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
@@ -608,26 +623,19 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
// Handle different assignee scenarios
|
||||
let finalAssignees = assignees
|
||||
let finalAssignedTo = null
|
||||
let finalAssignStrategy = 'random'
|
||||
let finalAssignStrategy = assignStrategy
|
||||
|
||||
if (isAnyoneTask) {
|
||||
// @Anyone was explicitly used - anyone can do the task
|
||||
finalAssignees = []
|
||||
finalAssignedTo = null
|
||||
finalAssignStrategy = 'no_assignee'
|
||||
} else if (assignees.length === 0) {
|
||||
// No assignees and no @Anyone - fallback to current user
|
||||
finalAssignees = [{ userId: userProfile?.id }]
|
||||
finalAssignedTo = userProfile?.id
|
||||
finalAssignStrategy = 'keep_last_assigned'
|
||||
} else if (assignees.length === 1) {
|
||||
// Single assignee
|
||||
finalAssignedTo = assignees[0].userId
|
||||
finalAssignStrategy = 'keep_last_assigned'
|
||||
finalAssignStrategy = assignStrategy
|
||||
} else {
|
||||
// Multiple assignees
|
||||
finalAssignedTo = null
|
||||
finalAssignStrategy = 'random'
|
||||
finalAssignedTo = assignees[0].userId
|
||||
finalAssignStrategy = assignStrategy
|
||||
}
|
||||
|
||||
const chore = {
|
||||
@@ -642,6 +650,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
priority: priority ? Number(priority) : 0,
|
||||
points: points > -1 ? points : null,
|
||||
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
|
||||
completionWindow:
|
||||
completionWindow < 0 || !dueDate ? null : completionWindow,
|
||||
requireApproval: requireApproval,
|
||||
isPrivate: isPrivate,
|
||||
status: 0,
|
||||
frequencyType: 'once',
|
||||
frequencyMetadata: {},
|
||||
@@ -713,7 +725,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
footer={
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'end',
|
||||
@@ -834,6 +845,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}}
|
||||
customRenderer={renderedParts}
|
||||
onEnterPressed={handleEnterPressed}
|
||||
onShiftEnterPressed={() => {
|
||||
if (!hasDescription) {
|
||||
setHasDescription(true)
|
||||
}
|
||||
setTimeout(() => richTextEditorRef.current?.focus(), 50)
|
||||
}}
|
||||
suggestions={{
|
||||
'#': {
|
||||
value: 'id',
|
||||
@@ -917,15 +934,21 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
values={assignees.map(a => a.userId)}
|
||||
isAnyone={isAnyoneTask}
|
||||
onChange={userIds => {
|
||||
if (userIds.includes('anyone')) {
|
||||
setIsAnyoneTask(true)
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
setIsAnyoneTask(false)
|
||||
setAssignees(userIds.map(userId => ({ userId })))
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
onClear={() => {
|
||||
setIsAnyoneTask(false)
|
||||
setAssignees([])
|
||||
}}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
@@ -952,41 +975,100 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1.5,
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasDescription(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='E' />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
px: 1.25,
|
||||
gap: 1,
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
Description
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-sm' sx={{ color: 'inherit' }}>
|
||||
Description
|
||||
</Typography>
|
||||
</Button>
|
||||
)}
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => setHasSubTasks(true)}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && (
|
||||
<KeyboardShortcutHint shortcut='J' />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
borderRadius: '128px',
|
||||
minHeight: 40,
|
||||
px: 1.25,
|
||||
gap: 1,
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
Subtasks
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-sm' sx={{ color: 'inherit' }}>
|
||||
Subtasks
|
||||
</Typography>
|
||||
</Button>
|
||||
)}
|
||||
<AdvancedOptionsTrigger
|
||||
open={showAdvanced}
|
||||
onToggle={() => setShowAdvanced(v => !v)}
|
||||
activeCount={
|
||||
[
|
||||
points > -1,
|
||||
requireApproval,
|
||||
completionWindow > -1,
|
||||
deadlineOffset > -1,
|
||||
].filter(Boolean).length
|
||||
}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<AdvancedOptionsSection
|
||||
open={showAdvanced}
|
||||
points={points}
|
||||
onPointsChange={setPoints}
|
||||
requireApproval={requireApproval}
|
||||
onRequireApprovalChange={setRequireApproval}
|
||||
completionWindow={completionWindow}
|
||||
onCompletionWindowChange={setCompletionWindow}
|
||||
deadlineOffset={deadlineOffset}
|
||||
onDeadlineOffsetChange={setDeadlineOffset}
|
||||
assignStrategy={assignStrategy}
|
||||
onAssignStrategyChange={setAssignStrategy}
|
||||
hasDueDate={!!dueDate}
|
||||
hasMultipleAssignees={assignees.length > 1}
|
||||
hasAssignees={assignees.length > 0}
|
||||
isPrivate={isPrivate}
|
||||
onIsPrivateChange={setIsPrivate}
|
||||
/>
|
||||
|
||||
{hasDescription && (
|
||||
<Box>
|
||||
<Typography level='body-sm'>Description:</Typography>
|
||||
|
||||
354
src/views/components/AdvancedOptionsSection.jsx
Normal file
354
src/views/components/AdvancedOptionsSection.jsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
Add,
|
||||
Approval,
|
||||
HourglassTop,
|
||||
Lock,
|
||||
MoreHoriz,
|
||||
People,
|
||||
Remove,
|
||||
Timer,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
|
||||
const STRATEGY_OPTIONS = [
|
||||
{ value: 'keep_last_assigned', label: 'Keep same assignee' },
|
||||
{ value: 'random', label: 'Random' },
|
||||
{ value: 'least_completed', label: 'Least completed' },
|
||||
{ value: 'round_robin', label: 'Round robin' },
|
||||
]
|
||||
|
||||
const FieldRow = ({ label, description, children, onLabelClick }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
py: 0.75,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
flex: '1 1 auto',
|
||||
cursor: onLabelClick ? 'pointer' : undefined,
|
||||
userSelect: onLabelClick ? 'none' : undefined,
|
||||
}}
|
||||
onClick={onLabelClick}
|
||||
>
|
||||
<Typography level='body-sm' fontWeight='md'>
|
||||
{label}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography level='body-xs' textColor='text.tertiary' sx={{ mt: 0.25 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
// Trigger button — place this inside the chip/action row
|
||||
export const AdvancedOptionsTrigger = ({
|
||||
open,
|
||||
onToggle,
|
||||
activeCount = 0,
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const showLabel = emptyDisplay === 'icon-text' || open || activeCount > 0
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={open || activeCount > 0 ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={onToggle}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
px: showLabel ? 1.25 : 0.75,
|
||||
gap: showLabel ? 1 : 0,
|
||||
transition: 'all 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<MoreHoriz sx={{ fontSize: 20 }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: showLabel ? 120 : 0,
|
||||
opacity: showLabel ? 1 : 0,
|
||||
transform: showLabel ? 'translateX(0)' : 'translateX(-4px)',
|
||||
transition:
|
||||
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
|
||||
}}
|
||||
>
|
||||
More
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{activeCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -8,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.solidBg',
|
||||
color: 'primary.solidColor',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{activeCount}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Panel — place this as a sibling below the description/subtask sections
|
||||
const AdvancedOptionsSection = ({
|
||||
open,
|
||||
points,
|
||||
onPointsChange,
|
||||
requireApproval,
|
||||
onRequireApprovalChange,
|
||||
completionWindow,
|
||||
onCompletionWindowChange,
|
||||
deadlineOffset,
|
||||
onDeadlineOffsetChange,
|
||||
assignStrategy,
|
||||
onAssignStrategyChange,
|
||||
isPrivate,
|
||||
onIsPrivateChange,
|
||||
hasDueDate,
|
||||
hasMultipleAssignees,
|
||||
hasAssignees,
|
||||
}) => {
|
||||
const displayPoints = points <= 0 ? 0 : points
|
||||
|
||||
const handleDecrement = () => {
|
||||
const next = Math.max(0, displayPoints - 1)
|
||||
onPointsChange(next === 0 ? -1 : next)
|
||||
}
|
||||
|
||||
const handleIncrement = () => {
|
||||
onPointsChange(displayPoints + 1)
|
||||
}
|
||||
|
||||
const handlePointsInput = e => {
|
||||
const v = parseInt(e.target.value)
|
||||
if (isNaN(v) || v <= 0) {
|
||||
onPointsChange(-1)
|
||||
} else {
|
||||
onPointsChange(Math.min(v, 9999))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: open ? '1fr' : '0fr',
|
||||
opacity: open ? 1 : 0,
|
||||
transition:
|
||||
'grid-template-rows 0.25s ease-in-out, opacity 0.2s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
bgcolor: 'background.level1',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Points */}
|
||||
<FieldRow
|
||||
label='Points'
|
||||
description='Award points for completing this task'
|
||||
>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleDecrement}
|
||||
disabled={displayPoints === 0}
|
||||
>
|
||||
<Remove sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={displayPoints === 0 ? '' : displayPoints}
|
||||
placeholder='0'
|
||||
onChange={handlePointsInput}
|
||||
sx={{ width: 64 }}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0,
|
||||
max: 9999,
|
||||
style: { textAlign: 'center' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<Add sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</FieldRow>
|
||||
|
||||
{/* Require approval */}
|
||||
<FieldRow
|
||||
label='Require approval'
|
||||
description='Task needs admin sign-off before it can be closed'
|
||||
onLabelClick={() => onRequireApprovalChange(!requireApproval)}
|
||||
>
|
||||
<Switch
|
||||
size='sm'
|
||||
checked={requireApproval}
|
||||
onChange={e => onRequireApprovalChange(e.target.checked)}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{/* Privacy */}
|
||||
<FieldRow
|
||||
label='Limited visibility'
|
||||
description={
|
||||
!hasAssignees
|
||||
? 'Assign someone to enable limited visibility'
|
||||
: 'Only you and assignees can see this task'
|
||||
}
|
||||
onLabelClick={
|
||||
hasAssignees ? () => onIsPrivateChange(!isPrivate) : undefined
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
size='sm'
|
||||
checked={isPrivate}
|
||||
disabled={!hasAssignees}
|
||||
onChange={e => onIsPrivateChange(e.target.checked)}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{/* Assignment strategy — only shown when there are multiple assignees */}
|
||||
{hasMultipleAssignees && (
|
||||
<FieldRow
|
||||
label='Assign strategy'
|
||||
description='How to pick the next assignee each recurrence'
|
||||
>
|
||||
<Select
|
||||
size='sm'
|
||||
value={assignStrategy}
|
||||
onChange={(_, v) => onAssignStrategyChange(v)}
|
||||
sx={{ minWidth: 190 }}
|
||||
>
|
||||
{STRATEGY_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
{/* Completion window and deadline — only when due date set */}
|
||||
{hasDueDate ? (
|
||||
<>
|
||||
<FieldRow
|
||||
label='Available from'
|
||||
description='Hours before the due date the task becomes available'
|
||||
>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
placeholder='—'
|
||||
value={completionWindow > -1 ? completionWindow : ''}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value)
|
||||
onCompletionWindowChange(isNaN(v) ? -1 : Math.max(0, v))
|
||||
}}
|
||||
endDecorator={
|
||||
<Typography level='body-xs' textColor='text.tertiary'>
|
||||
hrs
|
||||
</Typography>
|
||||
}
|
||||
sx={{ width: 96 }}
|
||||
slotProps={{ input: { min: 0 } }}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow
|
||||
label='Expires after'
|
||||
description='Hours after the due date when the task can no longer be completed'
|
||||
>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
placeholder='—'
|
||||
value={deadlineOffset > -1 ? deadlineOffset : ''}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value)
|
||||
onDeadlineOffsetChange(isNaN(v) ? -1 : Math.max(0, v))
|
||||
}}
|
||||
endDecorator={
|
||||
<Typography level='body-xs' textColor='text.tertiary'>
|
||||
hrs
|
||||
</Typography>
|
||||
}
|
||||
sx={{ width: 96 }}
|
||||
slotProps={{ input: { min: 0 } }}
|
||||
/>
|
||||
</FieldRow>
|
||||
</>
|
||||
) : (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
textColor='text.tertiary'
|
||||
sx={{ my: 0.5, fontStyle: 'italic' }}
|
||||
>
|
||||
Set a due date to configure completion window and deadline.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdvancedOptionsSection
|
||||
@@ -23,6 +23,7 @@ const BaseOptionPicker = ({
|
||||
getItemColor,
|
||||
getTriggerText,
|
||||
onClear,
|
||||
menuFooter,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
@@ -242,6 +243,9 @@ const BaseOptionPicker = ({
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
{menuFooter && (
|
||||
<Box sx={{ mt: items.length > 0 ? 0.5 : 0 }}>{menuFooter}</Box>
|
||||
)}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
|
||||
@@ -412,14 +412,37 @@ export const parseRepeatV2 = inputSentence => {
|
||||
}
|
||||
|
||||
case 'day_of_the_month:every':
|
||||
result.frequency = parseInt(match[1], 10)
|
||||
result.frequencyMetadata.months = ALL_MONTHS
|
||||
result.frequencyMetadata.unit = 'days'
|
||||
const dayOfMonth = parseInt(match[1], 10)
|
||||
result.frequencyType = 'interval'
|
||||
result.frequency = 1
|
||||
result.frequencyMetadata.unit = 'months'
|
||||
|
||||
// Calculate the next occurrence of this day of the month
|
||||
const todayEvery = new Date()
|
||||
let suggestedDueDate = new Date(
|
||||
todayEvery.getFullYear(),
|
||||
todayEvery.getMonth(),
|
||||
dayOfMonth,
|
||||
23,
|
||||
59,
|
||||
0,
|
||||
)
|
||||
if (suggestedDueDate <= todayEvery) {
|
||||
// Day has already passed this month, move to next month
|
||||
suggestedDueDate = new Date(
|
||||
todayEvery.getFullYear(),
|
||||
todayEvery.getMonth() + 1,
|
||||
dayOfMonth,
|
||||
23,
|
||||
59,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
result,
|
||||
name: pattern.name
|
||||
.replace('{day}', result.frequency)
|
||||
.replace('{months}', result.frequencyMetadata.months.join(', ')),
|
||||
name: pattern.name.replace('{day}', dayOfMonth),
|
||||
dueDate: suggestedDueDate.toISOString(),
|
||||
highlight: [
|
||||
{
|
||||
text: pattern.name,
|
||||
|
||||
@@ -231,6 +231,7 @@ const DueDatePickerField = ({
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Due Date'
|
||||
fullWidth={false}
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasDueDate && (
|
||||
@@ -266,7 +267,7 @@ const DueDatePickerField = ({
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)' }}>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
|
||||
{/* Date shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Label } from '@mui/icons-material'
|
||||
import { Add, Label } from '@mui/icons-material'
|
||||
import { Button } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import LabelModal from '../Modals/Inputs/LabelModal'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const LabelsPickerField = ({
|
||||
@@ -8,6 +11,8 @@ const LabelsPickerField = ({
|
||||
labels = [],
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const options = labels.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
@@ -15,33 +20,55 @@ const LabelsPickerField = ({
|
||||
}))
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
multiple
|
||||
values={values}
|
||||
onValuesChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Labels'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<Label
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
if (isEmpty) return 'Labels'
|
||||
if (selectedItems.length === 1) return selectedItems[0].name
|
||||
return `${selectedItems.length} labels`
|
||||
}}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
<>
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
multiple
|
||||
values={values}
|
||||
onValuesChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Labels'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<Label
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
if (isEmpty) return 'Labels'
|
||||
if (selectedItems.length === 1) return selectedItems[0].name
|
||||
return `${selectedItems.length} labels`
|
||||
}}
|
||||
menuMinWidth={220}
|
||||
menuFooter={
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={<Add sx={{ fontSize: '16px' }} />}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
sx={{ width: '100%', justifyContent: 'flex-start' }}
|
||||
>
|
||||
Create label
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<LabelModal
|
||||
isOpen={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
label={null}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ const SmartTaskTitleInput = ({
|
||||
onChange,
|
||||
suggestions,
|
||||
onEnterPressed,
|
||||
onShiftEnterPressed,
|
||||
customRenderer,
|
||||
isNativeScanner,
|
||||
onScanClick,
|
||||
@@ -158,7 +159,11 @@ const SmartTaskTitleInput = ({
|
||||
} else {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (onEnterPressed) {
|
||||
if (e.shiftKey) {
|
||||
if (onShiftEnterPressed) {
|
||||
onShiftEnterPressed(value)
|
||||
}
|
||||
} else if (onEnterPressed) {
|
||||
onEnterPressed(value)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user