Add nanoid
This commit is contained in:
parent
e081ed622a
commit
3599bc2b60
50 changed files with 1803 additions and 377 deletions
|
|
@ -11,4 +11,4 @@ echo " TUDUDI_USER_PASSWORD=your_password"
|
|||
echo "============================================="
|
||||
echo ""
|
||||
|
||||
NODE_ENV=development PORT=3002 DB_FILE=db/development.sqlite3 npm start
|
||||
NODE_ENV=development PORT=3002 DB_FILE=db/development.sqlite3 ./cmd/start.sh
|
||||
|
|
|
|||
|
|
@ -20,4 +20,7 @@ module.exports = {
|
|||
restoreMocks: true,
|
||||
testTimeout: 30000,
|
||||
maxWorkers: '100%',
|
||||
moduleNameMapper: {
|
||||
'^nanoid/non-secure$': '<rootDir>/tests/mocks/nanoid.js',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
63
backend/migrations/20240804000004-add-nanoid-to-areas.js
Normal file
63
backend/migrations/20240804000004-add-nanoid-to-areas.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use strict';
|
||||
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add nanoid column to areas table
|
||||
await queryInterface.addColumn('areas', 'nanoid', {
|
||||
type: Sequelize.STRING(21),
|
||||
allowNull: true, // Initially allow null during migration
|
||||
unique: false, // Initially not unique during migration
|
||||
});
|
||||
|
||||
// Create index on nanoid column for performance
|
||||
await queryInterface.addIndex('areas', ['nanoid'], {
|
||||
name: 'areas_nanoid_index',
|
||||
});
|
||||
|
||||
// Get all existing areas and populate nanoid values
|
||||
const areas = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM areas',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
// Update each area with a unique nanoid
|
||||
for (const area of areas) {
|
||||
const areaNanoid = nanoid();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE areas SET nanoid = ? WHERE id = ?',
|
||||
{
|
||||
replacements: [areaNanoid, area.id],
|
||||
type: Sequelize.QueryTypes.UPDATE,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Add unique constraint after all nanoids are populated
|
||||
await queryInterface.addConstraint('areas', {
|
||||
fields: ['nanoid'],
|
||||
type: 'unique',
|
||||
name: 'areas_nanoid_unique',
|
||||
});
|
||||
|
||||
// Change nanoid column to not allow null
|
||||
await queryInterface.changeColumn('areas', 'nanoid', {
|
||||
type: Sequelize.STRING(21),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove unique constraint
|
||||
await queryInterface.removeConstraint('areas', 'areas_nanoid_unique');
|
||||
|
||||
// Remove index
|
||||
await queryInterface.removeIndex('areas', 'areas_nanoid_index');
|
||||
|
||||
// Remove nanoid column
|
||||
await queryInterface.removeColumn('areas', 'nanoid');
|
||||
},
|
||||
};
|
||||
70
backend/migrations/20250804000001-add-nanoid-to-tasks.js
Normal file
70
backend/migrations/20250804000001-add-nanoid-to-tasks.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
'use strict';
|
||||
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils');
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Temporarily disable foreign key constraints
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
try {
|
||||
// Safely add nanoid column to tasks table (without unique constraint initially)
|
||||
await safeAddColumns(queryInterface, 'tasks', [
|
||||
{
|
||||
name: 'nanoid',
|
||||
definition: {
|
||||
type: Sequelize.STRING(21), // nanoid default length is 21
|
||||
allowNull: true, // Initially allow null, we'll populate it then make it not null
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Get all existing tasks that don't have nanoid yet
|
||||
const tasks = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM tasks WHERE nanoid IS NULL',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
// Generate and update nanoid for each existing task
|
||||
for (const task of tasks) {
|
||||
const taskNanoid = nanoid();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE tasks SET nanoid = ? WHERE id = ?',
|
||||
{
|
||||
replacements: [taskNanoid, task.id],
|
||||
type: Sequelize.QueryTypes.UPDATE,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Now make the column not null since all existing tasks have nanoids
|
||||
try {
|
||||
await queryInterface.changeColumn('tasks', 'nanoid', {
|
||||
type: Sequelize.STRING(21),
|
||||
allowNull: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Column already exists with correct constraints');
|
||||
}
|
||||
|
||||
// Add index for performance
|
||||
await safeAddIndex(queryInterface, 'tasks', ['nanoid'], {
|
||||
unique: true,
|
||||
name: 'tasks_nanoid_unique_index',
|
||||
});
|
||||
} finally {
|
||||
// Re-enable foreign key constraints
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove the index first
|
||||
await queryInterface.removeIndex('tasks', 'tasks_nanoid_unique_index');
|
||||
|
||||
// Remove the nanoid column
|
||||
await queryInterface.removeColumn('tasks', 'nanoid');
|
||||
},
|
||||
};
|
||||
77
backend/migrations/20250804000002-add-nanoid-to-projects.js
Normal file
77
backend/migrations/20250804000002-add-nanoid-to-projects.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use strict';
|
||||
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils');
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Temporarily disable foreign key constraints
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
try {
|
||||
// Safely add nanoid column to projects table
|
||||
await safeAddColumns(queryInterface, 'projects', [
|
||||
{
|
||||
name: 'nanoid',
|
||||
definition: {
|
||||
type: Sequelize.STRING(21), // nanoid default length is 21
|
||||
allowNull: true, // Initially allow null, we'll populate it then make it not null
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Get all existing projects that don't have nanoid yet
|
||||
const projects = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM projects WHERE nanoid IS NULL',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
// Generate and update nanoid for each existing project
|
||||
for (const project of projects) {
|
||||
const projectNanoid = nanoid();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE projects SET nanoid = ? WHERE id = ?',
|
||||
{
|
||||
replacements: [projectNanoid, project.id],
|
||||
type: Sequelize.QueryTypes.UPDATE,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Now make the column not null since all existing projects have nanoids
|
||||
try {
|
||||
await queryInterface.changeColumn('projects', 'nanoid', {
|
||||
type: Sequelize.STRING(21),
|
||||
allowNull: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Column already exists with correct constraints');
|
||||
}
|
||||
|
||||
// Add index for performance
|
||||
await safeAddIndex(queryInterface, 'projects', ['nanoid'], {
|
||||
unique: true,
|
||||
name: 'projects_nanoid_unique_index',
|
||||
});
|
||||
} finally {
|
||||
// Re-enable foreign key constraints
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove the index first
|
||||
try {
|
||||
await queryInterface.removeIndex(
|
||||
'projects',
|
||||
'projects_nanoid_unique_index'
|
||||
);
|
||||
} catch (error) {
|
||||
// Index might not exist
|
||||
}
|
||||
|
||||
// Remove the nanoid column
|
||||
await queryInterface.removeColumn('projects', 'nanoid');
|
||||
},
|
||||
};
|
||||
77
backend/migrations/20250804000003-add-nanoid-to-notes.js
Normal file
77
backend/migrations/20250804000003-add-nanoid-to-notes.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use strict';
|
||||
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils');
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Temporarily disable foreign key constraints
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
try {
|
||||
// Safely add nanoid column to notes table
|
||||
await safeAddColumns(queryInterface, 'notes', [
|
||||
{
|
||||
name: 'nanoid',
|
||||
definition: {
|
||||
type: Sequelize.STRING(21), // nanoid default length is 21
|
||||
allowNull: true, // Initially allow null, we'll populate it then make it not null
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Get all existing notes that don't have nanoid yet
|
||||
const notes = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM notes WHERE nanoid IS NULL',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
// Generate and update nanoid for each existing note
|
||||
for (const note of notes) {
|
||||
const noteNanoid = nanoid();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE notes SET nanoid = ? WHERE id = ?',
|
||||
{
|
||||
replacements: [noteNanoid, note.id],
|
||||
type: Sequelize.QueryTypes.UPDATE,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Now make the column not null since all existing notes have nanoids
|
||||
try {
|
||||
await queryInterface.changeColumn('notes', 'nanoid', {
|
||||
type: Sequelize.STRING(21),
|
||||
allowNull: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Column already exists with correct constraints');
|
||||
}
|
||||
|
||||
// Add index for performance
|
||||
await safeAddIndex(queryInterface, 'notes', ['nanoid'], {
|
||||
unique: true,
|
||||
name: 'notes_nanoid_unique_index',
|
||||
});
|
||||
} finally {
|
||||
// Re-enable foreign key constraints
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove the index first
|
||||
try {
|
||||
await queryInterface.removeIndex(
|
||||
'notes',
|
||||
'notes_nanoid_unique_index'
|
||||
);
|
||||
} catch (error) {
|
||||
// Index might not exist
|
||||
}
|
||||
|
||||
// Remove the nanoid column
|
||||
await queryInterface.removeColumn('notes', 'nanoid');
|
||||
},
|
||||
};
|
||||
77
backend/migrations/20250804000004-add-nanoid-to-tags.js
Normal file
77
backend/migrations/20250804000004-add-nanoid-to-tags.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use strict';
|
||||
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils');
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Temporarily disable foreign key constraints
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
try {
|
||||
// Safely add nanoid column to tags table
|
||||
await safeAddColumns(queryInterface, 'tags', [
|
||||
{
|
||||
name: 'nanoid',
|
||||
definition: {
|
||||
type: Sequelize.STRING(21), // nanoid default length is 21
|
||||
allowNull: true, // Initially allow null, we'll populate it then make it not null
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Get all existing tags that don't have nanoid yet
|
||||
const tags = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM tags WHERE nanoid IS NULL',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
// Generate and update nanoid for each existing tag
|
||||
for (const tag of tags) {
|
||||
const tagNanoid = nanoid();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE tags SET nanoid = ? WHERE id = ?',
|
||||
{
|
||||
replacements: [tagNanoid, tag.id],
|
||||
type: Sequelize.QueryTypes.UPDATE,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Now make the column not null since all existing tags have nanoids
|
||||
try {
|
||||
await queryInterface.changeColumn('tags', 'nanoid', {
|
||||
type: Sequelize.STRING(21),
|
||||
allowNull: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Column already exists with correct constraints');
|
||||
}
|
||||
|
||||
// Add index for performance
|
||||
await safeAddIndex(queryInterface, 'tags', ['nanoid'], {
|
||||
unique: true,
|
||||
name: 'tags_nanoid_unique_index',
|
||||
});
|
||||
} finally {
|
||||
// Re-enable foreign key constraints
|
||||
await queryInterface.sequelize.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove the index first
|
||||
try {
|
||||
await queryInterface.removeIndex(
|
||||
'tags',
|
||||
'tags_nanoid_unique_index'
|
||||
);
|
||||
} catch (error) {
|
||||
// Index might not exist
|
||||
}
|
||||
|
||||
// Remove the nanoid column
|
||||
await queryInterface.removeColumn('tags', 'nanoid');
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Area = sequelize.define(
|
||||
|
|
@ -9,6 +10,12 @@ module.exports = (sequelize) => {
|
|||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
nanoid: {
|
||||
type: DataTypes.STRING(21),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: () => nanoid(),
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Note = sequelize.define(
|
||||
|
|
@ -9,6 +10,12 @@ module.exports = (sequelize) => {
|
|||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
nanoid: {
|
||||
type: DataTypes.STRING(21),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: () => nanoid(),
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Project = sequelize.define(
|
||||
|
|
@ -9,6 +10,12 @@ module.exports = (sequelize) => {
|
|||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
nanoid: {
|
||||
type: DataTypes.STRING(21),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: () => nanoid(),
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Tag = sequelize.define(
|
||||
|
|
@ -9,6 +10,12 @@ module.exports = (sequelize) => {
|
|||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
nanoid: {
|
||||
type: DataTypes.STRING(21),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: () => nanoid(),
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const { nanoid } = require('nanoid/non-secure');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Task = sequelize.define(
|
||||
|
|
@ -15,6 +16,12 @@ module.exports = (sequelize) => {
|
|||
unique: true,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
},
|
||||
nanoid: {
|
||||
type: DataTypes.STRING(21),
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: () => nanoid(),
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ router.get('/areas', async (req, res) => {
|
|||
|
||||
const areas = await Area.findAll({
|
||||
where: { user_id: req.session.userId },
|
||||
attributes: ['id', 'nanoid', 'name', 'description'],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
|
|
@ -64,7 +65,10 @@ router.post('/areas', async (req, res) => {
|
|||
user_id: req.session.userId,
|
||||
});
|
||||
|
||||
res.status(201).json(area);
|
||||
res.status(201).json({
|
||||
...area.toJSON(),
|
||||
nanoid: area.nanoid, // Explicitly include nanoid
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating area:', error);
|
||||
res.status(400).json({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,41 @@
|
|||
const express = require('express');
|
||||
const { Note, Tag, Project, sequelize } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const { extractNanoidFromSlug } = require('../utils/slug-utils');
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to validate tag name (same as in tags.js)
|
||||
function validateTagName(name) {
|
||||
if (!name || !name.trim()) {
|
||||
return { valid: false, error: 'Tag name is required' };
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// Check for invalid characters that can break URLs or cause issues
|
||||
const invalidChars = /[#%&{}\\<>*?/$!'":@+`|=]/;
|
||||
if (invalidChars.test(trimmedName)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tag name contains invalid characters. Please avoid: # % & { } \\ < > * ? / $ ! \' " : @ + ` | =',
|
||||
};
|
||||
}
|
||||
|
||||
// Check length limits
|
||||
if (trimmedName.length > 50) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tag name must be 50 characters or less',
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmedName.length < 1) {
|
||||
return { valid: false, error: 'Tag name cannot be empty' };
|
||||
}
|
||||
|
||||
return { valid: true, name: trimmedName };
|
||||
}
|
||||
|
||||
// Helper function to update note tags
|
||||
async function updateNoteTags(note, tagsArray, userId) {
|
||||
if (!tagsArray || tagsArray.length === 0) {
|
||||
|
|
@ -11,11 +44,31 @@ async function updateNoteTags(note, tagsArray, userId) {
|
|||
}
|
||||
|
||||
try {
|
||||
const tagNames = tagsArray.filter(
|
||||
(name, index, arr) => arr.indexOf(name) === index
|
||||
); // unique
|
||||
// Validate and filter tag names
|
||||
const validTagNames = [];
|
||||
const invalidTags = [];
|
||||
|
||||
for (const name of tagsArray) {
|
||||
const validation = validateTagName(name);
|
||||
if (validation.valid) {
|
||||
// Check for duplicates
|
||||
if (!validTagNames.includes(validation.name)) {
|
||||
validTagNames.push(validation.name);
|
||||
}
|
||||
} else {
|
||||
invalidTags.push({ name, error: validation.error });
|
||||
}
|
||||
}
|
||||
|
||||
// If there are invalid tags, throw an error
|
||||
if (invalidTags.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const tags = await Promise.all(
|
||||
tagNames.map(async (name) => {
|
||||
validTagNames.map(async (name) => {
|
||||
const [tag] = await Tag.findOrCreate({
|
||||
where: { name, user_id: userId },
|
||||
defaults: { name, user_id: userId },
|
||||
|
|
@ -26,6 +79,7 @@ async function updateNoteTags(note, tagsArray, userId) {
|
|||
await note.setTags(tags);
|
||||
} catch (error) {
|
||||
console.error('Failed to update tags:', error.message);
|
||||
throw error; // Re-throw to handle at route level
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,8 +95,16 @@ router.get('/notes', async (req, res) => {
|
|||
|
||||
let whereClause = { user_id: req.session.userId };
|
||||
let includeClause = [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] },
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
},
|
||||
];
|
||||
|
||||
// Filter by tag
|
||||
|
|
@ -65,18 +127,47 @@ router.get('/notes', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /api/note/:id
|
||||
// GET /api/note/:id (supports both numeric ID and nanoid-slug)
|
||||
router.get('/note/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const identifier = req.params.id;
|
||||
let whereClause;
|
||||
|
||||
// Check if identifier is numeric (regular ID) or nanoid-slug
|
||||
if (/^\d+$/.test(identifier)) {
|
||||
// It's a numeric ID
|
||||
whereClause = {
|
||||
id: parseInt(identifier),
|
||||
user_id: req.session.userId,
|
||||
};
|
||||
} else {
|
||||
// It's a nanoid-slug, extract the nanoid
|
||||
const nanoid = extractNanoidFromSlug(identifier);
|
||||
if (!nanoid) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid note identifier' });
|
||||
}
|
||||
whereClause = { nanoid: nanoid, user_id: req.session.userId };
|
||||
}
|
||||
|
||||
const note = await Note.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: whereClause,
|
||||
include: [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] },
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -134,12 +225,23 @@ router.post('/note', async (req, res) => {
|
|||
// Reload note with associations
|
||||
const noteWithAssociations = await Note.findByPk(note.id, {
|
||||
include: [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] },
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
res.status(201).json(noteWithAssociations);
|
||||
res.status(201).json({
|
||||
...noteWithAssociations.toJSON(),
|
||||
nanoid: noteWithAssociations.nanoid, // Explicitly include nanoid
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error);
|
||||
res.status(400).json({
|
||||
|
|
@ -205,8 +307,16 @@ router.patch('/note/:id', async (req, res) => {
|
|||
// Reload note with associations
|
||||
const noteWithAssociations = await Note.findByPk(note.id, {
|
||||
include: [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] },
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
required: false,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,41 @@ const config = getConfig();
|
|||
const fs = require('fs');
|
||||
const { Project, Task, Tag, Area, Note, sequelize } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const { extractNanoidFromSlug } = require('../utils/slug-utils');
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to validate tag name (same as in tags.js)
|
||||
function validateTagName(name) {
|
||||
if (!name || !name.trim()) {
|
||||
return { valid: false, error: 'Tag name is required' };
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// Check for invalid characters that can break URLs or cause issues
|
||||
const invalidChars = /[#%&{}\\<>*?/$!'":@+`|=]/;
|
||||
if (invalidChars.test(trimmedName)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tag name contains invalid characters. Please avoid: # % & { } \\ < > * ? / $ ! \' " : @ + ` | =',
|
||||
};
|
||||
}
|
||||
|
||||
// Check length limits
|
||||
if (trimmedName.length > 50) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tag name must be 50 characters or less',
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmedName.length < 1) {
|
||||
return { valid: false, error: 'Tag name cannot be empty' };
|
||||
}
|
||||
|
||||
return { valid: true, name: trimmedName };
|
||||
}
|
||||
|
||||
// Helper function to safely format dates
|
||||
const formatDate = (date) => {
|
||||
if (!date) return null;
|
||||
|
|
@ -59,24 +92,42 @@ const upload = multer({
|
|||
async function updateProjectTags(project, tagsData, userId) {
|
||||
if (!tagsData) return;
|
||||
|
||||
const tagNames = tagsData
|
||||
.map((tag) => tag.name)
|
||||
.filter((name) => name && name.trim())
|
||||
.filter((name, index, arr) => arr.indexOf(name) === index); // unique
|
||||
// Validate and filter tag names
|
||||
const validTagNames = [];
|
||||
const invalidTags = [];
|
||||
|
||||
if (tagNames.length === 0) {
|
||||
for (const tag of tagsData) {
|
||||
const validation = validateTagName(tag.name);
|
||||
if (validation.valid) {
|
||||
// Check for duplicates
|
||||
if (!validTagNames.includes(validation.name)) {
|
||||
validTagNames.push(validation.name);
|
||||
}
|
||||
} else {
|
||||
invalidTags.push({ name: tag.name, error: validation.error });
|
||||
}
|
||||
}
|
||||
|
||||
// If there are invalid tags, throw an error
|
||||
if (invalidTags.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (validTagNames.length === 0) {
|
||||
await project.setTags([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find existing tags
|
||||
const existingTags = await Tag.findAll({
|
||||
where: { user_id: userId, name: tagNames },
|
||||
where: { user_id: userId, name: validTagNames },
|
||||
});
|
||||
|
||||
// Create new tags
|
||||
const existingTagNames = existingTags.map((tag) => tag.name);
|
||||
const newTagNames = tagNames.filter(
|
||||
const newTagNames = validTagNames.filter(
|
||||
(name) => !existingTagNames.includes(name)
|
||||
);
|
||||
|
||||
|
|
@ -116,7 +167,7 @@ router.get('/projects', async (req, res) => {
|
|||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { active, pin_to_sidebar, area_id } = req.query;
|
||||
const { active, pin_to_sidebar, area_id, area } = req.query;
|
||||
|
||||
let whereClause = { user_id: req.session.userId };
|
||||
|
||||
|
|
@ -134,8 +185,21 @@ router.get('/projects', async (req, res) => {
|
|||
whereClause.pin_to_sidebar = false;
|
||||
}
|
||||
|
||||
// Filter by area
|
||||
if (area_id && area_id !== '') {
|
||||
// Filter by area - support both numeric area_id and nanoid-slug area
|
||||
if (area && area !== '') {
|
||||
// Extract nanoid from nanoid-slug format
|
||||
const nanoid = extractNanoidFromSlug(area);
|
||||
if (nanoid) {
|
||||
const areaRecord = await Area.findOne({
|
||||
where: { nanoid: nanoid, user_id: req.session.userId },
|
||||
attributes: ['id'],
|
||||
});
|
||||
if (areaRecord) {
|
||||
whereClause.area_id = areaRecord.id;
|
||||
}
|
||||
}
|
||||
} else if (area_id && area_id !== '') {
|
||||
// Legacy support for numeric area_id
|
||||
whereClause.area_id = area_id;
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +218,7 @@ router.get('/projects', async (req, res) => {
|
|||
},
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
|
|
@ -211,15 +275,36 @@ router.get('/projects', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /api/project/:id
|
||||
// GET /api/project/:id (supports both numeric ID and nanoid-slug)
|
||||
router.get('/project/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const identifier = req.params.id;
|
||||
let whereClause;
|
||||
|
||||
// Check if identifier is numeric (regular ID) or nanoid-slug
|
||||
if (/^\d+$/.test(identifier)) {
|
||||
// It's a numeric ID
|
||||
whereClause = {
|
||||
id: parseInt(identifier),
|
||||
user_id: req.session.userId,
|
||||
};
|
||||
} else {
|
||||
// It's a nanoid-slug, extract the nanoid
|
||||
const nanoid = extractNanoidFromSlug(identifier);
|
||||
if (!nanoid) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid project identifier' });
|
||||
}
|
||||
whereClause = { nanoid: nanoid, user_id: req.session.userId };
|
||||
}
|
||||
|
||||
const project = await Project.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: Task,
|
||||
|
|
@ -231,7 +316,7 @@ router.get('/project/:id', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -241,7 +326,7 @@ router.get('/project/:id', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -263,7 +348,7 @@ router.get('/project/:id', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
|
|
@ -271,7 +356,7 @@ router.get('/project/:id', async (req, res) => {
|
|||
{ model: Area, required: false, attributes: ['id', 'name'] },
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
|
|
@ -377,7 +462,7 @@ router.post('/project', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
|
|
@ -387,6 +472,7 @@ router.post('/project', async (req, res) => {
|
|||
|
||||
res.status(201).json({
|
||||
...projectJson,
|
||||
nanoid: projectWithAssociations.nanoid, // Explicitly include nanoid
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
due_date_at: formatDate(projectWithAssociations.due_date_at),
|
||||
});
|
||||
|
|
@ -451,7 +537,7 @@ router.patch('/project/:id', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,13 +1,46 @@
|
|||
const express = require('express');
|
||||
const { Tag, Task, Note, Project, sequelize } = require('../models');
|
||||
const { extractNanoidFromSlug } = require('../utils/slug-utils');
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to validate tag name
|
||||
function validateTagName(name) {
|
||||
if (!name || !name.trim()) {
|
||||
return { valid: false, error: 'Tag name is required' };
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// Check for invalid characters that can break URLs or cause issues
|
||||
const invalidChars = /[#%&{}\\<>*?/$!'":@+`|=]/;
|
||||
if (invalidChars.test(trimmedName)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tag name contains invalid characters. Please avoid: # % & { } \\ < > * ? / $ ! \' " : @ + ` | =',
|
||||
};
|
||||
}
|
||||
|
||||
// Check length limits
|
||||
if (trimmedName.length > 50) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tag name must be 50 characters or less',
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmedName.length < 1) {
|
||||
return { valid: false, error: 'Tag name cannot be empty' };
|
||||
}
|
||||
|
||||
return { valid: true, name: trimmedName };
|
||||
}
|
||||
|
||||
// GET /api/tags
|
||||
router.get('/tags', async (req, res) => {
|
||||
try {
|
||||
const tags = await Tag.findAll({
|
||||
where: { user_id: req.currentUser.id },
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
res.json(tags);
|
||||
|
|
@ -17,19 +50,28 @@ router.get('/tags', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /api/tag/:identifier (supports both ID and name)
|
||||
// GET /api/tag/:identifier (supports both ID, name, and nanoid-slug)
|
||||
router.get('/tag/:identifier', async (req, res) => {
|
||||
try {
|
||||
const identifier = req.params.identifier;
|
||||
let whereClause;
|
||||
|
||||
// Check if identifier is a number (ID) or string (name)
|
||||
// Check if identifier is numeric (ID), nanoid-slug, or tag name
|
||||
if (/^\d+$/.test(identifier)) {
|
||||
// It's a numeric ID
|
||||
whereClause = {
|
||||
id: parseInt(identifier),
|
||||
user_id: req.currentUser.id,
|
||||
};
|
||||
} else if (identifier.includes('-') && identifier.length > 21) {
|
||||
// It's likely a nanoid-slug, extract the nanoid
|
||||
const nanoid = extractNanoidFromSlug(identifier);
|
||||
if (!nanoid) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid tag identifier' });
|
||||
}
|
||||
whereClause = { nanoid: nanoid, user_id: req.currentUser.id };
|
||||
} else {
|
||||
// It's a tag name - decode URI component to handle special characters
|
||||
const tagName = decodeURIComponent(identifier);
|
||||
|
|
@ -38,7 +80,7 @@ router.get('/tag/:identifier', async (req, res) => {
|
|||
|
||||
const tag = await Tag.findOne({
|
||||
where: whereClause,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
|
|
@ -57,17 +99,19 @@ router.post('/tag', async (req, res) => {
|
|||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Tag name is required' });
|
||||
const validation = validateTagName(name);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error });
|
||||
}
|
||||
|
||||
const tag = await Tag.create({
|
||||
name: name.trim(),
|
||||
name: validation.name,
|
||||
user_id: req.currentUser.id,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: tag.id,
|
||||
nanoid: tag.nanoid, // Explicitly include nanoid
|
||||
name: tag.name,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -107,11 +151,12 @@ router.patch('/tag/:identifier', async (req, res) => {
|
|||
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Tag name is required' });
|
||||
const validation = validateTagName(name);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error });
|
||||
}
|
||||
|
||||
await tag.update({ name: name.trim() });
|
||||
await tag.update({ name: validation.name });
|
||||
|
||||
res.json({
|
||||
id: tag.id,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,38 @@ const TaskEventService = require('../services/taskEventService');
|
|||
const moment = require('moment-timezone');
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to validate tag name (same as in tags.js)
|
||||
function validateTagName(name) {
|
||||
if (!name || !name.trim()) {
|
||||
return { valid: false, error: 'Tag name is required' };
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
// Check for invalid characters that can break URLs or cause issues
|
||||
const invalidChars = /[#%&{}\\<>*?/$!'":@+`|=]/;
|
||||
if (invalidChars.test(trimmedName)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tag name contains invalid characters. Please avoid: # % & { } \\ < > * ? / $ ! \' " : @ + ` | =',
|
||||
};
|
||||
}
|
||||
|
||||
// Check length limits
|
||||
if (trimmedName.length > 50) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tag name must be 50 characters or less',
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmedName.length < 1) {
|
||||
return { valid: false, error: 'Tag name cannot be empty' };
|
||||
}
|
||||
|
||||
return { valid: true, name: trimmedName };
|
||||
}
|
||||
|
||||
// Helper function to serialize task with today move count
|
||||
async function serializeTask(task) {
|
||||
const taskJson = task.toJSON();
|
||||
|
|
@ -18,10 +50,18 @@ async function serializeTask(task) {
|
|||
|
||||
return {
|
||||
...taskWithoutSubtasks,
|
||||
nanoid: task.nanoid, // Explicitly include nanoid
|
||||
tags: taskJson.Tags || [],
|
||||
Project: taskJson.Project
|
||||
? {
|
||||
...taskJson.Project,
|
||||
nanoid: taskJson.Project.nanoid, // Explicitly include Project nanoid
|
||||
}
|
||||
: null,
|
||||
subtasks: Subtasks
|
||||
? Subtasks.map((subtask) => ({
|
||||
...subtask,
|
||||
nanoid: subtask.nanoid, // Also include nanoid for subtasks
|
||||
tags: subtask.Tags || [],
|
||||
due_date: subtask.due_date
|
||||
? subtask.due_date.toISOString().split('T')[0]
|
||||
|
|
@ -45,24 +85,42 @@ async function serializeTask(task) {
|
|||
async function updateTaskTags(task, tagsData, userId) {
|
||||
if (!tagsData) return;
|
||||
|
||||
const tagNames = tagsData
|
||||
.map((tag) => tag.name)
|
||||
.filter((name) => name && name.trim())
|
||||
.filter((name, index, arr) => arr.indexOf(name) === index); // unique
|
||||
// Validate and filter tag names
|
||||
const validTagNames = [];
|
||||
const invalidTags = [];
|
||||
|
||||
if (tagNames.length === 0) {
|
||||
for (const tag of tagsData) {
|
||||
const validation = validateTagName(tag.name);
|
||||
if (validation.valid) {
|
||||
// Check for duplicates
|
||||
if (!validTagNames.includes(validation.name)) {
|
||||
validTagNames.push(validation.name);
|
||||
}
|
||||
} else {
|
||||
invalidTags.push({ name: tag.name, error: validation.error });
|
||||
}
|
||||
}
|
||||
|
||||
// If there are invalid tags, throw an error
|
||||
if (invalidTags.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (validTagNames.length === 0) {
|
||||
await task.setTags([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find existing tags
|
||||
const existingTags = await Tag.findAll({
|
||||
where: { user_id: userId, name: tagNames },
|
||||
where: { user_id: userId, name: validTagNames },
|
||||
});
|
||||
|
||||
// Create new tags
|
||||
const existingTagNames = existingTags.map((tag) => tag.name);
|
||||
const newTagNames = tagNames.filter(
|
||||
const newTagNames = validTagNames.filter(
|
||||
(name) => !existingTagNames.includes(name)
|
||||
);
|
||||
|
||||
|
|
@ -225,15 +283,23 @@ async function filterTasksByParams(params, userId) {
|
|||
parent_task_id: null, // Exclude subtasks from main task lists
|
||||
};
|
||||
let includeClause = [
|
||||
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } },
|
||||
{ model: Project, attributes: ['name'], required: false },
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: Task,
|
||||
as: 'Subtasks',
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -365,7 +431,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -380,7 +446,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -408,7 +474,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -423,7 +489,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -463,7 +529,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -478,7 +544,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -530,7 +596,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -545,7 +611,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -573,7 +639,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -588,7 +654,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -627,7 +693,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -642,7 +708,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -680,7 +746,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -695,7 +761,7 @@ async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
|
|
@ -831,10 +897,14 @@ router.get('/task/uuid/:uuid', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{ model: Project, attributes: ['name'], required: false },
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -851,6 +921,49 @@ router.get('/task/uuid/:uuid', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /api/task/nanoid/:nanoid
|
||||
router.get('/task/nanoid/:nanoid', async (req, res) => {
|
||||
try {
|
||||
const task = await Task.findOne({
|
||||
where: { nanoid: req.params.nanoid, user_id: req.currentUser.id },
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: Task,
|
||||
as: 'Subtasks',
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found.' });
|
||||
}
|
||||
|
||||
const serializedTask = await serializeTask(task);
|
||||
|
||||
res.json(serializedTask);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task by nanoid:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/task/:id
|
||||
router.get('/task/:id', async (req, res) => {
|
||||
try {
|
||||
|
|
@ -859,17 +972,21 @@ router.get('/task/:id', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{ model: Project, attributes: ['name'], required: false },
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: Task,
|
||||
as: 'Subtasks',
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
|
|
@ -901,10 +1018,14 @@ router.get('/task/:id/subtasks', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{ model: Project, attributes: ['name'], required: false },
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [['created_at', 'ASC']],
|
||||
});
|
||||
|
|
@ -1055,22 +1176,20 @@ router.post('/task', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{ model: Project, attributes: ['name'], required: false },
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const taskJson = taskWithAssociations.toJSON();
|
||||
const serializedTask = await serializeTask(taskWithAssociations);
|
||||
|
||||
res.status(201).json({
|
||||
...taskJson,
|
||||
tags: taskJson.Tags || [],
|
||||
due_date: taskWithAssociations.due_date
|
||||
? taskWithAssociations.due_date.toISOString().split('T')[0]
|
||||
: null,
|
||||
});
|
||||
res.status(201).json(serializedTask);
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
res.status(400).json({
|
||||
|
|
@ -1115,7 +1234,7 @@ router.patch('/task/:id', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
|
|
@ -1575,10 +1694,14 @@ router.patch('/task/:id', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{ model: Project, attributes: ['name'], required: false },
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -1605,17 +1728,21 @@ router.patch('/task/:id/toggle_completion', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{ model: Project, attributes: ['name'], required: false },
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: Task,
|
||||
as: 'Subtasks',
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
|
|
@ -1876,10 +2003,14 @@ router.patch('/task/:id/toggle-today', async (req, res) => {
|
|||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{ model: Project, attributes: ['name'], required: false },
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'nanoid'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
28
backend/tests/mocks/nanoid.js
Normal file
28
backend/tests/mocks/nanoid.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Mock implementation of nanoid for testing
|
||||
// This provides a consistent, deterministic nanoid for tests
|
||||
|
||||
let counter = 0;
|
||||
|
||||
function nanoid(size = 21) {
|
||||
// Generate a deterministic ID for testing
|
||||
const prefix = 'test';
|
||||
const suffix = counter.toString().padStart(4, '0');
|
||||
counter++;
|
||||
|
||||
// Make it the requested size by padding or truncating
|
||||
const id = (prefix + suffix)
|
||||
.repeat(Math.ceil(size / (prefix + suffix).length))
|
||||
.substring(0, size);
|
||||
return id;
|
||||
}
|
||||
|
||||
function customAlphabet(alphabet, defaultSize = 21) {
|
||||
return (size = defaultSize) => {
|
||||
return nanoid(size);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nanoid,
|
||||
customAlphabet,
|
||||
};
|
||||
80
backend/utils/slug-utils.js
Normal file
80
backend/utils/slug-utils.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Creates a URL-safe slug from a string
|
||||
* @param {string} text - The text to slugify
|
||||
* @param {number} maxLength - Maximum length of the slug (default: 50)
|
||||
* @returns {string} The slugified text
|
||||
*/
|
||||
function createSlug(text, maxLength = 50) {
|
||||
if (!text) return '';
|
||||
|
||||
return (
|
||||
text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
// Remove or replace special characters
|
||||
.replace(/[^\w\s-]/g, '') // Remove non-word chars except spaces and hyphens
|
||||
.replace(/[\s_-]+/g, '-') // Replace spaces, underscores, multiple hyphens with single hyphen
|
||||
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
|
||||
.substring(0, maxLength) // Limit length
|
||||
.replace(/-$/, '')
|
||||
); // Remove trailing hyphen if created by substring
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a nanoid-slug URL for a given entity
|
||||
* @param {string} nanoid - The nanoid of the entity
|
||||
* @param {string} name - The name/title of the entity
|
||||
* @param {number} maxSlugLength - Maximum length of the slug part (default: 40)
|
||||
* @returns {string} The nanoid-slug URL part (e.g., "abc123-clean-the-backyard")
|
||||
*/
|
||||
function createNanoidSlug(nanoid, name, maxSlugLength = 40) {
|
||||
if (!nanoid) throw new Error('Nanoid is required');
|
||||
|
||||
const slug = createSlug(name, maxSlugLength);
|
||||
return slug ? `${nanoid}-${slug}` : nanoid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts nanoid from a nanoid-slug URL part
|
||||
* @param {string} nanoidSlug - The nanoid-slug (e.g., "abc123-clean-the-backyard")
|
||||
* @returns {string} The extracted nanoid
|
||||
*/
|
||||
function extractNanoidFromSlug(nanoidSlug) {
|
||||
if (!nanoidSlug) return '';
|
||||
|
||||
// Nanoid is always 21 characters by default, extract the first part before the first hyphen
|
||||
// But handle cases where the nanoid itself might contain hyphens
|
||||
const parts = nanoidSlug.split('-');
|
||||
if (parts.length === 1) {
|
||||
// No slug, just nanoid
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
// Look for the nanoid part (21 chars) - it should be the first part
|
||||
const firstPart = parts[0];
|
||||
if (firstPart.length === 21) {
|
||||
return firstPart;
|
||||
}
|
||||
|
||||
// Fallback: try to find 21-character alphanumeric string
|
||||
const nanoidMatch = nanoidSlug.match(/^([A-Za-z0-9_-]{21})/);
|
||||
return nanoidMatch ? nanoidMatch[1] : nanoidSlug.split('-')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string looks like a valid nanoid
|
||||
* @param {string} str - String to validate
|
||||
* @returns {boolean} True if it looks like a nanoid
|
||||
*/
|
||||
function isValidNanoid(str) {
|
||||
return /^[A-Za-z0-9_-]{21}$/.test(str);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSlug,
|
||||
createNanoidSlug,
|
||||
extractNanoidFromSlug,
|
||||
isValidNanoid,
|
||||
};
|
||||
|
|
@ -176,7 +176,7 @@ const App: React.FC = () => {
|
|||
/>
|
||||
<Route path="/today" element={<TasksToday />} />
|
||||
<Route
|
||||
path="/task/:uuid"
|
||||
path="/task/:nanoid"
|
||||
element={<TaskDetails />}
|
||||
/>
|
||||
<Route
|
||||
|
|
@ -199,18 +199,21 @@ const App: React.FC = () => {
|
|||
<Route path="/inbox" element={<InboxItems />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route
|
||||
path="/project/:id"
|
||||
path="/project/:nanoidSlug"
|
||||
element={<ProjectDetails />}
|
||||
/>
|
||||
<Route path="/areas" element={<Areas />} />
|
||||
<Route path="/area/:id" element={<AreaDetails />} />
|
||||
<Route path="/tags" element={<Tags />} />
|
||||
<Route
|
||||
path="/tag/:identifier"
|
||||
path="/tag/:nanoidSlug"
|
||||
element={<TagDetails />}
|
||||
/>
|
||||
<Route path="/notes" element={<Notes />} />
|
||||
<Route path="/note/:id" element={<NoteDetails />} />
|
||||
<Route
|
||||
path="/note/:nanoidSlug"
|
||||
element={<NoteDetails />}
|
||||
/>
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
|
|
|
|||
|
|
@ -181,7 +181,14 @@ const Areas: React.FC = () => {
|
|||
{areas.map((area: any) => (
|
||||
<Link
|
||||
key={area.id}
|
||||
to={`/projects?area_id=${area.id}`}
|
||||
to={
|
||||
area.nanoid
|
||||
? `/projects?area=${area.nanoid}-${area.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`
|
||||
: `/projects?area_id=${area.id}`
|
||||
}
|
||||
className={`bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col group hover:opacity-90 transition-opacity cursor-pointer ${
|
||||
dropdownOpen === area.id ? 'z-50' : ''
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -436,7 +436,20 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
key={projectRef}
|
||||
>
|
||||
<Link
|
||||
to={`/project/${matchingProject.id}`}
|
||||
to={
|
||||
matchingProject.nanoid
|
||||
? `/project/${matchingProject.nanoid}-${matchingProject.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
: `/project/${matchingProject.id}`
|
||||
}
|
||||
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
|
||||
>
|
||||
{projectRef}
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ const InboxItems: React.FC = () => {
|
|||
<span>
|
||||
{t('task.created', 'Task')}{' '}
|
||||
<a
|
||||
href={`/task/${createdTask.uuid}`}
|
||||
href={`/task/${createdTask.nanoid}`}
|
||||
className="text-green-200 underline hover:text-green-100"
|
||||
>
|
||||
{createdTask.name}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import NoteModal from './NoteModal';
|
|||
import MarkdownRenderer from '../Shared/MarkdownRenderer';
|
||||
import { Note } from '../../entities/Note';
|
||||
import {
|
||||
fetchNotes,
|
||||
fetchNoteBySlug,
|
||||
deleteNote as apiDeleteNote,
|
||||
updateNote as apiUpdateNote,
|
||||
} from '../../utils/notesService';
|
||||
|
|
@ -19,7 +19,7 @@ import { createProject, fetchProjects } from '../../utils/projectsService';
|
|||
import { Project } from '../../entities/Project';
|
||||
|
||||
const NoteDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { nanoidSlug } = useParams<{ nanoidSlug: string }>();
|
||||
const [note, setNote] = useState<Note | null>(null);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
|
||||
|
|
@ -36,8 +36,7 @@ const NoteDetails: React.FC = () => {
|
|||
const fetchNote = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const notes = await fetchNotes();
|
||||
const foundNote = notes.find((n: Note) => n.id === Number(id));
|
||||
const foundNote = await fetchNoteBySlug(nanoidSlug!);
|
||||
setNote(foundNote || null);
|
||||
if (!foundNote) {
|
||||
setIsError(true);
|
||||
|
|
@ -50,7 +49,7 @@ const NoteDetails: React.FC = () => {
|
|||
}
|
||||
};
|
||||
fetchNote();
|
||||
}, [id]);
|
||||
}, [nanoidSlug]);
|
||||
|
||||
// Load projects for the modal
|
||||
useEffect(() => {
|
||||
|
|
@ -157,7 +156,26 @@ const NoteDetails: React.FC = () => {
|
|||
<div className="flex items-center">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<Link
|
||||
to={`/project/${(note.project || note.Project)?.id}`}
|
||||
to={
|
||||
(
|
||||
note.project ||
|
||||
note.Project
|
||||
)?.nanoid
|
||||
? `/project/${(note.project || note.Project)?.nanoid}-${(
|
||||
note.project ||
|
||||
note.Project
|
||||
)?.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
: `/project/${(note.project || note.Project)?.id}`
|
||||
}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
||||
>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -300,7 +300,15 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
|
|||
}
|
||||
} else {
|
||||
// Handle project click - navigate to project page
|
||||
navigate(`/project/${item.id}`);
|
||||
if (item.nanoid) {
|
||||
const slug = item.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
navigate(`/project/${item.nanoid}-${slug}`);
|
||||
} else {
|
||||
navigate(`/project/${item.id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import NoteCard from '../Shared/NoteCard';
|
|||
import { Task } from '../../entities/Task';
|
||||
import { Note } from '../../entities/Note';
|
||||
import {
|
||||
fetchProjectById,
|
||||
fetchProjectBySlug,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
fetchProjects,
|
||||
|
|
@ -38,7 +38,7 @@ import AutoSuggestNextActionBox from './AutoSuggestNextActionBox';
|
|||
import SortFilterButton, { SortOption } from '../Shared/SortFilterButton';
|
||||
|
||||
const ProjectDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { nanoidSlug } = useParams<{ nanoidSlug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast } = useToast();
|
||||
|
|
@ -133,12 +133,19 @@ const ProjectDetails: React.FC = () => {
|
|||
setOrderBy(sortParam);
|
||||
}, []);
|
||||
|
||||
// Fetch project data when id changes
|
||||
// Fetch project data when nanoidSlug changes
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (!nanoidSlug) return;
|
||||
|
||||
// Skip loading if we already have the project data for this id
|
||||
if (project && project.id?.toString() === id) {
|
||||
// Skip loading if we already have the project data for this nanoidSlug
|
||||
if (
|
||||
project &&
|
||||
project.nanoid &&
|
||||
`${project.nanoid}-${project.name
|
||||
?.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}` === nanoidSlug
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +157,7 @@ const ProjectDetails: React.FC = () => {
|
|||
}
|
||||
setError(false);
|
||||
|
||||
const projectData = await fetchProjectById(id);
|
||||
const projectData = await fetchProjectBySlug(nanoidSlug);
|
||||
setProject(projectData);
|
||||
setTasks(projectData.tasks || projectData.Tasks || []);
|
||||
|
||||
|
|
@ -181,7 +188,7 @@ const ProjectDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
loadProjectData();
|
||||
}, [id]);
|
||||
}, [nanoidSlug]);
|
||||
|
||||
const handleTaskCreate = async (taskName: string) => {
|
||||
if (!project) {
|
||||
|
|
@ -202,7 +209,7 @@ const ProjectDetails: React.FC = () => {
|
|||
<span>
|
||||
{t('task.created', 'Task')}{' '}
|
||||
<a
|
||||
href={`/task/${newTask.uuid}`}
|
||||
href={`/task/${newTask.nanoid}`}
|
||||
className="text-green-200 underline hover:text-green-100"
|
||||
>
|
||||
{newTask.name}
|
||||
|
|
@ -341,10 +348,10 @@ const ProjectDetails: React.FC = () => {
|
|||
);
|
||||
} catch {
|
||||
// Optionally refetch data on error to ensure consistency
|
||||
if (id) {
|
||||
if (nanoidSlug) {
|
||||
// Refetch project data on error to ensure consistency
|
||||
try {
|
||||
const projectData = await fetchProjectById(id);
|
||||
const projectData = await fetchProjectBySlug(nanoidSlug);
|
||||
setProject(projectData);
|
||||
setTasks(projectData.tasks || projectData.Tasks || []);
|
||||
const fetchedNotes =
|
||||
|
|
@ -409,7 +416,7 @@ const ProjectDetails: React.FC = () => {
|
|||
<span>
|
||||
{t('task.created', 'Task')}{' '}
|
||||
<a
|
||||
href={`/task/${newTask.uuid}`}
|
||||
href={`/task/${newTask.nanoid}`}
|
||||
className="text-green-200 underline hover:text-green-100"
|
||||
>
|
||||
{newTask.name}
|
||||
|
|
@ -687,9 +694,25 @@ const ProjectDetails: React.FC = () => {
|
|||
<button
|
||||
onClick={() => {
|
||||
// Navigate to tag details page
|
||||
navigate(
|
||||
`/tag/${encodeURIComponent(tag.name)}`
|
||||
);
|
||||
if (tag.nanoid) {
|
||||
const slug = tag.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
);
|
||||
navigate(
|
||||
`/tag/${tag.nanoid}-${slug}`
|
||||
);
|
||||
} else {
|
||||
navigate(
|
||||
`/tag/${encodeURIComponent(tag.name)}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="text-xs text-white/90 hover:text-blue-200 transition-colors cursor-pointer font-medium"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,16 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
}}
|
||||
>
|
||||
{viewMode === 'cards' && (
|
||||
<Link to={`/project/${project.id}`}>
|
||||
<Link
|
||||
to={
|
||||
project.nanoid
|
||||
? `/project/${project.nanoid}-${project.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`
|
||||
: `/project/${project.id}`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg relative cursor-pointer hover:opacity-90 transition-opacity"
|
||||
style={{ height: '140px' }}
|
||||
|
|
@ -81,7 +90,14 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
|
||||
{viewMode === 'list' && (
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
to={
|
||||
project.nanoid
|
||||
? `/project/${project.nanoid}-${project.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`
|
||||
: `/project/${project.id}`
|
||||
}
|
||||
className="w-10 h-10 mr-3 flex-shrink-0"
|
||||
>
|
||||
{project.image_url ? (
|
||||
|
|
@ -109,7 +125,14 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
>
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
to={
|
||||
project.nanoid
|
||||
? `/project/${project.nanoid}-${project.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`
|
||||
: `/project/${project.id}`
|
||||
}
|
||||
className={`${
|
||||
viewMode === 'cards'
|
||||
? 'text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline line-clamp-2'
|
||||
|
|
|
|||
|
|
@ -11,12 +11,13 @@ import MarkdownRenderer from './MarkdownRenderer';
|
|||
interface NoteCardProps {
|
||||
note: {
|
||||
id?: string | number;
|
||||
nanoid?: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
tags?: { name: string }[];
|
||||
Tags?: { name: string }[];
|
||||
project?: { name: string; id?: number };
|
||||
Project?: { name: string; id?: number };
|
||||
tags?: { name: string; nanoid?: string }[];
|
||||
Tags?: { name: string; nanoid?: string }[];
|
||||
project?: { name: string; id?: number; nanoid?: string };
|
||||
Project?: { name: string; id?: number; nanoid?: string };
|
||||
};
|
||||
onEdit?: (note: any) => void;
|
||||
onDelete?: (note: any) => void;
|
||||
|
|
@ -61,7 +62,14 @@ const NoteCard: React.FC<NoteCardProps> = ({
|
|||
return (
|
||||
<div className="relative group">
|
||||
<Link
|
||||
to={`/note/${note.id}`}
|
||||
to={
|
||||
note.nanoid
|
||||
? `/note/${note.nanoid}-${note.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`
|
||||
: `/note/${note.id}`
|
||||
}
|
||||
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col hover:opacity-80 transition-opacity duration-300 ease-in-out cursor-pointer"
|
||||
style={{
|
||||
minHeight: '280px',
|
||||
|
|
@ -118,10 +126,19 @@ const NoteCard: React.FC<NoteCardProps> = ({
|
|||
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2 min-w-0 flex-1">
|
||||
{showProject && project && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(`/project/${project.id}`);
|
||||
if (project.nanoid) {
|
||||
navigate(
|
||||
`/project/${project.nanoid}-${project.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`
|
||||
);
|
||||
} else {
|
||||
navigate(`/project/${project.id}`);
|
||||
}
|
||||
}}
|
||||
className="flex items-center min-w-0 hover:text-gray-700 dark:hover:text-gray-300 hover:underline transition-colors bg-transparent border-none p-0 cursor-pointer"
|
||||
>
|
||||
|
|
@ -144,9 +161,24 @@ const NoteCard: React.FC<NoteCardProps> = ({
|
|||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/tag/${encodeURIComponent(tag.name)}`
|
||||
);
|
||||
if (tag.nanoid) {
|
||||
navigate(
|
||||
`/tag/${tag.nanoid}-${tag.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
navigate(
|
||||
`/tag/${encodeURIComponent(tag.name)}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="hover:text-gray-700 dark:hover:text-gray-300 hover:underline transition-colors bg-transparent border-none p-0 cursor-pointer"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -15,15 +15,11 @@ import { Project } from '../../entities/Project';
|
|||
import TaskList from '../Task/TaskList';
|
||||
import ProjectItem from '../Project/ProjectItem';
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
import { Tag } from '../../entities/Tag';
|
||||
|
||||
const TagDetails: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { identifier } = useParams<{ identifier: string }>();
|
||||
const { nanoidSlug } = useParams<{ nanoidSlug: string }>();
|
||||
const [tag, setTag] = useState<Tag | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
|
|
@ -41,53 +37,48 @@ const TagDetails: React.FC = () => {
|
|||
useEffect(() => {
|
||||
const fetchTagData = async () => {
|
||||
try {
|
||||
// First fetch tag details
|
||||
const tagResponse = await fetch(
|
||||
`/api/tag/${encodeURIComponent(identifier!)}`
|
||||
// First fetch tag details using nanoid-slug
|
||||
const { fetchTagBySlug } = await import(
|
||||
'../../utils/tagsService'
|
||||
);
|
||||
if (tagResponse.ok) {
|
||||
const tagData = await tagResponse.json();
|
||||
setTag(tagData);
|
||||
const tagData = await fetchTagBySlug(nanoidSlug!);
|
||||
setTag(tagData);
|
||||
|
||||
// Now fetch entities that have this tag using the tag name
|
||||
const [tasksResponse, notesResponse, projectsResponse] =
|
||||
await Promise.all([
|
||||
fetch(
|
||||
`/api/tasks?tag=${encodeURIComponent(tagData.name)}`
|
||||
),
|
||||
fetch(
|
||||
`/api/notes?tag=${encodeURIComponent(tagData.name)}`
|
||||
),
|
||||
fetch(`/api/projects`), // Projects API doesn't support tag filtering yet
|
||||
]);
|
||||
// Now fetch entities that have this tag using the tag name
|
||||
const [tasksResponse, notesResponse, projectsResponse] =
|
||||
await Promise.all([
|
||||
fetch(
|
||||
`/api/tasks?tag=${encodeURIComponent(tagData.name)}`
|
||||
),
|
||||
fetch(
|
||||
`/api/notes?tag=${encodeURIComponent(tagData.name)}`
|
||||
),
|
||||
fetch(`/api/projects`), // Projects API doesn't support tag filtering yet
|
||||
]);
|
||||
|
||||
if (tasksResponse.ok) {
|
||||
const tasksData = await tasksResponse.json();
|
||||
setTasks(tasksData.tasks || []);
|
||||
}
|
||||
if (tasksResponse.ok) {
|
||||
const tasksData = await tasksResponse.json();
|
||||
setTasks(tasksData.tasks || []);
|
||||
}
|
||||
|
||||
if (notesResponse.ok) {
|
||||
const notesData = await notesResponse.json();
|
||||
setNotes(notesData || []);
|
||||
}
|
||||
if (notesResponse.ok) {
|
||||
const notesData = await notesResponse.json();
|
||||
setNotes(notesData || []);
|
||||
}
|
||||
|
||||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json();
|
||||
// Filter projects client-side since API doesn't support tag filtering
|
||||
const allProjects =
|
||||
projectsData.projects || projectsData || [];
|
||||
const filteredProjects = allProjects.filter(
|
||||
(project: any) =>
|
||||
project.tags &&
|
||||
project.tags.some(
|
||||
(tag: any) => tag.name === tagData.name
|
||||
)
|
||||
);
|
||||
setProjects(filteredProjects);
|
||||
}
|
||||
} else {
|
||||
const tagError = await tagResponse.json();
|
||||
setError(tagError.error || 'Failed to fetch tag.');
|
||||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json();
|
||||
// Filter projects client-side since API doesn't support tag filtering
|
||||
const allProjects =
|
||||
projectsData.projects || projectsData || [];
|
||||
const filteredProjects = allProjects.filter(
|
||||
(project: any) =>
|
||||
project.tags &&
|
||||
project.tags.some(
|
||||
(tag: any) => tag.name === tagData.name
|
||||
)
|
||||
);
|
||||
setProjects(filteredProjects);
|
||||
}
|
||||
} catch {
|
||||
setError(t('tags.error'));
|
||||
|
|
@ -96,7 +87,7 @@ const TagDetails: React.FC = () => {
|
|||
}
|
||||
};
|
||||
fetchTagData();
|
||||
}, [identifier, t]);
|
||||
}, [nanoidSlug, t]);
|
||||
|
||||
// Task handlers
|
||||
const handleTaskUpdate = async (updatedTask: Task) => {
|
||||
|
|
@ -164,7 +155,15 @@ const TagDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleEditProject = (project: Project) => {
|
||||
navigate(`/project/${project.id}/edit`);
|
||||
if (project.nanoid) {
|
||||
const slug = project.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
navigate(`/project/${project.nanoid}-${slug}/edit`);
|
||||
} else {
|
||||
navigate(`/project/${project.id}/edit`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -280,7 +279,20 @@ const TagDetails: React.FC = () => {
|
|||
<div className="flex-grow overflow-hidden pr-4">
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<Link
|
||||
to={`/note/${note.id}`}
|
||||
to={
|
||||
note.nanoid
|
||||
? `/note/${note.nanoid}-${note.title
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
: `/note/${note.id}`
|
||||
}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
|
||||
>
|
||||
{note.title}
|
||||
|
|
|
|||
|
|
@ -297,7 +297,20 @@ const Tags: React.FC = () => {
|
|||
{/* Tag Name and Metrics - inline */}
|
||||
<div className="flex items-center space-x-3 flex-grow">
|
||||
<Link
|
||||
to={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
to={
|
||||
tag.nanoid
|
||||
? `/tag/${tag.nanoid}-${tag.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
: `/tag/${encodeURIComponent(tag.name)}`
|
||||
}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
|
||||
>
|
||||
{tag.name}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
updateTask,
|
||||
deleteTask,
|
||||
toggleTaskCompletion,
|
||||
fetchTaskByNanoid,
|
||||
} from '../../utils/tasksService';
|
||||
import { createProject } from '../../utils/projectsService';
|
||||
import { useStore } from '../../store/useStore';
|
||||
|
|
@ -31,16 +32,16 @@ import TaskTimeline from './TaskTimeline';
|
|||
import { isTaskOverdue } from '../../utils/dateUtils';
|
||||
|
||||
const TaskDetails: React.FC = () => {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const { nanoid } = useParams<{ nanoid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const projects = useStore((state) => state.projectsStore.projects);
|
||||
const tagsStore = useStore((state) => state.tagsStore);
|
||||
const tasksStore = useStore((state) => state.tasksStore);
|
||||
const task = useStore((state) =>
|
||||
state.tasksStore.tasks.find((t) => t.uuid === uuid)
|
||||
const projects = useStore((state: any) => state.projectsStore.projects);
|
||||
const tagsStore = useStore((state: any) => state.tagsStore);
|
||||
const tasksStore = useStore((state: any) => state.tasksStore);
|
||||
const task = useStore((state: any) =>
|
||||
state.tasksStore.tasks.find((t: Task) => t.nanoid === nanoid)
|
||||
);
|
||||
|
||||
// Get subtasks from the task data (already loaded in global store)
|
||||
|
|
@ -70,7 +71,7 @@ const TaskDetails: React.FC = () => {
|
|||
if (pendingStateStr) {
|
||||
const pendingState = JSON.parse(pendingStateStr);
|
||||
const isRecent = Date.now() - pendingState.timestamp < 2000; // Within 2 seconds
|
||||
const isCorrectTask = pendingState.taskUuid === uuid;
|
||||
const isCorrectTask = pendingState.taskId === nanoid;
|
||||
|
||||
if (isRecent && isCorrectTask && pendingState.isOpen) {
|
||||
// Use microtask to avoid lifecycle method warning
|
||||
|
|
@ -89,7 +90,7 @@ const TaskDetails: React.FC = () => {
|
|||
if (pendingEditStateStr) {
|
||||
const pendingEditState = JSON.parse(pendingEditStateStr);
|
||||
const isRecent = Date.now() - pendingEditState.timestamp < 5000; // Within 5 seconds
|
||||
const isCorrectTask = pendingEditState.taskUuid === uuid;
|
||||
const isCorrectTask = pendingEditState.taskId === nanoid;
|
||||
|
||||
if (isRecent && isCorrectTask && pendingEditState.isOpen) {
|
||||
// Use microtask to avoid lifecycle method warning
|
||||
|
|
@ -104,7 +105,7 @@ const TaskDetails: React.FC = () => {
|
|||
sessionStorage.removeItem('pendingModalState');
|
||||
sessionStorage.removeItem('pendingTaskEditModalState');
|
||||
}
|
||||
}, [uuid, tagsStore]);
|
||||
}, [nanoid, tagsStore]);
|
||||
|
||||
// Date and recurrence formatting functions (from TaskHeader)
|
||||
const formatDueDate = (dueDate: string) => {
|
||||
|
|
@ -147,8 +148,8 @@ const TaskDetails: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
const fetchTaskData = async () => {
|
||||
if (!uuid) {
|
||||
setError('No task UUID provided');
|
||||
if (!nanoid) {
|
||||
setError('No task nanoid provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -157,7 +158,9 @@ const TaskDetails: React.FC = () => {
|
|||
if (!task) {
|
||||
try {
|
||||
setLoading(true);
|
||||
await tasksStore.loadTaskByUuid(uuid);
|
||||
const fetchedTask = await fetchTaskByNanoid(nanoid);
|
||||
// Add the task to the store
|
||||
tasksStore.setTasks([...tasksStore.tasks, fetchedTask]);
|
||||
} catch (fetchError) {
|
||||
setError('Task not found');
|
||||
console.error('Error fetching task:', fetchError);
|
||||
|
|
@ -170,7 +173,7 @@ const TaskDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
fetchTaskData();
|
||||
}, [uuid, task, tasksStore]);
|
||||
}, [nanoid, task, tasksStore]);
|
||||
|
||||
const handleEdit = (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
|
|
@ -182,7 +185,7 @@ const TaskDetails: React.FC = () => {
|
|||
// Store modal state in sessionStorage to persist across re-mounts
|
||||
const modalState = {
|
||||
isOpen: true,
|
||||
taskUuid: uuid,
|
||||
taskId: nanoid,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
sessionStorage.setItem(
|
||||
|
|
@ -200,8 +203,16 @@ const TaskDetails: React.FC = () => {
|
|||
try {
|
||||
const updatedTask = await toggleTaskCompletion(task.id);
|
||||
// Update the task in the global store
|
||||
if (uuid) {
|
||||
await tasksStore.loadTaskByUuid(uuid);
|
||||
if (nanoid) {
|
||||
const updatedTask = await fetchTaskByNanoid(nanoid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
(t: Task) => t.nanoid === nanoid
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const updatedTasks = [...tasksStore.tasks];
|
||||
updatedTasks[existingIndex] = updatedTask;
|
||||
tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
}
|
||||
|
||||
const statusMessage =
|
||||
|
|
@ -226,8 +237,16 @@ const TaskDetails: React.FC = () => {
|
|||
if (task?.id) {
|
||||
await updateTask(task.id, updatedTask);
|
||||
// Update the task in the global store
|
||||
if (uuid) {
|
||||
await tasksStore.loadTaskByUuid(uuid);
|
||||
if (nanoid) {
|
||||
const updatedTask = await fetchTaskByNanoid(nanoid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
(t: Task) => t.nanoid === nanoid
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const updatedTasks = [...tasksStore.tasks];
|
||||
updatedTasks[existingIndex] = updatedTask;
|
||||
tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
}
|
||||
showSuccessToast(
|
||||
t('task.updateSuccess', 'Task updated successfully')
|
||||
|
|
@ -288,7 +307,7 @@ const TaskDetails: React.FC = () => {
|
|||
const modalState = {
|
||||
isOpen: true,
|
||||
focusSubtasks: true,
|
||||
taskUuid: uuid,
|
||||
taskId: nanoid,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
sessionStorage.setItem(
|
||||
|
|
@ -300,7 +319,7 @@ const TaskDetails: React.FC = () => {
|
|||
setFocusSubtasks(true);
|
||||
setIsTaskModalOpen(true);
|
||||
},
|
||||
[uuid]
|
||||
[nanoid]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -360,8 +379,21 @@ const TaskDetails: React.FC = () => {
|
|||
<div className="flex items-center">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<Link
|
||||
to={`/project/${task.Project.id}`}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
||||
to={
|
||||
task.Project.nanoid
|
||||
? `/project/${task.Project.nanoid}-${task.Project.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
: `/project/${task.Project.id}`
|
||||
}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:underline"
|
||||
>
|
||||
{task.Project.name}
|
||||
</Link>
|
||||
|
|
@ -376,21 +408,43 @@ const TaskDetails: React.FC = () => {
|
|||
<div className="flex items-center">
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
<span>
|
||||
{task.tags.map((tag, index) => (
|
||||
<React.Fragment
|
||||
key={tag.id || tag.name}
|
||||
>
|
||||
<Link
|
||||
to={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
||||
{task.tags.map(
|
||||
(
|
||||
tag: any,
|
||||
index: number
|
||||
) => (
|
||||
<React.Fragment
|
||||
key={
|
||||
tag.id ||
|
||||
tag.name
|
||||
}
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
{index <
|
||||
task.tags!.length -
|
||||
1 && ', '}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Link
|
||||
to={
|
||||
tag.nanoid
|
||||
? `/tag/${tag.nanoid}-${tag.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
: `/tag/${encodeURIComponent(tag.name)}`
|
||||
}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:underline"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
{index <
|
||||
task.tags!
|
||||
.length -
|
||||
1 && ', '}
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -524,7 +578,7 @@ const TaskDetails: React.FC = () => {
|
|||
</h4>
|
||||
{subtasks.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{subtasks.map((subtask) => (
|
||||
{subtasks.map((subtask: Task) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="group"
|
||||
|
|
@ -554,7 +608,7 @@ const TaskDetails: React.FC = () => {
|
|||
subtask.status
|
||||
}
|
||||
onToggleCompletion={async (
|
||||
e
|
||||
e?: React.MouseEvent
|
||||
) => {
|
||||
e?.stopPropagation();
|
||||
if (
|
||||
|
|
@ -570,11 +624,36 @@ const TaskDetails: React.FC = () => {
|
|||
) {
|
||||
// Refresh task data which includes updated subtasks
|
||||
if (
|
||||
uuid
|
||||
nanoid
|
||||
) {
|
||||
await tasksStore.loadTaskByUuid(
|
||||
uuid
|
||||
);
|
||||
const updatedTask =
|
||||
await fetchTaskByNanoid(
|
||||
nanoid
|
||||
);
|
||||
const existingIndex =
|
||||
tasksStore.tasks.findIndex(
|
||||
(
|
||||
t: Task
|
||||
) =>
|
||||
t.nanoid ===
|
||||
nanoid
|
||||
);
|
||||
if (
|
||||
existingIndex >=
|
||||
0
|
||||
) {
|
||||
const updatedTasks =
|
||||
[
|
||||
...tasksStore.tasks,
|
||||
];
|
||||
updatedTasks[
|
||||
existingIndex
|
||||
] =
|
||||
updatedTask;
|
||||
tasksStore.setTasks(
|
||||
updatedTasks
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh timeline to show subtask completion activity
|
||||
|
|
|
|||
|
|
@ -263,7 +263,17 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
<div className="flex items-center">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
to={
|
||||
project.nanoid
|
||||
? `/project/${project.nanoid}-${project.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(/^-|-$/g, '')}`
|
||||
: `/project/${project.id}`
|
||||
}
|
||||
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
|
||||
onClick={(e) => {
|
||||
// Prevent navigation if we're already on this project's page
|
||||
|
|
@ -293,7 +303,29 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
{task.tags.map((tag, index) => (
|
||||
<React.Fragment key={tag.name}>
|
||||
<Link
|
||||
to={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
to={
|
||||
tag.nanoid
|
||||
? `/tag/${tag.nanoid}-${tag.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
: `/tag/${tag.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
}
|
||||
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
|
|
@ -499,7 +531,17 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
<div className="flex items-center">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
to={
|
||||
project.nanoid
|
||||
? `/project/${project.nanoid}-${project.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(/^-|-$/g, '')}`
|
||||
: `/project/${project.id}`
|
||||
}
|
||||
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
|
||||
onClick={(e) => {
|
||||
// Prevent navigation if we're already on this project's page
|
||||
|
|
@ -523,7 +565,29 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
{task.tags.map((tag, index) => (
|
||||
<React.Fragment key={tag.name}>
|
||||
<Link
|
||||
to={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
to={
|
||||
tag.nanoid
|
||||
? `/tag/${tag.nanoid}-${tag.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
: `/tag/${tag.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(
|
||||
/^-|-$/g,
|
||||
''
|
||||
)}`
|
||||
}
|
||||
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
|
|
|
|||
|
|
@ -237,15 +237,15 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
};
|
||||
|
||||
const handleTaskClick = () => {
|
||||
if (task.uuid) {
|
||||
navigate(`/task/${task.uuid}`);
|
||||
if (task.nanoid) {
|
||||
navigate(`/task/${task.nanoid}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubtaskClick = async () => {
|
||||
// Navigate to the parent task URL (not the subtask URL)
|
||||
if (task.uuid) {
|
||||
navigate(`/task/${task.uuid}`);
|
||||
if (task.nanoid) {
|
||||
navigate(`/task/${task.nanoid}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -255,9 +255,9 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
setSelectedSubtask(null);
|
||||
};
|
||||
|
||||
const handleSubtaskDelete = () => {
|
||||
const handleSubtaskDelete = async () => {
|
||||
if (selectedSubtask && selectedSubtask.id) {
|
||||
onTaskDelete(selectedSubtask.id);
|
||||
await onTaskDelete(selectedSubtask.id);
|
||||
setSubtaskModalOpen(false);
|
||||
setSelectedSubtask(null);
|
||||
}
|
||||
|
|
@ -269,9 +269,9 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
setParentTask(null);
|
||||
};
|
||||
|
||||
const handleParentTaskDelete = () => {
|
||||
const handleParentTaskDelete = async () => {
|
||||
if (parentTask && parentTask.id) {
|
||||
onTaskDelete(parentTask.id);
|
||||
await onTaskDelete(parentTask.id);
|
||||
setParentTaskModalOpen(false);
|
||||
setParentTask(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<span>
|
||||
{t('task.updated', 'Task')}{' '}
|
||||
<a
|
||||
href={`/task/${formData.uuid}`}
|
||||
href={`/task/${formData.nanoid}`}
|
||||
className="text-green-200 underline hover:text-green-100"
|
||||
>
|
||||
{formData.name}
|
||||
|
|
@ -362,7 +362,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<span>
|
||||
{t('task.deleted', 'Task')}{' '}
|
||||
<a
|
||||
href={`/task/${formData.uuid}`}
|
||||
href={`/task/${formData.nanoid}`}
|
||||
className="text-green-200 underline hover:text-green-100"
|
||||
>
|
||||
{formData.name}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,16 @@ const TaskTags: React.FC<TaskTagsProps> = ({
|
|||
const navigate = useNavigate();
|
||||
|
||||
const handleTagClick = (tag: Tag) => {
|
||||
navigate(`/tag/${encodeURIComponent(tag.name)}`);
|
||||
if (tag.nanoid) {
|
||||
const slug = tag.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
navigate(`/tag/${tag.nanoid}-${slug}`);
|
||||
} else {
|
||||
// Fallback to old URL format if nanoid is not available
|
||||
navigate(`/tag/${encodeURIComponent(tag.name)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render anything if there are no tags
|
||||
|
|
|
|||
|
|
@ -209,9 +209,11 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId, refreshKey }) => {
|
|||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
|
||||
<ClockIcon className="h-6 w-6 mb-2" />
|
||||
<span className="text-sm">No activity yet</span>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<ClockIcon className="h-12 w-12 mb-3 opacity-50" />
|
||||
<span className="text-sm text-center">
|
||||
{t('task.noActivityYet', 'No activity yet')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ const Tasks: React.FC = () => {
|
|||
<span>
|
||||
{t('task.created', 'Task')}{' '}
|
||||
<a
|
||||
href={`/task/${newTask.uuid}`}
|
||||
href={`/task/${newTask.nanoid}`}
|
||||
className="text-green-200 underline hover:text-green-100"
|
||||
>
|
||||
{newTask.name}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export interface Area {
|
||||
id?: number;
|
||||
nanoid?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Tag } from './Tag';
|
|||
|
||||
export interface Note {
|
||||
id?: number;
|
||||
nanoid?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
created_at?: string;
|
||||
|
|
@ -11,10 +12,12 @@ export interface Note {
|
|||
Tags?: Tag[]; // Sequelize association naming (capitalized)
|
||||
project?: {
|
||||
id: number;
|
||||
nanoid?: string;
|
||||
name: string;
|
||||
};
|
||||
Project?: {
|
||||
id: number;
|
||||
nanoid?: string;
|
||||
name: string;
|
||||
}; // Sequelize association naming (capitalized)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Note } from './Note';
|
|||
|
||||
export interface Project {
|
||||
id?: number;
|
||||
nanoid?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
active: boolean;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export interface Tag {
|
||||
id?: number;
|
||||
nanoid?: string;
|
||||
name: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Project } from './Project';
|
|||
export interface Task {
|
||||
id?: number;
|
||||
uuid?: string;
|
||||
nanoid?: string;
|
||||
name: string;
|
||||
status: StatusType | number;
|
||||
priority?: PriorityType | number;
|
||||
|
|
|
|||
|
|
@ -51,3 +51,13 @@ export const deleteNote = async (noteId: number): Promise<void> => {
|
|||
|
||||
await handleAuthResponse(response, 'Failed to delete note.');
|
||||
};
|
||||
|
||||
export const fetchNoteBySlug = async (nanoidSlug: string): Promise<Note> => {
|
||||
const response = await fetch(`/api/note/${nanoidSlug}`, {
|
||||
credentials: 'include',
|
||||
headers: getDefaultHeaders(),
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to fetch note.');
|
||||
return await response.json();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -102,3 +102,17 @@ export const deleteProject = async (projectId: number): Promise<void> => {
|
|||
|
||||
await handleAuthResponse(response, 'Failed to delete project.');
|
||||
};
|
||||
|
||||
export const fetchProjectBySlug = async (
|
||||
nanoidSlug: string
|
||||
): Promise<Project> => {
|
||||
const response = await fetch(`/api/project/${nanoidSlug}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to fetch project.');
|
||||
return await response.json();
|
||||
};
|
||||
|
|
|
|||
120
frontend/utils/slugUtils.ts
Normal file
120
frontend/utils/slugUtils.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Creates a URL-safe slug from a string
|
||||
* @param text - The text to slugify
|
||||
* @param maxLength - Maximum length of the slug (default: 50)
|
||||
* @returns The slugified text
|
||||
*/
|
||||
export function createSlug(text: string, maxLength: number = 50): string {
|
||||
if (!text) return '';
|
||||
|
||||
return (
|
||||
text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
// Remove or replace special characters
|
||||
.replace(/[^\w\s-]/g, '') // Remove non-word chars except spaces and hyphens
|
||||
.replace(/[\s_-]+/g, '-') // Replace spaces, underscores, multiple hyphens with single hyphen
|
||||
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
|
||||
.substring(0, maxLength) // Limit length
|
||||
.replace(/-$/, '')
|
||||
); // Remove trailing hyphen if created by substring
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a nanoid-slug URL for a given entity
|
||||
* @param nanoid - The nanoid of the entity
|
||||
* @param name - The name/title of the entity
|
||||
* @param maxSlugLength - Maximum length of the slug part (default: 40)
|
||||
* @returns The nanoid-slug URL part (e.g., "abc123-clean-the-backyard")
|
||||
*/
|
||||
export function createNanoidSlug(
|
||||
nanoid: string,
|
||||
name: string,
|
||||
maxSlugLength: number = 40
|
||||
): string {
|
||||
if (!nanoid) throw new Error('Nanoid is required');
|
||||
|
||||
const slug = createSlug(name, maxSlugLength);
|
||||
return slug ? `${nanoid}-${slug}` : nanoid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts nanoid from a nanoid-slug URL part
|
||||
* @param nanoidSlug - The nanoid-slug (e.g., "abc123-clean-the-backyard")
|
||||
* @returns The extracted nanoid
|
||||
*/
|
||||
export function extractNanoidFromSlug(nanoidSlug: string): string {
|
||||
if (!nanoidSlug) return '';
|
||||
|
||||
// Nanoid is always 21 characters by default, extract the first part before the first hyphen
|
||||
// But handle cases where the nanoid itself might contain hyphens
|
||||
const parts = nanoidSlug.split('-');
|
||||
if (parts.length === 1) {
|
||||
// No slug, just nanoid
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
// Look for the nanoid part (21 chars) - it should be the first part
|
||||
const firstPart = parts[0];
|
||||
if (firstPart.length === 21) {
|
||||
return firstPart;
|
||||
}
|
||||
|
||||
// Fallback: try to find 21-character alphanumeric string
|
||||
const nanoidMatch = nanoidSlug.match(/^([A-Za-z0-9_-]{21})/);
|
||||
return nanoidMatch ? nanoidMatch[1] : nanoidSlug.split('-')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string looks like a valid nanoid
|
||||
* @param str - String to validate
|
||||
* @returns True if it looks like a nanoid
|
||||
*/
|
||||
export function isValidNanoid(str: string): boolean {
|
||||
return /^[A-Za-z0-9_-]{21}$/.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a project URL using nanoid-slug format
|
||||
* @param project - Project object with nanoid and name
|
||||
* @returns The project URL path (e.g., "/project/abc123-clean-the-backyard")
|
||||
*/
|
||||
export function createProjectUrl(project: {
|
||||
nanoid?: string;
|
||||
name: string;
|
||||
}): string {
|
||||
if (!project.nanoid) {
|
||||
throw new Error('Project nanoid is required');
|
||||
}
|
||||
const nanoidSlug = createNanoidSlug(project.nanoid, project.name);
|
||||
return `/project/${nanoidSlug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a note URL using nanoid-slug format
|
||||
* @param note - Note object with nanoid and title
|
||||
* @returns The note URL path (e.g., "/note/abc123-meeting-notes")
|
||||
*/
|
||||
export function createNoteUrl(note: {
|
||||
nanoid?: string;
|
||||
title: string;
|
||||
}): string {
|
||||
if (!note.nanoid) {
|
||||
throw new Error('Note nanoid is required');
|
||||
}
|
||||
const nanoidSlug = createNanoidSlug(note.nanoid, note.title);
|
||||
return `/note/${nanoidSlug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tag URL using nanoid-slug format
|
||||
* @param tag - Tag object with nanoid and name
|
||||
* @returns The tag URL path (e.g., "/tag/abc123-work-tag")
|
||||
*/
|
||||
export function createTagUrl(tag: { nanoid?: string; name: string }): string {
|
||||
if (!tag.nanoid) {
|
||||
throw new Error('Tag nanoid is required');
|
||||
}
|
||||
const nanoidSlug = createNanoidSlug(tag.nanoid, tag.name);
|
||||
return `/tag/${nanoidSlug}`;
|
||||
}
|
||||
|
|
@ -60,3 +60,15 @@ export const deleteTag = async (tagId: number): Promise<void> => {
|
|||
|
||||
await handleAuthResponse(response, 'Failed to delete tag.');
|
||||
};
|
||||
|
||||
export const fetchTagBySlug = async (nanoidSlug: string): Promise<Tag> => {
|
||||
const response = await fetch(`/api/tag/${nanoidSlug}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to fetch tag.');
|
||||
return await response.json();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -97,6 +97,16 @@ export const fetchTaskByUuid = async (uuid: string): Promise<Task> => {
|
|||
return await response.json();
|
||||
};
|
||||
|
||||
export const fetchTaskByNanoid = async (nanoid: string): Promise<Task> => {
|
||||
const response = await fetch(`/api/task/nanoid/${nanoid}`, {
|
||||
credentials: 'include',
|
||||
headers: getDefaultHeaders(),
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to fetch task.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const fetchSubtasks = async (parentTaskId: number): Promise<Task[]> => {
|
||||
const response = await fetch(`/api/task/${parentTaskId}/subtasks`, {
|
||||
credentials: 'include',
|
||||
|
|
|
|||
310
package-lock.json
generated
310
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -23,7 +23,7 @@
|
|||
"frontend:lint-fix": "eslint --fix 'frontend/**/*.{js,jsx,ts,tsx}'",
|
||||
"frontend:format": "prettier -c 'frontend/**/*.{js,jsx,ts,tsx}'",
|
||||
"frontend:format:fix": "prettier --write 'frontend/**/*.{js,jsx,ts,tsx}'",
|
||||
"backend:start": "cd backend && ./cmd/start.sh",
|
||||
"backend:start": "cd backend && ./cmd/start-dev.sh",
|
||||
"backend:dev": "cd backend && nodemon app.js",
|
||||
"backend:test": "cd backend && cross-env NODE_ENV=test jest",
|
||||
"backend:test:watch": "cd backend && cross-env NODE_ENV=test jest --watch",
|
||||
|
|
@ -127,6 +127,7 @@
|
|||
"moment-timezone": "~0.6.0",
|
||||
"morgan": "~1.10.0",
|
||||
"multer": "~2.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"node-cron": "~4.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue