parent
bf281b740d
commit
b0b613f7bd
50 changed files with 1313 additions and 454 deletions
|
|
@ -0,0 +1,34 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
safeAddColumns,
|
||||||
|
safeRemoveColumn,
|
||||||
|
} = require('../utils/migration-utils');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await safeAddColumns(queryInterface, 'views', [
|
||||||
|
{
|
||||||
|
name: 'defer',
|
||||||
|
definition: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Defer timeframe filter',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'extras',
|
||||||
|
definition: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'JSON array of extras filters',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface) => {
|
||||||
|
await safeRemoveColumn(queryInterface, 'views', 'extras');
|
||||||
|
await safeRemoveColumn(queryInterface, 'views', 'defer');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -51,6 +51,10 @@ module.exports = (sequelize) => {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
|
defer: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
tags: {
|
tags: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|
@ -62,6 +66,17 @@ module.exports = (sequelize) => {
|
||||||
this.setDataValue('tags', JSON.stringify(value));
|
this.setDataValue('tags', JSON.stringify(value));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
extras: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
get() {
|
||||||
|
const rawValue = this.getDataValue('extras');
|
||||||
|
return rawValue ? JSON.parse(rawValue) : [];
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setDataValue('extras', JSON.stringify(value));
|
||||||
|
},
|
||||||
|
},
|
||||||
recurring: {
|
recurring: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const priorityToInt = (priorityStr) => {
|
||||||
* - due: filter by due date (today,tomorrow,next_week,next_month)
|
* - due: filter by due date (today,tomorrow,next_week,next_month)
|
||||||
* - tags: comma-separated list of tag names to filter by
|
* - tags: comma-separated list of tag names to filter by
|
||||||
* - recurring: filter by recurrence type (recurring,non_recurring,instances)
|
* - recurring: filter by recurrence type (recurring,non_recurring,instances)
|
||||||
|
* - extras: comma-separated list of extra filters (recurring,overdue,has_content,deferred,has_tags,assigned_to_project)
|
||||||
* - limit: number of results to return (default: 20)
|
* - limit: number of results to return (default: 20)
|
||||||
* - offset: number of results to skip (default: 0)
|
* - offset: number of results to skip (default: 0)
|
||||||
* - excludeSubtasks: if 'true', exclude tasks that have a parent_task_id or recurring_parent_id
|
* - excludeSubtasks: if 'true', exclude tasks that have a parent_task_id or recurring_parent_id
|
||||||
|
|
@ -46,6 +47,7 @@ router.get('/', async (req, res) => {
|
||||||
defer,
|
defer,
|
||||||
tags: tagsParam,
|
tags: tagsParam,
|
||||||
recurring,
|
recurring,
|
||||||
|
extras: extrasParam,
|
||||||
limit: limitParam,
|
limit: limitParam,
|
||||||
offset: offsetParam,
|
offset: offsetParam,
|
||||||
excludeSubtasks,
|
excludeSubtasks,
|
||||||
|
|
@ -57,6 +59,18 @@ router.get('/', async (req, res) => {
|
||||||
const tagNames = tagsParam
|
const tagNames = tagsParam
|
||||||
? tagsParam.split(',').map((t) => t.trim())
|
? tagsParam.split(',').map((t) => t.trim())
|
||||||
: [];
|
: [];
|
||||||
|
const extras =
|
||||||
|
extrasParam && typeof extrasParam === 'string'
|
||||||
|
? extrasParam
|
||||||
|
.split(',')
|
||||||
|
.map((extra) => extra.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const extrasSet = new Set(extras);
|
||||||
|
const userTimezone = req.currentUser?.timezone || 'UTC';
|
||||||
|
const nowMoment = moment().tz(userTimezone);
|
||||||
|
const startOfToday = nowMoment.clone().startOf('day');
|
||||||
|
const nowDate = nowMoment.toDate();
|
||||||
|
|
||||||
// Pagination support
|
// Pagination support
|
||||||
const hasPagination =
|
const hasPagination =
|
||||||
|
|
@ -88,35 +102,31 @@ router.get('/', async (req, res) => {
|
||||||
// Calculate due date range based on filter
|
// Calculate due date range based on filter
|
||||||
let dueDateCondition = null;
|
let dueDateCondition = null;
|
||||||
if (due) {
|
if (due) {
|
||||||
const now = moment().startOf('day');
|
|
||||||
let startDate, endDate;
|
let startDate, endDate;
|
||||||
|
|
||||||
switch (due) {
|
switch (due) {
|
||||||
case 'today':
|
case 'today':
|
||||||
startDate = now.clone();
|
startDate = startOfToday.clone();
|
||||||
endDate = now.clone().endOf('day');
|
endDate = startOfToday.clone().endOf('day');
|
||||||
break;
|
break;
|
||||||
case 'tomorrow':
|
case 'tomorrow':
|
||||||
startDate = now.clone().add(1, 'day');
|
startDate = startOfToday.clone().add(1, 'day');
|
||||||
endDate = now.clone().add(1, 'day').endOf('day');
|
endDate = startOfToday.clone().add(1, 'day').endOf('day');
|
||||||
break;
|
break;
|
||||||
case 'next_week':
|
case 'next_week':
|
||||||
startDate = now.clone();
|
startDate = startOfToday.clone();
|
||||||
endDate = now.clone().add(7, 'days').endOf('day');
|
endDate = startOfToday.clone().add(7, 'days').endOf('day');
|
||||||
break;
|
break;
|
||||||
case 'next_month':
|
case 'next_month':
|
||||||
startDate = now.clone();
|
startDate = startOfToday.clone();
|
||||||
endDate = now.clone().add(1, 'month').endOf('day');
|
endDate = startOfToday.clone().add(1, 'month').endOf('day');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
dueDateCondition = {
|
dueDateCondition = {
|
||||||
due_date: {
|
due_date: {
|
||||||
[Op.between]: [
|
[Op.between]: [startDate.toDate(), endDate.toDate()],
|
||||||
startDate.toISOString(),
|
|
||||||
endDate.toISOString(),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -125,35 +135,31 @@ router.get('/', async (req, res) => {
|
||||||
// Calculate defer until date range based on filter
|
// Calculate defer until date range based on filter
|
||||||
let deferDateCondition = null;
|
let deferDateCondition = null;
|
||||||
if (defer) {
|
if (defer) {
|
||||||
const now = moment().startOf('day');
|
|
||||||
let startDate, endDate;
|
let startDate, endDate;
|
||||||
|
|
||||||
switch (defer) {
|
switch (defer) {
|
||||||
case 'today':
|
case 'today':
|
||||||
startDate = now.clone();
|
startDate = startOfToday.clone();
|
||||||
endDate = now.clone().endOf('day');
|
endDate = startOfToday.clone().endOf('day');
|
||||||
break;
|
break;
|
||||||
case 'tomorrow':
|
case 'tomorrow':
|
||||||
startDate = now.clone().add(1, 'day');
|
startDate = startOfToday.clone().add(1, 'day');
|
||||||
endDate = now.clone().add(1, 'day').endOf('day');
|
endDate = startOfToday.clone().add(1, 'day').endOf('day');
|
||||||
break;
|
break;
|
||||||
case 'next_week':
|
case 'next_week':
|
||||||
startDate = now.clone();
|
startDate = startOfToday.clone();
|
||||||
endDate = now.clone().add(7, 'days').endOf('day');
|
endDate = startOfToday.clone().add(7, 'days').endOf('day');
|
||||||
break;
|
break;
|
||||||
case 'next_month':
|
case 'next_month':
|
||||||
startDate = now.clone();
|
startDate = startOfToday.clone();
|
||||||
endDate = now.clone().add(1, 'month').endOf('day');
|
endDate = startOfToday.clone().add(1, 'month').endOf('day');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
deferDateCondition = {
|
deferDateCondition = {
|
||||||
defer_until: {
|
defer_until: {
|
||||||
[Op.between]: [
|
[Op.between]: [startDate.toDate(), endDate.toDate()],
|
||||||
startDate.toISOString(),
|
|
||||||
endDate.toISOString(),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +170,7 @@ router.get('/', async (req, res) => {
|
||||||
const taskConditions = {
|
const taskConditions = {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
};
|
};
|
||||||
|
const taskExtraConditions = [];
|
||||||
|
|
||||||
// Exclude subtasks and recurring instances if requested
|
// Exclude subtasks and recurring instances if requested
|
||||||
if (excludeSubtasks === 'true') {
|
if (excludeSubtasks === 'true') {
|
||||||
|
|
@ -196,12 +203,12 @@ router.get('/', async (req, res) => {
|
||||||
|
|
||||||
// Add due date filter if specified
|
// Add due date filter if specified
|
||||||
if (dueDateCondition) {
|
if (dueDateCondition) {
|
||||||
Object.assign(taskConditions, dueDateCondition);
|
taskExtraConditions.push(dueDateCondition);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add defer until filter if specified
|
// Add defer until filter if specified
|
||||||
if (deferDateCondition) {
|
if (deferDateCondition) {
|
||||||
Object.assign(taskConditions, deferDateCondition);
|
taskExtraConditions.push(deferDateCondition);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add recurring filter if specified
|
// Add recurring filter if specified
|
||||||
|
|
@ -227,6 +234,64 @@ router.get('/', async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (extrasSet.has('recurring')) {
|
||||||
|
taskExtraConditions.push({
|
||||||
|
[Op.or]: [
|
||||||
|
{ recurrence_type: { [Op.ne]: 'none' } },
|
||||||
|
{ recurring_parent_id: { [Op.ne]: null } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extrasSet.has('overdue')) {
|
||||||
|
taskExtraConditions.push({
|
||||||
|
due_date: { [Op.lt]: nowDate },
|
||||||
|
});
|
||||||
|
taskExtraConditions.push({
|
||||||
|
completed_at: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extrasSet.has('has_content')) {
|
||||||
|
const noteHasContent = sequelize.where(
|
||||||
|
sequelize.fn(
|
||||||
|
'LENGTH',
|
||||||
|
sequelize.fn('TRIM', sequelize.col('Task.note'))
|
||||||
|
),
|
||||||
|
{ [Op.gt]: 0 }
|
||||||
|
);
|
||||||
|
const descriptionHasContent = sequelize.where(
|
||||||
|
sequelize.fn(
|
||||||
|
'LENGTH',
|
||||||
|
sequelize.fn('TRIM', sequelize.col('Task.description'))
|
||||||
|
),
|
||||||
|
{ [Op.gt]: 0 }
|
||||||
|
);
|
||||||
|
taskExtraConditions.push({
|
||||||
|
[Op.or]: [noteHasContent, descriptionHasContent],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extrasSet.has('deferred')) {
|
||||||
|
taskExtraConditions.push({
|
||||||
|
defer_until: { [Op.gt]: nowDate },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extrasSet.has('assigned_to_project')) {
|
||||||
|
taskExtraConditions.push({
|
||||||
|
project_id: { [Op.ne]: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskExtraConditions.length > 0) {
|
||||||
|
if (taskConditions[Op.and]) {
|
||||||
|
taskConditions[Op.and].push(...taskExtraConditions);
|
||||||
|
} else {
|
||||||
|
taskConditions[Op.and] = taskExtraConditions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const taskInclude = [
|
const taskInclude = [
|
||||||
{
|
{
|
||||||
model: Project,
|
model: Project,
|
||||||
|
|
@ -245,32 +310,28 @@ router.get('/', async (req, res) => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add tag filter if specified
|
const requireTags = tagIds.length > 0 || extrasSet.has('has_tags');
|
||||||
|
const tagInclude = {
|
||||||
|
model: Tag,
|
||||||
|
through: { attributes: [] },
|
||||||
|
attributes: ['id', 'name', 'uid'],
|
||||||
|
required: requireTags,
|
||||||
|
};
|
||||||
|
|
||||||
if (tagIds.length > 0) {
|
if (tagIds.length > 0) {
|
||||||
taskInclude.push({
|
tagInclude.where = {
|
||||||
model: Tag,
|
|
||||||
where: {
|
|
||||||
id: { [Op.in]: tagIds },
|
id: { [Op.in]: tagIds },
|
||||||
},
|
};
|
||||||
through: { attributes: [] },
|
|
||||||
attributes: ['id', 'name', 'uid'],
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Always include tags for display, even if not filtering
|
|
||||||
taskInclude.push({
|
|
||||||
model: Tag,
|
|
||||||
through: { attributes: [] },
|
|
||||||
attributes: ['id', 'name', 'uid'],
|
|
||||||
required: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
taskInclude.push(tagInclude);
|
||||||
|
|
||||||
// Count total tasks if pagination is requested
|
// Count total tasks if pagination is requested
|
||||||
if (hasPagination) {
|
if (hasPagination) {
|
||||||
|
const countInclude = requireTags ? [tagInclude] : undefined;
|
||||||
totalCount += await Task.count({
|
totalCount += await Task.count({
|
||||||
where: taskConditions,
|
where: taskConditions,
|
||||||
include: tagIds.length > 0 ? taskInclude : undefined,
|
include: countInclude,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -333,19 +394,25 @@ router.get('/', async (req, res) => {
|
||||||
Object.assign(projectConditions, projectDueCondition);
|
Object.assign(projectConditions, projectDueCondition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requireProjectTags =
|
||||||
|
tagIds.length > 0 || extrasSet.has('has_tags');
|
||||||
const projectInclude = [];
|
const projectInclude = [];
|
||||||
|
|
||||||
// Add tag filter if specified
|
if (requireProjectTags) {
|
||||||
if (tagIds.length > 0) {
|
const projectTagInclude = {
|
||||||
projectInclude.push({
|
|
||||||
model: Tag,
|
model: Tag,
|
||||||
where: {
|
|
||||||
id: { [Op.in]: tagIds },
|
|
||||||
},
|
|
||||||
through: { attributes: [] },
|
through: { attributes: [] },
|
||||||
attributes: [],
|
attributes: [],
|
||||||
required: true,
|
required: true,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (tagIds.length > 0) {
|
||||||
|
projectTagInclude.where = {
|
||||||
|
id: { [Op.in]: tagIds },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
projectInclude.push(projectTagInclude);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count total projects if pagination is requested
|
// Count total projects if pagination is requested
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,17 @@ router.get('/:identifier', async (req, res) => {
|
||||||
// POST /api/views - Create a new view
|
// POST /api/views - Create a new view
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, search_query, filters, priority, due, tags, recurring } =
|
const {
|
||||||
req.body;
|
name,
|
||||||
|
search_query,
|
||||||
|
filters,
|
||||||
|
priority,
|
||||||
|
due,
|
||||||
|
defer,
|
||||||
|
tags,
|
||||||
|
extras,
|
||||||
|
recurring,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!name || name.trim() === '') {
|
if (!name || name.trim() === '') {
|
||||||
return res.status(400).json({ error: 'View name is required' });
|
return res.status(400).json({ error: 'View name is required' });
|
||||||
|
|
@ -78,7 +87,9 @@ router.post('/', async (req, res) => {
|
||||||
filters: filters || [],
|
filters: filters || [],
|
||||||
priority: priority || null,
|
priority: priority || null,
|
||||||
due: due || null,
|
due: due || null,
|
||||||
|
defer: defer || null,
|
||||||
tags: tags || [],
|
tags: tags || [],
|
||||||
|
extras: extras || [],
|
||||||
recurring: recurring || null,
|
recurring: recurring || null,
|
||||||
is_pinned: false,
|
is_pinned: false,
|
||||||
});
|
});
|
||||||
|
|
@ -114,7 +125,9 @@ router.patch('/:identifier', async (req, res) => {
|
||||||
filters,
|
filters,
|
||||||
priority,
|
priority,
|
||||||
due,
|
due,
|
||||||
|
defer,
|
||||||
tags,
|
tags,
|
||||||
|
extras,
|
||||||
recurring,
|
recurring,
|
||||||
is_pinned,
|
is_pinned,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
@ -125,7 +138,9 @@ router.patch('/:identifier', async (req, res) => {
|
||||||
if (filters !== undefined) updates.filters = filters;
|
if (filters !== undefined) updates.filters = filters;
|
||||||
if (priority !== undefined) updates.priority = priority;
|
if (priority !== undefined) updates.priority = priority;
|
||||||
if (due !== undefined) updates.due = due;
|
if (due !== undefined) updates.due = due;
|
||||||
|
if (defer !== undefined) updates.defer = defer;
|
||||||
if (tags !== undefined) updates.tags = tags;
|
if (tags !== undefined) updates.tags = tags;
|
||||||
|
if (extras !== undefined) updates.extras = extras;
|
||||||
if (recurring !== undefined) updates.recurring = recurring;
|
if (recurring !== undefined) updates.recurring = recurring;
|
||||||
if (is_pinned !== undefined) updates.is_pinned = is_pinned;
|
if (is_pinned !== undefined) updates.is_pinned = is_pinned;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -629,7 +629,9 @@ async function importUserData(userId, backupData, options = { merge: true }) {
|
||||||
filters: viewData.filters,
|
filters: viewData.filters,
|
||||||
priority: viewData.priority,
|
priority: viewData.priority,
|
||||||
due: viewData.due,
|
due: viewData.due,
|
||||||
|
defer: viewData.defer,
|
||||||
tags: viewData.tags,
|
tags: viewData.tags,
|
||||||
|
extras: viewData.extras,
|
||||||
recurring: viewData.recurring,
|
recurring: viewData.recurring,
|
||||||
is_pinned: viewData.is_pinned,
|
is_pinned: viewData.is_pinned,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
|
|
||||||
|
|
@ -649,6 +649,197 @@ describe('Universal Search Routes', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Extras Filters', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Extras Project',
|
||||||
|
state: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const workTag = await Tag.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'extras-tag',
|
||||||
|
});
|
||||||
|
|
||||||
|
const recurringTemplate = await Task.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Recurring Template',
|
||||||
|
recurrence_type: 'weekly',
|
||||||
|
status: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Recurring Instance',
|
||||||
|
recurring_parent_id: recurringTemplate.id,
|
||||||
|
status: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Regular Task',
|
||||||
|
status: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Overdue Task',
|
||||||
|
due_date: moment().subtract(2, 'days').toDate(),
|
||||||
|
status: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Completed Overdue Task',
|
||||||
|
due_date: moment().subtract(3, 'days').toDate(),
|
||||||
|
completed_at: new Date(),
|
||||||
|
status: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Content Task',
|
||||||
|
note: 'Detailed context lives here',
|
||||||
|
status: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Deferred Task',
|
||||||
|
defer_until: moment().add(2, 'days').toDate(),
|
||||||
|
status: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const taggedTask = await Task.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Tagged Task',
|
||||||
|
status: 0,
|
||||||
|
});
|
||||||
|
await taggedTask.addTag(workTag);
|
||||||
|
|
||||||
|
await Task.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Project Task',
|
||||||
|
project_id: project.id,
|
||||||
|
status: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTaskNames = (response) =>
|
||||||
|
response.body.results
|
||||||
|
.filter((r) => r.type === 'Task')
|
||||||
|
.map((task) => task.original_name || task.name);
|
||||||
|
|
||||||
|
it('should return only recurring tasks when extras contains recurring', async () => {
|
||||||
|
const response = await agent.get('/api/search').query({
|
||||||
|
filters: 'Task',
|
||||||
|
extras: 'recurring',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const names = getTaskNames(response);
|
||||||
|
expect(names).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'Recurring Template',
|
||||||
|
'Recurring Instance',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(names).not.toContain('Regular Task');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return overdue tasks and exclude completed ones', async () => {
|
||||||
|
const response = await agent.get('/api/search').query({
|
||||||
|
filters: 'Task',
|
||||||
|
extras: 'overdue',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const names = getTaskNames(response);
|
||||||
|
expect(names).toContain('Overdue Task');
|
||||||
|
expect(names).not.toContain('Completed Overdue Task');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return tasks that have content', async () => {
|
||||||
|
const response = await agent.get('/api/search').query({
|
||||||
|
filters: 'Task',
|
||||||
|
extras: 'has_content',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const names = getTaskNames(response);
|
||||||
|
expect(names).toContain('Content Task');
|
||||||
|
expect(names).not.toContain('Regular Task');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return deferred tasks', async () => {
|
||||||
|
const response = await agent.get('/api/search').query({
|
||||||
|
filters: 'Task',
|
||||||
|
extras: 'deferred',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const names = getTaskNames(response);
|
||||||
|
expect(names).toContain('Deferred Task');
|
||||||
|
expect(names).not.toContain('Regular Task');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return tasks with tags', async () => {
|
||||||
|
const response = await agent.get('/api/search').query({
|
||||||
|
filters: 'Task',
|
||||||
|
extras: 'has_tags',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const names = getTaskNames(response);
|
||||||
|
expect(names).toContain('Tagged Task');
|
||||||
|
expect(names).not.toContain('Regular Task');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return tasks assigned to projects', async () => {
|
||||||
|
const response = await agent.get('/api/search').query({
|
||||||
|
filters: 'Task',
|
||||||
|
extras: 'assigned_to_project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const names = getTaskNames(response);
|
||||||
|
expect(names).toContain('Project Task');
|
||||||
|
expect(names).not.toContain('Regular Task');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return only projects that have tags when extras include has_tags', async () => {
|
||||||
|
const taggedProject = await Project.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Tagged Project',
|
||||||
|
state: 'active',
|
||||||
|
});
|
||||||
|
const plainProject = await Project.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'Plain Project',
|
||||||
|
state: 'active',
|
||||||
|
});
|
||||||
|
const projectTag = await Tag.create({
|
||||||
|
user_id: user.id,
|
||||||
|
name: 'project-tag',
|
||||||
|
});
|
||||||
|
await taggedProject.addTag(projectTag);
|
||||||
|
|
||||||
|
const response = await agent.get('/api/search').query({
|
||||||
|
filters: 'Project',
|
||||||
|
extras: 'has_tags',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const projectResults = response.body.results.filter(
|
||||||
|
(r) => r.type === 'Project'
|
||||||
|
);
|
||||||
|
const projectNames = projectResults.map((p) => p.name);
|
||||||
|
expect(projectNames).toContain('Tagged Project');
|
||||||
|
expect(projectNames).not.toContain('Plain Project');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('User Isolation', () => {
|
describe('User Isolation', () => {
|
||||||
let otherUser, otherAgent;
|
let otherUser, otherAgent;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,9 @@ describe('Views Routes', () => {
|
||||||
filters: ['Task', 'Project'],
|
filters: ['Task', 'Project'],
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
due: 'today',
|
due: 'today',
|
||||||
|
defer: 'tomorrow',
|
||||||
tags: ['work', 'important'],
|
tags: ['work', 'important'],
|
||||||
|
extras: ['recurring', 'has_content'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
|
|
@ -85,7 +87,21 @@ describe('Views Routes', () => {
|
||||||
expect(response.body.filters).toEqual(['Task', 'Project']);
|
expect(response.body.filters).toEqual(['Task', 'Project']);
|
||||||
expect(response.body.priority).toBe('high');
|
expect(response.body.priority).toBe('high');
|
||||||
expect(response.body.due).toBe('today');
|
expect(response.body.due).toBe('today');
|
||||||
|
expect(response.body.defer).toBe('tomorrow');
|
||||||
expect(response.body.tags).toEqual(['work', 'important']);
|
expect(response.body.tags).toEqual(['work', 'important']);
|
||||||
|
expect(response.body.extras).toEqual(['recurring', 'has_content']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a view that persists extras without tags', async () => {
|
||||||
|
const response = await agent.post('/api/views').send({
|
||||||
|
name: 'Recurring Tasks',
|
||||||
|
filters: ['Task'],
|
||||||
|
extras: ['recurring', 'overdue'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.extras).toEqual(['recurring', 'overdue']);
|
||||||
|
expect(response.body.tags).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require view name', async () => {
|
it('should require view name', async () => {
|
||||||
|
|
@ -246,7 +262,9 @@ describe('Views Routes', () => {
|
||||||
filters: ['Task', 'Project'],
|
filters: ['Task', 'Project'],
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
due: 'today',
|
due: 'today',
|
||||||
|
defer: 'next_week',
|
||||||
tags: ['work', 'urgent'],
|
tags: ['work', 'urgent'],
|
||||||
|
extras: ['recurring'],
|
||||||
is_pinned: true,
|
is_pinned: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -256,7 +274,9 @@ describe('Views Routes', () => {
|
||||||
expect(response.body.filters).toEqual(['Task', 'Project']);
|
expect(response.body.filters).toEqual(['Task', 'Project']);
|
||||||
expect(response.body.priority).toBe('high');
|
expect(response.body.priority).toBe('high');
|
||||||
expect(response.body.due).toBe('today');
|
expect(response.body.due).toBe('today');
|
||||||
|
expect(response.body.defer).toBe('next_week');
|
||||||
expect(response.body.tags).toEqual(['work', 'urgent']);
|
expect(response.body.tags).toEqual(['work', 'urgent']);
|
||||||
|
expect(response.body.extras).toEqual(['recurring']);
|
||||||
expect(response.body.is_pinned).toBe(true);
|
expect(response.body.is_pinned).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
APP_URL_DEFAULT="http://localhost:8080"
|
FRONTEND_PORT="${FRONTEND_PORT:-4180}"
|
||||||
BACKEND_URL="http://localhost:3002"
|
FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}"
|
||||||
|
BACKEND_PORT="${BACKEND_PORT:-3310}"
|
||||||
|
BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}"
|
||||||
|
BACKEND_URL="${BACKEND_URL:-http://${BACKEND_HOST}:${BACKEND_PORT}}"
|
||||||
BACKEND_HEALTH="${BACKEND_URL}/api/health"
|
BACKEND_HEALTH="${BACKEND_URL}/api/health"
|
||||||
|
APP_URL_DEFAULT="http://${FRONTEND_HOST}:${FRONTEND_PORT}"
|
||||||
FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}"
|
FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}"
|
||||||
|
FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-$FRONTEND_URL}"
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
red() { printf "\033[31m%s\033[0m\n" "$*"; }
|
red() { printf "\033[31m%s\033[0m\n" "$*"; }
|
||||||
|
|
@ -35,7 +40,8 @@ rm -f backend/db/test.sqlite3
|
||||||
yellow "Starting backend with test database..."
|
yellow "Starting backend with test database..."
|
||||||
(cd backend && \
|
(cd backend && \
|
||||||
NODE_ENV=test \
|
NODE_ENV=test \
|
||||||
PORT=3002 \
|
PORT=$BACKEND_PORT \
|
||||||
|
HOST=$BACKEND_HOST \
|
||||||
DB_FILE=db/test.sqlite3 \
|
DB_FILE=db/test.sqlite3 \
|
||||||
TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
|
TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
|
||||||
TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \
|
TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \
|
||||||
|
|
@ -51,8 +57,8 @@ cleanup() {
|
||||||
|
|
||||||
# Kill by known ports (best-effort)
|
# Kill by known ports (best-effort)
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true)
|
FRONTEND_PIDS_KILL=$(lsof -ti tcp:${FRONTEND_PORT} || true)
|
||||||
BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true)
|
BACKEND_PIDS_KILL=$(lsof -ti tcp:${BACKEND_PORT} || true)
|
||||||
if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
||||||
if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -82,6 +88,10 @@ for i in {1..60}; do
|
||||||
done
|
done
|
||||||
|
|
||||||
yellow "Starting frontend dev server..."
|
yellow "Starting frontend dev server..."
|
||||||
|
BACKEND_URL="$BACKEND_URL" \
|
||||||
|
FRONTEND_PORT="$FRONTEND_PORT" \
|
||||||
|
FRONTEND_HOST="$FRONTEND_HOST" \
|
||||||
|
FRONTEND_ORIGIN="$FRONTEND_ORIGIN" \
|
||||||
npm run frontend:dev >/dev/null 2>&1 &
|
npm run frontend:dev >/dev/null 2>&1 &
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,15 @@ TEST_PATTERN="$1"
|
||||||
BROWSER="${2:-Chromium}"
|
BROWSER="${2:-Chromium}"
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
APP_URL_DEFAULT="http://localhost:8080"
|
FRONTEND_PORT="${FRONTEND_PORT:-4180}"
|
||||||
BACKEND_URL="http://localhost:3002"
|
FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}"
|
||||||
|
BACKEND_PORT="${BACKEND_PORT:-3310}"
|
||||||
|
BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}"
|
||||||
|
BACKEND_URL="${BACKEND_URL:-http://${BACKEND_HOST}:${BACKEND_PORT}}"
|
||||||
BACKEND_HEALTH="${BACKEND_URL}/api/health"
|
BACKEND_HEALTH="${BACKEND_URL}/api/health"
|
||||||
|
APP_URL_DEFAULT="http://${FRONTEND_HOST}:${FRONTEND_PORT}"
|
||||||
FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}"
|
FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}"
|
||||||
|
FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-$FRONTEND_URL}"
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
red() { printf "\033[31m%s\033[0m\n" "$*"; }
|
red() { printf "\033[31m%s\033[0m\n" "$*"; }
|
||||||
|
|
@ -51,7 +56,8 @@ rm -f backend/db/test.sqlite3
|
||||||
yellow "Starting backend with test database..."
|
yellow "Starting backend with test database..."
|
||||||
(cd backend && \
|
(cd backend && \
|
||||||
NODE_ENV=test \
|
NODE_ENV=test \
|
||||||
PORT=3002 \
|
PORT=$BACKEND_PORT \
|
||||||
|
HOST=$BACKEND_HOST \
|
||||||
DB_FILE=db/test.sqlite3 \
|
DB_FILE=db/test.sqlite3 \
|
||||||
TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
|
TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
|
||||||
TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \
|
TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \
|
||||||
|
|
@ -67,8 +73,8 @@ cleanup() {
|
||||||
|
|
||||||
# Kill by ports (best-effort)
|
# Kill by ports (best-effort)
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true)
|
FRONTEND_PIDS_KILL=$(lsof -ti tcp:${FRONTEND_PORT} || true)
|
||||||
BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true)
|
BACKEND_PIDS_KILL=$(lsof -ti tcp:${BACKEND_PORT} || true)
|
||||||
if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
if [ -n "${FRONTEND_PIDS_KILL:-}" ]; then kill ${FRONTEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
||||||
if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
if [ -n "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -98,6 +104,10 @@ for i in {1..60}; do
|
||||||
done
|
done
|
||||||
|
|
||||||
yellow "Starting frontend dev server..."
|
yellow "Starting frontend dev server..."
|
||||||
|
BACKEND_URL="$BACKEND_URL" \
|
||||||
|
FRONTEND_PORT="$FRONTEND_PORT" \
|
||||||
|
FRONTEND_HOST="$FRONTEND_HOST" \
|
||||||
|
FRONTEND_ORIGIN="$FRONTEND_ORIGIN" \
|
||||||
npm run frontend:dev >/dev/null 2>&1 &
|
npm run frontend:dev >/dev/null 2>&1 &
|
||||||
FRONTEND_PID=$!
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,10 +261,7 @@ const App: React.FC = () => {
|
||||||
path="/about"
|
path="/about"
|
||||||
element={<About isDarkMode={isDarkMode} />}
|
element={<About isDarkMode={isDarkMode} />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="/backup" element={<BackupRestore />} />
|
||||||
path="/backup"
|
|
||||||
element={<BackupRestore />}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -97,9 +97,7 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
await loadBackups();
|
await loadBackups();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Export error:', error);
|
console.error('Export error:', error);
|
||||||
showErrorToast(
|
showErrorToast(t('backup.exportError', 'Failed to create backup'));
|
||||||
t('backup.exportError', 'Failed to create backup')
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false);
|
setIsExporting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +162,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
try {
|
try {
|
||||||
await deleteSavedBackup(backupUid);
|
await deleteSavedBackup(backupUid);
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
t('backup.deleteSuccess', 'Backup deleted successfully!')
|
t(
|
||||||
|
'backup.deleteSuccess',
|
||||||
|
'Backup deleted successfully!'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
// Reload the backup list
|
// Reload the backup list
|
||||||
await loadBackups();
|
await loadBackups();
|
||||||
|
|
@ -234,9 +235,7 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Import error:', error);
|
console.error('Import error:', error);
|
||||||
showErrorToast(
|
showErrorToast(t('backup.importError', 'Failed to import backup'));
|
||||||
t('backup.importError', 'Failed to import backup')
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -260,7 +259,9 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
title={confirmDialog.title}
|
title={confirmDialog.title}
|
||||||
message={confirmDialog.message}
|
message={confirmDialog.message}
|
||||||
onConfirm={confirmDialog.onConfirm}
|
onConfirm={confirmDialog.onConfirm}
|
||||||
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
|
onCancel={() =>
|
||||||
|
setConfirmDialog({ ...confirmDialog, isOpen: false })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
|
@ -291,7 +292,12 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||||
<span>{t('backup.createBackup', 'Create Backup')}</span>
|
<span>
|
||||||
|
{t(
|
||||||
|
'backup.createBackup',
|
||||||
|
'Create Backup'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -304,7 +310,12 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<ArrowUpTrayIcon className="h-5 w-5" />
|
<ArrowUpTrayIcon className="h-5 w-5" />
|
||||||
<span>{t('backup.importFromFile', 'Import from File')}</span>
|
<span>
|
||||||
|
{t(
|
||||||
|
'backup.importFromFile',
|
||||||
|
'Import from File'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -316,7 +327,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
{t('backup.createNewBackup', 'Create New Backup')}
|
{t(
|
||||||
|
'backup.createNewBackup',
|
||||||
|
'Create New Backup'
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{t(
|
{t(
|
||||||
|
|
@ -353,12 +367,18 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
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"
|
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>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backup.creating', 'Creating backup...')}
|
{t(
|
||||||
|
'backup.creating',
|
||||||
|
'Creating backup...'
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowDownTrayIcon className="h-5 w-5 mr-2" />
|
<ArrowDownTrayIcon className="h-5 w-5 mr-2" />
|
||||||
{t('backup.createBackupNow', 'Create Backup Now')}
|
{t(
|
||||||
|
'backup.createBackupNow',
|
||||||
|
'Create Backup Now'
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -367,14 +387,19 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
{t('backup.savedBackups', 'Saved Backups')}
|
{t(
|
||||||
|
'backup.savedBackups',
|
||||||
|
'Saved Backups'
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={loadBackups}
|
onClick={loadBackups}
|
||||||
disabled={isLoadingBackups}
|
disabled={isLoadingBackups}
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center"
|
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center"
|
||||||
>
|
>
|
||||||
<ArrowPathIcon className={`h-4 w-4 mr-1 ${isLoadingBackups ? 'animate-spin' : ''}`} />
|
<ArrowPathIcon
|
||||||
|
className={`h-4 w-4 mr-1 ${isLoadingBackups ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
{t('common.refresh', 'Refresh')}
|
{t('common.refresh', 'Refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -385,7 +410,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
</div>
|
</div>
|
||||||
) : savedBackups.length === 0 ? (
|
) : savedBackups.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
{t('backup.noBackups', 'No backups found. Create your first backup above.')}
|
{t(
|
||||||
|
'backup.noBackups',
|
||||||
|
'No backups found. Create your first backup above.'
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|
@ -393,74 +421,136 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
{t('backup.createdAt', 'Created')}
|
{t(
|
||||||
|
'backup.createdAt',
|
||||||
|
'Created'
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
{t('backup.version', 'Version')}
|
{t(
|
||||||
|
'backup.version',
|
||||||
|
'Version'
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
{t('backup.size', 'Size')}
|
{t(
|
||||||
|
'backup.size',
|
||||||
|
'Size'
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
{t('backup.contents', 'Contents')}
|
{t(
|
||||||
|
'backup.contents',
|
||||||
|
'Contents'
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
{t('backup.actions', 'Actions')}
|
{t(
|
||||||
|
'backup.actions',
|
||||||
|
'Actions'
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{savedBackups.map((backup) => (
|
{savedBackups.map(
|
||||||
<tr key={backup.uid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
(backup) => (
|
||||||
|
<tr
|
||||||
|
key={backup.uid}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
{formatDate(backup.created_at)}
|
{formatDate(
|
||||||
|
backup.created_at
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
{backup.version}
|
{
|
||||||
|
backup.version
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formatFileSize(backup.file_size)}
|
{formatFileSize(
|
||||||
|
backup.file_size
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||||
{backup.item_counts.tasks} tasks
|
{
|
||||||
|
backup
|
||||||
|
.item_counts
|
||||||
|
.tasks
|
||||||
|
}{' '}
|
||||||
|
tasks
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
{backup.item_counts.projects} projects
|
{
|
||||||
|
backup
|
||||||
|
.item_counts
|
||||||
|
.projects
|
||||||
|
}{' '}
|
||||||
|
projects
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||||
{backup.item_counts.notes} notes
|
{
|
||||||
|
backup
|
||||||
|
.item_counts
|
||||||
|
.notes
|
||||||
|
}{' '}
|
||||||
|
notes
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRestoreBackup(backup.uid)}
|
onClick={() =>
|
||||||
|
handleRestoreBackup(
|
||||||
|
backup.uid
|
||||||
|
)
|
||||||
|
}
|
||||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
title={t('backup.restore', 'Restore')}
|
title={t(
|
||||||
|
'backup.restore',
|
||||||
|
'Restore'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ArrowPathIcon className="h-5 w-5" />
|
<ArrowPathIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownloadBackup(backup.uid)}
|
onClick={() =>
|
||||||
|
handleDownloadBackup(
|
||||||
|
backup.uid
|
||||||
|
)
|
||||||
|
}
|
||||||
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
||||||
title={t('backup.download', 'Download')}
|
title={t(
|
||||||
|
'backup.download',
|
||||||
|
'Download'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteBackup(backup.uid)}
|
onClick={() =>
|
||||||
|
handleDeleteBackup(
|
||||||
|
backup.uid
|
||||||
|
)
|
||||||
|
}
|
||||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||||
title={t('backup.delete', 'Delete')}
|
title={t(
|
||||||
|
'backup.delete',
|
||||||
|
'Delete'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-5 w-5" />
|
<TrashIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -471,7 +561,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
{t('backup.importTitle', 'Import from File')}
|
{t(
|
||||||
|
'backup.importTitle',
|
||||||
|
'Import from File'
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{t(
|
{t(
|
||||||
|
|
@ -502,16 +595,24 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() =>
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
className="w-full flex items-center justify-center px-6 py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:border-blue-500 dark:hover:border-blue-400 hover:text-blue-600 dark:hover:text-blue-400 transition duration-150 ease-in-out"
|
className="w-full flex items-center justify-center px-6 py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:border-blue-500 dark:hover:border-blue-400 hover:text-blue-600 dark:hover:text-blue-400 transition duration-150 ease-in-out"
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<ArrowUpTrayIcon className="h-12 w-12 mx-auto mb-2" />
|
<ArrowUpTrayIcon className="h-12 w-12 mx-auto mb-2" />
|
||||||
<p className="text-base font-medium">
|
<p className="text-base font-medium">
|
||||||
{t('backup.selectFile', 'Select Backup File')}
|
{t(
|
||||||
|
'backup.selectFile',
|
||||||
|
'Select Backup File'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm mt-1">
|
<p className="text-sm mt-1">
|
||||||
{t('backup.clickToUpload', 'Click to browse files')}
|
{t(
|
||||||
|
'backup.clickToUpload',
|
||||||
|
'Click to browse files'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -524,7 +625,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
{selectedFile.name}
|
{selectedFile.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{(selectedFile.size / 1024).toFixed(2)} KB
|
{(
|
||||||
|
selectedFile.size / 1024
|
||||||
|
).toFixed(2)}{' '}
|
||||||
|
KB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isValidating && (
|
{isValidating && (
|
||||||
|
|
@ -554,66 +658,134 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{validationResult?.valid && validationResult.summary && (
|
{validationResult?.valid &&
|
||||||
|
validationResult.summary && (
|
||||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
{t('backup.backupContents', 'Backup contents:')}
|
{t(
|
||||||
|
'backup.backupContents',
|
||||||
|
'Backup contents:'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||||
{validationResult.summary.tasks} tasks
|
{
|
||||||
|
validationResult
|
||||||
|
.summary
|
||||||
|
.tasks
|
||||||
|
}{' '}
|
||||||
|
tasks
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||||
{validationResult.summary.projects} projects
|
{
|
||||||
|
validationResult
|
||||||
|
.summary
|
||||||
|
.projects
|
||||||
|
}{' '}
|
||||||
|
projects
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||||
{validationResult.summary.notes} notes
|
{
|
||||||
|
validationResult
|
||||||
|
.summary
|
||||||
|
.notes
|
||||||
|
}{' '}
|
||||||
|
notes
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||||
{validationResult.summary.tags} tags
|
{
|
||||||
|
validationResult
|
||||||
|
.summary
|
||||||
|
.tags
|
||||||
|
}{' '}
|
||||||
|
tags
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||||
{validationResult.summary.areas} areas
|
{
|
||||||
|
validationResult
|
||||||
|
.summary
|
||||||
|
.areas
|
||||||
|
}{' '}
|
||||||
|
areas
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||||
{validationResult.summary.views} views
|
{
|
||||||
|
validationResult
|
||||||
|
.summary
|
||||||
|
.views
|
||||||
|
}{' '}
|
||||||
|
views
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{validationResult && !validationResult.valid && (
|
{validationResult &&
|
||||||
|
!validationResult.valid && (
|
||||||
<div className="mt-4 pt-4 border-t border-red-200 dark:border-red-800">
|
<div className="mt-4 pt-4 border-t border-red-200 dark:border-red-800">
|
||||||
{validationResult.versionIncompatible ? (
|
{validationResult.versionIncompatible ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
||||||
{t('backup.versionIncompatible', 'Version Incompatible')}
|
{t(
|
||||||
|
'backup.versionIncompatible',
|
||||||
|
'Version Incompatible'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
{validationResult.message}
|
{
|
||||||
|
validationResult.message
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
|
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
|
||||||
{t('backup.backupVersion', 'Backup version')}: {validationResult.backupVersion}
|
{t(
|
||||||
|
'backup.backupVersion',
|
||||||
|
'Backup version'
|
||||||
|
)}
|
||||||
|
:{' '}
|
||||||
|
{
|
||||||
|
validationResult.backupVersion
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
{t('backup.currentVersion', 'Current version')}: {appVersion}
|
{t(
|
||||||
|
'backup.currentVersion',
|
||||||
|
'Current version'
|
||||||
|
)}
|
||||||
|
: {appVersion}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
||||||
{t('backup.validationErrors', 'Validation errors:')}
|
{t(
|
||||||
|
'backup.validationErrors',
|
||||||
|
'Validation errors:'
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-red-600 dark:text-red-400 space-y-1">
|
<ul className="text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||||
{validationResult.errors?.map((error, index) => (
|
{validationResult.errors?.map(
|
||||||
<li key={index}>• {error}</li>
|
(
|
||||||
))}
|
error,
|
||||||
|
index
|
||||||
|
) => (
|
||||||
|
<li
|
||||||
|
key={
|
||||||
|
index
|
||||||
|
}
|
||||||
|
>
|
||||||
|
•{' '}
|
||||||
|
{
|
||||||
|
error
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -650,12 +822,18 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||||
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"
|
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>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{t('backup.importing', 'Importing...')}
|
{t(
|
||||||
|
'backup.importing',
|
||||||
|
'Importing...'
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowUpTrayIcon className="h-5 w-5 mr-2" />
|
<ArrowUpTrayIcon className="h-5 w-5 mr-2" />
|
||||||
{t('backup.restoreBackup', 'Restore Backup')}
|
{t(
|
||||||
|
'backup.restoreBackup',
|
||||||
|
'Restore Backup'
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,10 @@ const Navbar: React.FC<NavbarProps> = ({
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||||
const [pomodoroEnabled, setPomodoroEnabled] = useState(true); // Default to true
|
const [pomodoroEnabled, setPomodoroEnabled] = useState(true); // Default to true
|
||||||
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({ backups: false, calendar: false });
|
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({
|
||||||
|
backups: false,
|
||||||
|
calendar: false,
|
||||||
|
});
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
@ -264,7 +267,10 @@ const Navbar: React.FC<NavbarProps> = ({
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
onClick={() => setIsDropdownOpen(false)}
|
onClick={() => setIsDropdownOpen(false)}
|
||||||
>
|
>
|
||||||
{t('navigation.backupRestore', 'Backup & Restore')}
|
{t(
|
||||||
|
'navigation.backupRestore',
|
||||||
|
'Backup & Restore'
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{currentUser?.is_admin === true && (
|
{currentUser?.is_admin === true && (
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,6 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (totalIssues === 0) {
|
if (totalIssues === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -313,10 +312,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
|
||||||
<ExclamationTriangleIcon className="h-6 w-6 text-yellow-500 dark:text-yellow-400 mr-3" />
|
<ExclamationTriangleIcon className="h-6 w-6 text-yellow-500 dark:text-yellow-400 mr-3" />
|
||||||
<div className="flex-1 text-left">
|
<div className="flex-1 text-left">
|
||||||
<p className="text-gray-700 dark:text-gray-300 font-medium">
|
<p className="text-gray-700 dark:text-gray-300 font-medium">
|
||||||
{t(
|
{t('productivity.issuesFound', { count: totalIssues })}
|
||||||
'productivity.issuesFound',
|
|
||||||
{ count: totalIssues }
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-yellow-600 dark:text-yellow-400 text-sm">
|
<p className="text-yellow-600 dark:text-yellow-400 text-sm">
|
||||||
{t(
|
{t(
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,6 @@ const getShareInitials = (value?: string | null) => {
|
||||||
return cleaned.substring(0, 2) || '?';
|
return cleaned.substring(0, 2) || '?';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const ProjectItem: React.FC<ProjectItemProps> = ({
|
const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
project,
|
project,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
|
@ -221,14 +220,10 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: t(
|
text: t('projectItem.overdue', 'Overdue {{count}} {{unit}} ago', {
|
||||||
'projectItem.overdue',
|
|
||||||
'Overdue {{count}} {{unit}} ago',
|
|
||||||
{
|
|
||||||
count: Math.abs(diff),
|
count: Math.abs(diff),
|
||||||
unit,
|
unit,
|
||||||
}
|
}),
|
||||||
),
|
|
||||||
isOverdue: true,
|
isOverdue: true,
|
||||||
};
|
};
|
||||||
}, [project.due_date_at, t]);
|
}, [project.due_date_at, t]);
|
||||||
|
|
@ -244,9 +239,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
const knownShares = sharedUsers ?? [];
|
const knownShares = sharedUsers ?? [];
|
||||||
const avatars = knownShares.slice(0, MAX_SHARE_AVATARS);
|
const avatars = knownShares.slice(0, MAX_SHARE_AVATARS);
|
||||||
const totalCount =
|
const totalCount =
|
||||||
(sharedUsers?.length ??
|
(sharedUsers?.length ?? project.share_count ?? avatars.length) || 0;
|
||||||
project.share_count ??
|
|
||||||
avatars.length) || 0;
|
|
||||||
const remaining = Math.max(0, totalCount - avatars.length);
|
const remaining = Math.max(0, totalCount - avatars.length);
|
||||||
|
|
||||||
return { avatars, remaining };
|
return { avatars, remaining };
|
||||||
|
|
@ -313,7 +306,10 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
return (
|
return (
|
||||||
<StateIcon
|
<StateIcon
|
||||||
className="h-4 w-4 text-white/80 drop-shadow-sm"
|
className="h-4 w-4 text-white/80 drop-shadow-sm"
|
||||||
title={getStateLabel(project.state, t)}
|
title={getStateLabel(
|
||||||
|
project.state,
|
||||||
|
t
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
@ -353,10 +349,14 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
'Permission denied'
|
'Permission denied'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(
|
||||||
|
null
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleEditProject(project);
|
handleEditProject(
|
||||||
|
project
|
||||||
|
);
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
}}
|
}}
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||||
|
|
@ -369,8 +369,12 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenShare(project);
|
onOpenShare(
|
||||||
setActiveDropdown(null);
|
project
|
||||||
|
);
|
||||||
|
setActiveDropdown(
|
||||||
|
null
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||||
>
|
>
|
||||||
|
|
@ -395,8 +399,12 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setProjectToDelete(project);
|
setProjectToDelete(
|
||||||
setIsConfirmDialogOpen(true);
|
project
|
||||||
|
);
|
||||||
|
setIsConfirmDialogOpen(
|
||||||
|
true
|
||||||
|
);
|
||||||
setActiveDropdown(null);
|
setActiveDropdown(null);
|
||||||
}}
|
}}
|
||||||
className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||||
|
|
@ -453,9 +461,13 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
title={
|
title={
|
||||||
(project as any).task_status
|
(project as any).task_status
|
||||||
? `${(project as any).task_status.done} of ${(project as any).task_status.total} tasks completed (${getCompletionPercentage()}%)`
|
? `${(project as any).task_status.done} of ${(project as any).task_status.total} tasks completed (${getCompletionPercentage()}%)`
|
||||||
: t('projectItem.completionPercentage', {
|
: t(
|
||||||
percentage: getCompletionPercentage(),
|
'projectItem.completionPercentage',
|
||||||
})
|
{
|
||||||
|
percentage:
|
||||||
|
getCompletionPercentage(),
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -475,23 +487,31 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
<div className="flex items-center min-w-0">
|
<div className="flex items-center min-w-0">
|
||||||
{dueInfo.isOverdue ? (
|
{dueInfo.isOverdue ? (
|
||||||
<span className="inline-flex items-center space-x-1 rounded-full bg-red-100 px-2 py-0.5 text-red-700 dark:bg-red-900/40 dark:text-red-300 font-semibold text-[11px] leading-snug">
|
<span className="inline-flex items-center space-x-1 rounded-full bg-red-100 px-2 py-0.5 text-red-700 dark:bg-red-900/40 dark:text-red-300 font-semibold text-[11px] leading-snug">
|
||||||
<ExclamationTriangleIcon className="h-3 w-3 flex-shrink-0" style={{ marginTop: '1px' }} />
|
<ExclamationTriangleIcon
|
||||||
|
className="h-3 w-3 flex-shrink-0"
|
||||||
|
style={{ marginTop: '1px' }}
|
||||||
|
/>
|
||||||
<span>{dueInfo.text}</span>
|
<span>{dueInfo.text}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate">{dueInfo.text}</span>
|
<span className="truncate">
|
||||||
|
{dueInfo.text}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end min-w-0 h-7">
|
<div className="flex items-center justify-end min-w-0 h-7">
|
||||||
{project.is_shared && (
|
{project.is_shared && (
|
||||||
<div className="flex items-center -space-x-2 h-full">
|
<div className="flex items-center -space-x-2 h-full">
|
||||||
<>
|
<>
|
||||||
{shareAvatars.avatars.map((share) => (
|
{shareAvatars.avatars.map(
|
||||||
|
(share) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={`${project.uid}-${share.user_id}`}
|
key={`${project.uid}-${share.user_id}`}
|
||||||
content={
|
content={
|
||||||
share.email
|
share.email
|
||||||
? getShareDisplayName(share.email)
|
? getShareDisplayName(
|
||||||
|
share.email
|
||||||
|
)
|
||||||
: t(
|
: t(
|
||||||
'projectItem.sharedUser',
|
'projectItem.sharedUser',
|
||||||
'Shared user'
|
'Shared user'
|
||||||
|
|
@ -500,17 +520,24 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
>
|
>
|
||||||
{share.avatar_image ? (
|
{share.avatar_image ? (
|
||||||
<img
|
<img
|
||||||
src={getApiPath(share.avatar_image)}
|
src={getApiPath(
|
||||||
alt={getShareDisplayName(share.email)}
|
share.avatar_image
|
||||||
|
)}
|
||||||
|
alt={getShareDisplayName(
|
||||||
|
share.email
|
||||||
|
)}
|
||||||
className="h-7 w-7 rounded-full border-2 border-white object-cover shadow-sm dark:border-gray-900"
|
className="h-7 w-7 rounded-full border-2 border-white object-cover shadow-sm dark:border-gray-900"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-gradient-to-br from-blue-500 to-purple-500 text-xs font-semibold text-white shadow-sm dark:border-gray-900">
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-gradient-to-br from-blue-500 to-purple-500 text-xs font-semibold text-white shadow-sm dark:border-gray-900">
|
||||||
{getShareInitials(share.email)}
|
{getShareInitials(
|
||||||
|
share.email
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
{shareAvatars.remaining > 0 && (
|
{shareAvatars.remaining > 0 && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={t(
|
content={t(
|
||||||
|
|
@ -522,7 +549,8 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-gray-200 text-xs font-semibold text-gray-700 shadow-sm dark:border-gray-900 dark:bg-gray-700 dark:text-gray-200">
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-gray-200 text-xs font-semibold text-gray-700 shadow-sm dark:border-gray-900 dark:bg-gray-700 dark:text-gray-200">
|
||||||
+{shareAvatars.remaining}
|
+
|
||||||
|
{shareAvatars.remaining}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,10 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({ backups: false, calendar: false });
|
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({
|
||||||
|
backups: false,
|
||||||
|
calendar: false,
|
||||||
|
});
|
||||||
|
|
||||||
const inboxItemsCount = store.inboxStore.pagination.total;
|
const inboxItemsCount = store.inboxStore.pagination.total;
|
||||||
|
|
||||||
|
|
@ -72,7 +75,7 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const navLinks = allNavLinks.filter(link => {
|
const navLinks = allNavLinks.filter((link) => {
|
||||||
if (link.featureFlag) {
|
if (link.featureFlag) {
|
||||||
return featureFlags[link.featureFlag as keyof FeatureFlags];
|
return featureFlags[link.featureFlag as keyof FeatureFlags];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1248,6 +1248,7 @@ const TaskDetails: React.FC = () => {
|
||||||
onToggleTodayPlan={handleToggleTodayPlan}
|
onToggleTodayPlan={handleToggleTodayPlan}
|
||||||
onQuickStatusToggle={handleQuickStatusToggle}
|
onQuickStatusToggle={handleQuickStatusToggle}
|
||||||
attachmentCount={attachmentCount}
|
attachmentCount={attachmentCount}
|
||||||
|
subtasksCount={subtasks.length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content - Full width layout */}
|
{/* Content - Full width layout */}
|
||||||
|
|
@ -1285,13 +1286,7 @@ const TaskDetails: React.FC = () => {
|
||||||
onLoadTags={() => tagsStore.loadTags()}
|
onLoadTags={() => tagsStore.loadTags()}
|
||||||
getTagLink={getTagLink}
|
getTagLink={getTagLink}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Schedule Pill */}
|
|
||||||
{activePill === 'schedule' && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<TaskDueDateCard
|
<TaskDueDateCard
|
||||||
task={task}
|
task={task}
|
||||||
isEditing={isEditingDueDate}
|
isEditing={isEditingDueDate}
|
||||||
|
|
@ -1311,8 +1306,13 @@ const TaskDetails: React.FC = () => {
|
||||||
onSave={handleSaveDeferUntil}
|
onSave={handleSaveDeferUntil}
|
||||||
onCancel={handleCancelDeferUntilEdit}
|
onCancel={handleCancelDeferUntilEdit}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
{/* Recurrence Pill */}
|
||||||
|
{activePill === 'recurrence' && (
|
||||||
|
<div className="grid grid-cols-1">
|
||||||
<TaskRecurrenceCard
|
<TaskRecurrenceCard
|
||||||
task={task}
|
task={task}
|
||||||
parentTask={parentTask}
|
parentTask={parentTask}
|
||||||
|
|
@ -1328,7 +1328,6 @@ const TaskDetails: React.FC = () => {
|
||||||
canEdit={!task.recurring_parent_id}
|
canEdit={!task.recurring_parent_id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subtasks Pill */}
|
{/* Subtasks Pill */}
|
||||||
|
|
@ -1362,17 +1361,12 @@ const TaskDetails: React.FC = () => {
|
||||||
|
|
||||||
{/* Activity Pill */}
|
{/* Activity Pill */}
|
||||||
{activePill === 'activity' && (
|
{activePill === 'activity' && (
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('task.recentActivity', 'Recent Activity')}
|
|
||||||
</h4>
|
|
||||||
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 p-6">
|
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 p-6">
|
||||||
<TaskTimeline
|
<TaskTimeline
|
||||||
taskUid={task.uid}
|
taskUid={task.uid}
|
||||||
refreshKey={timelineRefreshKey}
|
refreshKey={timelineRefreshKey}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* End of main content sections */}
|
{/* End of main content sections */}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ interface TaskDetailsHeaderProps {
|
||||||
onToggleTodayPlan?: () => void;
|
onToggleTodayPlan?: () => void;
|
||||||
onQuickStatusToggle?: () => void;
|
onQuickStatusToggle?: () => void;
|
||||||
attachmentCount?: number;
|
attachmentCount?: number;
|
||||||
|
subtasksCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
||||||
|
|
@ -61,6 +62,7 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
||||||
onToggleTodayPlan,
|
onToggleTodayPlan,
|
||||||
onQuickStatusToggle,
|
onQuickStatusToggle,
|
||||||
attachmentCount = 0,
|
attachmentCount = 0,
|
||||||
|
subtasksCount = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
|
|
@ -657,7 +659,7 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500 sm:pl-1 mt-1 sm:mt-0">
|
<span className="text-xs text-gray-400 dark:text-gray-500 sm:pl-1 mt-1 sm:mt-0">
|
||||||
{t(
|
{t(
|
||||||
'task.lastUpdatedAt',
|
'task.lastUpdatedAt',
|
||||||
'Last updated at'
|
'Updated at'
|
||||||
)}
|
)}
|
||||||
:{' '}
|
:{' '}
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
|
@ -761,23 +763,30 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onPillChange('subtasks')}
|
onClick={() => onPillChange('subtasks')}
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors relative ${
|
||||||
activePill === 'subtasks'
|
activePill === 'subtasks'
|
||||||
? 'bg-blue-500 dark:bg-blue-600 text-white'
|
? 'bg-blue-500 dark:bg-blue-600 text-white'
|
||||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('task.subtasks', 'Subtasks')}
|
{t('task.subtasks', 'Subtasks')}
|
||||||
|
{subtasksCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full border border-white dark:border-gray-900"></span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onPillChange('schedule')}
|
onClick={() => onPillChange('recurrence')}
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors relative ${
|
||||||
activePill === 'schedule'
|
activePill === 'recurrence'
|
||||||
? 'bg-blue-500 dark:bg-blue-600 text-white'
|
? 'bg-blue-500 dark:bg-blue-600 text-white'
|
||||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('task.schedule', 'Schedule')}
|
{t('task.recurrence', 'Recurrence')}
|
||||||
|
{task.recurrence_type &&
|
||||||
|
task.recurrence_type !== 'none' && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full border border-white dark:border-gray-900"></span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onPillChange('attachments')}
|
onClick={() => onPillChange('attachments')}
|
||||||
|
|
|
||||||
|
|
@ -113,9 +113,6 @@ const TaskRecurrenceCard: React.FC<TaskRecurrenceCardProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
{t('task.recurringSetup', 'Recurring Setup')}
|
|
||||||
</h4>
|
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 space-y-4 ${
|
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 space-y-4 ${
|
||||||
canEdit && !isEditing ? 'cursor-pointer' : ''
|
canEdit && !isEditing ? 'cursor-pointer' : ''
|
||||||
|
|
@ -239,7 +236,7 @@ const TaskRecurrenceCard: React.FC<TaskRecurrenceCardProps> = ({
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||||
{t(
|
{t(
|
||||||
'task.notRecurring',
|
'task.notRecurring',
|
||||||
'This task is not recurring yet.'
|
'Add recurrence details'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,16 @@ const deferOptions = [
|
||||||
{ value: 'next_month', labelKey: 'dateIndicators.nextMonth' },
|
{ value: 'next_month', labelKey: 'dateIndicators.nextMonth' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const recurringOptions = [
|
const extrasOptions = [
|
||||||
{ value: 'recurring', labelKey: 'search.recurringFilter.recurring' },
|
{ value: 'recurring', labelKey: 'search.extrasFilter.isRecurring' },
|
||||||
{ value: 'non_recurring', labelKey: 'search.recurringFilter.nonRecurring' },
|
{ value: 'overdue', labelKey: 'search.extrasFilter.isOverdue' },
|
||||||
{ value: 'instances', labelKey: 'search.recurringFilter.instances' },
|
{ value: 'has_content', labelKey: 'search.extrasFilter.hasContent' },
|
||||||
|
{ value: 'deferred', labelKey: 'search.extrasFilter.isDeferred' },
|
||||||
|
{ value: 'has_tags', labelKey: 'search.extrasFilter.hasTags' },
|
||||||
|
{
|
||||||
|
value: 'assigned_to_project',
|
||||||
|
labelKey: 'search.extrasFilter.isAssignedToProject',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const SearchMenu: React.FC<SearchMenuProps> = ({
|
const SearchMenu: React.FC<SearchMenuProps> = ({
|
||||||
|
|
@ -82,9 +88,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
||||||
const [selectedDue, setSelectedDue] = useState<string | null>(null);
|
const [selectedDue, setSelectedDue] = useState<string | null>(null);
|
||||||
const [selectedDefer, setSelectedDefer] = useState<string | null>(null);
|
const [selectedDefer, setSelectedDefer] = useState<string | null>(null);
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [selectedRecurring, setSelectedRecurring] = useState<string | null>(
|
const [selectedExtras, setSelectedExtras] = useState<string[]>([]);
|
||||||
null
|
|
||||||
);
|
|
||||||
const [availableTags, setAvailableTags] = useState<
|
const [availableTags, setAvailableTags] = useState<
|
||||||
Array<{ id: number; name: string }>
|
Array<{ id: number; name: string }>
|
||||||
>([]);
|
>([]);
|
||||||
|
|
@ -132,9 +136,11 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRecurringToggle = (recurring: string) => {
|
const handleExtrasToggle = (extra: string) => {
|
||||||
setSelectedRecurring(
|
setSelectedExtras((prev) =>
|
||||||
selectedRecurring === recurring ? null : recurring
|
prev.includes(extra)
|
||||||
|
? prev.filter((e) => e !== extra)
|
||||||
|
: [...prev, extra]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -162,7 +168,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
||||||
due: selectedDue || null,
|
due: selectedDue || null,
|
||||||
defer: selectedDefer || null,
|
defer: selectedDefer || null,
|
||||||
tags: selectedTags.length > 0 ? selectedTags : null,
|
tags: selectedTags.length > 0 ? selectedTags : null,
|
||||||
recurring: selectedRecurring || null,
|
extras: selectedExtras.length > 0 ? selectedExtras : null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -350,25 +356,45 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
||||||
parts.push(...tagsWithSeparators);
|
parts.push(...tagsWithSeparators);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add recurring filter
|
// Add extras filters
|
||||||
if (selectedRecurring) {
|
if (selectedExtras.length > 0) {
|
||||||
const recurringOption = recurringOptions.find(
|
|
||||||
(opt) => opt.value === selectedRecurring
|
|
||||||
);
|
|
||||||
const recurringLabel = recurringOption
|
|
||||||
? t(recurringOption.labelKey)
|
|
||||||
: selectedRecurring;
|
|
||||||
parts.push(
|
parts.push(
|
||||||
<span key="recurring-label">{t('search.thatAre') + ' '}</span>
|
<span key="extras-label">{t('search.thatAre') + ' '}</span>
|
||||||
);
|
);
|
||||||
parts.push(
|
const extrasElements = selectedExtras.map((extra) => {
|
||||||
|
const extraOption = extrasOptions.find(
|
||||||
|
(opt) => opt.value === extra
|
||||||
|
);
|
||||||
|
const extraLabel = extraOption
|
||||||
|
? t(extraOption.labelKey)
|
||||||
|
: extra;
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
key="recurring"
|
key={`extra-${extra}`}
|
||||||
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
||||||
>
|
>
|
||||||
{recurringLabel}
|
{extraLabel}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
const extrasWithSeparators: React.ReactNode[] = [];
|
||||||
|
extrasElements.forEach((extraEl, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
if (index === extrasElements.length - 1) {
|
||||||
|
extrasWithSeparators.push(
|
||||||
|
<span key={`sep-extra-and-${index}`}>
|
||||||
|
{' ' + t('search.and') + ' '}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
extrasWithSeparators.push(
|
||||||
|
<span key={`sep-extra-comma-${index}`}>{', '}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extrasWithSeparators.push(extraEl);
|
||||||
|
});
|
||||||
|
parts.push(...extrasWithSeparators);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
|
|
@ -389,7 +415,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
||||||
selectedDue ||
|
selectedDue ||
|
||||||
selectedDefer ||
|
selectedDefer ||
|
||||||
selectedTags.length > 0 ||
|
selectedTags.length > 0 ||
|
||||||
selectedRecurring;
|
selectedExtras.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -567,31 +593,23 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
||||||
{t('search.extras')}
|
{t('search.extras')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recurring Filters */}
|
{/* Extras Filters */}
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
|
||||||
{t('search.recurringFilter.label')}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{recurringOptions.map((option) => (
|
{extrasOptions.map((option) => (
|
||||||
<FilterBadge
|
<FilterBadge
|
||||||
key={option.value}
|
key={option.value}
|
||||||
name={t(option.labelKey)}
|
name={t(option.labelKey)}
|
||||||
color="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
color="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
||||||
isSelected={
|
isSelected={selectedExtras.includes(
|
||||||
selectedRecurring ===
|
|
||||||
option.value
|
option.value
|
||||||
}
|
)}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
handleRecurringToggle(
|
handleExtrasToggle(option.value)
|
||||||
option.value
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save as Smart View Section */}
|
{/* Save as Smart View Section */}
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
|
|
@ -683,7 +701,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
||||||
selectedDue={selectedDue}
|
selectedDue={selectedDue}
|
||||||
selectedDefer={selectedDefer}
|
selectedDefer={selectedDefer}
|
||||||
selectedTags={selectedTags}
|
selectedTags={selectedTags}
|
||||||
selectedRecurring={selectedRecurring}
|
selectedExtras={selectedExtras}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ interface SearchResultsProps {
|
||||||
selectedDue: string | null;
|
selectedDue: string | null;
|
||||||
selectedDefer: string | null;
|
selectedDefer: string | null;
|
||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
selectedRecurring: string | null;
|
selectedExtras: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
||||||
selectedDue,
|
selectedDue,
|
||||||
selectedDefer,
|
selectedDefer,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
selectedRecurring,
|
selectedExtras,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -56,7 +56,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
||||||
!selectedDue &&
|
!selectedDue &&
|
||||||
!selectedDefer &&
|
!selectedDefer &&
|
||||||
selectedTags.length === 0 &&
|
selectedTags.length === 0 &&
|
||||||
!selectedRecurring
|
selectedExtras.length === 0
|
||||||
) {
|
) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
return;
|
return;
|
||||||
|
|
@ -71,7 +71,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
||||||
due: selectedDue || undefined,
|
due: selectedDue || undefined,
|
||||||
defer: selectedDefer || undefined,
|
defer: selectedDefer || undefined,
|
||||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
recurring: selectedRecurring || undefined,
|
extras:
|
||||||
|
selectedExtras.length > 0 ? selectedExtras : undefined,
|
||||||
});
|
});
|
||||||
setResults(data.results);
|
setResults(data.results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -91,7 +92,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
||||||
selectedDue,
|
selectedDue,
|
||||||
selectedDefer,
|
selectedDefer,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
selectedRecurring,
|
selectedExtras,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const getIcon = (type: string) => {
|
const getIcon = (type: string) => {
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,9 @@ interface View {
|
||||||
filters: string[];
|
filters: string[];
|
||||||
priority: string | null;
|
priority: string | null;
|
||||||
due: string | null;
|
due: string | null;
|
||||||
|
defer: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
recurring: string | null;
|
extras: string[] | null;
|
||||||
is_pinned: boolean;
|
is_pinned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,21 +229,31 @@ const ViewDetail: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const viewData = await viewResponse.json();
|
const viewData = await viewResponse.json();
|
||||||
setView(viewData);
|
const normalizedView: View = {
|
||||||
|
...viewData,
|
||||||
|
tags: viewData.tags || [],
|
||||||
|
extras: viewData.extras || [],
|
||||||
|
defer: viewData.defer || null,
|
||||||
|
};
|
||||||
|
setView(normalizedView);
|
||||||
|
|
||||||
const currentOffset = resetPagination ? 0 : offset;
|
const currentOffset = resetPagination ? 0 : offset;
|
||||||
|
|
||||||
// Fetch search results with pagination and exclude subtasks
|
// Fetch search results with pagination and exclude subtasks
|
||||||
const response = await searchUniversal({
|
const response = await searchUniversal({
|
||||||
query: viewData.search_query || '',
|
query: normalizedView.search_query || '',
|
||||||
filters: viewData.filters,
|
filters: normalizedView.filters,
|
||||||
priority: viewData.priority || undefined,
|
priority: normalizedView.priority || undefined,
|
||||||
due: viewData.due || undefined,
|
due: normalizedView.due || undefined,
|
||||||
|
defer: normalizedView.defer || undefined,
|
||||||
tags:
|
tags:
|
||||||
viewData.tags && viewData.tags.length > 0
|
normalizedView.tags && normalizedView.tags.length > 0
|
||||||
? viewData.tags
|
? normalizedView.tags
|
||||||
|
: undefined,
|
||||||
|
extras:
|
||||||
|
normalizedView.extras && normalizedView.extras.length > 0
|
||||||
|
? normalizedView.extras
|
||||||
: undefined,
|
: undefined,
|
||||||
recurring: viewData.recurring || undefined,
|
|
||||||
limit: limit,
|
limit: limit,
|
||||||
offset: currentOffset,
|
offset: currentOffset,
|
||||||
excludeSubtasks: true,
|
excludeSubtasks: true,
|
||||||
|
|
@ -742,6 +753,19 @@ const ViewDetail: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{view.defer && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
||||||
|
{t('search.deferUntil')}
|
||||||
|
</p>
|
||||||
|
<span className="px-2 py-1 bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200 rounded text-xs font-medium capitalize">
|
||||||
|
{view.defer.replace(
|
||||||
|
/_/g,
|
||||||
|
' '
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{view.tags &&
|
{view.tags &&
|
||||||
view.tags.length > 0 && (
|
view.tags.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -764,17 +788,32 @@ const ViewDetail: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{view.recurring && (
|
{view.extras &&
|
||||||
|
view.extras.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
||||||
{t('views.recurring')}
|
{t('search.extras')}
|
||||||
</p>
|
</p>
|
||||||
<span className="px-2 py-1 bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded text-xs font-medium capitalize">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{view.recurring.replace(
|
{view.extras.map(
|
||||||
|
(
|
||||||
|
extra,
|
||||||
|
index
|
||||||
|
) => (
|
||||||
|
<span
|
||||||
|
key={
|
||||||
|
index
|
||||||
|
}
|
||||||
|
className="px-2 py-1 bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded text-xs font-medium capitalize"
|
||||||
|
>
|
||||||
|
{extra.replace(
|
||||||
/_/g,
|
/_/g,
|
||||||
' '
|
' '
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!view.filters.length &&
|
{!view.filters.length &&
|
||||||
|
|
@ -783,7 +822,9 @@ const ViewDetail: React.FC = () => {
|
||||||
!view.due &&
|
!view.due &&
|
||||||
(!view.tags ||
|
(!view.tags ||
|
||||||
view.tags.length === 0) &&
|
view.tags.length === 0) &&
|
||||||
!view.recurring && (
|
(!view.extras ||
|
||||||
|
view.extras.length ===
|
||||||
|
0) && (
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 italic">
|
<p className="text-sm text-gray-600 dark:text-gray-400 italic">
|
||||||
{t(
|
{t(
|
||||||
'views.noCriteriaSet'
|
'views.noCriteriaSet'
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ interface View {
|
||||||
filters: string[];
|
filters: string[];
|
||||||
priority: string | null;
|
priority: string | null;
|
||||||
due: string | null;
|
due: string | null;
|
||||||
|
defer: string | null;
|
||||||
|
tags: string[];
|
||||||
|
extras: string[] | null;
|
||||||
is_pinned: boolean;
|
is_pinned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,7 +46,13 @@ const Views: React.FC = () => {
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setViews(data);
|
const normalized: View[] = data.map((view: View) => ({
|
||||||
|
...view,
|
||||||
|
tags: view.tags || [],
|
||||||
|
extras: view.extras || [],
|
||||||
|
defer: view.defer || null,
|
||||||
|
}));
|
||||||
|
setViews(normalized);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching views:', error);
|
console.error('Error fetching views:', error);
|
||||||
|
|
@ -236,6 +245,24 @@ const Views: React.FC = () => {
|
||||||
{view.due}
|
{view.due}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{view.defer && (
|
||||||
|
<p>
|
||||||
|
•{' '}
|
||||||
|
{t('search.deferUntil')}{' '}
|
||||||
|
{view.defer}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{view.extras &&
|
||||||
|
view.extras.length > 0 && (
|
||||||
|
<p>
|
||||||
|
•{' '}
|
||||||
|
{t('search.extras')}
|
||||||
|
:{' '}
|
||||||
|
{view.extras.join(
|
||||||
|
', '
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,9 +222,7 @@ export const importBackup = async (
|
||||||
/**
|
/**
|
||||||
* Validate backup file without importing
|
* Validate backup file without importing
|
||||||
*/
|
*/
|
||||||
export const validateBackup = async (
|
export const validateBackup = async (file: File): Promise<ValidationResult> => {
|
||||||
file: File
|
|
||||||
): Promise<ValidationResult> => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('backup', file);
|
formData.append('backup', file);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ interface SearchParams {
|
||||||
due?: string;
|
due?: string;
|
||||||
defer?: string;
|
defer?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
recurring?: string;
|
extras?: string[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
excludeSubtasks?: boolean;
|
excludeSubtasks?: boolean;
|
||||||
|
|
@ -66,8 +66,8 @@ export const searchUniversal = async (
|
||||||
queryParams.append('tags', params.tags.join(','));
|
queryParams.append('tags', params.tags.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.recurring) {
|
if (params.extras && params.extras.length > 0) {
|
||||||
queryParams.append('recurring', params.recurring);
|
queryParams.append('extras', params.extras.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.limit !== undefined) {
|
if (params.limit !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -745,7 +745,7 @@
|
||||||
"dueDate": "تاريخ الاستحقاق",
|
"dueDate": "تاريخ الاستحقاق",
|
||||||
"deferUntil": "تأجيل حتى",
|
"deferUntil": "تأجيل حتى",
|
||||||
"recurringSetup": "إعداد متكرر",
|
"recurringSetup": "إعداد متكرر",
|
||||||
"notRecurring": "هذه المهمة ليست متكررة بعد.",
|
"notRecurring": "أضف تفاصيل التكرار",
|
||||||
"clickToEditTitle": "انقر لتحرير العنوان",
|
"clickToEditTitle": "انقر لتحرير العنوان",
|
||||||
"clickToEditContent": "انقر لتحرير المحتوى",
|
"clickToEditContent": "انقر لتحرير المحتوى",
|
||||||
"clickToAddContent": "انقر لإضافة محتوى",
|
"clickToAddContent": "انقر لإضافة محتوى",
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "حالات متكررة"
|
"instances": "حالات متكررة"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "تأجيل حتى",
|
"deferUntilFilter": "تأجيل حتى",
|
||||||
"deferUntil": "، تأجيل حتى"
|
"deferUntil": "، تأجيل حتى",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "يتكرر",
|
||||||
|
"isOverdue": "متأخر",
|
||||||
|
"hasContent": "يمتلك محتوى",
|
||||||
|
"isDeferred": "مؤجل",
|
||||||
|
"hasTags": "يمتلك علامات",
|
||||||
|
"isAssignedToProject": "مخصص لمشروع"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "أضف مهمة فرعية..."
|
"placeholder": "أضف مهمة فرعية..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "повтарящи се инстанции"
|
"instances": "повтарящи се инстанции"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Отложи до",
|
"deferUntilFilter": "Отложи до",
|
||||||
"deferUntil": ", отложи до"
|
"deferUntil": ", отложи до",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "е Повтарящо се",
|
||||||
|
"isOverdue": "е Просрочено",
|
||||||
|
"hasContent": "има Съдържание",
|
||||||
|
"isDeferred": "е Отложено",
|
||||||
|
"hasTags": "има Тагове",
|
||||||
|
"isAssignedToProject": "е Назначено на Проект"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Добавете подзадача..."
|
"placeholder": "Добавете подзадача..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "gentagende instanser"
|
"instances": "gentagende instanser"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Udskyd indtil",
|
"deferUntilFilter": "Udskyd indtil",
|
||||||
"deferUntil": ", udskyd indtil"
|
"deferUntil": ", udskyd indtil",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "er tilbagevendende",
|
||||||
|
"isOverdue": "er forfalden",
|
||||||
|
"hasContent": "har indhold",
|
||||||
|
"isDeferred": "er udsat",
|
||||||
|
"hasTags": "har tags",
|
||||||
|
"isAssignedToProject": "er tildelt projekt"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Tilføj en underopgave..."
|
"placeholder": "Tilføj en underopgave..."
|
||||||
|
|
|
||||||
|
|
@ -1215,7 +1215,15 @@
|
||||||
"instances": "wiederkehrende Instanzen"
|
"instances": "wiederkehrende Instanzen"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Bis zu",
|
"deferUntilFilter": "Bis zu",
|
||||||
"deferUntil": ", bis zu"
|
"deferUntil": ", bis zu",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "ist wiederkehrend",
|
||||||
|
"isOverdue": "ist überfällig",
|
||||||
|
"hasContent": "hat Inhalt",
|
||||||
|
"isDeferred": "ist verschoben",
|
||||||
|
"hasTags": "hat Tags",
|
||||||
|
"isAssignedToProject": "ist einem Projekt zugewiesen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Fügen Sie eine Unteraufgabe hinzu..."
|
"placeholder": "Fügen Sie eine Unteraufgabe hinzu..."
|
||||||
|
|
|
||||||
|
|
@ -1210,7 +1210,15 @@
|
||||||
"instances": "επαναλαμβανόμενες περιπτώσεις"
|
"instances": "επαναλαμβανόμενες περιπτώσεις"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Αναβολή μέχρι",
|
"deferUntilFilter": "Αναβολή μέχρι",
|
||||||
"deferUntil": ", αναβολή μέχρι"
|
"deferUntil": ", αναβολή μέχρι",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "είναι Επαναλαμβανόμενο",
|
||||||
|
"isOverdue": "είναι Υπερβολικό",
|
||||||
|
"hasContent": "έχει Περιεχόμενο",
|
||||||
|
"isDeferred": "είναι Αναβληθέν",
|
||||||
|
"hasTags": "έχει Ετικέτες",
|
||||||
|
"isAssignedToProject": "είναι Ανατεθειμένο σε Έργο"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Προσθέστε μια υποεργασία..."
|
"placeholder": "Προσθέστε μια υποεργασία..."
|
||||||
|
|
|
||||||
|
|
@ -723,7 +723,7 @@
|
||||||
"dueDate": "Due Date",
|
"dueDate": "Due Date",
|
||||||
"noDueDate": "No due date",
|
"noDueDate": "No due date",
|
||||||
"recurringSetup": "Recurring Setup",
|
"recurringSetup": "Recurring Setup",
|
||||||
"notRecurring": "This task is not recurring yet.",
|
"notRecurring": "Add recurrence details",
|
||||||
"instanceOf": "This is an instance of a recurring task",
|
"instanceOf": "This is an instance of a recurring task",
|
||||||
"parentTask": "Parent Task",
|
"parentTask": "Parent Task",
|
||||||
"clickToEditTitle": "Click to edit title",
|
"clickToEditTitle": "Click to edit title",
|
||||||
|
|
@ -1179,11 +1179,13 @@
|
||||||
"deferUntilFilter": "Defer Until",
|
"deferUntilFilter": "Defer Until",
|
||||||
"deferUntil": ", defer until",
|
"deferUntil": ", defer until",
|
||||||
"tagsFilter": "Tags",
|
"tagsFilter": "Tags",
|
||||||
"recurringFilter": {
|
"extrasFilter": {
|
||||||
"label": "Recurring",
|
"isRecurring": "is Recurring",
|
||||||
"recurring": "recurring templates",
|
"isOverdue": "is Overdue",
|
||||||
"nonRecurring": "non-recurring",
|
"hasContent": "has Content",
|
||||||
"instances": "recurring instances"
|
"isDeferred": "is Deferred",
|
||||||
|
"hasTags": "has Tags",
|
||||||
|
"isAssignedToProject": "is Assigned to Project"
|
||||||
},
|
},
|
||||||
"saveAsSmartView": "Save as Smart View",
|
"saveAsSmartView": "Save as Smart View",
|
||||||
"viewName": "View Name",
|
"viewName": "View Name",
|
||||||
|
|
|
||||||
|
|
@ -1207,7 +1207,15 @@
|
||||||
"instances": "instancias recurrentes"
|
"instances": "instancias recurrentes"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Aplazar hasta",
|
"deferUntilFilter": "Aplazar hasta",
|
||||||
"deferUntil": ", aplazar hasta"
|
"deferUntil": ", aplazar hasta",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "es Recurrente",
|
||||||
|
"isOverdue": "está Vencido",
|
||||||
|
"hasContent": "tiene Contenido",
|
||||||
|
"isDeferred": "está Diferido",
|
||||||
|
"hasTags": "tiene Etiquetas",
|
||||||
|
"isAssignedToProject": "está Asignado al Proyecto"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Agregar una subtarea..."
|
"placeholder": "Agregar una subtarea..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "toistuvat instanssit"
|
"instances": "toistuvat instanssit"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Viivästytä kunnes",
|
"deferUntilFilter": "Viivästytä kunnes",
|
||||||
"deferUntil": ", viivästytä kunnes"
|
"deferUntil": ", viivästytä kunnes",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "on Toistuva",
|
||||||
|
"isOverdue": "on Erääntynyt",
|
||||||
|
"hasContent": "on Sisältöä",
|
||||||
|
"isDeferred": "on Siirretty",
|
||||||
|
"hasTags": "on Tunnisteita",
|
||||||
|
"isAssignedToProject": "on Määrätty Projektiin"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Lisää alitehtävä..."
|
"placeholder": "Lisää alitehtävä..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "instances récurrentes"
|
"instances": "instances récurrentes"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Différer jusqu'à",
|
"deferUntilFilter": "Différer jusqu'à",
|
||||||
"deferUntil": ", différer jusqu'à"
|
"deferUntil": ", différer jusqu'à",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "est Récurrent",
|
||||||
|
"isOverdue": "est En Retard",
|
||||||
|
"hasContent": "a du Contenu",
|
||||||
|
"isDeferred": "est Différé",
|
||||||
|
"hasTags": "a des Étiquettes",
|
||||||
|
"isAssignedToProject": "est Assigné au Projet"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Ajouter une sous-tâche..."
|
"placeholder": "Ajouter une sous-tâche..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "instansi berulang"
|
"instances": "instansi berulang"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Tunda Hingga",
|
"deferUntilFilter": "Tunda Hingga",
|
||||||
"deferUntil": ", tunda hingga"
|
"deferUntil": ", tunda hingga",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "adalah Berulang",
|
||||||
|
"isOverdue": "adalah Terlambat",
|
||||||
|
"hasContent": "memiliki Konten",
|
||||||
|
"isDeferred": "adalah Ditunda",
|
||||||
|
"hasTags": "memiliki Tag",
|
||||||
|
"isAssignedToProject": "adalah Ditugaskan ke Proyek"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Tambahkan subtugas..."
|
"placeholder": "Tambahkan subtugas..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "istanze ricorrenti"
|
"instances": "istanze ricorrenti"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Rimanda fino a",
|
"deferUntilFilter": "Rimanda fino a",
|
||||||
"deferUntil": ", rimanda fino a"
|
"deferUntil": ", rimanda fino a",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "è Ricorrente",
|
||||||
|
"isOverdue": "è Scaduto",
|
||||||
|
"hasContent": "ha Contenuto",
|
||||||
|
"isDeferred": "è Rinviato",
|
||||||
|
"hasTags": "ha Tag",
|
||||||
|
"isAssignedToProject": "è Assegnato al Progetto"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Aggiungi un sottocompito..."
|
"placeholder": "Aggiungi un sottocompito..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "定期的なインスタンス"
|
"instances": "定期的なインスタンス"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "フィルターまで遅延",
|
"deferUntilFilter": "フィルターまで遅延",
|
||||||
"deferUntil": "、遅延するまで"
|
"deferUntil": "、遅延するまで",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "繰り返し",
|
||||||
|
"isOverdue": "期限切れ",
|
||||||
|
"hasContent": "コンテンツあり",
|
||||||
|
"isDeferred": "保留中",
|
||||||
|
"hasTags": "タグあり",
|
||||||
|
"isAssignedToProject": "プロジェクトに割り当てられている"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "サブタスクを追加..."
|
"placeholder": "サブタスクを追加..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "반복 인스턴스"
|
"instances": "반복 인스턴스"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "지연할 때까지",
|
"deferUntilFilter": "지연할 때까지",
|
||||||
"deferUntil": ", 지연할 때까지"
|
"deferUntil": ", 지연할 때까지",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "반복됨",
|
||||||
|
"isOverdue": "연체됨",
|
||||||
|
"hasContent": "내용이 있음",
|
||||||
|
"isDeferred": "연기됨",
|
||||||
|
"hasTags": "태그가 있음",
|
||||||
|
"isAssignedToProject": "프로젝트에 할당됨"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "하위 작업 추가..."
|
"placeholder": "하위 작업 추가..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "herhalende instanties"
|
"instances": "herhalende instanties"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Uitstellen tot",
|
"deferUntilFilter": "Uitstellen tot",
|
||||||
"deferUntil": ", uitstellen tot"
|
"deferUntil": ", uitstellen tot",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "is Herhalend",
|
||||||
|
"isOverdue": "is Achterstallig",
|
||||||
|
"hasContent": "heeft Inhoud",
|
||||||
|
"isDeferred": "is Uitgesteld",
|
||||||
|
"hasTags": "heeft Tags",
|
||||||
|
"isAssignedToProject": "is Toegewezen aan Project"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Voeg een subtaak toe..."
|
"placeholder": "Voeg een subtaak toe..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "gjentakende instanser"
|
"instances": "gjentakende instanser"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Utsett til",
|
"deferUntilFilter": "Utsett til",
|
||||||
"deferUntil": ", utsett til"
|
"deferUntil": ", utsett til",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "er Gjentakende",
|
||||||
|
"isOverdue": "er Forfalt",
|
||||||
|
"hasContent": "har Innhold",
|
||||||
|
"isDeferred": "er Utsatt",
|
||||||
|
"hasTags": "har Tagger",
|
||||||
|
"isAssignedToProject": "er Tildelt Prosjekt"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Legg til en underoppgave..."
|
"placeholder": "Legg til en underoppgave..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "powtarzające się instancje"
|
"instances": "powtarzające się instancje"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Odłóż do",
|
"deferUntilFilter": "Odłóż do",
|
||||||
"deferUntil": ", odłóż do"
|
"deferUntil": ", odłóż do",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "jest powtarzający się",
|
||||||
|
"isOverdue": "jest przeterminowany",
|
||||||
|
"hasContent": "ma zawartość",
|
||||||
|
"isDeferred": "jest odroczony",
|
||||||
|
"hasTags": "ma tagi",
|
||||||
|
"isAssignedToProject": "jest przypisany do projektu"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Dodaj podzadanie..."
|
"placeholder": "Dodaj podzadanie..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "instâncias recorrentes"
|
"instances": "instâncias recorrentes"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Aguardar Até",
|
"deferUntilFilter": "Aguardar Até",
|
||||||
"deferUntil": ", aguardar até"
|
"deferUntil": ", aguardar até",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "é Recorrente",
|
||||||
|
"isOverdue": "está Atrasado",
|
||||||
|
"hasContent": "tem Conteúdo",
|
||||||
|
"isDeferred": "está Adiado",
|
||||||
|
"hasTags": "tem Tags",
|
||||||
|
"isAssignedToProject": "está Atribuído ao Projeto"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Adicionar uma subtarefa..."
|
"placeholder": "Adicionar uma subtarefa..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "instanțe recurente"
|
"instances": "instanțe recurente"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Amână până la",
|
"deferUntilFilter": "Amână până la",
|
||||||
"deferUntil": ", amână până la"
|
"deferUntil": ", amână până la",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "este Recurent",
|
||||||
|
"isOverdue": "este Întârziat",
|
||||||
|
"hasContent": "are Conținut",
|
||||||
|
"isDeferred": "este Amânat",
|
||||||
|
"hasTags": "are Etichete",
|
||||||
|
"isAssignedToProject": "este Atribuit Proiectului"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Adaugă o subtask..."
|
"placeholder": "Adaugă o subtask..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "повторяющиеся экземпляры"
|
"instances": "повторяющиеся экземпляры"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Отложить до",
|
"deferUntilFilter": "Отложить до",
|
||||||
"deferUntil": ", отложить до"
|
"deferUntil": ", отложить до",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "является повторяющимся",
|
||||||
|
"isOverdue": "просрочено",
|
||||||
|
"hasContent": "имеет содержимое",
|
||||||
|
"isDeferred": "отложено",
|
||||||
|
"hasTags": "имеет теги",
|
||||||
|
"isAssignedToProject": "назначено на проект"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Добавить подзадачу..."
|
"placeholder": "Добавить подзадачу..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "ponavljajoče se instance"
|
"instances": "ponavljajoče se instance"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Odloži do",
|
"deferUntilFilter": "Odloži do",
|
||||||
"deferUntil": ", odloži do"
|
"deferUntil": ", odloži do",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "je ponavljajoče",
|
||||||
|
"isOverdue": "je zapadlo",
|
||||||
|
"hasContent": "ima vsebino",
|
||||||
|
"isDeferred": "je odloženo",
|
||||||
|
"hasTags": "ima oznake",
|
||||||
|
"isAssignedToProject": "je dodeljeno projektu"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Dodaj podnalogo..."
|
"placeholder": "Dodaj podnalogo..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "återkommande instanser"
|
"instances": "återkommande instanser"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Skjut upp tills",
|
"deferUntilFilter": "Skjut upp tills",
|
||||||
"deferUntil": ", skjuta upp tills"
|
"deferUntil": ", skjuta upp tills",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "är Återkommande",
|
||||||
|
"isOverdue": "är Försenad",
|
||||||
|
"hasContent": "har Innehåll",
|
||||||
|
"isDeferred": "är Utsatt",
|
||||||
|
"hasTags": "har Taggar",
|
||||||
|
"isAssignedToProject": "är Tilldelad Projekt"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Lägg till en deluppgift..."
|
"placeholder": "Lägg till en deluppgift..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "tekrarlayan örnekler"
|
"instances": "tekrarlayan örnekler"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Ertele",
|
"deferUntilFilter": "Ertele",
|
||||||
"deferUntil": ", ertele"
|
"deferUntil": ", ertele",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "Tekrarlayan",
|
||||||
|
"isOverdue": "Vadesi Geçmiş",
|
||||||
|
"hasContent": "İçerik Var",
|
||||||
|
"isDeferred": "Ertelenmiş",
|
||||||
|
"hasTags": "Etiket Var",
|
||||||
|
"isAssignedToProject": "Projeye Atanmış"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Bir alt görev ekle..."
|
"placeholder": "Bir alt görev ekle..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "повторювані екземпляри"
|
"instances": "повторювані екземпляри"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Відкласти до",
|
"deferUntilFilter": "Відкласти до",
|
||||||
"deferUntil": ", відкласти до"
|
"deferUntil": ", відкласти до",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "є повторюваним",
|
||||||
|
"isOverdue": "прострочено",
|
||||||
|
"hasContent": "має вміст",
|
||||||
|
"isDeferred": "відкладено",
|
||||||
|
"hasTags": "має теги",
|
||||||
|
"isAssignedToProject": "призначено проекту"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Додати підзадачу..."
|
"placeholder": "Додати підзадачу..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "các phiên bản lặp lại"
|
"instances": "các phiên bản lặp lại"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "Hoãn lại cho đến",
|
"deferUntilFilter": "Hoãn lại cho đến",
|
||||||
"deferUntil": ", hoãn lại cho đến"
|
"deferUntil": ", hoãn lại cho đến",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "là Lặp lại",
|
||||||
|
"isOverdue": "đã Quá hạn",
|
||||||
|
"hasContent": "có Nội dung",
|
||||||
|
"isDeferred": "đã Hoãn lại",
|
||||||
|
"hasTags": "có Thẻ",
|
||||||
|
"isAssignedToProject": "được Giao cho Dự án"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "Thêm một công việc phụ..."
|
"placeholder": "Thêm một công việc phụ..."
|
||||||
|
|
|
||||||
|
|
@ -1206,7 +1206,15 @@
|
||||||
"instances": "循环实例"
|
"instances": "循环实例"
|
||||||
},
|
},
|
||||||
"deferUntilFilter": "延迟到",
|
"deferUntilFilter": "延迟到",
|
||||||
"deferUntil": ",延迟到"
|
"deferUntil": ",延迟到",
|
||||||
|
"extrasFilter": {
|
||||||
|
"isRecurring": "是重复的",
|
||||||
|
"isOverdue": "已逾期",
|
||||||
|
"hasContent": "有内容",
|
||||||
|
"isDeferred": "已延迟",
|
||||||
|
"hasTags": "有标签",
|
||||||
|
"isAssignedToProject": "已分配给项目"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subtasks": {
|
"subtasks": {
|
||||||
"placeholder": "添加子任务..."
|
"placeholder": "添加子任务..."
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||||
const webpack = require('webpack');
|
const webpack = require('webpack');
|
||||||
|
|
||||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||||
|
const frontendPort = parseInt(process.env.FRONTEND_PORT || '8080', 10);
|
||||||
|
const frontendHost = process.env.FRONTEND_HOST || '0.0.0.0';
|
||||||
|
const backendUrl = process.env.BACKEND_URL || 'http://localhost:3002';
|
||||||
|
const frontendUrl = new URL(
|
||||||
|
process.env.FRONTEND_ORIGIN || `http://localhost:${frontendPort}`
|
||||||
|
);
|
||||||
|
const frontendOrigin = frontendUrl.origin;
|
||||||
|
const frontendCookieDomain = frontendUrl.hostname;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: './frontend/index.tsx',
|
entry: './frontend/index.tsx',
|
||||||
|
|
@ -29,22 +37,22 @@ module.exports = {
|
||||||
},
|
},
|
||||||
hot: isDevelopment,
|
hot: isDevelopment,
|
||||||
watchFiles: isDevelopment ? ['frontend/**/*'] : [],
|
watchFiles: isDevelopment ? ['frontend/**/*'] : [],
|
||||||
port: 8080,
|
port: frontendPort,
|
||||||
host: '0.0.0.0',
|
host: frontendHost,
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
proxy: [
|
proxy: [
|
||||||
{
|
{
|
||||||
context: ['/api', '/locales'],
|
context: ['/api', '/locales'],
|
||||||
target: 'http://localhost:3002',
|
target: backendUrl,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
cookieDomainRewrite: 'localhost',
|
cookieDomainRewrite: frontendCookieDomain,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
},
|
},
|
||||||
onProxyRes: function (proxyRes, req, res) {
|
onProxyRes: function (proxyRes, req, res) {
|
||||||
proxyRes.headers['Access-Control-Allow-Origin'] =
|
proxyRes.headers['Access-Control-Allow-Origin'] =
|
||||||
'http://localhost:8080';
|
frontendOrigin;
|
||||||
proxyRes.headers['Access-Control-Allow-Credentials'] =
|
proxyRes.headers['Access-Control-Allow-Credentials'] =
|
||||||
'true';
|
'true';
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue