Add nanoid

This commit is contained in:
Chris Veleris 2025-08-04 23:43:36 +03:00 committed by Chris
parent e081ed622a
commit 3599bc2b60
50 changed files with 1803 additions and 377 deletions

View file

@ -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

View file

@ -20,4 +20,7 @@ module.exports = {
restoreMocks: true,
testTimeout: 30000,
maxWorkers: '100%',
moduleNameMapper: {
'^nanoid/non-secure$': '<rootDir>/tests/mocks/nanoid.js',
},
};

View 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');
},
};

View 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');
},
};

View 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');
},
};

View 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');
},
};

View 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');
},
};

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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({

View file

@ -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'],
},
],
});

View file

@ -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: [] },
},
],

View file

@ -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,

View file

@ -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,
},
],
});

View 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,
};

View 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,
};

View file

@ -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"

View file

@ -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' : ''
}`}

View file

@ -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}

View file

@ -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}

View file

@ -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"
>
{

View file

@ -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}`);
}
}
};

View file

@ -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"
>

View file

@ -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'

View file

@ -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"
>

View file

@ -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}

View file

@ -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}

View file

@ -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

View file

@ -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()

View file

@ -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);
}

View file

@ -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}

View file

@ -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

View file

@ -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>
);
}

View file

@ -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}

View file

@ -1,5 +1,6 @@
export interface Area {
id?: number;
nanoid?: string;
name: string;
description?: string;
active?: boolean;

View file

@ -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)
}

View file

@ -5,6 +5,7 @@ import { Note } from './Note';
export interface Project {
id?: number;
nanoid?: string;
name: string;
description?: string;
active: boolean;

View file

@ -1,4 +1,5 @@
export interface Tag {
id?: number;
nanoid?: string;
name: string;
}

View file

@ -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;

View file

@ -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();
};

View file

@ -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
View 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}`;
}

View file

@ -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();
};

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -5,7 +5,7 @@
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"strict": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,