2451 lines
129 KiB
TypeScript
2451 lines
129 KiB
TypeScript
import React, {
|
|
useState,
|
|
useEffect,
|
|
ChangeEvent,
|
|
FormEvent,
|
|
useCallback,
|
|
} from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { getLocalesPath, getApiPath } from '../../config/paths';
|
|
import {
|
|
InformationCircleIcon,
|
|
EyeIcon,
|
|
EyeSlashIcon,
|
|
UserIcon,
|
|
ClockIcon,
|
|
ShieldCheckIcon,
|
|
LightBulbIcon,
|
|
CogIcon,
|
|
ClipboardDocumentListIcon,
|
|
BoltIcon,
|
|
ChevronRightIcon,
|
|
ExclamationTriangleIcon,
|
|
FaceSmileIcon,
|
|
CheckIcon,
|
|
SunIcon,
|
|
MoonIcon,
|
|
KeyIcon,
|
|
TrashIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import TelegramIcon from '../Icons/TelegramIcon';
|
|
import { useToast } from '../Shared/ToastContext';
|
|
import { dispatchTelegramStatusChange } from '../../contexts/TelegramStatusContext';
|
|
import LanguageDropdown from '../Shared/LanguageDropdown';
|
|
import FirstDayOfWeekDropdown from '../Shared/FirstDayOfWeekDropdown';
|
|
import ConfirmDialog from '../Shared/ConfirmDialog';
|
|
import { getLocaleFirstDayOfWeek } from '../../utils/profileService';
|
|
import {
|
|
getTimezonesByRegion,
|
|
getRegionDisplayName,
|
|
} from '../../utils/timezoneUtils';
|
|
import TimezoneDropdown from '../Shared/TimezoneDropdown';
|
|
import type { ApiKeySummary } from '../../utils/apiKeysService';
|
|
import {
|
|
fetchApiKeys,
|
|
createApiKey,
|
|
revokeApiKey,
|
|
deleteApiKey,
|
|
} from '../../utils/apiKeysService';
|
|
|
|
interface ProfileSettingsProps {
|
|
currentUser: { uid: string; email: string };
|
|
isDarkMode?: boolean;
|
|
toggleDarkMode?: () => void;
|
|
}
|
|
|
|
interface Profile {
|
|
uid: string;
|
|
email: string;
|
|
name?: string;
|
|
surname?: string;
|
|
appearance: 'light' | 'dark';
|
|
language: string;
|
|
timezone: string;
|
|
first_day_of_week: number;
|
|
avatar_image: string | null;
|
|
telegram_bot_token: string | null;
|
|
telegram_chat_id: string | null;
|
|
telegram_allowed_users: string | null;
|
|
task_summary_enabled: boolean;
|
|
task_summary_frequency: string;
|
|
task_intelligence_enabled: boolean;
|
|
auto_suggest_next_actions_enabled: boolean;
|
|
productivity_assistant_enabled: boolean;
|
|
next_task_suggestion_enabled: boolean;
|
|
pomodoro_enabled: boolean;
|
|
}
|
|
|
|
interface TelegramBotInfo {
|
|
username: string;
|
|
first_name?: string;
|
|
polling_status: any;
|
|
chat_url: string;
|
|
}
|
|
|
|
const formatFrequency = (frequency: string): string => {
|
|
if (frequency.endsWith('h')) {
|
|
const value = frequency.replace('h', '');
|
|
return `${value} ${parseInt(value) === 1 ? 'hour' : 'hours'}`;
|
|
} else if (frequency === 'daily') {
|
|
return '1 day';
|
|
} else if (frequency === 'weekly') {
|
|
return '1 week';
|
|
} else if (frequency === 'weekdays') {
|
|
return 'Weekdays';
|
|
}
|
|
return frequency;
|
|
};
|
|
|
|
const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|
isDarkMode,
|
|
toggleDarkMode,
|
|
}) => {
|
|
const { t, i18n } = useTranslation();
|
|
const { showSuccessToast, showErrorToast } = useToast();
|
|
|
|
const [activeTab, setActiveTab] = useState('general');
|
|
// Generate timezone list using date-fns-tz and Intl API
|
|
const timezonesByRegion = React.useMemo(() => {
|
|
return getTimezonesByRegion();
|
|
}, []);
|
|
|
|
// Password visibility state
|
|
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
|
|
const [profile, setProfile] = useState<Profile | null>(null);
|
|
const [formData, setFormData] = useState<
|
|
Partial<
|
|
Profile & {
|
|
currentPassword: string;
|
|
newPassword: string;
|
|
confirmPassword: string;
|
|
}
|
|
>
|
|
>({
|
|
name: '',
|
|
surname: '',
|
|
appearance: isDarkMode ? 'dark' : 'light',
|
|
language: 'en',
|
|
timezone: 'UTC',
|
|
first_day_of_week: 1, // Monday by default
|
|
avatar_image: '',
|
|
telegram_bot_token: '',
|
|
telegram_allowed_users: '',
|
|
task_intelligence_enabled: true,
|
|
task_summary_enabled: false,
|
|
task_summary_frequency: 'daily',
|
|
auto_suggest_next_actions_enabled: true,
|
|
productivity_assistant_enabled: true,
|
|
next_task_suggestion_enabled: true,
|
|
pomodoro_enabled: true,
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [updateKey, setUpdateKey] = useState(0);
|
|
const [isChangingLanguage, setIsChangingLanguage] = useState(false);
|
|
const [isPolling, setIsPolling] = useState(false);
|
|
const [telegramSetupStatus, setTelegramSetupStatus] = useState<
|
|
'idle' | 'loading' | 'success' | 'error'
|
|
>('idle');
|
|
const [telegramBotInfo, setTelegramBotInfo] =
|
|
useState<TelegramBotInfo | null>(null);
|
|
const [apiKeys, setApiKeys] = useState<ApiKeySummary[]>([]);
|
|
const [apiKeysLoading, setApiKeysLoading] = useState(false);
|
|
const [apiKeysLoaded, setApiKeysLoaded] = useState(false);
|
|
const [newApiKeyName, setNewApiKeyName] = useState('');
|
|
const [newApiKeyExpiration, setNewApiKeyExpiration] = useState('');
|
|
const [isCreatingApiKey, setIsCreatingApiKey] = useState(false);
|
|
const [generatedApiToken, setGeneratedApiToken] = useState<string | null>(
|
|
null
|
|
);
|
|
const [revokeInFlightId, setRevokeInFlightId] = useState<number | null>(
|
|
null
|
|
);
|
|
const [deleteInFlightId, setDeleteInFlightId] = useState<number | null>(
|
|
null
|
|
);
|
|
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeySummary | null>(
|
|
null
|
|
);
|
|
|
|
const forceUpdate = useCallback(() => {
|
|
setUpdateKey((prevKey) => prevKey + 1);
|
|
}, []);
|
|
|
|
const loadApiKeys = useCallback(async () => {
|
|
setApiKeysLoading(true);
|
|
try {
|
|
const keys = await fetchApiKeys();
|
|
setApiKeys(keys);
|
|
} catch (error) {
|
|
showErrorToast((error as Error).message);
|
|
} finally {
|
|
setApiKeysLoading(false);
|
|
setApiKeysLoaded(true);
|
|
}
|
|
}, [showErrorToast]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'apiKeys' && !apiKeysLoaded) {
|
|
loadApiKeys();
|
|
}
|
|
}, [activeTab, apiKeysLoaded, loadApiKeys]);
|
|
|
|
// Password validation
|
|
const validatePasswordForm = (): {
|
|
valid: boolean;
|
|
errors: { [key: string]: string };
|
|
} => {
|
|
const errors: { [key: string]: string } = {};
|
|
|
|
// Only validate if user is trying to change password
|
|
if (
|
|
formData.currentPassword ||
|
|
formData.newPassword ||
|
|
formData.confirmPassword
|
|
) {
|
|
if (!formData.currentPassword) {
|
|
errors.currentPassword = t(
|
|
'profile.currentPasswordRequired',
|
|
'Current password is required'
|
|
);
|
|
}
|
|
|
|
if (!formData.newPassword) {
|
|
errors.newPassword = t(
|
|
'profile.newPasswordRequired',
|
|
'New password is required'
|
|
);
|
|
} else if (formData.newPassword.length < 6) {
|
|
errors.newPassword = t(
|
|
'profile.passwordTooShort',
|
|
'Password must be at least 6 characters'
|
|
);
|
|
}
|
|
|
|
if (formData.newPassword !== formData.confirmPassword) {
|
|
errors.confirmPassword = t(
|
|
'profile.passwordMismatch',
|
|
'Passwords do not match'
|
|
);
|
|
}
|
|
}
|
|
|
|
return { valid: Object.keys(errors).length === 0, errors };
|
|
};
|
|
|
|
const handleChange = (
|
|
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
|
) => {
|
|
const { name, value } = e.target;
|
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
};
|
|
|
|
const handleLanguageChange = async (value: string) => {
|
|
try {
|
|
setIsChangingLanguage(true);
|
|
|
|
await i18n.changeLanguage(value);
|
|
|
|
document.documentElement.lang = value;
|
|
|
|
const resources = i18n.getResourceBundle(value, 'translation');
|
|
|
|
if (!resources || Object.keys(resources).length === 0) {
|
|
const loadPath = getLocalesPath(`${value}/translation.json`);
|
|
try {
|
|
const response = await fetch(loadPath);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
i18n.addResourceBundle(
|
|
value,
|
|
'translation',
|
|
data,
|
|
true,
|
|
true
|
|
);
|
|
|
|
if (window.forceLanguageReload) {
|
|
window.forceLanguageReload(value);
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore errors loading language resources
|
|
}
|
|
}
|
|
|
|
setTimeout(() => {
|
|
forceUpdate();
|
|
|
|
const checkAndLoadResources = i18n.getResourceBundle(
|
|
value,
|
|
'translation'
|
|
);
|
|
if (
|
|
!checkAndLoadResources ||
|
|
Object.keys(checkAndLoadResources).length === 0
|
|
) {
|
|
if (window.forceLanguageReload) {
|
|
window.forceLanguageReload(value);
|
|
}
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (isChangingLanguage) {
|
|
setIsChangingLanguage(false);
|
|
}
|
|
}, 800);
|
|
}, 200);
|
|
} catch {
|
|
setIsChangingLanguage(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateApiKey = async () => {
|
|
if (!newApiKeyName.trim()) {
|
|
showErrorToast(
|
|
t('profile.apiKeys.nameRequired', 'API key name is required.')
|
|
);
|
|
return;
|
|
}
|
|
|
|
setIsCreatingApiKey(true);
|
|
try {
|
|
const payload: { name: string; expires_at?: string } = {
|
|
name: newApiKeyName.trim(),
|
|
};
|
|
|
|
if (newApiKeyExpiration) {
|
|
const parsed = new Date(`${newApiKeyExpiration}T23:59:59.999Z`);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
throw new Error(
|
|
t(
|
|
'profile.apiKeys.invalidExpiration',
|
|
'Expiration date is invalid.'
|
|
)
|
|
);
|
|
}
|
|
payload.expires_at = parsed.toISOString();
|
|
}
|
|
|
|
const response = await createApiKey(payload);
|
|
setGeneratedApiToken(response.token);
|
|
setApiKeys((prev) => [response.apiKey, ...prev]);
|
|
setNewApiKeyName('');
|
|
setNewApiKeyExpiration('');
|
|
showSuccessToast(
|
|
t('profile.apiKeys.created', 'API key created successfully.')
|
|
);
|
|
} catch (error) {
|
|
showErrorToast((error as Error).message);
|
|
} finally {
|
|
setIsCreatingApiKey(false);
|
|
}
|
|
};
|
|
|
|
const handleRevokeApiKey = async (apiKeyId: number) => {
|
|
setRevokeInFlightId(apiKeyId);
|
|
try {
|
|
const updatedKey = await revokeApiKey(apiKeyId);
|
|
setApiKeys((prev) =>
|
|
prev.map((key) => (key.id === apiKeyId ? updatedKey : key))
|
|
);
|
|
showSuccessToast(
|
|
t('profile.apiKeys.revokedMessage', 'API key revoked.')
|
|
);
|
|
} catch (error) {
|
|
showErrorToast((error as Error).message);
|
|
} finally {
|
|
setRevokeInFlightId(null);
|
|
}
|
|
};
|
|
|
|
const confirmDeleteApiKey = async () => {
|
|
if (!apiKeyToDelete) return;
|
|
const apiKeyId = apiKeyToDelete.id;
|
|
setDeleteInFlightId(apiKeyId);
|
|
try {
|
|
await deleteApiKey(apiKeyId);
|
|
setApiKeys((prev) => prev.filter((key) => key.id !== apiKeyId));
|
|
showSuccessToast(t('profile.apiKeys.deleted', 'API key deleted.'));
|
|
setApiKeyToDelete(null);
|
|
} catch (error) {
|
|
showErrorToast((error as Error).message);
|
|
} finally {
|
|
setDeleteInFlightId(null);
|
|
}
|
|
};
|
|
|
|
const handleCopyGeneratedToken = async () => {
|
|
if (!generatedApiToken) return;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(generatedApiToken);
|
|
showSuccessToast(
|
|
t('profile.apiKeys.copied', 'API key copied to clipboard.')
|
|
);
|
|
} catch {
|
|
showErrorToast(
|
|
t(
|
|
'profile.apiKeys.copyFailed',
|
|
'Unable to copy API key to clipboard.'
|
|
)
|
|
);
|
|
}
|
|
};
|
|
|
|
const closeDeleteDialog = () => {
|
|
if (deleteInFlightId) return;
|
|
setApiKeyToDelete(null);
|
|
};
|
|
|
|
const getApiKeyStatus = (apiKey: ApiKeySummary) => {
|
|
if (apiKey.revoked_at) {
|
|
return {
|
|
label: t('profile.apiKeys.status.revoked', 'Revoked'),
|
|
className: 'text-red-600 dark:text-red-400',
|
|
};
|
|
}
|
|
|
|
if (apiKey.expires_at && new Date(apiKey.expires_at) < new Date()) {
|
|
return {
|
|
label: t('profile.apiKeys.status.expired', 'Expired'),
|
|
className: 'text-yellow-600 dark:text-yellow-400',
|
|
};
|
|
}
|
|
|
|
return {
|
|
label: t('profile.apiKeys.status.active', 'Active'),
|
|
className: 'text-green-600 dark:text-green-400',
|
|
};
|
|
};
|
|
|
|
const formatDateTime = (value: string | null) => {
|
|
if (!value) {
|
|
return t('profile.apiKeys.never', 'Never');
|
|
}
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return value;
|
|
}
|
|
return parsed.toLocaleString();
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchProfile = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch(getApiPath('profile'));
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
t('profile.fetchError', 'Failed to fetch profile data.')
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
setProfile(data);
|
|
setFormData({
|
|
name: data.name || '',
|
|
surname: data.surname || '',
|
|
appearance:
|
|
data.appearance || (isDarkMode ? 'dark' : 'light'),
|
|
language: data.language || 'en',
|
|
timezone: data.timezone || 'UTC',
|
|
first_day_of_week:
|
|
data.first_day_of_week !== undefined
|
|
? data.first_day_of_week
|
|
: 1,
|
|
avatar_image: data.avatar_image || '',
|
|
telegram_bot_token: data.telegram_bot_token || '',
|
|
telegram_allowed_users: data.telegram_allowed_users || '',
|
|
task_intelligence_enabled:
|
|
data.task_intelligence_enabled !== undefined
|
|
? data.task_intelligence_enabled
|
|
: true,
|
|
task_summary_enabled:
|
|
data.task_summary_enabled !== undefined
|
|
? data.task_summary_enabled
|
|
: false,
|
|
task_summary_frequency:
|
|
data.task_summary_frequency || 'daily',
|
|
auto_suggest_next_actions_enabled:
|
|
data.auto_suggest_next_actions_enabled !== undefined
|
|
? data.auto_suggest_next_actions_enabled
|
|
: true,
|
|
productivity_assistant_enabled:
|
|
data.productivity_assistant_enabled !== undefined
|
|
? data.productivity_assistant_enabled
|
|
: true,
|
|
next_task_suggestion_enabled:
|
|
data.next_task_suggestion_enabled !== undefined
|
|
? data.next_task_suggestion_enabled
|
|
: true,
|
|
pomodoro_enabled:
|
|
data.pomodoro_enabled !== undefined
|
|
? data.pomodoro_enabled
|
|
: true,
|
|
});
|
|
|
|
// Note: Task summary status checking functionality removed for now
|
|
|
|
if (data.telegram_bot_token) {
|
|
fetchPollingStatus();
|
|
}
|
|
} catch (error) {
|
|
showErrorToast((error as Error).message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchPollingStatus = async () => {
|
|
try {
|
|
const response = await fetch(
|
|
getApiPath('telegram/polling-status')
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
t(
|
|
'profile.pollingStatusError',
|
|
'Failed to fetch polling status.'
|
|
)
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
setIsPolling(data.status?.running || false);
|
|
|
|
// Auto-start polling if user has a bot token but polling is not running
|
|
if (data.telegram_bot_token && !data.status?.running) {
|
|
handleStartPolling();
|
|
}
|
|
} catch {
|
|
// Ignore errors fetching polling status
|
|
}
|
|
};
|
|
fetchProfile();
|
|
}, []);
|
|
|
|
// Fetch Telegram bot info when profile loads
|
|
useEffect(() => {
|
|
const fetchTelegramInfo = async () => {
|
|
if (profile?.telegram_bot_token) {
|
|
try {
|
|
// Fetch bot info
|
|
const setupResponse = await fetch(
|
|
getApiPath('telegram/setup'),
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
token: profile.telegram_bot_token,
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (setupResponse.ok) {
|
|
const setupData = await setupResponse.json();
|
|
if (setupData.bot) {
|
|
setTelegramBotInfo({
|
|
username: setupData.bot.username,
|
|
first_name: setupData.bot.first_name,
|
|
chat_url: `https://t.me/${setupData.bot.username}`,
|
|
polling_status: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Also fetch and auto-start polling status
|
|
const pollingResponse = await fetch(
|
|
getApiPath('telegram/polling-status'),
|
|
{
|
|
credentials: 'include',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (pollingResponse.ok) {
|
|
const pollingData = await pollingResponse.json();
|
|
setIsPolling(pollingData.status?.running || false);
|
|
|
|
// Auto-start polling if not running
|
|
if (!pollingData.status?.running) {
|
|
setTimeout(() => {
|
|
handleStartPolling();
|
|
}, 1000);
|
|
} else {
|
|
// Dispatch healthy status if already running
|
|
dispatchTelegramStatusChange('healthy');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching Telegram info:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchTelegramInfo();
|
|
}, [profile?.telegram_bot_token]);
|
|
|
|
useEffect(() => {}, [updateKey, i18n.language]);
|
|
|
|
useEffect(() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
appearance: isDarkMode ? 'dark' : 'light',
|
|
}));
|
|
}, [isDarkMode]);
|
|
|
|
useEffect(() => {
|
|
const handleLanguageChanged = () => {
|
|
forceUpdate();
|
|
};
|
|
|
|
const handleAppLanguageChanged = () => {
|
|
forceUpdate();
|
|
setTimeout(() => {
|
|
setIsChangingLanguage(false);
|
|
}, 300);
|
|
};
|
|
|
|
i18n.on('languageChanged', handleLanguageChanged);
|
|
window.addEventListener(
|
|
'app-language-changed',
|
|
handleAppLanguageChanged as EventListener
|
|
);
|
|
|
|
return () => {
|
|
i18n.off('languageChanged', handleLanguageChanged);
|
|
window.removeEventListener(
|
|
'app-language-changed',
|
|
handleAppLanguageChanged as EventListener
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
const handleSetupTelegram = async () => {
|
|
setTelegramSetupStatus('loading');
|
|
setTelegramBotInfo(null);
|
|
|
|
try {
|
|
if (
|
|
!formData.telegram_bot_token ||
|
|
!formData.telegram_bot_token.includes(':')
|
|
) {
|
|
throw new Error(t('profile.invalidTelegramToken'));
|
|
}
|
|
|
|
const response = await fetch(getApiPath('telegram/setup'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ token: formData.telegram_bot_token }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || t('profile.telegramSetupFailed'));
|
|
}
|
|
|
|
const data = await response.json();
|
|
setTelegramSetupStatus('success');
|
|
|
|
// Extract bot info properly
|
|
const bot = data.bot;
|
|
let botDisplayName = 'Bot';
|
|
|
|
if (bot) {
|
|
if (bot.first_name && bot.username) {
|
|
botDisplayName = `${bot.first_name} (@${bot.username})`;
|
|
} else if (bot.first_name) {
|
|
botDisplayName = bot.first_name;
|
|
} else if (bot.username) {
|
|
botDisplayName = `@${bot.username}`;
|
|
}
|
|
}
|
|
|
|
showSuccessToast(
|
|
t(
|
|
'profile.telegramSetupSuccess',
|
|
'Telegram bot "{{botName}}" configured successfully!',
|
|
{ botName: botDisplayName }
|
|
)
|
|
);
|
|
|
|
if (data.bot) {
|
|
setTelegramBotInfo({
|
|
username: data.bot.username,
|
|
first_name: data.bot.first_name,
|
|
chat_url: `https://t.me/${data.bot.username}`,
|
|
polling_status: null,
|
|
});
|
|
setIsPolling(true);
|
|
|
|
// Send welcome message on first setup
|
|
if (profile?.telegram_chat_id) {
|
|
try {
|
|
await fetch(getApiPath('telegram/send-welcome'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
chatId: profile.telegram_chat_id,
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
console.error('Error sending welcome message:', error);
|
|
}
|
|
}
|
|
|
|
if (!data.bot.polling_status?.running) {
|
|
setTimeout(() => {
|
|
handleStartPolling();
|
|
}, 1000);
|
|
}
|
|
|
|
// Dispatch status change event
|
|
dispatchTelegramStatusChange('healthy');
|
|
}
|
|
} catch (error) {
|
|
setTelegramSetupStatus('error');
|
|
showErrorToast((error as Error).message);
|
|
}
|
|
};
|
|
|
|
const handleStartPolling = async () => {
|
|
try {
|
|
const response = await fetch(getApiPath('telegram/start-polling'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || t('profile.startPollingFailed'));
|
|
}
|
|
|
|
const data = await response.json();
|
|
setIsPolling(true);
|
|
showSuccessToast(t('profile.pollingStarted'));
|
|
|
|
// Dispatch status change event
|
|
dispatchTelegramStatusChange('healthy');
|
|
|
|
if (telegramBotInfo) {
|
|
setTelegramBotInfo({
|
|
...telegramBotInfo,
|
|
polling_status: data.status,
|
|
});
|
|
}
|
|
} catch {
|
|
showErrorToast(t('profile.pollingError'));
|
|
dispatchTelegramStatusChange('problem');
|
|
}
|
|
};
|
|
|
|
const handleStopPolling = async () => {
|
|
try {
|
|
const response = await fetch(getApiPath('telegram/stop-polling'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || t('profile.stopPollingFailed'));
|
|
}
|
|
|
|
const data = await response.json();
|
|
setIsPolling(false);
|
|
showSuccessToast(
|
|
t('profile.pollingStopped', 'Polling stopped successfully.')
|
|
);
|
|
|
|
// Dispatch status change event
|
|
dispatchTelegramStatusChange('problem');
|
|
|
|
if (telegramBotInfo) {
|
|
setTelegramBotInfo({
|
|
...telegramBotInfo,
|
|
polling_status: data.status,
|
|
});
|
|
}
|
|
} catch {
|
|
showErrorToast(t('profile.pollingError'));
|
|
dispatchTelegramStatusChange('problem');
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
// Check if user is trying to change password
|
|
const isPasswordChange =
|
|
formData.currentPassword ||
|
|
formData.newPassword ||
|
|
formData.confirmPassword;
|
|
|
|
// Only validate password if user is trying to change password
|
|
if (isPasswordChange) {
|
|
const passwordValidation = validatePasswordForm();
|
|
if (!passwordValidation.valid) {
|
|
showErrorToast(Object.values(passwordValidation.errors)[0]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Prepare data to send - exclude password fields if not changing password
|
|
const dataToSend = { ...formData };
|
|
if (!isPasswordChange) {
|
|
delete dataToSend.currentPassword;
|
|
delete dataToSend.newPassword;
|
|
delete dataToSend.confirmPassword;
|
|
}
|
|
|
|
const response = await fetch(getApiPath('profile'), {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify(dataToSend),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || 'Failed to update profile.');
|
|
}
|
|
|
|
const updatedProfile: Profile = await response.json();
|
|
setProfile(updatedProfile);
|
|
|
|
// Update formData to reflect the saved changes, preserving any fields not in response
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
appearance:
|
|
updatedProfile.appearance || prev.appearance || 'light',
|
|
language: updatedProfile.language || prev.language || 'en',
|
|
timezone: updatedProfile.timezone || prev.timezone || 'UTC',
|
|
avatar_image:
|
|
updatedProfile.avatar_image !== undefined
|
|
? updatedProfile.avatar_image
|
|
: prev.avatar_image || '',
|
|
telegram_bot_token:
|
|
updatedProfile.telegram_bot_token !== undefined
|
|
? updatedProfile.telegram_bot_token
|
|
: prev.telegram_bot_token || '',
|
|
telegram_allowed_users:
|
|
updatedProfile.telegram_allowed_users !== undefined
|
|
? updatedProfile.telegram_allowed_users
|
|
: prev.telegram_allowed_users || '',
|
|
task_intelligence_enabled:
|
|
updatedProfile.task_intelligence_enabled !== undefined
|
|
? updatedProfile.task_intelligence_enabled
|
|
: prev.task_intelligence_enabled !== undefined
|
|
? prev.task_intelligence_enabled
|
|
: true,
|
|
task_summary_enabled:
|
|
updatedProfile.task_summary_enabled !== undefined
|
|
? updatedProfile.task_summary_enabled
|
|
: prev.task_summary_enabled !== undefined
|
|
? prev.task_summary_enabled
|
|
: false,
|
|
task_summary_frequency:
|
|
updatedProfile.task_summary_frequency ||
|
|
prev.task_summary_frequency ||
|
|
'daily',
|
|
auto_suggest_next_actions_enabled:
|
|
updatedProfile.auto_suggest_next_actions_enabled !==
|
|
undefined
|
|
? updatedProfile.auto_suggest_next_actions_enabled
|
|
: prev.auto_suggest_next_actions_enabled !== undefined
|
|
? prev.auto_suggest_next_actions_enabled
|
|
: true,
|
|
productivity_assistant_enabled:
|
|
updatedProfile.productivity_assistant_enabled !== undefined
|
|
? updatedProfile.productivity_assistant_enabled
|
|
: prev.productivity_assistant_enabled !== undefined
|
|
? prev.productivity_assistant_enabled
|
|
: true,
|
|
next_task_suggestion_enabled:
|
|
updatedProfile.next_task_suggestion_enabled !== undefined
|
|
? updatedProfile.next_task_suggestion_enabled
|
|
: prev.next_task_suggestion_enabled !== undefined
|
|
? prev.next_task_suggestion_enabled
|
|
: true,
|
|
pomodoro_enabled:
|
|
updatedProfile.pomodoro_enabled !== undefined
|
|
? updatedProfile.pomodoro_enabled
|
|
: prev.pomodoro_enabled !== undefined
|
|
? prev.pomodoro_enabled
|
|
: true,
|
|
}));
|
|
|
|
// Apply appearance change after save
|
|
if (
|
|
updatedProfile.appearance !== (isDarkMode ? 'dark' : 'light') &&
|
|
toggleDarkMode
|
|
) {
|
|
toggleDarkMode();
|
|
}
|
|
|
|
// Apply language change after save
|
|
if (updatedProfile.language !== i18n.language) {
|
|
await handleLanguageChange(updatedProfile.language);
|
|
}
|
|
|
|
// Notify other components about Pomodoro setting change
|
|
if (updatedProfile.pomodoro_enabled !== undefined) {
|
|
window.dispatchEvent(
|
|
new CustomEvent('pomodoroSettingChanged', {
|
|
detail: { enabled: updatedProfile.pomodoro_enabled },
|
|
})
|
|
);
|
|
}
|
|
|
|
// Clear password fields on successful save
|
|
if (isPasswordChange) {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
}));
|
|
}
|
|
|
|
const successMessage = isPasswordChange
|
|
? t(
|
|
'profile.passwordChangeSuccess',
|
|
'Password changed successfully!'
|
|
)
|
|
: t('profile.successMessage', 'Profile updated successfully!');
|
|
showSuccessToast(successMessage);
|
|
} catch (err) {
|
|
showErrorToast((err as Error).message);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
|
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
|
{t('common.loading')}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const tabs = [
|
|
{
|
|
id: 'general',
|
|
name: t('profile.tabs.general', 'General'),
|
|
icon: 'user',
|
|
},
|
|
{
|
|
id: 'security',
|
|
name: t('profile.tabs.security', 'Security'),
|
|
icon: 'shield',
|
|
},
|
|
{
|
|
id: 'apiKeys',
|
|
name: t('profile.tabs.apiKeys', 'API Keys'),
|
|
icon: 'key',
|
|
},
|
|
{
|
|
id: 'productivity',
|
|
name: t('profile.tabs.productivity', 'Productivity'),
|
|
icon: 'clock',
|
|
},
|
|
{
|
|
id: 'telegram',
|
|
name: t('profile.tabs.telegram', 'Telegram'),
|
|
icon: 'chat',
|
|
},
|
|
{
|
|
id: 'ai',
|
|
name: t('profile.tabs.ai', 'AI Features'),
|
|
icon: 'sparkles',
|
|
},
|
|
];
|
|
|
|
const renderTabIcon = (iconType: string) => {
|
|
switch (iconType) {
|
|
case 'user':
|
|
return <UserIcon className="w-5 h-5" />;
|
|
case 'clock':
|
|
return <ClockIcon className="w-5 h-5" />;
|
|
case 'chat':
|
|
return <TelegramIcon className="w-5 h-5" />;
|
|
case 'shield':
|
|
return <ShieldCheckIcon className="w-5 h-5" />;
|
|
case 'sparkles':
|
|
return <LightBulbIcon className="w-5 h-5" />;
|
|
case 'key':
|
|
return <KeyIcon className="w-5 h-5" />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="max-w-5xl mx-auto p-6"
|
|
key={`profile-settings-${updateKey}`}
|
|
>
|
|
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-6">
|
|
{t('profile.title')}
|
|
</h2>
|
|
|
|
{/* Navigation Tabs */}
|
|
<div className="mb-8">
|
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
|
<nav className="-mb-px flex space-x-2 sm:space-x-8 overflow-x-auto scrollbar-hide">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`group inline-flex items-center py-2 px-1 sm:px-2 border-b-2 font-medium text-sm whitespace-nowrap ${
|
|
activeTab === tab.id
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
<span className="mr-1 sm:mr-2">
|
|
{renderTabIcon(tab.icon)}
|
|
</span>
|
|
{tab.name}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-8">
|
|
{/* General Tab */}
|
|
{activeTab === 'general' && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
|
<UserIcon className="w-6 h-6 mr-3 text-blue-500" />
|
|
{t(
|
|
'profile.accountSettings',
|
|
'Account & Preferences'
|
|
)}
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t('profile.name', 'Name')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
value={formData.name || ''}
|
|
onChange={handleChange}
|
|
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder={t(
|
|
'profile.enterName',
|
|
'Enter your name'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t('profile.surname', 'Surname')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="surname"
|
|
value={formData.surname || ''}
|
|
onChange={handleChange}
|
|
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder={t(
|
|
'profile.enterSurname',
|
|
'Enter your surname'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t('profile.appearance')}
|
|
</label>
|
|
<div className="flex rounded-md border border-gray-300 dark:border-gray-600 overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
appearance: 'light',
|
|
}))
|
|
}
|
|
className={`flex-1 flex items-center justify-center px-4 py-2 text-sm font-medium transition-colors ${
|
|
formData.appearance === 'light'
|
|
? 'bg-blue-500 text-white'
|
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
<SunIcon className="h-4 w-4 mr-2" />
|
|
{t('profile.lightMode', 'Light')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
appearance: 'dark',
|
|
}))
|
|
}
|
|
className={`flex-1 flex items-center justify-center px-4 py-2 text-sm font-medium transition-colors ${
|
|
formData.appearance === 'dark'
|
|
? 'bg-blue-500 text-white'
|
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
<MoonIcon className="h-4 w-4 mr-2" />
|
|
{t('profile.darkMode', 'Dark')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t('profile.language')}
|
|
</label>
|
|
<LanguageDropdown
|
|
value={formData.language || 'en'}
|
|
onChange={(languageCode) => {
|
|
// Auto-set first day of week based on language/locale
|
|
const localeFirstDay =
|
|
getLocaleFirstDayOfWeek(
|
|
languageCode
|
|
);
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
language: languageCode,
|
|
first_day_of_week:
|
|
localeFirstDay,
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t('profile.timezone')}
|
|
</label>
|
|
<TimezoneDropdown
|
|
value={formData.timezone || 'UTC'}
|
|
onChange={(timezone) =>
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
timezone,
|
|
}))
|
|
}
|
|
timezonesByRegion={timezonesByRegion}
|
|
getRegionDisplayName={
|
|
getRegionDisplayName
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t(
|
|
'profile.firstDayOfWeek',
|
|
'First day of week'
|
|
)}
|
|
</label>
|
|
<FirstDayOfWeekDropdown
|
|
value={formData.first_day_of_week || 1}
|
|
onChange={(value) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
first_day_of_week: value,
|
|
}));
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Security Tab */}
|
|
{activeTab === 'security' && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
|
<ShieldCheckIcon className="w-6 h-6 mr-3 text-red-500" />
|
|
{t('profile.security', 'Security Settings')}
|
|
</h3>
|
|
|
|
{/* Password Change Section */}
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
|
<UserIcon className="w-5 h-5 mr-2 text-blue-500" />
|
|
{t(
|
|
'profile.changePassword',
|
|
'Change Password'
|
|
)}
|
|
</h4>
|
|
|
|
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
|
|
<p className="text-sm">
|
|
<InformationCircleIcon className="w-4 h-4 inline mr-1" />
|
|
{t(
|
|
'profile.passwordChangeOptional',
|
|
'Leave password fields empty to update other settings without changing your password.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t(
|
|
'profile.currentPassword',
|
|
'Current Password'
|
|
)}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={
|
|
showCurrentPassword
|
|
? 'text'
|
|
: 'password'
|
|
}
|
|
name="currentPassword"
|
|
value={
|
|
formData.currentPassword ||
|
|
''
|
|
}
|
|
onChange={handleChange}
|
|
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder={t(
|
|
'profile.enterCurrentPassword',
|
|
'Enter your current password'
|
|
)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
|
onClick={() =>
|
|
setShowCurrentPassword(
|
|
!showCurrentPassword
|
|
)
|
|
}
|
|
>
|
|
{showCurrentPassword ? (
|
|
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
|
) : (
|
|
<EyeIcon className="h-5 w-5 text-gray-400" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t(
|
|
'profile.newPassword',
|
|
'New Password'
|
|
)}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={
|
|
showNewPassword
|
|
? 'text'
|
|
: 'password'
|
|
}
|
|
name="newPassword"
|
|
value={
|
|
formData.newPassword || ''
|
|
}
|
|
onChange={handleChange}
|
|
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder={t(
|
|
'profile.enterNewPassword',
|
|
'Enter your new password'
|
|
)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
|
onClick={() =>
|
|
setShowNewPassword(
|
|
!showNewPassword
|
|
)
|
|
}
|
|
>
|
|
{showNewPassword ? (
|
|
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
|
) : (
|
|
<EyeIcon className="h-5 w-5 text-gray-400" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t(
|
|
'profile.confirmPassword',
|
|
'Confirm New Password'
|
|
)}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={
|
|
showConfirmPassword
|
|
? 'text'
|
|
: 'password'
|
|
}
|
|
name="confirmPassword"
|
|
value={
|
|
formData.confirmPassword ||
|
|
''
|
|
}
|
|
onChange={handleChange}
|
|
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder={t(
|
|
'profile.confirmNewPassword',
|
|
'Confirm your new password'
|
|
)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
|
onClick={() =>
|
|
setShowConfirmPassword(
|
|
!showConfirmPassword
|
|
)
|
|
}
|
|
>
|
|
{showConfirmPassword ? (
|
|
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
|
) : (
|
|
<EyeIcon className="h-5 w-5 text-gray-400" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
'profile.passwordChangeNote',
|
|
'Password changes will be saved when you click "Save Changes" at the bottom of the form.'
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* API Keys Tab */}
|
|
{activeTab === 'apiKeys' && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
|
<KeyIcon className="w-6 h-6 mr-3 text-indigo-500" />
|
|
{t('profile.apiKeys.title', 'API Keys')}
|
|
</h3>
|
|
|
|
<p className="text-sm text-gray-600 dark:text-gray-300 mb-6">
|
|
{t(
|
|
'profile.apiKeys.description',
|
|
'Generate personal access tokens for integrations or CLI usage. You can revoke or delete keys at any time.'
|
|
)}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t(
|
|
'profile.apiKeys.nameLabel',
|
|
'Key name'
|
|
)}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newApiKeyName}
|
|
onChange={(event) =>
|
|
setNewApiKeyName(event.target.value)
|
|
}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
handleCreateApiKey();
|
|
}
|
|
}}
|
|
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder={t(
|
|
'profile.apiKeys.namePlaceholder',
|
|
'e.g. Personal laptop'
|
|
)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t(
|
|
'profile.apiKeys.expirationLabel',
|
|
'Expires on (optional)'
|
|
)}
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={newApiKeyExpiration}
|
|
onChange={(event) =>
|
|
setNewApiKeyExpiration(
|
|
event.target.value
|
|
)
|
|
}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
handleCreateApiKey();
|
|
}
|
|
}}
|
|
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<button
|
|
type="button"
|
|
disabled={isCreatingApiKey}
|
|
onClick={handleCreateApiKey}
|
|
className={`w-full inline-flex justify-center items-center px-4 py-2 rounded-md text-white ${
|
|
isCreatingApiKey
|
|
? 'bg-gray-400 cursor-not-allowed'
|
|
: 'bg-blue-600 hover:bg-blue-500'
|
|
}`}
|
|
>
|
|
{isCreatingApiKey
|
|
? t('common.saving', 'Saving...')
|
|
: t(
|
|
'profile.apiKeys.generateButton',
|
|
'Generate key'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{generatedApiToken && (
|
|
<div className="mt-4 p-4 bg-green-50 dark:bg-green-900/40 border border-green-200 dark:border-green-700 rounded-md">
|
|
<p className="text-sm text-green-900 dark:text-green-100 mb-2">
|
|
{t(
|
|
'profile.apiKeys.copyNotice',
|
|
'Copy this token now. It will not be shown again.'
|
|
)}
|
|
</p>
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
<code className="flex-1 bg-white dark:bg-gray-900 rounded px-3 py-2 text-sm font-mono text-gray-800 dark:text-gray-100 overflow-x-auto">
|
|
{generatedApiToken}
|
|
</code>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopyGeneratedToken}
|
|
className="inline-flex items-center justify-center px-4 py-2 rounded-md bg-green-600 text-white hover:bg-green-500"
|
|
>
|
|
<ClipboardDocumentListIcon className="w-5 h-5 mr-2" />
|
|
{t(
|
|
'profile.apiKeys.copyButton',
|
|
'Copy key'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-6">
|
|
{apiKeysLoading && (
|
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
{t(
|
|
'profile.apiKeys.loading',
|
|
'Loading API keys...'
|
|
)}
|
|
</p>
|
|
)}
|
|
|
|
{!apiKeysLoading && apiKeys.length === 0 && (
|
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
{t(
|
|
'profile.apiKeys.empty',
|
|
'No API keys yet. Generate one to begin.'
|
|
)}
|
|
</p>
|
|
)}
|
|
|
|
{!apiKeysLoading && apiKeys.length > 0 && (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
|
|
<thead className="bg-gray-100 dark:bg-gray-800">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{t(
|
|
'profile.apiKeys.table.name',
|
|
'Name'
|
|
)}
|
|
</th>
|
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{t(
|
|
'profile.apiKeys.table.prefix',
|
|
'Prefix'
|
|
)}
|
|
</th>
|
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{t(
|
|
'profile.apiKeys.table.status',
|
|
'Status'
|
|
)}
|
|
</th>
|
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{t(
|
|
'profile.apiKeys.table.lastUsed',
|
|
'Last used'
|
|
)}
|
|
</th>
|
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{t(
|
|
'profile.apiKeys.table.expires',
|
|
'Expires'
|
|
)}
|
|
</th>
|
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
|
{t(
|
|
'profile.apiKeys.table.actions',
|
|
'Actions'
|
|
)}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
{apiKeys.map((key) => {
|
|
const status =
|
|
getApiKeyStatus(key);
|
|
return (
|
|
<tr key={key.id}>
|
|
<td className="px-4 py-3">
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
{key.name}
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
'profile.apiKeys.createdAt',
|
|
'Created {{date}}',
|
|
{
|
|
date: formatDateTime(
|
|
key.created_at
|
|
),
|
|
}
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm font-mono text-gray-800 dark:text-gray-200">
|
|
{
|
|
key.token_prefix
|
|
}
|
|
...
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
<span
|
|
className={
|
|
status.className
|
|
}
|
|
>
|
|
{
|
|
status.label
|
|
}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200">
|
|
{formatDateTime(
|
|
key.last_used_at
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200">
|
|
{key.expires_at
|
|
? formatDateTime(
|
|
key.expires_at
|
|
)
|
|
: t(
|
|
'profile.apiKeys.noExpiry',
|
|
'None'
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right text-sm">
|
|
<div className="flex justify-end space-x-2">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
handleRevokeApiKey(
|
|
key.id
|
|
)
|
|
}
|
|
disabled={
|
|
Boolean(
|
|
key.revoked_at
|
|
) ||
|
|
revokeInFlightId ===
|
|
key.id
|
|
}
|
|
className={`inline-flex items-center px-3 py-1.5 rounded-md border text-xs font-medium ${
|
|
key.revoked_at
|
|
? 'border-gray-400 text-gray-400 cursor-not-allowed'
|
|
: 'border-yellow-600 text-yellow-700 hover:bg-yellow-50'
|
|
}`}
|
|
>
|
|
{key.revoked_at
|
|
? t(
|
|
'profile.apiKeys.revokedLabel',
|
|
'Revoked'
|
|
)
|
|
: t(
|
|
'profile.apiKeys.revokeButton',
|
|
'Revoke'
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setApiKeyToDelete(
|
|
key
|
|
)
|
|
}
|
|
disabled={
|
|
deleteInFlightId ===
|
|
key.id ||
|
|
Boolean(
|
|
apiKeyToDelete &&
|
|
apiKeyToDelete.id ===
|
|
key.id
|
|
)
|
|
}
|
|
className={`inline-flex items-center justify-center px-3 py-1.5 rounded-md border text-xs font-medium ${
|
|
deleteInFlightId ===
|
|
key.id
|
|
? 'border-gray-400 text-gray-400 cursor-not-allowed'
|
|
: 'border-red-600 text-red-700 hover:bg-red-50'
|
|
}`}
|
|
aria-label={t(
|
|
'profile.apiKeys.deleteAria',
|
|
'Delete API key'
|
|
)}
|
|
>
|
|
<TrashIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Productivity Tab */}
|
|
{activeTab === 'productivity' && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
|
<ClockIcon className="w-6 h-6 mr-3 text-green-500" />
|
|
{t(
|
|
'profile.productivityFeatures',
|
|
'Productivity Features'
|
|
)}
|
|
</h3>
|
|
|
|
<div className="space-y-6">
|
|
{/* Pomodoro Timer */}
|
|
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
<div>
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t(
|
|
'profile.enablePomodoro',
|
|
'Enable Pomodoro Timer'
|
|
)}
|
|
</label>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
{t(
|
|
'profile.pomodoroDescription',
|
|
'Enable the Pomodoro timer in the navigation bar for focused work sessions.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div
|
|
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
|
formData.pomodoro_enabled
|
|
? 'bg-blue-500'
|
|
: 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
onClick={() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
pomodoro_enabled:
|
|
!prev.pomodoro_enabled,
|
|
}));
|
|
}}
|
|
>
|
|
<span
|
|
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
|
formData.pomodoro_enabled
|
|
? 'translate-x-6'
|
|
: 'translate-x-0'
|
|
}`}
|
|
></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Telegram Tab */}
|
|
{activeTab === 'telegram' && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-blue-300 dark:border-blue-700 mb-8">
|
|
<h3 className="text-xl font-semibold text-blue-700 dark:text-blue-300 mb-6 flex items-center">
|
|
<TelegramIcon className="w-6 h-6 mr-3 text-blue-500" />
|
|
{t(
|
|
'profile.telegramIntegration',
|
|
'Telegram Integration'
|
|
)}
|
|
</h3>
|
|
|
|
{/* Bot Setup Subsection */}
|
|
<div className="mb-8 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
|
<CogIcon className="w-5 h-5 mr-2 text-blue-500" />
|
|
{t('profile.botSetup', 'Bot Setup')}
|
|
</h4>
|
|
|
|
<div className="space-y-4">
|
|
<div className="text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
|
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
|
<p>
|
|
{t(
|
|
'profile.telegramDescription',
|
|
'Connect your tududi account to a Telegram bot to add items to your inbox via Telegram messages.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t(
|
|
'profile.telegramBotToken',
|
|
'Telegram Bot Token'
|
|
)}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="telegram_bot_token"
|
|
value={
|
|
formData.telegram_bot_token ||
|
|
''
|
|
}
|
|
onChange={handleChange}
|
|
placeholder="123456789:ABCDefGhIJKlmNoPQRsTUVwxyZ"
|
|
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
/>
|
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
'profile.telegramTokenDescription',
|
|
'Create a bot with @BotFather on Telegram and paste the token here.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t(
|
|
'profile.telegramAllowedUsers',
|
|
'Allowed Users'
|
|
)}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="telegram_allowed_users"
|
|
value={
|
|
formData.telegram_allowed_users ||
|
|
''
|
|
}
|
|
onChange={handleChange}
|
|
placeholder="@username1, 123456789, @username2"
|
|
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
/>
|
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
|
<p>
|
|
{t(
|
|
'profile.telegramAllowedUsersDescription',
|
|
'Control who can send messages to your bot. Leave empty to allow all users.'
|
|
)}
|
|
</p>
|
|
<div className="space-y-1">
|
|
<p className="font-semibold text-gray-600 dark:text-gray-300">
|
|
{t(
|
|
'profile.examples',
|
|
'Examples:'
|
|
)}
|
|
</p>
|
|
<ul className="list-disc list-inside ml-2 space-y-0.5">
|
|
<li>
|
|
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
|
@alice, @bob
|
|
</span>
|
|
{' - '}
|
|
{t(
|
|
'profile.exampleUsernames',
|
|
'Allow specific usernames'
|
|
)}
|
|
</li>
|
|
<li>
|
|
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
|
123456789, 987654321
|
|
</span>
|
|
{' - '}
|
|
{t(
|
|
'profile.exampleUserIds',
|
|
'Allow specific user IDs'
|
|
)}
|
|
</li>
|
|
<li>
|
|
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
|
@alice, 123456789
|
|
</span>
|
|
{' - '}
|
|
{t(
|
|
'profile.exampleMixed',
|
|
'Mix usernames and user IDs'
|
|
)}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{profile?.telegram_chat_id && (
|
|
<div className="p-2 bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-800 rounded text-green-800 dark:text-green-200">
|
|
<p className="text-sm">
|
|
{t(
|
|
'profile.telegramConnected',
|
|
'Your Telegram account is connected! Send messages to your bot to add items to your tududi inbox.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{(telegramBotInfo ||
|
|
profile?.telegram_bot_token) && (
|
|
<div className="p-2 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
|
|
<p className="font-medium mb-2">
|
|
{t(
|
|
'profile.botConfigured',
|
|
'Bot configured successfully!'
|
|
)}
|
|
</p>
|
|
<div className="text-sm space-y-1">
|
|
{telegramBotInfo?.first_name && (
|
|
<p>
|
|
<span className="font-semibold">
|
|
Bot Name:{' '}
|
|
</span>
|
|
{
|
|
telegramBotInfo.first_name
|
|
}
|
|
</p>
|
|
)}
|
|
{telegramBotInfo?.username && (
|
|
<p>
|
|
<span className="font-semibold">
|
|
{t(
|
|
'profile.botUsername',
|
|
'Bot Username:'
|
|
)}{' '}
|
|
</span>
|
|
@
|
|
{
|
|
telegramBotInfo.username
|
|
}
|
|
</p>
|
|
)}
|
|
<div className="mt-2">
|
|
<p className="font-semibold mb-1">
|
|
{t(
|
|
'profile.pollingStatus',
|
|
'Polling Status:'
|
|
)}{' '}
|
|
</p>
|
|
<div className="flex items-center mb-2">
|
|
<div
|
|
className={`w-3 h-3 rounded-full mr-2 ${isPolling ? 'bg-green-500' : 'bg-red-500'}`}
|
|
></div>
|
|
<span>
|
|
{isPolling
|
|
? t(
|
|
'profile.pollingActive'
|
|
)
|
|
: t(
|
|
'profile.pollingInactive'
|
|
)}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs mb-2">
|
|
{t(
|
|
'profile.pollingNote',
|
|
'Polling periodically checks for new messages from Telegram and adds them to your inbox.'
|
|
)}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{isPolling ? (
|
|
<button
|
|
onClick={
|
|
handleStopPolling
|
|
}
|
|
className="px-3 py-1 bg-red-600 text-white dark:bg-red-700 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800"
|
|
>
|
|
{t(
|
|
'profile.stopPolling',
|
|
'Stop Polling'
|
|
)}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={
|
|
handleStartPolling
|
|
}
|
|
className="px-3 py-1 bg-blue-600 text-white dark:bg-blue-700 rounded text-sm hover:bg-blue-700 dark:hover:bg-blue-800"
|
|
>
|
|
{t(
|
|
'profile.startPolling',
|
|
'Start Polling'
|
|
)}
|
|
</button>
|
|
)}
|
|
{telegramBotInfo?.chat_url && (
|
|
<a
|
|
href={
|
|
telegramBotInfo.chat_url
|
|
}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-3 py-1 bg-green-600 text-white dark:bg-green-700 rounded text-sm hover:bg-green-700 dark:hover:bg-green-800"
|
|
>
|
|
{t(
|
|
'profile.openTelegram',
|
|
'Open in Telegram'
|
|
)}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleSetupTelegram}
|
|
disabled={
|
|
!formData.telegram_bot_token ||
|
|
telegramSetupStatus === 'loading'
|
|
}
|
|
className={`px-4 py-2 rounded-md ${
|
|
!formData.telegram_bot_token ||
|
|
telegramSetupStatus === 'loading'
|
|
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
|
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
|
}`}
|
|
>
|
|
{telegramSetupStatus === 'loading'
|
|
? t(
|
|
'profile.settingUp',
|
|
'Setting up...'
|
|
)
|
|
: t(
|
|
'profile.setupTelegram',
|
|
'Setup Telegram'
|
|
)}
|
|
</button>
|
|
|
|
{/* Status indicator */}
|
|
{telegramSetupStatus === 'success' && (
|
|
<div className="mt-2 flex items-center text-green-600 dark:text-green-400">
|
|
<svg
|
|
className="w-5 h-5 mr-2"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span className="text-sm font-medium">
|
|
Bot configured successfully!
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{telegramSetupStatus === 'error' && (
|
|
<div className="mt-2 flex items-center text-red-600 dark:text-red-400">
|
|
<svg
|
|
className="w-5 h-5 mr-2"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
<span className="text-sm font-medium">
|
|
Setup failed. Please check your
|
|
token.
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Task Summary Notifications Subsection */}
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
|
<ClipboardDocumentListIcon className="w-5 h-5 mr-2 text-green-500" />
|
|
{t(
|
|
'profile.taskSummaryNotifications',
|
|
'Task Summary Notifications'
|
|
)}
|
|
</h4>
|
|
|
|
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
|
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
|
<p>
|
|
{t(
|
|
'profile.taskSummaryDescription',
|
|
'Receive regular summaries of your tasks via Telegram. This feature requires your Telegram integration to be set up.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t(
|
|
'profile.enableTaskSummary',
|
|
'Enable Task Summaries'
|
|
)}
|
|
</label>
|
|
<div
|
|
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
|
formData.task_summary_enabled
|
|
? 'bg-blue-500'
|
|
: 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
onClick={() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
task_summary_enabled:
|
|
!prev.task_summary_enabled,
|
|
}));
|
|
}}
|
|
>
|
|
<span
|
|
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
|
formData.task_summary_enabled
|
|
? 'translate-x-6'
|
|
: 'translate-x-0'
|
|
}`}
|
|
></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
{t(
|
|
'profile.summaryFrequency',
|
|
'Summary Frequency'
|
|
)}
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
'1h',
|
|
'2h',
|
|
'4h',
|
|
'8h',
|
|
'12h',
|
|
'daily',
|
|
'weekly',
|
|
].map((frequency) => (
|
|
<button
|
|
key={frequency}
|
|
type="button"
|
|
className={`px-3 py-1.5 text-sm rounded-full ${
|
|
formData.task_summary_frequency ===
|
|
frequency
|
|
? 'bg-blue-500 text-white'
|
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
}`}
|
|
onClick={() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
task_summary_frequency:
|
|
frequency,
|
|
}));
|
|
}}
|
|
>
|
|
{t(
|
|
`profile.frequency.${frequency}`,
|
|
formatFrequency(frequency)
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
{t(
|
|
'profile.frequencyHelp',
|
|
'Choose how often you want to receive task summaries.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<button
|
|
type="button"
|
|
disabled={
|
|
!profile?.telegram_bot_token ||
|
|
!profile?.telegram_chat_id
|
|
}
|
|
className={`px-4 py-2 rounded-md ${
|
|
!profile?.telegram_bot_token ||
|
|
!profile?.telegram_chat_id
|
|
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
|
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
|
}`}
|
|
onClick={async () => {
|
|
try {
|
|
const response = await fetch(
|
|
getApiPath(
|
|
'profile/task-summary/send-now'
|
|
),
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type':
|
|
'application/json',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const data =
|
|
await response.json();
|
|
throw new Error(
|
|
data.error ||
|
|
t(
|
|
'profile.sendSummaryFailed'
|
|
)
|
|
);
|
|
}
|
|
|
|
const data =
|
|
await response.json();
|
|
showSuccessToast(data.message);
|
|
} catch (error) {
|
|
showErrorToast(
|
|
(error as Error).message
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
{t(
|
|
'profile.sendTestSummary',
|
|
'Send Test Summary'
|
|
)}
|
|
</button>
|
|
{(!profile?.telegram_bot_token ||
|
|
!profile?.telegram_chat_id) && (
|
|
<p className="mt-2 text-xs text-red-500">
|
|
{t(
|
|
'profile.telegramRequiredForSummaries',
|
|
'Telegram integration must be set up to use task summaries.'
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI Features Tab */}
|
|
{activeTab === 'ai' && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
|
<LightBulbIcon className="w-6 h-6 mr-3 text-blue-500" />
|
|
{t(
|
|
'profile.aiProductivityFeatures',
|
|
'AI & Productivity Features'
|
|
)}
|
|
</h3>
|
|
|
|
{/* Task Intelligence Subsection */}
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
|
<BoltIcon className="w-5 h-5 mr-2 text-purple-500" />
|
|
{t(
|
|
'profile.taskIntelligence',
|
|
'Task Intelligence'
|
|
)}
|
|
</h4>
|
|
|
|
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
|
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
|
<p>
|
|
{t(
|
|
'profile.taskIntelligenceDescription',
|
|
'Show popup alerts while typing task names that suggest improvements like "Make it more descriptive!", "Be more specific!", or "Add an action verb!". Disable this if you prefer typing in your own shorthand without suggestions.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t(
|
|
'profile.enableTaskIntelligence',
|
|
'Enable Task Intelligence Assistant'
|
|
)}
|
|
</label>
|
|
<div
|
|
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
|
formData.task_intelligence_enabled
|
|
? 'bg-blue-500'
|
|
: 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
onClick={() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
task_intelligence_enabled:
|
|
!prev.task_intelligence_enabled,
|
|
}));
|
|
}}
|
|
>
|
|
<span
|
|
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
|
formData.task_intelligence_enabled
|
|
? 'translate-x-6'
|
|
: 'translate-x-0'
|
|
}`}
|
|
></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Auto-Suggest Next Actions Subsection */}
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
|
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
|
<ChevronRightIcon className="w-5 h-5 mr-2 text-green-500" />
|
|
{t(
|
|
'profile.autoSuggestNextActions',
|
|
'Auto-Suggest Next Actions'
|
|
)}
|
|
</h4>
|
|
|
|
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
|
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
|
<p>
|
|
{t(
|
|
'profile.autoSuggestNextActionsDescription',
|
|
'When creating a project, automatically prompt for the very next physical action to take.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t(
|
|
'profile.enableAutoSuggestNextActions',
|
|
'Enable Next Action Prompts'
|
|
)}
|
|
</label>
|
|
<div
|
|
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
|
formData.auto_suggest_next_actions_enabled
|
|
? 'bg-blue-500'
|
|
: 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
onClick={() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
auto_suggest_next_actions_enabled:
|
|
!prev.auto_suggest_next_actions_enabled,
|
|
}));
|
|
}}
|
|
>
|
|
<span
|
|
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
|
formData.auto_suggest_next_actions_enabled
|
|
? 'translate-x-6'
|
|
: 'translate-x-0'
|
|
}`}
|
|
></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Productivity Assistant Subsection */}
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
|
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
|
<ExclamationTriangleIcon className="w-5 h-5 mr-2 text-yellow-500" />
|
|
{t(
|
|
'profile.productivityAssistant',
|
|
'Productivity Assistant'
|
|
)}
|
|
</h4>
|
|
|
|
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
|
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
|
<p>
|
|
{t(
|
|
'profile.productivityAssistantDescription',
|
|
'Show productivity insights that help identify stalled projects, vague tasks, and workflow improvements on your Today page.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t(
|
|
'profile.enableProductivityAssistant',
|
|
'Enable Productivity Insights'
|
|
)}
|
|
</label>
|
|
<div
|
|
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
|
formData.productivity_assistant_enabled
|
|
? 'bg-blue-500'
|
|
: 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
onClick={() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
productivity_assistant_enabled:
|
|
!prev.productivity_assistant_enabled,
|
|
}));
|
|
}}
|
|
>
|
|
<span
|
|
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
|
formData.productivity_assistant_enabled
|
|
? 'translate-x-6'
|
|
: 'translate-x-0'
|
|
}`}
|
|
></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Next Task Suggestion Subsection */}
|
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
|
|
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
|
<FaceSmileIcon className="w-5 h-5 mr-2 text-green-500" />
|
|
{t(
|
|
'profile.nextTaskSuggestion',
|
|
'Next Task Suggestion'
|
|
)}
|
|
</h4>
|
|
|
|
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
|
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
|
<p>
|
|
{t(
|
|
'profile.nextTaskSuggestionDescription',
|
|
'Automatically suggest the next best task to work on when you have nothing in progress, prioritizing due today tasks, then suggested tasks, then next actions.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{t(
|
|
'profile.enableNextTaskSuggestion',
|
|
'Enable Next Task Suggestions'
|
|
)}
|
|
</label>
|
|
<div
|
|
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
|
formData.next_task_suggestion_enabled
|
|
? 'bg-blue-500'
|
|
: 'bg-gray-300 dark:bg-gray-600'
|
|
}`}
|
|
onClick={() => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
next_task_suggestion_enabled:
|
|
!prev.next_task_suggestion_enabled,
|
|
}));
|
|
}}
|
|
>
|
|
<span
|
|
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
|
formData.next_task_suggestion_enabled
|
|
? 'translate-x-6'
|
|
: 'translate-x-0'
|
|
}`}
|
|
></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Save Button */}
|
|
<div className="flex justify-end dark:border-gray-700">
|
|
<button
|
|
type="submit"
|
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors duration-200 flex items-center space-x-2"
|
|
>
|
|
<CheckIcon className="w-5 h-5" />
|
|
<span>
|
|
{t('profile.saveChanges', 'Save Changes')}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
{apiKeyToDelete && (
|
|
<ConfirmDialog
|
|
title={t('profile.apiKeys.deleteTitle', 'Delete API key')}
|
|
message={t(
|
|
'profile.apiKeys.deleteConfirm',
|
|
'Delete this API key? This action cannot be undone.'
|
|
)}
|
|
onConfirm={confirmDeleteApiKey}
|
|
onCancel={closeDeleteDialog}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ProfileSettings;
|