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,
|
||||
allowNull: true,
|
||||
},
|
||||
defer: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
tags: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
|
|
@ -62,6 +66,17 @@ module.exports = (sequelize) => {
|
|||
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: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const priorityToInt = (priorityStr) => {
|
|||
* - due: filter by due date (today,tomorrow,next_week,next_month)
|
||||
* - tags: comma-separated list of tag names to filter by
|
||||
* - 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)
|
||||
* - offset: number of results to skip (default: 0)
|
||||
* - 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,
|
||||
tags: tagsParam,
|
||||
recurring,
|
||||
extras: extrasParam,
|
||||
limit: limitParam,
|
||||
offset: offsetParam,
|
||||
excludeSubtasks,
|
||||
|
|
@ -57,6 +59,18 @@ router.get('/', async (req, res) => {
|
|||
const tagNames = tagsParam
|
||||
? 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
|
||||
const hasPagination =
|
||||
|
|
@ -88,35 +102,31 @@ router.get('/', async (req, res) => {
|
|||
// Calculate due date range based on filter
|
||||
let dueDateCondition = null;
|
||||
if (due) {
|
||||
const now = moment().startOf('day');
|
||||
let startDate, endDate;
|
||||
|
||||
switch (due) {
|
||||
case 'today':
|
||||
startDate = now.clone();
|
||||
endDate = now.clone().endOf('day');
|
||||
startDate = startOfToday.clone();
|
||||
endDate = startOfToday.clone().endOf('day');
|
||||
break;
|
||||
case 'tomorrow':
|
||||
startDate = now.clone().add(1, 'day');
|
||||
endDate = now.clone().add(1, 'day').endOf('day');
|
||||
startDate = startOfToday.clone().add(1, 'day');
|
||||
endDate = startOfToday.clone().add(1, 'day').endOf('day');
|
||||
break;
|
||||
case 'next_week':
|
||||
startDate = now.clone();
|
||||
endDate = now.clone().add(7, 'days').endOf('day');
|
||||
startDate = startOfToday.clone();
|
||||
endDate = startOfToday.clone().add(7, 'days').endOf('day');
|
||||
break;
|
||||
case 'next_month':
|
||||
startDate = now.clone();
|
||||
endDate = now.clone().add(1, 'month').endOf('day');
|
||||
startDate = startOfToday.clone();
|
||||
endDate = startOfToday.clone().add(1, 'month').endOf('day');
|
||||
break;
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
dueDateCondition = {
|
||||
due_date: {
|
||||
[Op.between]: [
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString(),
|
||||
],
|
||||
[Op.between]: [startDate.toDate(), endDate.toDate()],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -125,35 +135,31 @@ router.get('/', async (req, res) => {
|
|||
// Calculate defer until date range based on filter
|
||||
let deferDateCondition = null;
|
||||
if (defer) {
|
||||
const now = moment().startOf('day');
|
||||
let startDate, endDate;
|
||||
|
||||
switch (defer) {
|
||||
case 'today':
|
||||
startDate = now.clone();
|
||||
endDate = now.clone().endOf('day');
|
||||
startDate = startOfToday.clone();
|
||||
endDate = startOfToday.clone().endOf('day');
|
||||
break;
|
||||
case 'tomorrow':
|
||||
startDate = now.clone().add(1, 'day');
|
||||
endDate = now.clone().add(1, 'day').endOf('day');
|
||||
startDate = startOfToday.clone().add(1, 'day');
|
||||
endDate = startOfToday.clone().add(1, 'day').endOf('day');
|
||||
break;
|
||||
case 'next_week':
|
||||
startDate = now.clone();
|
||||
endDate = now.clone().add(7, 'days').endOf('day');
|
||||
startDate = startOfToday.clone();
|
||||
endDate = startOfToday.clone().add(7, 'days').endOf('day');
|
||||
break;
|
||||
case 'next_month':
|
||||
startDate = now.clone();
|
||||
endDate = now.clone().add(1, 'month').endOf('day');
|
||||
startDate = startOfToday.clone();
|
||||
endDate = startOfToday.clone().add(1, 'month').endOf('day');
|
||||
break;
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
deferDateCondition = {
|
||||
defer_until: {
|
||||
[Op.between]: [
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString(),
|
||||
],
|
||||
[Op.between]: [startDate.toDate(), endDate.toDate()],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -164,6 +170,7 @@ router.get('/', async (req, res) => {
|
|||
const taskConditions = {
|
||||
user_id: userId,
|
||||
};
|
||||
const taskExtraConditions = [];
|
||||
|
||||
// Exclude subtasks and recurring instances if requested
|
||||
if (excludeSubtasks === 'true') {
|
||||
|
|
@ -196,12 +203,12 @@ router.get('/', async (req, res) => {
|
|||
|
||||
// Add due date filter if specified
|
||||
if (dueDateCondition) {
|
||||
Object.assign(taskConditions, dueDateCondition);
|
||||
taskExtraConditions.push(dueDateCondition);
|
||||
}
|
||||
|
||||
// Add defer until filter if specified
|
||||
if (deferDateCondition) {
|
||||
Object.assign(taskConditions, deferDateCondition);
|
||||
taskExtraConditions.push(deferDateCondition);
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
{
|
||||
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) {
|
||||
taskInclude.push({
|
||||
model: Tag,
|
||||
where: {
|
||||
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,
|
||||
});
|
||||
tagInclude.where = {
|
||||
id: { [Op.in]: tagIds },
|
||||
};
|
||||
}
|
||||
|
||||
taskInclude.push(tagInclude);
|
||||
|
||||
// Count total tasks if pagination is requested
|
||||
if (hasPagination) {
|
||||
const countInclude = requireTags ? [tagInclude] : undefined;
|
||||
totalCount += await Task.count({
|
||||
where: taskConditions,
|
||||
include: tagIds.length > 0 ? taskInclude : undefined,
|
||||
include: countInclude,
|
||||
distinct: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -333,19 +394,25 @@ router.get('/', async (req, res) => {
|
|||
Object.assign(projectConditions, projectDueCondition);
|
||||
}
|
||||
|
||||
const requireProjectTags =
|
||||
tagIds.length > 0 || extrasSet.has('has_tags');
|
||||
const projectInclude = [];
|
||||
|
||||
// Add tag filter if specified
|
||||
if (tagIds.length > 0) {
|
||||
projectInclude.push({
|
||||
if (requireProjectTags) {
|
||||
const projectTagInclude = {
|
||||
model: Tag,
|
||||
where: {
|
||||
id: { [Op.in]: tagIds },
|
||||
},
|
||||
through: { attributes: [] },
|
||||
attributes: [],
|
||||
required: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (tagIds.length > 0) {
|
||||
projectTagInclude.where = {
|
||||
id: { [Op.in]: tagIds },
|
||||
};
|
||||
}
|
||||
|
||||
projectInclude.push(projectTagInclude);
|
||||
}
|
||||
|
||||
// Count total projects if pagination is requested
|
||||
|
|
|
|||
|
|
@ -64,8 +64,17 @@ router.get('/:identifier', async (req, res) => {
|
|||
// POST /api/views - Create a new view
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, search_query, filters, priority, due, tags, recurring } =
|
||||
req.body;
|
||||
const {
|
||||
name,
|
||||
search_query,
|
||||
filters,
|
||||
priority,
|
||||
due,
|
||||
defer,
|
||||
tags,
|
||||
extras,
|
||||
recurring,
|
||||
} = req.body;
|
||||
|
||||
if (!name || name.trim() === '') {
|
||||
return res.status(400).json({ error: 'View name is required' });
|
||||
|
|
@ -78,7 +87,9 @@ router.post('/', async (req, res) => {
|
|||
filters: filters || [],
|
||||
priority: priority || null,
|
||||
due: due || null,
|
||||
defer: defer || null,
|
||||
tags: tags || [],
|
||||
extras: extras || [],
|
||||
recurring: recurring || null,
|
||||
is_pinned: false,
|
||||
});
|
||||
|
|
@ -114,7 +125,9 @@ router.patch('/:identifier', async (req, res) => {
|
|||
filters,
|
||||
priority,
|
||||
due,
|
||||
defer,
|
||||
tags,
|
||||
extras,
|
||||
recurring,
|
||||
is_pinned,
|
||||
} = req.body;
|
||||
|
|
@ -125,7 +138,9 @@ router.patch('/:identifier', async (req, res) => {
|
|||
if (filters !== undefined) updates.filters = filters;
|
||||
if (priority !== undefined) updates.priority = priority;
|
||||
if (due !== undefined) updates.due = due;
|
||||
if (defer !== undefined) updates.defer = defer;
|
||||
if (tags !== undefined) updates.tags = tags;
|
||||
if (extras !== undefined) updates.extras = extras;
|
||||
if (recurring !== undefined) updates.recurring = recurring;
|
||||
if (is_pinned !== undefined) updates.is_pinned = is_pinned;
|
||||
|
||||
|
|
|
|||
|
|
@ -629,7 +629,9 @@ async function importUserData(userId, backupData, options = { merge: true }) {
|
|||
filters: viewData.filters,
|
||||
priority: viewData.priority,
|
||||
due: viewData.due,
|
||||
defer: viewData.defer,
|
||||
tags: viewData.tags,
|
||||
extras: viewData.extras,
|
||||
recurring: viewData.recurring,
|
||||
is_pinned: viewData.is_pinned,
|
||||
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', () => {
|
||||
let otherUser, otherAgent;
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ describe('Views Routes', () => {
|
|||
filters: ['Task', 'Project'],
|
||||
priority: 'high',
|
||||
due: 'today',
|
||||
defer: 'tomorrow',
|
||||
tags: ['work', 'important'],
|
||||
extras: ['recurring', 'has_content'],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
|
|
@ -85,7 +87,21 @@ describe('Views Routes', () => {
|
|||
expect(response.body.filters).toEqual(['Task', 'Project']);
|
||||
expect(response.body.priority).toBe('high');
|
||||
expect(response.body.due).toBe('today');
|
||||
expect(response.body.defer).toBe('tomorrow');
|
||||
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 () => {
|
||||
|
|
@ -246,7 +262,9 @@ describe('Views Routes', () => {
|
|||
filters: ['Task', 'Project'],
|
||||
priority: 'high',
|
||||
due: 'today',
|
||||
defer: 'next_week',
|
||||
tags: ['work', 'urgent'],
|
||||
extras: ['recurring'],
|
||||
is_pinned: true,
|
||||
});
|
||||
|
||||
|
|
@ -256,7 +274,9 @@ describe('Views Routes', () => {
|
|||
expect(response.body.filters).toEqual(['Task', 'Project']);
|
||||
expect(response.body.priority).toBe('high');
|
||||
expect(response.body.due).toBe('today');
|
||||
expect(response.body.defer).toBe('next_week');
|
||||
expect(response.body.tags).toEqual(['work', 'urgent']);
|
||||
expect(response.body.extras).toEqual(['recurring']);
|
||||
expect(response.body.is_pinned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@
|
|||
set -euo pipefail
|
||||
|
||||
# Config
|
||||
APP_URL_DEFAULT="http://localhost:8080"
|
||||
BACKEND_URL="http://localhost:3002"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-4180}"
|
||||
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"
|
||||
APP_URL_DEFAULT="http://${FRONTEND_HOST}:${FRONTEND_PORT}"
|
||||
FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}"
|
||||
FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-$FRONTEND_URL}"
|
||||
|
||||
# Colors
|
||||
red() { printf "\033[31m%s\033[0m\n" "$*"; }
|
||||
|
|
@ -35,7 +40,8 @@ rm -f backend/db/test.sqlite3
|
|||
yellow "Starting backend with test database..."
|
||||
(cd backend && \
|
||||
NODE_ENV=test \
|
||||
PORT=3002 \
|
||||
PORT=$BACKEND_PORT \
|
||||
HOST=$BACKEND_HOST \
|
||||
DB_FILE=db/test.sqlite3 \
|
||||
TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
|
||||
TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \
|
||||
|
|
@ -51,8 +57,8 @@ cleanup() {
|
|||
|
||||
# Kill by known ports (best-effort)
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true)
|
||||
BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true)
|
||||
FRONTEND_PIDS_KILL=$(lsof -ti tcp:${FRONTEND_PORT} || 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 "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
||||
fi
|
||||
|
|
@ -82,6 +88,10 @@ for i in {1..60}; do
|
|||
done
|
||||
|
||||
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 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,15 @@ TEST_PATTERN="$1"
|
|||
BROWSER="${2:-Chromium}"
|
||||
|
||||
# Config
|
||||
APP_URL_DEFAULT="http://localhost:8080"
|
||||
BACKEND_URL="http://localhost:3002"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-4180}"
|
||||
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"
|
||||
APP_URL_DEFAULT="http://${FRONTEND_HOST}:${FRONTEND_PORT}"
|
||||
FRONTEND_URL="${APP_URL:-$APP_URL_DEFAULT}"
|
||||
FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-$FRONTEND_URL}"
|
||||
|
||||
# Colors
|
||||
red() { printf "\033[31m%s\033[0m\n" "$*"; }
|
||||
|
|
@ -51,7 +56,8 @@ rm -f backend/db/test.sqlite3
|
|||
yellow "Starting backend with test database..."
|
||||
(cd backend && \
|
||||
NODE_ENV=test \
|
||||
PORT=3002 \
|
||||
PORT=$BACKEND_PORT \
|
||||
HOST=$BACKEND_HOST \
|
||||
DB_FILE=db/test.sqlite3 \
|
||||
TUDUDI_USER_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
|
||||
TUDUDI_USER_PASSWORD="${E2E_PASSWORD:-password123}" \
|
||||
|
|
@ -67,8 +73,8 @@ cleanup() {
|
|||
|
||||
# Kill by ports (best-effort)
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
FRONTEND_PIDS_KILL=$(lsof -ti tcp:8080 || true)
|
||||
BACKEND_PIDS_KILL=$(lsof -ti tcp:3002 || true)
|
||||
FRONTEND_PIDS_KILL=$(lsof -ti tcp:${FRONTEND_PORT} || 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 "${BACKEND_PIDS_KILL:-}" ]; then kill ${BACKEND_PIDS_KILL} >/dev/null 2>&1 || true; fi
|
||||
fi
|
||||
|
|
@ -98,6 +104,10 @@ for i in {1..60}; do
|
|||
done
|
||||
|
||||
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 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
|
|
@ -122,4 +132,4 @@ yellow "Running Playwright tests matching: ${TEST_PATTERN} on ${BROWSER}..."
|
|||
APP_URL="$FRONTEND_URL" \
|
||||
E2E_EMAIL="${E2E_EMAIL:-test@tududi.com}" \
|
||||
E2E_PASSWORD="${E2E_PASSWORD:-password123}" \
|
||||
npx playwright test --grep "$TEST_PATTERN" --project="$BROWSER"
|
||||
npx playwright test --grep "$TEST_PATTERN" --project="$BROWSER"
|
||||
|
|
|
|||
|
|
@ -261,10 +261,7 @@ const App: React.FC = () => {
|
|||
path="/about"
|
||||
element={<About isDarkMode={isDarkMode} />}
|
||||
/>
|
||||
<Route
|
||||
path="/backup"
|
||||
element={<BackupRestore />}
|
||||
/>
|
||||
<Route path="/backup" element={<BackupRestore />} />
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -97,9 +97,7 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
await loadBackups();
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
showErrorToast(
|
||||
t('backup.exportError', 'Failed to create backup')
|
||||
);
|
||||
showErrorToast(t('backup.exportError', 'Failed to create backup'));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
|
|
@ -164,7 +162,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
try {
|
||||
await deleteSavedBackup(backupUid);
|
||||
showSuccessToast(
|
||||
t('backup.deleteSuccess', 'Backup deleted successfully!')
|
||||
t(
|
||||
'backup.deleteSuccess',
|
||||
'Backup deleted successfully!'
|
||||
)
|
||||
);
|
||||
// Reload the backup list
|
||||
await loadBackups();
|
||||
|
|
@ -234,9 +235,7 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
showErrorToast(
|
||||
t('backup.importError', 'Failed to import backup')
|
||||
);
|
||||
showErrorToast(t('backup.importError', 'Failed to import backup'));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
|
|
@ -260,7 +259,9 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
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">
|
||||
|
|
@ -291,7 +292,12 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||
<span>{t('backup.createBackup', 'Create Backup')}</span>
|
||||
<span>
|
||||
{t(
|
||||
'backup.createBackup',
|
||||
'Create Backup'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -304,7 +310,12 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<ArrowUpTrayIcon className="h-5 w-5" />
|
||||
<span>{t('backup.importFromFile', 'Import from File')}</span>
|
||||
<span>
|
||||
{t(
|
||||
'backup.importFromFile',
|
||||
'Import from File'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -316,7 +327,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{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"
|
||||
></path>
|
||||
</svg>
|
||||
{t('backup.creating', 'Creating backup...')}
|
||||
{t(
|
||||
'backup.creating',
|
||||
'Creating backup...'
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowDownTrayIcon className="h-5 w-5 mr-2" />
|
||||
{t('backup.createBackupNow', 'Create Backup Now')}
|
||||
{t(
|
||||
'backup.createBackupNow',
|
||||
'Create Backup Now'
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -367,14 +387,19 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{t('backup.savedBackups', 'Saved Backups')}
|
||||
{t(
|
||||
'backup.savedBackups',
|
||||
'Saved Backups'
|
||||
)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={loadBackups}
|
||||
disabled={isLoadingBackups}
|
||||
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')}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -385,7 +410,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
</div>
|
||||
) : savedBackups.length === 0 ? (
|
||||
<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 className="overflow-x-auto">
|
||||
|
|
@ -393,74 +421,136 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<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 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 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 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 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{savedBackups.map((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">
|
||||
{formatDate(backup.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{backup.version}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(backup.file_size)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
{backup.item_counts.tasks} tasks
|
||||
</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">
|
||||
{backup.item_counts.projects} projects
|
||||
</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">
|
||||
{backup.item_counts.notes} notes
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => handleRestoreBackup(backup.uid)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title={t('backup.restore', 'Restore')}
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownloadBackup(backup.uid)}
|
||||
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
||||
title={t('backup.download', 'Download')}
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBackup(backup.uid)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title={t('backup.delete', 'Delete')}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{savedBackups.map(
|
||||
(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">
|
||||
{formatDate(
|
||||
backup.created_at
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{
|
||||
backup.version
|
||||
}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(
|
||||
backup.file_size
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<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">
|
||||
{
|
||||
backup
|
||||
.item_counts
|
||||
.tasks
|
||||
}{' '}
|
||||
tasks
|
||||
</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">
|
||||
{
|
||||
backup
|
||||
.item_counts
|
||||
.projects
|
||||
}{' '}
|
||||
projects
|
||||
</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">
|
||||
{
|
||||
backup
|
||||
.item_counts
|
||||
.notes
|
||||
}{' '}
|
||||
notes
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleRestoreBackup(
|
||||
backup.uid
|
||||
)
|
||||
}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title={t(
|
||||
'backup.restore',
|
||||
'Restore'
|
||||
)}
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleDownloadBackup(
|
||||
backup.uid
|
||||
)
|
||||
}
|
||||
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
||||
title={t(
|
||||
'backup.download',
|
||||
'Download'
|
||||
)}
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleDeleteBackup(
|
||||
backup.uid
|
||||
)
|
||||
}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title={t(
|
||||
'backup.delete',
|
||||
'Delete'
|
||||
)}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -471,7 +561,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t(
|
||||
|
|
@ -502,16 +595,24 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
/>
|
||||
|
||||
<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"
|
||||
>
|
||||
<div className="text-center">
|
||||
<ArrowUpTrayIcon className="h-12 w-12 mx-auto mb-2" />
|
||||
<p className="text-base font-medium">
|
||||
{t('backup.selectFile', 'Select Backup File')}
|
||||
{t(
|
||||
'backup.selectFile',
|
||||
'Select Backup File'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{t('backup.clickToUpload', 'Click to browse files')}
|
||||
{t(
|
||||
'backup.clickToUpload',
|
||||
'Click to browse files'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -524,7 +625,10 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
{selectedFile.name}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
{isValidating && (
|
||||
|
|
@ -554,71 +658,139 @@ const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{validationResult?.valid && validationResult.summary && (
|
||||
<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">
|
||||
{t('backup.backupContents', 'Backup contents:')}
|
||||
</p>
|
||||
<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">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.tasks} tasks
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.projects} projects
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.notes} notes
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.tags} tags
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.areas} areas
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.views} views
|
||||
{validationResult?.valid &&
|
||||
validationResult.summary && (
|
||||
<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">
|
||||
{t(
|
||||
'backup.backupContents',
|
||||
'Backup contents:'
|
||||
)}
|
||||
</p>
|
||||
<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">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{
|
||||
validationResult
|
||||
.summary
|
||||
.tasks
|
||||
}{' '}
|
||||
tasks
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{
|
||||
validationResult
|
||||
.summary
|
||||
.projects
|
||||
}{' '}
|
||||
projects
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{
|
||||
validationResult
|
||||
.summary
|
||||
.notes
|
||||
}{' '}
|
||||
notes
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{
|
||||
validationResult
|
||||
.summary
|
||||
.tags
|
||||
}{' '}
|
||||
tags
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{
|
||||
validationResult
|
||||
.summary
|
||||
.areas
|
||||
}{' '}
|
||||
areas
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{
|
||||
validationResult
|
||||
.summary
|
||||
.views
|
||||
}{' '}
|
||||
views
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{validationResult && !validationResult.valid && (
|
||||
<div className="mt-4 pt-4 border-t border-red-200 dark:border-red-800">
|
||||
{validationResult.versionIncompatible ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
||||
{t('backup.versionIncompatible', 'Version Incompatible')}
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{validationResult.message}
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
|
||||
{t('backup.backupVersion', 'Backup version')}: {validationResult.backupVersion}
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t('backup.currentVersion', 'Current version')}: {appVersion}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
||||
{t('backup.validationErrors', 'Validation errors:')}
|
||||
</p>
|
||||
<ul className="text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||
{validationResult.errors?.map((error, index) => (
|
||||
<li key={index}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{validationResult &&
|
||||
!validationResult.valid && (
|
||||
<div className="mt-4 pt-4 border-t border-red-200 dark:border-red-800">
|
||||
{validationResult.versionIncompatible ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
||||
{t(
|
||||
'backup.versionIncompatible',
|
||||
'Version Incompatible'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{
|
||||
validationResult.message
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
|
||||
{t(
|
||||
'backup.backupVersion',
|
||||
'Backup version'
|
||||
)}
|
||||
:{' '}
|
||||
{
|
||||
validationResult.backupVersion
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t(
|
||||
'backup.currentVersion',
|
||||
'Current version'
|
||||
)}
|
||||
: {appVersion}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
||||
{t(
|
||||
'backup.validationErrors',
|
||||
'Validation errors:'
|
||||
)}
|
||||
</p>
|
||||
<ul className="text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||
{validationResult.errors?.map(
|
||||
(
|
||||
error,
|
||||
index
|
||||
) => (
|
||||
<li
|
||||
key={
|
||||
index
|
||||
}
|
||||
>
|
||||
•{' '}
|
||||
{
|
||||
error
|
||||
}
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -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"
|
||||
></path>
|
||||
</svg>
|
||||
{t('backup.importing', 'Importing...')}
|
||||
{t(
|
||||
'backup.importing',
|
||||
'Importing...'
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUpTrayIcon className="h-5 w-5 mr-2" />
|
||||
{t('backup.restoreBackup', 'Restore Backup')}
|
||||
{t(
|
||||
'backup.restoreBackup',
|
||||
'Restore Backup'
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
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 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"
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
{t('navigation.backupRestore', 'Backup & Restore')}
|
||||
{t(
|
||||
'navigation.backupRestore',
|
||||
'Backup & Restore'
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
{currentUser?.is_admin === true && (
|
||||
|
|
|
|||
|
|
@ -299,7 +299,6 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
if (totalIssues === 0) {
|
||||
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" />
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-gray-700 dark:text-gray-300 font-medium">
|
||||
{t(
|
||||
'productivity.issuesFound',
|
||||
{ count: totalIssues }
|
||||
)}
|
||||
{t('productivity.issuesFound', { count: totalIssues })}
|
||||
</p>
|
||||
<p className="text-yellow-600 dark:text-yellow-400 text-sm">
|
||||
{t(
|
||||
|
|
|
|||
|
|
@ -98,7 +98,6 @@ const getShareInitials = (value?: string | null) => {
|
|||
return cleaned.substring(0, 2) || '?';
|
||||
};
|
||||
|
||||
|
||||
const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||
project,
|
||||
viewMode,
|
||||
|
|
@ -221,14 +220,10 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
}
|
||||
|
||||
return {
|
||||
text: t(
|
||||
'projectItem.overdue',
|
||||
'Overdue {{count}} {{unit}} ago',
|
||||
{
|
||||
count: Math.abs(diff),
|
||||
unit,
|
||||
}
|
||||
),
|
||||
text: t('projectItem.overdue', 'Overdue {{count}} {{unit}} ago', {
|
||||
count: Math.abs(diff),
|
||||
unit,
|
||||
}),
|
||||
isOverdue: true,
|
||||
};
|
||||
}, [project.due_date_at, t]);
|
||||
|
|
@ -244,9 +239,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
const knownShares = sharedUsers ?? [];
|
||||
const avatars = knownShares.slice(0, MAX_SHARE_AVATARS);
|
||||
const totalCount =
|
||||
(sharedUsers?.length ??
|
||||
project.share_count ??
|
||||
avatars.length) || 0;
|
||||
(sharedUsers?.length ?? project.share_count ?? avatars.length) || 0;
|
||||
const remaining = Math.max(0, totalCount - avatars.length);
|
||||
|
||||
return { avatars, remaining };
|
||||
|
|
@ -313,7 +306,10 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
return (
|
||||
<StateIcon
|
||||
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'
|
||||
)
|
||||
);
|
||||
setActiveDropdown(null);
|
||||
setActiveDropdown(
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleEditProject(project);
|
||||
handleEditProject(
|
||||
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"
|
||||
|
|
@ -369,8 +369,12 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenShare(project);
|
||||
setActiveDropdown(null);
|
||||
onOpenShare(
|
||||
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"
|
||||
>
|
||||
|
|
@ -395,8 +399,12 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
);
|
||||
return;
|
||||
}
|
||||
setProjectToDelete(project);
|
||||
setIsConfirmDialogOpen(true);
|
||||
setProjectToDelete(
|
||||
project
|
||||
);
|
||||
setIsConfirmDialogOpen(
|
||||
true
|
||||
);
|
||||
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"
|
||||
|
|
@ -453,9 +461,13 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
title={
|
||||
(project as any).task_status
|
||||
? `${(project as any).task_status.done} of ${(project as any).task_status.total} tasks completed (${getCompletionPercentage()}%)`
|
||||
: t('projectItem.completionPercentage', {
|
||||
percentage: getCompletionPercentage(),
|
||||
})
|
||||
: t(
|
||||
'projectItem.completionPercentage',
|
||||
{
|
||||
percentage:
|
||||
getCompletionPercentage(),
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
|
|
@ -471,46 +483,61 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
: '0/0'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center min-w-0">
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center min-w-0">
|
||||
{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">
|
||||
<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>
|
||||
) : (
|
||||
<span className="truncate">{dueInfo.text}</span>
|
||||
<span className="truncate">
|
||||
{dueInfo.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end min-w-0 h-7">
|
||||
{project.is_shared && (
|
||||
<div className="flex items-center -space-x-2 h-full">
|
||||
<>
|
||||
{shareAvatars.avatars.map((share) => (
|
||||
<Tooltip
|
||||
key={`${project.uid}-${share.user_id}`}
|
||||
content={
|
||||
share.email
|
||||
? getShareDisplayName(share.email)
|
||||
: t(
|
||||
'projectItem.sharedUser',
|
||||
'Shared user'
|
||||
)
|
||||
}
|
||||
>
|
||||
{share.avatar_image ? (
|
||||
<img
|
||||
src={getApiPath(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"
|
||||
/>
|
||||
) : (
|
||||
<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)}
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
))}
|
||||
{shareAvatars.avatars.map(
|
||||
(share) => (
|
||||
<Tooltip
|
||||
key={`${project.uid}-${share.user_id}`}
|
||||
content={
|
||||
share.email
|
||||
? getShareDisplayName(
|
||||
share.email
|
||||
)
|
||||
: t(
|
||||
'projectItem.sharedUser',
|
||||
'Shared user'
|
||||
)
|
||||
}
|
||||
>
|
||||
{share.avatar_image ? (
|
||||
<img
|
||||
src={getApiPath(
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
{shareAvatars.remaining > 0 && (
|
||||
<Tooltip
|
||||
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">
|
||||
+{shareAvatars.remaining}
|
||||
+
|
||||
{shareAvatars.remaining}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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;
|
||||
|
||||
|
|
@ -72,7 +75,7 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
},
|
||||
];
|
||||
|
||||
const navLinks = allNavLinks.filter(link => {
|
||||
const navLinks = allNavLinks.filter((link) => {
|
||||
if (link.featureFlag) {
|
||||
return featureFlags[link.featureFlag as keyof FeatureFlags];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1248,6 +1248,7 @@ const TaskDetails: React.FC = () => {
|
|||
onToggleTodayPlan={handleToggleTodayPlan}
|
||||
onQuickStatusToggle={handleQuickStatusToggle}
|
||||
attachmentCount={attachmentCount}
|
||||
subtasksCount={subtasks.length}
|
||||
/>
|
||||
|
||||
{/* Content - Full width layout */}
|
||||
|
|
@ -1285,49 +1286,47 @@ const TaskDetails: React.FC = () => {
|
|||
onLoadTags={() => tagsStore.loadTags()}
|
||||
getTagLink={getTagLink}
|
||||
/>
|
||||
|
||||
<TaskDueDateCard
|
||||
task={task}
|
||||
isEditing={isEditingDueDate}
|
||||
editedDueDate={editedDueDate}
|
||||
onChangeDate={setEditedDueDate}
|
||||
onStartEdit={handleStartDueDateEdit}
|
||||
onSave={handleSaveDueDate}
|
||||
onCancel={handleCancelDueDateEdit}
|
||||
/>
|
||||
|
||||
<TaskDeferUntilCard
|
||||
task={task}
|
||||
isEditing={isEditingDeferUntil}
|
||||
editedDeferUntil={editedDeferUntil}
|
||||
onChangeDateTime={setEditedDeferUntil}
|
||||
onStartEdit={handleStartDeferUntilEdit}
|
||||
onSave={handleSaveDeferUntil}
|
||||
onCancel={handleCancelDeferUntilEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule Pill */}
|
||||
{activePill === 'schedule' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<TaskDueDateCard
|
||||
{/* Recurrence Pill */}
|
||||
{activePill === 'recurrence' && (
|
||||
<div className="grid grid-cols-1">
|
||||
<TaskRecurrenceCard
|
||||
task={task}
|
||||
isEditing={isEditingDueDate}
|
||||
editedDueDate={editedDueDate}
|
||||
onChangeDate={setEditedDueDate}
|
||||
onStartEdit={handleStartDueDateEdit}
|
||||
onSave={handleSaveDueDate}
|
||||
onCancel={handleCancelDueDateEdit}
|
||||
parentTask={parentTask}
|
||||
loadingParent={loadingParent}
|
||||
isEditing={isEditingRecurrence}
|
||||
recurrenceForm={recurrenceForm}
|
||||
onStartEdit={handleStartRecurrenceEdit}
|
||||
onChange={handleRecurrenceChange}
|
||||
onSave={handleSaveRecurrence}
|
||||
onCancel={handleCancelRecurrenceEdit}
|
||||
loadingIterations={loadingIterations}
|
||||
nextIterations={nextIterations}
|
||||
canEdit={!task.recurring_parent_id}
|
||||
/>
|
||||
|
||||
<TaskDeferUntilCard
|
||||
task={task}
|
||||
isEditing={isEditingDeferUntil}
|
||||
editedDeferUntil={editedDeferUntil}
|
||||
onChangeDateTime={setEditedDeferUntil}
|
||||
onStartEdit={handleStartDeferUntilEdit}
|
||||
onSave={handleSaveDeferUntil}
|
||||
onCancel={handleCancelDeferUntilEdit}
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<TaskRecurrenceCard
|
||||
task={task}
|
||||
parentTask={parentTask}
|
||||
loadingParent={loadingParent}
|
||||
isEditing={isEditingRecurrence}
|
||||
recurrenceForm={recurrenceForm}
|
||||
onStartEdit={handleStartRecurrenceEdit}
|
||||
onChange={handleRecurrenceChange}
|
||||
onSave={handleSaveRecurrence}
|
||||
onCancel={handleCancelRecurrenceEdit}
|
||||
loadingIterations={loadingIterations}
|
||||
nextIterations={nextIterations}
|
||||
canEdit={!task.recurring_parent_id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1362,16 +1361,11 @@ const TaskDetails: React.FC = () => {
|
|||
|
||||
{/* Activity Pill */}
|
||||
{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">
|
||||
<TaskTimeline
|
||||
taskUid={task.uid}
|
||||
refreshKey={timelineRefreshKey}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 p-6">
|
||||
<TaskTimeline
|
||||
taskUid={task.uid}
|
||||
refreshKey={timelineRefreshKey}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ interface TaskDetailsHeaderProps {
|
|||
onToggleTodayPlan?: () => void;
|
||||
onQuickStatusToggle?: () => void;
|
||||
attachmentCount?: number;
|
||||
subtasksCount?: number;
|
||||
}
|
||||
|
||||
const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
||||
|
|
@ -61,6 +62,7 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
onToggleTodayPlan,
|
||||
onQuickStatusToggle,
|
||||
attachmentCount = 0,
|
||||
subtasksCount = 0,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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">
|
||||
{t(
|
||||
'task.lastUpdatedAt',
|
||||
'Last updated at'
|
||||
'Updated at'
|
||||
)}
|
||||
:{' '}
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
|
|
@ -761,23 +763,30 @@ const TaskDetailsHeader: React.FC<TaskDetailsHeaderProps> = ({
|
|||
</button>
|
||||
<button
|
||||
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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{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
|
||||
onClick={() => onPillChange('schedule')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
activePill === 'schedule'
|
||||
onClick={() => onPillChange('recurrence')}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors relative ${
|
||||
activePill === 'recurrence'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{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
|
||||
onClick={() => onPillChange('attachments')}
|
||||
|
|
|
|||
|
|
@ -113,9 +113,6 @@ const TaskRecurrenceCard: React.FC<TaskRecurrenceCardProps> = ({
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('task.recurringSetup', 'Recurring Setup')}
|
||||
</h4>
|
||||
<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 ${
|
||||
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">
|
||||
{t(
|
||||
'task.notRecurring',
|
||||
'This task is not recurring yet.'
|
||||
'Add recurrence details'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -62,10 +62,16 @@ const deferOptions = [
|
|||
{ value: 'next_month', labelKey: 'dateIndicators.nextMonth' },
|
||||
];
|
||||
|
||||
const recurringOptions = [
|
||||
{ value: 'recurring', labelKey: 'search.recurringFilter.recurring' },
|
||||
{ value: 'non_recurring', labelKey: 'search.recurringFilter.nonRecurring' },
|
||||
{ value: 'instances', labelKey: 'search.recurringFilter.instances' },
|
||||
const extrasOptions = [
|
||||
{ value: 'recurring', labelKey: 'search.extrasFilter.isRecurring' },
|
||||
{ value: 'overdue', labelKey: 'search.extrasFilter.isOverdue' },
|
||||
{ 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> = ({
|
||||
|
|
@ -82,9 +88,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
const [selectedDue, setSelectedDue] = useState<string | null>(null);
|
||||
const [selectedDefer, setSelectedDefer] = useState<string | null>(null);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedRecurring, setSelectedRecurring] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [selectedExtras, setSelectedExtras] = useState<string[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<
|
||||
Array<{ id: number; name: string }>
|
||||
>([]);
|
||||
|
|
@ -132,9 +136,11 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const handleRecurringToggle = (recurring: string) => {
|
||||
setSelectedRecurring(
|
||||
selectedRecurring === recurring ? null : recurring
|
||||
const handleExtrasToggle = (extra: string) => {
|
||||
setSelectedExtras((prev) =>
|
||||
prev.includes(extra)
|
||||
? prev.filter((e) => e !== extra)
|
||||
: [...prev, extra]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -162,7 +168,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
due: selectedDue || null,
|
||||
defer: selectedDefer || 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);
|
||||
}
|
||||
|
||||
// Add recurring filter
|
||||
if (selectedRecurring) {
|
||||
const recurringOption = recurringOptions.find(
|
||||
(opt) => opt.value === selectedRecurring
|
||||
);
|
||||
const recurringLabel = recurringOption
|
||||
? t(recurringOption.labelKey)
|
||||
: selectedRecurring;
|
||||
// Add extras filters
|
||||
if (selectedExtras.length > 0) {
|
||||
parts.push(
|
||||
<span key="recurring-label">{t('search.thatAre') + ' '}</span>
|
||||
);
|
||||
parts.push(
|
||||
<span
|
||||
key="recurring"
|
||||
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
||||
>
|
||||
{recurringLabel}
|
||||
</span>
|
||||
<span key="extras-label">{t('search.thatAre') + ' '}</span>
|
||||
);
|
||||
const extrasElements = selectedExtras.map((extra) => {
|
||||
const extraOption = extrasOptions.find(
|
||||
(opt) => opt.value === extra
|
||||
);
|
||||
const extraLabel = extraOption
|
||||
? t(extraOption.labelKey)
|
||||
: extra;
|
||||
return (
|
||||
<span
|
||||
key={`extra-${extra}`}
|
||||
style={{ fontWeight: 800, fontStyle: 'normal' }}
|
||||
>
|
||||
{extraLabel}
|
||||
</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;
|
||||
|
|
@ -389,7 +415,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
selectedDue ||
|
||||
selectedDefer ||
|
||||
selectedTags.length > 0 ||
|
||||
selectedRecurring;
|
||||
selectedExtras.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -567,29 +593,21 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
{t('search.extras')}
|
||||
</div>
|
||||
|
||||
{/* Recurring 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">
|
||||
{recurringOptions.map((option) => (
|
||||
<FilterBadge
|
||||
key={option.value}
|
||||
name={t(option.labelKey)}
|
||||
color="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
||||
isSelected={
|
||||
selectedRecurring ===
|
||||
option.value
|
||||
}
|
||||
onToggle={() =>
|
||||
handleRecurringToggle(
|
||||
option.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Extras Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{extrasOptions.map((option) => (
|
||||
<FilterBadge
|
||||
key={option.value}
|
||||
name={t(option.labelKey)}
|
||||
color="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
|
||||
isSelected={selectedExtras.includes(
|
||||
option.value
|
||||
)}
|
||||
onToggle={() =>
|
||||
handleExtrasToggle(option.value)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -683,7 +701,7 @@ const SearchMenu: React.FC<SearchMenuProps> = ({
|
|||
selectedDue={selectedDue}
|
||||
selectedDefer={selectedDefer}
|
||||
selectedTags={selectedTags}
|
||||
selectedRecurring={selectedRecurring}
|
||||
selectedExtras={selectedExtras}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ interface SearchResultsProps {
|
|||
selectedDue: string | null;
|
||||
selectedDefer: string | null;
|
||||
selectedTags: string[];
|
||||
selectedRecurring: string | null;
|
||||
selectedExtras: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
|||
selectedDue,
|
||||
selectedDefer,
|
||||
selectedTags,
|
||||
selectedRecurring,
|
||||
selectedExtras,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -56,7 +56,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
|||
!selectedDue &&
|
||||
!selectedDefer &&
|
||||
selectedTags.length === 0 &&
|
||||
!selectedRecurring
|
||||
selectedExtras.length === 0
|
||||
) {
|
||||
setResults([]);
|
||||
return;
|
||||
|
|
@ -71,7 +71,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
|||
due: selectedDue || undefined,
|
||||
defer: selectedDefer || undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
recurring: selectedRecurring || undefined,
|
||||
extras:
|
||||
selectedExtras.length > 0 ? selectedExtras : undefined,
|
||||
});
|
||||
setResults(data.results);
|
||||
} catch (error) {
|
||||
|
|
@ -91,7 +92,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({
|
|||
selectedDue,
|
||||
selectedDefer,
|
||||
selectedTags,
|
||||
selectedRecurring,
|
||||
selectedExtras,
|
||||
]);
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ interface View {
|
|||
filters: string[];
|
||||
priority: string | null;
|
||||
due: string | null;
|
||||
defer: string | null;
|
||||
tags: string[];
|
||||
recurring: string | null;
|
||||
extras: string[] | null;
|
||||
is_pinned: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -228,21 +229,31 @@ const ViewDetail: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
// Fetch search results with pagination and exclude subtasks
|
||||
const response = await searchUniversal({
|
||||
query: viewData.search_query || '',
|
||||
filters: viewData.filters,
|
||||
priority: viewData.priority || undefined,
|
||||
due: viewData.due || undefined,
|
||||
query: normalizedView.search_query || '',
|
||||
filters: normalizedView.filters,
|
||||
priority: normalizedView.priority || undefined,
|
||||
due: normalizedView.due || undefined,
|
||||
defer: normalizedView.defer || undefined,
|
||||
tags:
|
||||
viewData.tags && viewData.tags.length > 0
|
||||
? viewData.tags
|
||||
normalizedView.tags && normalizedView.tags.length > 0
|
||||
? normalizedView.tags
|
||||
: undefined,
|
||||
extras:
|
||||
normalizedView.extras && normalizedView.extras.length > 0
|
||||
? normalizedView.extras
|
||||
: undefined,
|
||||
recurring: viewData.recurring || undefined,
|
||||
limit: limit,
|
||||
offset: currentOffset,
|
||||
excludeSubtasks: true,
|
||||
|
|
@ -742,6 +753,19 @@ const ViewDetail: React.FC = () => {
|
|||
</span>
|
||||
</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.length > 0 && (
|
||||
<div>
|
||||
|
|
@ -764,26 +788,43 @@ const ViewDetail: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view.recurring && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
||||
{t('views.recurring')}
|
||||
</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">
|
||||
{view.recurring.replace(
|
||||
/_/g,
|
||||
' '
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{view.extras &&
|
||||
view.extras.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">
|
||||
{t('search.extras')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{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,
|
||||
' '
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!view.filters.length &&
|
||||
!view.search_query &&
|
||||
!view.priority &&
|
||||
!view.due &&
|
||||
(!view.tags ||
|
||||
view.tags.length === 0) &&
|
||||
!view.recurring && (
|
||||
(!view.extras ||
|
||||
view.extras.length ===
|
||||
0) && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 italic">
|
||||
{t(
|
||||
'views.noCriteriaSet'
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ interface View {
|
|||
filters: string[];
|
||||
priority: string | null;
|
||||
due: string | null;
|
||||
defer: string | null;
|
||||
tags: string[];
|
||||
extras: string[] | null;
|
||||
is_pinned: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +46,13 @@ const Views: React.FC = () => {
|
|||
});
|
||||
if (response.ok) {
|
||||
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) {
|
||||
console.error('Error fetching views:', error);
|
||||
|
|
@ -236,6 +245,24 @@ const Views: React.FC = () => {
|
|||
{view.due}
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -222,9 +222,7 @@ export const importBackup = async (
|
|||
/**
|
||||
* Validate backup file without importing
|
||||
*/
|
||||
export const validateBackup = async (
|
||||
file: File
|
||||
): Promise<ValidationResult> => {
|
||||
export const validateBackup = async (file: File): Promise<ValidationResult> => {
|
||||
const formData = new FormData();
|
||||
formData.append('backup', file);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ interface SearchParams {
|
|||
due?: string;
|
||||
defer?: string;
|
||||
tags?: string[];
|
||||
recurring?: string;
|
||||
extras?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
excludeSubtasks?: boolean;
|
||||
|
|
@ -66,8 +66,8 @@ export const searchUniversal = async (
|
|||
queryParams.append('tags', params.tags.join(','));
|
||||
}
|
||||
|
||||
if (params.recurring) {
|
||||
queryParams.append('recurring', params.recurring);
|
||||
if (params.extras && params.extras.length > 0) {
|
||||
queryParams.append('extras', params.extras.join(','));
|
||||
}
|
||||
|
||||
if (params.limit !== undefined) {
|
||||
|
|
|
|||
|
|
@ -745,7 +745,7 @@
|
|||
"dueDate": "تاريخ الاستحقاق",
|
||||
"deferUntil": "تأجيل حتى",
|
||||
"recurringSetup": "إعداد متكرر",
|
||||
"notRecurring": "هذه المهمة ليست متكررة بعد.",
|
||||
"notRecurring": "أضف تفاصيل التكرار",
|
||||
"clickToEditTitle": "انقر لتحرير العنوان",
|
||||
"clickToEditContent": "انقر لتحرير المحتوى",
|
||||
"clickToAddContent": "انقر لإضافة محتوى",
|
||||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "حالات متكررة"
|
||||
},
|
||||
"deferUntilFilter": "تأجيل حتى",
|
||||
"deferUntil": "، تأجيل حتى"
|
||||
"deferUntil": "، تأجيل حتى",
|
||||
"extrasFilter": {
|
||||
"isRecurring": "يتكرر",
|
||||
"isOverdue": "متأخر",
|
||||
"hasContent": "يمتلك محتوى",
|
||||
"isDeferred": "مؤجل",
|
||||
"hasTags": "يمتلك علامات",
|
||||
"isAssignedToProject": "مخصص لمشروع"
|
||||
}
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "أضف مهمة فرعية..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "повтарящи се инстанции"
|
||||
},
|
||||
"deferUntilFilter": "Отложи до",
|
||||
"deferUntil": ", отложи до"
|
||||
"deferUntil": ", отложи до",
|
||||
"extrasFilter": {
|
||||
"isRecurring": "е Повтарящо се",
|
||||
"isOverdue": "е Просрочено",
|
||||
"hasContent": "има Съдържание",
|
||||
"isDeferred": "е Отложено",
|
||||
"hasTags": "има Тагове",
|
||||
"isAssignedToProject": "е Назначено на Проект"
|
||||
}
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Добавете подзадача..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "gentagende instanser"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Tilføj en underopgave..."
|
||||
|
|
|
|||
|
|
@ -1215,7 +1215,15 @@
|
|||
"instances": "wiederkehrende Instanzen"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Fügen Sie eine Unteraufgabe hinzu..."
|
||||
|
|
|
|||
|
|
@ -1210,7 +1210,15 @@
|
|||
"instances": "επαναλαμβανόμενες περιπτώσεις"
|
||||
},
|
||||
"deferUntilFilter": "Αναβολή μέχρι",
|
||||
"deferUntil": ", αναβολή μέχρι"
|
||||
"deferUntil": ", αναβολή μέχρι",
|
||||
"extrasFilter": {
|
||||
"isRecurring": "είναι Επαναλαμβανόμενο",
|
||||
"isOverdue": "είναι Υπερβολικό",
|
||||
"hasContent": "έχει Περιεχόμενο",
|
||||
"isDeferred": "είναι Αναβληθέν",
|
||||
"hasTags": "έχει Ετικέτες",
|
||||
"isAssignedToProject": "είναι Ανατεθειμένο σε Έργο"
|
||||
}
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Προσθέστε μια υποεργασία..."
|
||||
|
|
|
|||
|
|
@ -723,7 +723,7 @@
|
|||
"dueDate": "Due Date",
|
||||
"noDueDate": "No due date",
|
||||
"recurringSetup": "Recurring Setup",
|
||||
"notRecurring": "This task is not recurring yet.",
|
||||
"notRecurring": "Add recurrence details",
|
||||
"instanceOf": "This is an instance of a recurring task",
|
||||
"parentTask": "Parent Task",
|
||||
"clickToEditTitle": "Click to edit title",
|
||||
|
|
@ -1179,11 +1179,13 @@
|
|||
"deferUntilFilter": "Defer Until",
|
||||
"deferUntil": ", defer until",
|
||||
"tagsFilter": "Tags",
|
||||
"recurringFilter": {
|
||||
"label": "Recurring",
|
||||
"recurring": "recurring templates",
|
||||
"nonRecurring": "non-recurring",
|
||||
"instances": "recurring instances"
|
||||
"extrasFilter": {
|
||||
"isRecurring": "is Recurring",
|
||||
"isOverdue": "is Overdue",
|
||||
"hasContent": "has Content",
|
||||
"isDeferred": "is Deferred",
|
||||
"hasTags": "has Tags",
|
||||
"isAssignedToProject": "is Assigned to Project"
|
||||
},
|
||||
"saveAsSmartView": "Save as Smart View",
|
||||
"viewName": "View Name",
|
||||
|
|
|
|||
|
|
@ -1207,7 +1207,15 @@
|
|||
"instances": "instancias recurrentes"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Agregar una subtarea..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "toistuvat instanssit"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Lisää alitehtävä..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "instances récurrentes"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Ajouter une sous-tâche..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "instansi berulang"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Tambahkan subtugas..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "istanze ricorrenti"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Aggiungi un sottocompito..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "定期的なインスタンス"
|
||||
},
|
||||
"deferUntilFilter": "フィルターまで遅延",
|
||||
"deferUntil": "、遅延するまで"
|
||||
"deferUntil": "、遅延するまで",
|
||||
"extrasFilter": {
|
||||
"isRecurring": "繰り返し",
|
||||
"isOverdue": "期限切れ",
|
||||
"hasContent": "コンテンツあり",
|
||||
"isDeferred": "保留中",
|
||||
"hasTags": "タグあり",
|
||||
"isAssignedToProject": "プロジェクトに割り当てられている"
|
||||
}
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "サブタスクを追加..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "반복 인스턴스"
|
||||
},
|
||||
"deferUntilFilter": "지연할 때까지",
|
||||
"deferUntil": ", 지연할 때까지"
|
||||
"deferUntil": ", 지연할 때까지",
|
||||
"extrasFilter": {
|
||||
"isRecurring": "반복됨",
|
||||
"isOverdue": "연체됨",
|
||||
"hasContent": "내용이 있음",
|
||||
"isDeferred": "연기됨",
|
||||
"hasTags": "태그가 있음",
|
||||
"isAssignedToProject": "프로젝트에 할당됨"
|
||||
}
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "하위 작업 추가..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "herhalende instanties"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Voeg een subtaak toe..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "gjentakende instanser"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Legg til en underoppgave..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "powtarzające się instancje"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Dodaj podzadanie..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "instâncias recorrentes"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Adicionar uma subtarefa..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "instanțe recurente"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Adaugă o subtask..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "повторяющиеся экземпляры"
|
||||
},
|
||||
"deferUntilFilter": "Отложить до",
|
||||
"deferUntil": ", отложить до"
|
||||
"deferUntil": ", отложить до",
|
||||
"extrasFilter": {
|
||||
"isRecurring": "является повторяющимся",
|
||||
"isOverdue": "просрочено",
|
||||
"hasContent": "имеет содержимое",
|
||||
"isDeferred": "отложено",
|
||||
"hasTags": "имеет теги",
|
||||
"isAssignedToProject": "назначено на проект"
|
||||
}
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Добавить подзадачу..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "ponavljajoče se instance"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Dodaj podnalogo..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "återkommande instanser"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Lägg till en deluppgift..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "tekrarlayan örnekler"
|
||||
},
|
||||
"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": {
|
||||
"placeholder": "Bir alt görev ekle..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "повторювані екземпляри"
|
||||
},
|
||||
"deferUntilFilter": "Відкласти до",
|
||||
"deferUntil": ", відкласти до"
|
||||
"deferUntil": ", відкласти до",
|
||||
"extrasFilter": {
|
||||
"isRecurring": "є повторюваним",
|
||||
"isOverdue": "прострочено",
|
||||
"hasContent": "має вміст",
|
||||
"isDeferred": "відкладено",
|
||||
"hasTags": "має теги",
|
||||
"isAssignedToProject": "призначено проекту"
|
||||
}
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Додати підзадачу..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "các phiên bản lặp lại"
|
||||
},
|
||||
"deferUntilFilter": "Hoãn lại cho đến",
|
||||
"deferUntil": ", hoãn lại cho đến"
|
||||
"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": {
|
||||
"placeholder": "Thêm một công việc phụ..."
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,15 @@
|
|||
"instances": "循环实例"
|
||||
},
|
||||
"deferUntilFilter": "延迟到",
|
||||
"deferUntil": ",延迟到"
|
||||
"deferUntil": ",延迟到",
|
||||
"extrasFilter": {
|
||||
"isRecurring": "是重复的",
|
||||
"isOverdue": "已逾期",
|
||||
"hasContent": "有内容",
|
||||
"isDeferred": "已延迟",
|
||||
"hasTags": "有标签",
|
||||
"isAssignedToProject": "已分配给项目"
|
||||
}
|
||||
},
|
||||
"subtasks": {
|
||||
"placeholder": "添加子任务..."
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ const CopyWebpackPlugin = require('copy-webpack-plugin');
|
|||
const webpack = require('webpack');
|
||||
|
||||
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 = {
|
||||
entry: './frontend/index.tsx',
|
||||
|
|
@ -29,22 +37,22 @@ module.exports = {
|
|||
},
|
||||
hot: isDevelopment,
|
||||
watchFiles: isDevelopment ? ['frontend/**/*'] : [],
|
||||
port: 8080,
|
||||
host: '0.0.0.0',
|
||||
port: frontendPort,
|
||||
host: frontendHost,
|
||||
historyApiFallback: true,
|
||||
proxy: [
|
||||
{
|
||||
context: ['/api', '/locales'],
|
||||
target: 'http://localhost:3002',
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
cookieDomainRewrite: 'localhost',
|
||||
cookieDomainRewrite: frontendCookieDomain,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
onProxyRes: function (proxyRes, req, res) {
|
||||
proxyRes.headers['Access-Control-Allow-Origin'] =
|
||||
'http://localhost:8080';
|
||||
frontendOrigin;
|
||||
proxyRes.headers['Access-Control-Allow-Credentials'] =
|
||||
'true';
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue