Fix today pagination (#596)
* fixup! Feat notifications (#594) * Add pagination to today * Add defer to search * fixup! Add defer to search * Add preuploaded banners * fixup! Add preuploaded banners * Fix project banner * fixup! Fix project banner * fixup! fixup! Fix project banner
|
|
@ -43,6 +43,7 @@ router.get('/', async (req, res) => {
|
|||
filters,
|
||||
priority,
|
||||
due,
|
||||
defer,
|
||||
tags: tagsParam,
|
||||
recurring,
|
||||
limit: limitParam,
|
||||
|
|
@ -121,6 +122,43 @@ router.get('/', async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Calculate defer until date range based on filter
|
||||
let deferDateCondition = null;
|
||||
if (defer) {
|
||||
const now = moment().startOf('day');
|
||||
let startDate, endDate;
|
||||
|
||||
switch (defer) {
|
||||
case 'today':
|
||||
startDate = now.clone();
|
||||
endDate = now.clone().endOf('day');
|
||||
break;
|
||||
case 'tomorrow':
|
||||
startDate = now.clone().add(1, 'day');
|
||||
endDate = now.clone().add(1, 'day').endOf('day');
|
||||
break;
|
||||
case 'next_week':
|
||||
startDate = now.clone();
|
||||
endDate = now.clone().add(7, 'days').endOf('day');
|
||||
break;
|
||||
case 'next_month':
|
||||
startDate = now.clone();
|
||||
endDate = now.clone().add(1, 'month').endOf('day');
|
||||
break;
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
deferDateCondition = {
|
||||
defer_until: {
|
||||
[Op.between]: [
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Search Tasks
|
||||
if (filterTypes.includes('Task')) {
|
||||
const taskConditions = {
|
||||
|
|
@ -161,6 +199,11 @@ router.get('/', async (req, res) => {
|
|||
Object.assign(taskConditions, dueDateCondition);
|
||||
}
|
||||
|
||||
// Add defer until filter if specified
|
||||
if (deferDateCondition) {
|
||||
Object.assign(taskConditions, deferDateCondition);
|
||||
}
|
||||
|
||||
// Add recurring filter if specified
|
||||
if (recurring) {
|
||||
switch (recurring) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const {
|
|||
Role,
|
||||
} = require('../models');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { faker } = require('@faker-js/faker');
|
||||
const { createMassiveTaskData } = require('./massive-tasks');
|
||||
|
||||
async function seedDatabase() {
|
||||
|
|
@ -312,6 +313,33 @@ async function seedDatabase() {
|
|||
tasks.push(backlogTask);
|
||||
}
|
||||
|
||||
// Create tasks marked for today (today = true) to test pagination
|
||||
console.log('📅 Creating tasks marked for today...');
|
||||
const todayMarkedTasks = Array.from({ length: 30 }, (_, i) => ({
|
||||
name: `${faker.lorem.sentence({ min: 3, max: 6 })} #${i + 1}`,
|
||||
description: faker.lorem.paragraph(),
|
||||
note:
|
||||
Math.random() < 0.3
|
||||
? `${faker.lorem.sentence()}\n\n- ${faker.lorem.sentence()}\n- ${faker.lorem.sentence()}`
|
||||
: null,
|
||||
priority: Math.floor(Math.random() * 3),
|
||||
status: Math.floor(Math.random() * 3), // 0, 1, or 2
|
||||
user_id: testUser.id,
|
||||
today: true, // Mark for today
|
||||
project_id:
|
||||
Math.random() < 0.3
|
||||
? projects[Math.floor(Math.random() * projects.length)].id
|
||||
: null,
|
||||
}));
|
||||
|
||||
for (const taskData of todayMarkedTasks) {
|
||||
const task = await Task.create(taskData);
|
||||
tasks.push(task);
|
||||
}
|
||||
console.log(
|
||||
` ✅ Created ${todayMarkedTasks.length} tasks marked for today\n`
|
||||
);
|
||||
|
||||
// Create tasks due today for realistic "Due Today" section
|
||||
console.log('📅 Creating tasks due today...');
|
||||
const todayTaskNames = [
|
||||
|
|
@ -350,7 +378,6 @@ async function seedDatabase() {
|
|||
|
||||
// Create subtasks for some tasks
|
||||
console.log('📋 Creating subtasks for parent tasks...');
|
||||
const { faker } = require('@faker-js/faker');
|
||||
|
||||
// Select 15-20 random tasks to have subtasks
|
||||
const parentTaskIndices = [];
|
||||
|
|
|
|||
363
frontend/components/Project/BannerEditModal.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getPresetBanners, PresetBanner } from '../../utils/bannersService';
|
||||
import { getApiPath } from '../../config/paths';
|
||||
|
||||
interface BannerEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (imageUrl: string) => Promise<void>;
|
||||
currentImageUrl?: string;
|
||||
}
|
||||
|
||||
const BannerEditModal: React.FC<BannerEditModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
currentImageUrl = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string>(currentImageUrl);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [presetBanners] = useState<PresetBanner[]>(getPresetBanners());
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setImagePreview(currentImageUrl);
|
||||
}, [currentImageUrl, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handlePresetBannerSelect = (banner: PresetBanner) => {
|
||||
setImageFile(null);
|
||||
setImagePreview(banner.url);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const maxSizeBytes = 10 * 1024 * 1024;
|
||||
if (file.size > maxSizeBytes) {
|
||||
setError(
|
||||
t(
|
||||
'errors.projectImageTooLarge',
|
||||
'Image is too large. Please choose a file under 10MB.'
|
||||
)
|
||||
);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setImageFile(file);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
setImagePreview(ev.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (): Promise<string | null> => {
|
||||
if (!imageFile) return null;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
|
||||
const response = await fetch(getApiPath('upload/project-image'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let serverMessage = 'Failed to upload image';
|
||||
try {
|
||||
const errData = await response.json();
|
||||
if (errData?.error) serverMessage = errData.error;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
throw new Error(serverMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result?.imageUrl) {
|
||||
return result.imageUrl;
|
||||
}
|
||||
|
||||
throw new Error('Image URL missing from upload response');
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
setError(
|
||||
t(
|
||||
'errors.projectImageUpload',
|
||||
'Failed to upload image. Please try a smaller file or a different format.'
|
||||
)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setImageFile(null);
|
||||
setImagePreview('');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let imageUrl = imagePreview;
|
||||
|
||||
// Upload image if a new one was selected
|
||||
if (imageFile) {
|
||||
const uploadedImageUrl = await handleImageUpload();
|
||||
if (uploadedImageUrl) {
|
||||
imageUrl = uploadedImageUrl;
|
||||
} else {
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(imageUrl);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Error saving banner:', error);
|
||||
setError(t('errors.bannerSaveFailed', 'Failed to save banner'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
setImageFile(null);
|
||||
setError(null);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
|
||||
isClosing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-5xl transform transition-transform duration-300 ${
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
} h-full sm:h-auto sm:my-4`}
|
||||
>
|
||||
<div className="flex flex-col h-full sm:min-h-[700px] sm:max-h-[90vh]">
|
||||
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800 sm:rounded-lg">
|
||||
<div className="flex-1 relative">
|
||||
<div
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">
|
||||
{t(
|
||||
'project.editBanner',
|
||||
'Edit Project Banner'
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePreview && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t(
|
||||
'project.currentBanner',
|
||||
'Current Banner'
|
||||
)}
|
||||
</h3>
|
||||
<div className="relative inline-block w-full">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Banner preview"
|
||||
className="w-full h-48 object-cover rounded-md border border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveImage}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 shadow-lg"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t(
|
||||
'project.choosePreset',
|
||||
'Choose a preset banner:'
|
||||
)}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{presetBanners.map((banner) => (
|
||||
<button
|
||||
key={banner.filename}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handlePresetBannerSelect(
|
||||
banner
|
||||
)
|
||||
}
|
||||
className={`relative rounded-md overflow-hidden border-2 transition-all ${
|
||||
imagePreview ===
|
||||
banner.url
|
||||
? 'border-blue-500 ring-2 ring-blue-300 dark:ring-blue-700'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={banner.url}
|
||||
alt={`Banner by ${banner.creator}`}
|
||||
className="w-full h-24 object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-60 text-white text-xs px-2 py-1 text-center">
|
||||
{banner.creator}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t(
|
||||
'project.orUploadOwn',
|
||||
'Or upload your own:'
|
||||
)}
|
||||
</h3>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
{t(
|
||||
'project.browseImage',
|
||||
'Browse Image'
|
||||
)}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{t(
|
||||
'project.uploadImageHint',
|
||||
'Upload an image for your project (max 10MB)'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-end space-x-3 sm:rounded-b-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isUploading || isSaving}
|
||||
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
|
||||
isUploading || isSaving
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isUploading
|
||||
? t('common.uploading', 'Uploading...')
|
||||
: isSaving
|
||||
? t('common.saving', 'Saving...')
|
||||
: t('common.save', 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerEditModal;
|
||||
|
|
@ -5,12 +5,17 @@ import {
|
|||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
ShareIcon,
|
||||
CameraIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import BannerBadge from '../Shared/BannerBadge';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TFunction } from 'i18next';
|
||||
import {
|
||||
getCreatorFromBannerUrl,
|
||||
isPresetBanner,
|
||||
} from '../../utils/bannersService';
|
||||
|
||||
interface ProjectBannerProps {
|
||||
project: Project;
|
||||
|
|
@ -19,6 +24,7 @@ interface ProjectBannerProps {
|
|||
getStateIcon: (state: string) => React.ReactNode;
|
||||
onDeleteClick: () => void;
|
||||
editButtonRef: RefObject<HTMLButtonElement>;
|
||||
onEditBannerClick?: () => void;
|
||||
}
|
||||
|
||||
const ProjectBanner: React.FC<ProjectBannerProps> = ({
|
||||
|
|
@ -28,8 +34,13 @@ const ProjectBanner: React.FC<ProjectBannerProps> = ({
|
|||
getStateIcon,
|
||||
onDeleteClick,
|
||||
editButtonRef,
|
||||
onEditBannerClick,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const creatorName =
|
||||
project.image_url && isPresetBanner(project.image_url)
|
||||
? getCreatorFromBannerUrl(project.image_url)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
|
@ -38,10 +49,16 @@ const ProjectBanner: React.FC<ProjectBannerProps> = ({
|
|||
<img
|
||||
src={project.image_url}
|
||||
alt={project.name}
|
||||
className="w-full h-64 object-cover"
|
||||
className="w-full h-[282px] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-64 bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
|
||||
<div className="w-full h-[282px] bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
|
||||
)}
|
||||
|
||||
{creatorName && (
|
||||
<div className="absolute top-2 right-2 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded backdrop-blur-sm">
|
||||
Photo by {creatorName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
|
||||
|
|
@ -148,6 +165,20 @@ const ProjectBanner: React.FC<ProjectBannerProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="absolute bottom-2 right-2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
{onEditBannerClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEditBannerClick();
|
||||
}}
|
||||
className="p-2 bg-black bg-opacity-50 text-purple-400 hover:text-purple-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
|
||||
title={t('project.editBanner', 'Edit Banner')}
|
||||
>
|
||||
<CameraIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={editButtonRef}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { usePersistedModal } from '../../hooks/usePersistedModal';
|
|||
import { getApiPath } from '../../config/paths';
|
||||
import ProjectInsightsPanel from './ProjectInsightsPanel';
|
||||
import ProjectBanner from './ProjectBanner';
|
||||
import BannerEditModal from './BannerEditModal';
|
||||
import ProjectTasksSection from './ProjectTasksSection';
|
||||
import ProjectNotesSection from './ProjectNotesSection';
|
||||
import { useProjectMetrics } from './useProjectMetrics';
|
||||
|
|
@ -65,6 +66,7 @@ const ProjectDetails: React.FC = () => {
|
|||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isBannerEditModalOpen, setIsBannerEditModalOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'tasks' | 'notes'>('tasks');
|
||||
const [taskStatusFilter, setTaskStatusFilter] = useState<
|
||||
'all' | 'active' | 'completed'
|
||||
|
|
@ -454,6 +456,29 @@ const ProjectDetails: React.FC = () => {
|
|||
closeModal();
|
||||
};
|
||||
|
||||
const handleEditBannerClick = () => {
|
||||
setIsBannerEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveBanner = async (imageUrl: string) => {
|
||||
if (!project || !project.uid) return;
|
||||
|
||||
const updatedProject = await updateProject(project.uid, {
|
||||
...project,
|
||||
image_url: imageUrl,
|
||||
});
|
||||
|
||||
setProject((prev) => ({
|
||||
...updatedProject,
|
||||
area: updatedProject.area || prev?.area,
|
||||
Area: (updatedProject as any).Area || (prev as any)?.Area,
|
||||
}));
|
||||
|
||||
showSuccessToast(
|
||||
t('success.bannerUpdated', 'Banner updated successfully!')
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateNextAction = async (
|
||||
projectId: number,
|
||||
actionDescription: string
|
||||
|
|
@ -740,6 +765,7 @@ const ProjectDetails: React.FC = () => {
|
|||
<ClipboardDocumentListIcon className="h-3 w-3 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
);
|
||||
case 'in_progress':
|
||||
case 'active':
|
||||
return (
|
||||
<PlayIcon className="h-3 w-3 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
);
|
||||
|
|
@ -868,6 +894,7 @@ const ProjectDetails: React.FC = () => {
|
|||
setIsConfirmDialogOpen(true);
|
||||
}}
|
||||
editButtonRef={editButtonRef}
|
||||
onEditBannerClick={handleEditBannerClick}
|
||||
/>
|
||||
|
||||
<div className="w-full px-4 sm:px-6 lg:px-10">
|
||||
|
|
@ -1132,6 +1159,13 @@ const ProjectDetails: React.FC = () => {
|
|||
areas={areas}
|
||||
/>
|
||||
|
||||
<BannerEditModal
|
||||
isOpen={isBannerEditModalOpen}
|
||||
onClose={() => setIsBannerEditModalOpen(false)}
|
||||
onSave={handleSaveBanner}
|
||||
currentImageUrl={project.image_url}
|
||||
/>
|
||||
|
||||
<NoteModal
|
||||
isOpen={isNoteModalOpen}
|
||||
onClose={() => {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ const getStateLabel = (state: ProjectState | undefined, t: any): string => {
|
|||
case 'planned':
|
||||
return t('projects.states.planned', 'Planned');
|
||||
case 'in_progress':
|
||||
case 'active':
|
||||
return t('projects.states.in_progress', 'In Progress');
|
||||
case 'blocked':
|
||||
return t('projects.states.blocked', 'Blocked');
|
||||
|
|
|
|||
|
|
@ -17,12 +17,10 @@ import {
|
|||
TagIcon,
|
||||
Squares2X2Icon,
|
||||
TrashIcon,
|
||||
CameraIcon,
|
||||
CalendarIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PlayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { getApiPath } from '../../config/paths';
|
||||
|
||||
interface ProjectModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -51,18 +49,12 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
tags: [],
|
||||
priority: null,
|
||||
due_date_at: null,
|
||||
image_url: '',
|
||||
}
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<string[]>(
|
||||
project?.tags?.map((tag) => tag.name) || []
|
||||
);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string>(
|
||||
project?.image_url || ''
|
||||
);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const { tagsStore } = useStore();
|
||||
|
|
@ -71,7 +63,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
const { addNewTags } = tagsStore;
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
|
@ -83,7 +74,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
state: false,
|
||||
tags: false,
|
||||
area: false,
|
||||
image: false,
|
||||
priority: false,
|
||||
dueDate: false,
|
||||
});
|
||||
|
|
@ -131,10 +121,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
...project,
|
||||
tags: project.tags || [],
|
||||
due_date_at: dueDateValue || null,
|
||||
image_url: project.image_url || '',
|
||||
});
|
||||
setTags(project.tags?.map((tag) => tag.name) || []);
|
||||
setImagePreview(project.image_url || '');
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
|
|
@ -144,12 +132,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
tags: [],
|
||||
priority: null,
|
||||
due_date_at: null,
|
||||
image_url: '',
|
||||
});
|
||||
setTags([]);
|
||||
setImagePreview('');
|
||||
}
|
||||
setImageFile(null);
|
||||
setError(null);
|
||||
}, [project]);
|
||||
|
||||
|
|
@ -274,92 +259,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
}));
|
||||
};
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Simple client-side guard (10MB max)
|
||||
const maxSizeBytes = 10 * 1024 * 1024;
|
||||
if (file.size > maxSizeBytes) {
|
||||
setError(
|
||||
t(
|
||||
'errors.projectImageTooLarge',
|
||||
'Image is too large. Please choose a file under 10MB.'
|
||||
)
|
||||
);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setImageFile(file);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
setImagePreview(ev.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (): Promise<string | null> => {
|
||||
if (!imageFile) return null;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
|
||||
const response = await fetch(getApiPath('upload/project-image'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let serverMessage = 'Failed to upload image';
|
||||
try {
|
||||
const errData = await response.json();
|
||||
if (errData?.error) serverMessage = errData.error;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
throw new Error(serverMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result?.imageUrl) {
|
||||
return result.imageUrl;
|
||||
}
|
||||
|
||||
throw new Error('Image URL missing from upload response');
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
setError(
|
||||
t(
|
||||
'errors.projectImageUpload',
|
||||
'Failed to upload image. Please try a smaller file or a different format.'
|
||||
)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setImageFile(null);
|
||||
setImagePreview('');
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
image_url: '',
|
||||
}));
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate required fields
|
||||
if (!formData.name.trim()) {
|
||||
|
|
@ -380,22 +279,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
addNewTags(newTagNames);
|
||||
}
|
||||
|
||||
let imageUrl = formData.image_url;
|
||||
|
||||
// Upload image if a new one was selected
|
||||
if (imageFile) {
|
||||
const uploadedImageUrl = await handleImageUpload();
|
||||
if (uploadedImageUrl) {
|
||||
imageUrl = uploadedImageUrl;
|
||||
} else {
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const projectData = {
|
||||
...formData,
|
||||
image_url: imageUrl,
|
||||
tags: tags.map((name) => ({ name })),
|
||||
};
|
||||
|
||||
|
|
@ -446,9 +331,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
formData.state !== 'idea' ||
|
||||
tags.length > 0 ||
|
||||
formData.priority !== null ||
|
||||
formData.due_date_at !== null ||
|
||||
imageFile !== null ||
|
||||
imagePreview !== ''
|
||||
formData.due_date_at !== null
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -459,8 +342,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
formData.area_id !== project.area_id ||
|
||||
formData.state !== project.state ||
|
||||
formData.priority !== project.priority ||
|
||||
formData.due_date_at !== project.due_date_at ||
|
||||
imageFile !== null;
|
||||
formData.due_date_at !== project.due_date_at;
|
||||
|
||||
// Compare tags
|
||||
const originalTags = project.tags?.map((tag) => tag.name) || [];
|
||||
|
|
@ -707,81 +589,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{expandedSections.image && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t(
|
||||
'project.projectImage',
|
||||
'Project Image'
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{imagePreview ? (
|
||||
<div className="mb-3">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={
|
||||
imagePreview
|
||||
}
|
||||
alt="Project preview"
|
||||
className="w-32 h-20 object-cover rounded-md border border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
handleRemoveImage
|
||||
}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={
|
||||
handleImageSelect
|
||||
}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
{t(
|
||||
'project.browseImage',
|
||||
'Browse Image'
|
||||
)}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t(
|
||||
'project.uploadImageHint',
|
||||
'Upload an image for your project (max 10MB)'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandedSections.priority && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
|
|
@ -901,28 +708,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
)}
|
||||
</button>
|
||||
|
||||
{/* Project Image Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleSection('image')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.image
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={t(
|
||||
'project.projectImage',
|
||||
'Project Image'
|
||||
)}
|
||||
>
|
||||
<CameraIcon className="h-5 w-5" />
|
||||
{formData.image_url && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Priority Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -997,25 +782,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isUploading}
|
||||
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm ${
|
||||
isUploading
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out text-sm"
|
||||
data-testid="project-save-button"
|
||||
>
|
||||
{isUploading
|
||||
? 'Uploading...'
|
||||
: project
|
||||
? t(
|
||||
'modals.updateProject',
|
||||
'Update Project'
|
||||
)
|
||||
: t(
|
||||
'modals.createProject',
|
||||
'Create Project'
|
||||
)}
|
||||
{project
|
||||
? t(
|
||||
'modals.updateProject',
|
||||
'Update Project'
|
||||
)
|
||||
: t(
|
||||
'modals.createProject',
|
||||
'Create Project'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ChevronRightIcon,
|
||||
Cog6ToothIcon,
|
||||
CalendarDaysIcon,
|
||||
QueueListIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import {
|
||||
fetchTasks,
|
||||
|
|
@ -120,6 +121,21 @@ const TasksToday: React.FC = () => {
|
|||
tasks_completed_today: [],
|
||||
});
|
||||
|
||||
// Pagination state for Today Plan tasks
|
||||
const [pagination, setPagination] = useState({
|
||||
total: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Client-side pagination for Due Today tasks (since backend returns all)
|
||||
const [dueTodayDisplayLimit, setDueTodayDisplayLimit] = useState(20);
|
||||
|
||||
// Client-side pagination for Completed Today tasks (since backend returns all)
|
||||
const [completedTodayDisplayLimit, setCompletedTodayDisplayLimit] =
|
||||
useState(20);
|
||||
|
||||
// Helper function to get completion trend vs average
|
||||
const getCompletionTrend = () => {
|
||||
const todayCount = metrics.tasks_completed_today.length;
|
||||
|
|
@ -216,7 +232,9 @@ const TasksToday: React.FC = () => {
|
|||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await fetchTasks('?type=today');
|
||||
const result = await fetchTasks(
|
||||
`?type=today&limit=20&offset=0`
|
||||
);
|
||||
if (isMounted.current) {
|
||||
setMetrics({
|
||||
...result.metrics,
|
||||
|
|
@ -228,6 +246,12 @@ const TasksToday: React.FC = () => {
|
|||
tasks_completed_today:
|
||||
result.tasks_completed_today || [],
|
||||
} as any);
|
||||
|
||||
// Update pagination state if pagination metadata is present
|
||||
if (result.pagination) {
|
||||
setPagination(result.pagination);
|
||||
}
|
||||
|
||||
useStore.getState().tasksStore.setTasks(result.tasks);
|
||||
setIsError(false);
|
||||
}
|
||||
|
|
@ -744,6 +768,101 @@ const TasksToday: React.FC = () => {
|
|||
[handleTaskUpdate]
|
||||
);
|
||||
|
||||
// Load more tasks (pagination)
|
||||
const handleLoadMore = useCallback(
|
||||
async (all: boolean = false) => {
|
||||
if (!isMounted.current || isLoading) return;
|
||||
if (!all && !pagination.hasMore) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let limit: number, offset: number;
|
||||
if (all) {
|
||||
// Load all remaining tasks
|
||||
limit = pagination.total > 0 ? pagination.total : 10000;
|
||||
offset = 0;
|
||||
} else {
|
||||
// Load next page
|
||||
limit = pagination.limit;
|
||||
offset = pagination.offset + pagination.limit;
|
||||
}
|
||||
|
||||
const result = await fetchTasks(
|
||||
`?type=today&limit=${limit}&offset=${offset}`
|
||||
);
|
||||
|
||||
if (isMounted.current) {
|
||||
if (all) {
|
||||
// Replace all tasks when loading all
|
||||
setMetrics({
|
||||
...result.metrics,
|
||||
tasks_in_progress: result.tasks_in_progress || [],
|
||||
tasks_due_today: result.tasks_due_today || [],
|
||||
today_plan_tasks: result.tasks || [],
|
||||
suggested_tasks: result.suggested_tasks || [],
|
||||
tasks_completed_today:
|
||||
result.tasks_completed_today || [],
|
||||
} as any);
|
||||
|
||||
useStore.getState().tasksStore.setTasks(result.tasks);
|
||||
} else {
|
||||
// Append new tasks to existing ones
|
||||
setMetrics((prevMetrics) => ({
|
||||
...result.metrics,
|
||||
tasks_in_progress: [
|
||||
...(prevMetrics.tasks_in_progress || []),
|
||||
...(result.tasks_in_progress || []),
|
||||
],
|
||||
tasks_due_today: [
|
||||
...(prevMetrics.tasks_due_today || []),
|
||||
...(result.tasks_due_today || []),
|
||||
],
|
||||
today_plan_tasks: [
|
||||
...(prevMetrics.today_plan_tasks || []),
|
||||
...(result.tasks || []),
|
||||
],
|
||||
suggested_tasks: [
|
||||
...(prevMetrics.suggested_tasks || []),
|
||||
...(result.suggested_tasks || []),
|
||||
],
|
||||
tasks_completed_today: [
|
||||
...(prevMetrics.tasks_completed_today || []),
|
||||
...(result.tasks_completed_today || []),
|
||||
],
|
||||
}));
|
||||
|
||||
// Append tasks to store
|
||||
const currentTasks =
|
||||
useStore.getState().tasksStore.tasks;
|
||||
useStore
|
||||
.getState()
|
||||
.tasksStore.setTasks([
|
||||
...currentTasks,
|
||||
...result.tasks,
|
||||
]);
|
||||
}
|
||||
|
||||
// Update pagination state
|
||||
if (result.pagination) {
|
||||
setPagination(result.pagination);
|
||||
}
|
||||
|
||||
// If loading all, mark hasMore as false
|
||||
if (all) {
|
||||
setPagination((prev) => ({ ...prev, hasMore: false }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more tasks:', error);
|
||||
} finally {
|
||||
if (isMounted.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[pagination, isLoading]
|
||||
);
|
||||
|
||||
// Calculate today's progress for the progress bar
|
||||
const getTodayProgress = () => {
|
||||
const todayTasks = metrics.today_plan_tasks || [];
|
||||
|
|
@ -1091,6 +1210,70 @@ const TasksToday: React.FC = () => {
|
|||
onTaskCompletionToggle={handleTaskCompletionToggle}
|
||||
/>
|
||||
|
||||
{/* Load More Buttons for Today Plan Tasks */}
|
||||
{pagination.hasMore && (
|
||||
<div className="flex justify-center pt-4 pb-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleLoadMore(false)}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('common.loading', 'Loading...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<QueueListIcon className="h-4 w-4 mr-2" />
|
||||
{t('common.loadMore', 'Load More')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLoadMore(true)}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{t('common.showAll', 'Show All')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination info for Today Plan tasks */}
|
||||
{(metrics.today_plan_tasks || []).length > 0 && (
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
|
||||
{t(
|
||||
'tasks.showingItems',
|
||||
'Showing {{current}} of {{total}} items',
|
||||
{
|
||||
current: (metrics.today_plan_tasks || [])
|
||||
.length,
|
||||
total: pagination.total,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Tasks - Separate setting */}
|
||||
{!isSettingsLoaded ? (
|
||||
// Invisible placeholder for suggestions
|
||||
|
|
@ -1145,7 +1328,10 @@ const TasksToday: React.FC = () => {
|
|||
{t('tasks.dueToday')}
|
||||
</h3>
|
||||
<TaskList
|
||||
tasks={metrics.tasks_due_today}
|
||||
tasks={metrics.tasks_due_today.slice(
|
||||
0,
|
||||
dueTodayDisplayLimit
|
||||
)}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={localProjects}
|
||||
|
|
@ -1154,6 +1340,49 @@ const TasksToday: React.FC = () => {
|
|||
handleTaskCompletionToggle
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Load More Buttons for Due Today Tasks */}
|
||||
{dueTodayDisplayLimit <
|
||||
metrics.tasks_due_today.length && (
|
||||
<div className="flex justify-center pt-4 pb-2 gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
setDueTodayDisplayLimit(
|
||||
(prev) => prev + 20
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<QueueListIcon className="h-4 w-4 mr-2" />
|
||||
{t('common.loadMore', 'Load More')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDueTodayDisplayLimit(
|
||||
metrics.tasks_due_today.length
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
{t('common.showAll', 'Show All')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination info for Due Today tasks */}
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
|
||||
{t(
|
||||
'tasks.showingItems',
|
||||
'Showing {{current}} of {{total}} items',
|
||||
{
|
||||
current: Math.min(
|
||||
dueTodayDisplayLimit,
|
||||
metrics.tasks_due_today.length
|
||||
),
|
||||
total: metrics.tasks_due_today.length,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1184,14 +1413,71 @@ const TasksToday: React.FC = () => {
|
|||
</div>
|
||||
{!isCompletedCollapsed &&
|
||||
(completedToday.length > 0 ? (
|
||||
<TaskList
|
||||
tasks={completedToday}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={localProjects}
|
||||
onToggleToday={handleToggleToday}
|
||||
showCompletedTasks={true}
|
||||
/>
|
||||
<>
|
||||
<TaskList
|
||||
tasks={completedToday.slice(
|
||||
0,
|
||||
completedTodayDisplayLimit
|
||||
)}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={localProjects}
|
||||
onToggleToday={
|
||||
handleToggleToday
|
||||
}
|
||||
showCompletedTasks={true}
|
||||
/>
|
||||
|
||||
{/* Load More Buttons for Completed Today Tasks */}
|
||||
{completedTodayDisplayLimit <
|
||||
completedToday.length && (
|
||||
<div className="flex justify-center pt-4 pb-2 gap-3">
|
||||
<button
|
||||
onClick={() =>
|
||||
setCompletedTodayDisplayLimit(
|
||||
(prev) =>
|
||||
prev + 20
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<QueueListIcon className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
'common.loadMore',
|
||||
'Load More'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setCompletedTodayDisplayLimit(
|
||||
completedToday.length
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
{t(
|
||||
'common.showAll',
|
||||
'Show All'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination info for Completed Today tasks */}
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
|
||||
{t(
|
||||
'tasks.showingItems',
|
||||
'Showing {{current}} of {{total}} items',
|
||||
{
|
||||
current: Math.min(
|
||||
completedTodayDisplayLimit,
|
||||
completedToday.length
|
||||
),
|
||||
total: completedToday.length,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center mt-4">
|
||||
{t(
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ const dueOptions = [
|
|||
{ value: 'next_month', labelKey: 'dateIndicators.nextMonth' },
|
||||
];
|
||||
|
||||
const deferOptions = [
|
||||
{ value: 'today', labelKey: 'dateIndicators.today' },
|
||||
{ value: 'tomorrow', labelKey: 'dateIndicators.tomorrow' },
|
||||
{ value: 'next_week', labelKey: 'dateIndicators.nextWeek' },
|
||||
{ value: 'next_month', labelKey: 'dateIndicators.nextMonth' },
|
||||
];
|
||||
|
||||
const recurringOptions = [
|
||||
{ value: 'recurring', labelKey: 'search.recurringFilter.recurring' },
|
||||
{ value: 'non_recurring', labelKey: 'search.recurringFilter.nonRecurring' },
|
||||
|
|
@ -73,6 +80,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
null
|
||||
);
|
||||
const [selectedDue, setSelectedDue] = useState<string | null>(null);
|
||||
const [selectedDefer, setSelectedDefer] = useState<string | null>(null);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedRecurring, setSelectedRecurring] = useState<string | null>(
|
||||
null
|
||||
|
|
@ -112,6 +120,10 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
setSelectedDue(selectedDue === due ? null : due);
|
||||
};
|
||||
|
||||
const handleDeferToggle = (defer: string) => {
|
||||
setSelectedDefer(selectedDefer === defer ? null : defer);
|
||||
};
|
||||
|
||||
const handleTagToggle = (tagName: string) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tagName)
|
||||
|
|
@ -148,6 +160,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
filters: selectedFilters,
|
||||
priority: selectedPriority || null,
|
||||
due: selectedDue || null,
|
||||
defer: selectedDefer || null,
|
||||
tags: selectedTags.length > 0 ? selectedTags : null,
|
||||
recurring: selectedRecurring || null,
|
||||
}),
|
||||
|
|
@ -283,6 +296,27 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// Add defer until filter
|
||||
if (selectedDefer) {
|
||||
const deferOption = deferOptions.find(
|
||||
(opt) => opt.value === selectedDefer
|
||||
);
|
||||
const deferLabel = deferOption
|
||||
? t(deferOption.labelKey)
|
||||
: selectedDefer;
|
||||
parts.push(
|
||||
<span key="defer-label">{t('search.deferUntil') + ' '}</span>
|
||||
);
|
||||
parts.push(
|
||||
<span
|
||||
key="defer"
|
||||
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
||||
>
|
||||
{deferLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Add tags filter
|
||||
if (selectedTags.length > 0) {
|
||||
parts.push(
|
||||
|
|
@ -353,6 +387,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
searchQuery.trim() ||
|
||||
selectedPriority ||
|
||||
selectedDue ||
|
||||
selectedDefer ||
|
||||
selectedTags.length > 0 ||
|
||||
selectedRecurring;
|
||||
|
||||
|
|
@ -477,6 +512,27 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defer Until Filters */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
||||
{t('search.deferUntilFilter')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{deferOptions.map((option) => (
|
||||
<FilterBadge
|
||||
key={option.value}
|
||||
name={t(option.labelKey)}
|
||||
isSelected={
|
||||
selectedDefer === option.value
|
||||
}
|
||||
onToggle={() =>
|
||||
handleDeferToggle(option.value)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags Filters */}
|
||||
{availableTags.length > 0 && (
|
||||
<div>
|
||||
|
|
@ -625,6 +681,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
selectedFilters={selectedFilters}
|
||||
selectedPriority={selectedPriority}
|
||||
selectedDue={selectedDue}
|
||||
selectedDefer={selectedDefer}
|
||||
selectedTags={selectedTags}
|
||||
selectedRecurring={selectedRecurring}
|
||||
onClose={onClose}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface SearchResultsProps {
|
|||
selectedFilters: string[];
|
||||
selectedPriority: string | null;
|
||||
selectedDue: string | null;
|
||||
selectedDefer: string | null;
|
||||
selectedTags: string[];
|
||||
selectedRecurring: string | null;
|
||||
onClose: () => void;
|
||||
|
|
@ -36,6 +37,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
|||
selectedFilters,
|
||||
selectedPriority,
|
||||
selectedDue,
|
||||
selectedDefer,
|
||||
selectedTags,
|
||||
selectedRecurring,
|
||||
onClose,
|
||||
|
|
@ -52,6 +54,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
|||
selectedFilters.length === 0 &&
|
||||
!selectedPriority &&
|
||||
!selectedDue &&
|
||||
!selectedDefer &&
|
||||
selectedTags.length === 0 &&
|
||||
!selectedRecurring
|
||||
) {
|
||||
|
|
@ -66,6 +69,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
|||
filters: selectedFilters,
|
||||
priority: selectedPriority || undefined,
|
||||
due: selectedDue || undefined,
|
||||
defer: selectedDefer || undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
recurring: selectedRecurring || undefined,
|
||||
});
|
||||
|
|
@ -85,6 +89,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
|||
selectedFilters,
|
||||
selectedPriority,
|
||||
selectedDue,
|
||||
selectedDefer,
|
||||
selectedTags,
|
||||
selectedRecurring,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export type ProjectState =
|
|||
| 'idea'
|
||||
| 'planned'
|
||||
| 'in_progress'
|
||||
| 'active'
|
||||
| 'blocked'
|
||||
| 'completed';
|
||||
|
||||
|
|
|
|||
72
frontend/utils/bannersService.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
export interface PresetBanner {
|
||||
filename: string;
|
||||
url: string;
|
||||
creator: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts creator name from banner filename
|
||||
* Format: "creator-name-rest-of-filename.jpg"
|
||||
* Example: "jon-moore-5fIoyoKlz7A-unsplash.jpg" -> "Jon Moore"
|
||||
*/
|
||||
export function extractCreatorFromFilename(filename: string): string {
|
||||
// Remove extension
|
||||
const nameWithoutExt = filename.replace(/\.(jpg|jpeg|png|webp)$/i, '');
|
||||
|
||||
// Split by hyphen and take the first two parts (first and last name)
|
||||
const parts = nameWithoutExt.split('-');
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// Capitalize first letter of each word
|
||||
const firstName = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
||||
const lastName = parts[1].charAt(0).toUpperCase() + parts[1].slice(1);
|
||||
return `${firstName} ${lastName}`;
|
||||
}
|
||||
|
||||
// Fallback: just capitalize the first part
|
||||
return parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of preset banner images
|
||||
* In a real implementation, this would fetch from the server
|
||||
* For now, we'll hardcode the known banners
|
||||
*/
|
||||
export function getPresetBanners(): PresetBanner[] {
|
||||
const banners = [
|
||||
'erwan-hesry-Q34YB7yjAxA-unsplash.jpg',
|
||||
'joanna-kosinska-spAkZnUleVw-unsplash.jpg',
|
||||
'jon-moore-5fIoyoKlz7A-unsplash.jpg',
|
||||
'marita-kavelashvili-ugnrXk1129g-unsplash.jpg',
|
||||
'mike-kotsch-9wTWFyInJ4Y-unsplash.jpg',
|
||||
'ohmky-uEusW9AW7QU-unsplash.jpg',
|
||||
'osman-rana-GXEZuWo5m4I-unsplash.jpg',
|
||||
'wil-stewart--m9PKhID7Nk-unsplash.jpg',
|
||||
];
|
||||
|
||||
return banners.map((filename) => ({
|
||||
filename,
|
||||
url: `/banners/${filename}`,
|
||||
creator: extractCreatorFromFilename(filename),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an image URL is a preset banner
|
||||
*/
|
||||
export function isPresetBanner(imageUrl: string): boolean {
|
||||
if (!imageUrl) return false;
|
||||
return imageUrl.startsWith('/banners/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets creator name from a preset banner URL
|
||||
*/
|
||||
export function getCreatorFromBannerUrl(imageUrl: string): string | null {
|
||||
if (!isPresetBanner(imageUrl)) return null;
|
||||
|
||||
const filename = imageUrl.split('/').pop();
|
||||
if (!filename) return null;
|
||||
|
||||
return extractCreatorFromFilename(filename);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ interface SearchParams {
|
|||
filters?: string[];
|
||||
priority?: string;
|
||||
due?: string;
|
||||
defer?: string;
|
||||
tags?: string[];
|
||||
recurring?: string;
|
||||
limit?: number;
|
||||
|
|
@ -57,6 +58,10 @@ export const searchUniversal = async (
|
|||
queryParams.append('due', params.due);
|
||||
}
|
||||
|
||||
if (params.defer) {
|
||||
queryParams.append('defer', params.defer);
|
||||
}
|
||||
|
||||
if (params.tags && params.tags.length > 0) {
|
||||
queryParams.append('tags', params.tags.join(','));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ export const fetchTasks = async (
|
|||
tasks_due_today?: Task[];
|
||||
suggested_tasks?: Task[];
|
||||
tasks_completed_today?: Task[];
|
||||
pagination?: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}> => {
|
||||
// For today view, include dashboard task lists
|
||||
const includeLists = query.includes('type=today');
|
||||
|
|
@ -60,6 +66,8 @@ export const fetchTasks = async (
|
|||
tasks_due_today: tasksResult.tasks_due_today,
|
||||
suggested_tasks: tasksResult.suggested_tasks,
|
||||
tasks_completed_today: tasksResult.tasks_completed_today,
|
||||
// Pagination metadata
|
||||
pagination: tasksResult.pagination,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
BIN
public/banners/erwan-hesry-Q34YB7yjAxA-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 471 KiB |
BIN
public/banners/joanna-kosinska-spAkZnUleVw-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
public/banners/jon-moore-5fIoyoKlz7A-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/banners/marita-kavelashvili-ugnrXk1129g-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 463 KiB |
BIN
public/banners/mike-kotsch-9wTWFyInJ4Y-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 399 KiB |
BIN
public/banners/ohmky-uEusW9AW7QU-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 333 KiB |
BIN
public/banners/osman-rana-GXEZuWo5m4I-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
public/banners/wil-stewart--m9PKhID7Nk-unsplash.jpg
Normal file
|
After Width: | Height: | Size: 406 KiB |
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "قوالب متكررة",
|
||||
"nonRecurring": "غير متكرر",
|
||||
"instances": "حالات متكررة"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "تأجيل حتى",
|
||||
"deferUntil": "، تأجيل حتى"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "أضف مهمة فرعية..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "повтарящи се шаблони",
|
||||
"nonRecurring": "неповтарящи се",
|
||||
"instances": "повтарящи се инстанции"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Отложи до",
|
||||
"deferUntil": ", отложи до"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Добавете подзадача..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "gentagende skabeloner",
|
||||
"nonRecurring": "ikke-gentagende",
|
||||
"instances": "gentagende instanser"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Udskyd indtil",
|
||||
"deferUntil": ", udskyd indtil"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Tilføj en underopgave..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1162,9 +1162,11 @@
|
|||
"recurring": "wiederkehrende Vorlagen",
|
||||
"nonRecurring": "nicht-wiederkehrend",
|
||||
"instances": "wiederkehrende Instanzen"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Bis zu",
|
||||
"deferUntil": ", bis zu"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Fügen Sie eine Unteraufgabe hinzu..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1157,9 +1157,11 @@
|
|||
"recurring": "επαναλαμβανόμενα πρότυπα",
|
||||
"nonRecurring": "μη επαναλαμβανόμενο",
|
||||
"instances": "επαναλαμβανόμενες περιπτώσεις"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Αναβολή μέχρι",
|
||||
"deferUntil": ", αναβολή μέχρι"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Προσθέστε μια υποεργασία..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -779,11 +779,13 @@
|
|||
"idea": "Idea",
|
||||
"planned": "Planned",
|
||||
"in_progress": "In Progress",
|
||||
"active": "In Progress",
|
||||
"blocked": "Blocked",
|
||||
"completed": "Completed",
|
||||
"idea_desc": "Captured but not planned yet",
|
||||
"planned_desc": "Scoped and ready to start",
|
||||
"in_progress_desc": "Active work happening",
|
||||
"active_desc": "Active work happening",
|
||||
"blocked_desc": "Temporarily paused or stuck",
|
||||
"completed_desc": "Finished and done"
|
||||
},
|
||||
|
|
@ -1134,6 +1136,8 @@
|
|||
"extras": "Extras",
|
||||
"priorityFilter": "Priority",
|
||||
"dueFilter": "Due",
|
||||
"deferUntilFilter": "Defer Until",
|
||||
"deferUntil": ", defer until",
|
||||
"tagsFilter": "Tags",
|
||||
"recurringFilter": {
|
||||
"label": "Recurring",
|
||||
|
|
|
|||
|
|
@ -1154,9 +1154,11 @@
|
|||
"recurring": "plantillas recurrentes",
|
||||
"nonRecurring": "no recurrente",
|
||||
"instances": "instancias recurrentes"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Aplazar hasta",
|
||||
"deferUntil": ", aplazar hasta"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Agregar una subtarea..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "toistuvat mallit",
|
||||
"nonRecurring": "ei-toistuva",
|
||||
"instances": "toistuvat instanssit"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Viivästytä kunnes",
|
||||
"deferUntil": ", viivästytä kunnes"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Lisää alitehtävä..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "modèles récurrents",
|
||||
"nonRecurring": "non récurrent",
|
||||
"instances": "instances récurrentes"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Différer jusqu'à",
|
||||
"deferUntil": ", différer jusqu'à"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Ajouter une sous-tâche..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "template berulang",
|
||||
"nonRecurring": "tidak berulang",
|
||||
"instances": "instansi berulang"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Tunda Hingga",
|
||||
"deferUntil": ", tunda hingga"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Tambahkan subtugas..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "modelli ricorrenti",
|
||||
"nonRecurring": "non ricorrente",
|
||||
"instances": "istanze ricorrenti"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Rimanda fino a",
|
||||
"deferUntil": ", rimanda fino a"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Aggiungi un sottocompito..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "定期的なテンプレート",
|
||||
"nonRecurring": "非定期的",
|
||||
"instances": "定期的なインスタンス"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "フィルターまで遅延",
|
||||
"deferUntil": "、遅延するまで"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "サブタスクを追加..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "반복 템플릿",
|
||||
"nonRecurring": "비반복",
|
||||
"instances": "반복 인스턴스"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "지연할 때까지",
|
||||
"deferUntil": ", 지연할 때까지"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "하위 작업 추가..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "herhalende sjablonen",
|
||||
"nonRecurring": "niet-herhalend",
|
||||
"instances": "herhalende instanties"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Uitstellen tot",
|
||||
"deferUntil": ", uitstellen tot"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Voeg een subtaak toe..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "gjentakende maler",
|
||||
"nonRecurring": "ikke-gjentakende",
|
||||
"instances": "gjentakende instanser"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Utsett til",
|
||||
"deferUntil": ", utsett til"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Legg til en underoppgave..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "powtarzające się szablony",
|
||||
"nonRecurring": "niepowtarzające się",
|
||||
"instances": "powtarzające się instancje"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Odłóż do",
|
||||
"deferUntil": ", odłóż do"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Dodaj podzadanie..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "modelos recorrentes",
|
||||
"nonRecurring": "não recorrente",
|
||||
"instances": "instâncias recorrentes"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Aguardar Até",
|
||||
"deferUntil": ", aguardar até"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Adicionar uma subtarefa..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "șabloane recurente",
|
||||
"nonRecurring": "non-recurent",
|
||||
"instances": "instanțe recurente"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Amână până la",
|
||||
"deferUntil": ", amână până la"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Adaugă o subtask..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "повторяющиеся шаблоны",
|
||||
"nonRecurring": "неповторяющийся",
|
||||
"instances": "повторяющиеся экземпляры"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Отложить до",
|
||||
"deferUntil": ", отложить до"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Добавить подзадачу..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "ponavljajoče se predloge",
|
||||
"nonRecurring": "neponavljajoče se",
|
||||
"instances": "ponavljajoče se instance"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Odloži do",
|
||||
"deferUntil": ", odloži do"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Dodaj podnalogo..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "återkommande mallar",
|
||||
"nonRecurring": "icke-återkommande",
|
||||
"instances": "återkommande instanser"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Skjut upp tills",
|
||||
"deferUntil": ", skjuta upp tills"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Lägg till en deluppgift..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "tekrarlayan şablonlar",
|
||||
"nonRecurring": "tekrarlamayan",
|
||||
"instances": "tekrarlayan örnekler"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Ertele",
|
||||
"deferUntil": ", ertele"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Bir alt görev ekle..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "повторювані шаблони",
|
||||
"nonRecurring": "неповторювані",
|
||||
"instances": "повторювані екземпляри"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Відкласти до",
|
||||
"deferUntil": ", відкласти до"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Додати підзадачу..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "mẫu lặp lại",
|
||||
"nonRecurring": "không lặp lại",
|
||||
"instances": "các phiên bản lặp lại"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "Hoãn lại cho đến",
|
||||
"deferUntil": ", hoãn lại cho đến"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Thêm một công việc phụ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1153,9 +1153,11 @@
|
|||
"recurring": "循环模板",
|
||||
"nonRecurring": "非循环",
|
||||
"instances": "循环实例"
|
||||
}
|
||||
},
|
||||
"deferUntilFilter": "延迟到",
|
||||
"deferUntil": ",延迟到"
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "添加子任务..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||