feat: Add link parsing functionality to notes in ProjectViewPage and TaskCommentsModal
This commit is contained in:
@@ -28,6 +28,41 @@ export default function ProjectViewPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||
|
||||
// Helper function to parse note text with links
|
||||
const parseNoteText = (text) => {
|
||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = linkRegex.exec(text)) !== null) {
|
||||
// Add text before the link
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
// Add the link
|
||||
parts.push(
|
||||
<a
|
||||
key={match.index}
|
||||
href={match[2]}
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
};
|
||||
|
||||
// Helper function to add a new note to the list
|
||||
const addNote = (newNote) => {
|
||||
setNotes(prevNotes => [newNote, ...prevNotes]);
|
||||
@@ -758,7 +793,9 @@ export default function ProjectViewPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-900 leading-relaxed">{n.note}</p>
|
||||
<div className="text-gray-900 leading-relaxed">
|
||||
{parseNoteText(n.note)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function NoteForm({ projectId, onNoteAdded }) {
|
||||
const { t } = useTranslation();
|
||||
const [note, setNote] = useState("");
|
||||
const [status, setStatus] = useState(null);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [filteredProjects, setFilteredProjects] = useState([]);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [triggerChar, setTriggerChar] = useState(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const res = await fetch("/api/projects");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setProjects(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch projects:", error);
|
||||
}
|
||||
}
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (note && cursorPosition > 0) {
|
||||
const beforeCursor = note.slice(0, cursorPosition);
|
||||
const match = beforeCursor.match(/([@#])([^@#]*)$/);
|
||||
if (match) {
|
||||
const char = match[1];
|
||||
const query = match[2].toLowerCase();
|
||||
setTriggerChar(char);
|
||||
const filtered = projects.filter(project =>
|
||||
project.project_name.toLowerCase().includes(query)
|
||||
);
|
||||
setFilteredProjects(filtered);
|
||||
setShowDropdown(filtered.length > 0);
|
||||
setSelectedIndex(0);
|
||||
} else {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
} else {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [note, cursorPosition, projects]);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
@@ -37,19 +81,83 @@ export default function NoteForm({ projectId, onNoteAdded }) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
if (showDropdown) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredProjects.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredProjects.length) % filteredProjects.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filteredProjects.length > 0) {
|
||||
selectProject(filteredProjects[selectedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange(e) {
|
||||
setNote(e.target.value);
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
}
|
||||
|
||||
function handleInputBlur() {
|
||||
// Delay hiding dropdown to allow for clicks on dropdown items
|
||||
setTimeout(() => setShowDropdown(false), 150);
|
||||
}
|
||||
|
||||
function selectProject(project) {
|
||||
const beforeCursor = note.slice(0, cursorPosition);
|
||||
const afterCursor = note.slice(cursorPosition);
|
||||
const match = beforeCursor.match(/([@#])([^@#]*)$/);
|
||||
if (match) {
|
||||
const start = match.index;
|
||||
const end = cursorPosition;
|
||||
const link = `[${triggerChar}${project.project_name}](/projects/${project.project_id})`;
|
||||
const newNote = note.slice(0, start) + link + afterCursor;
|
||||
setNote(newNote);
|
||||
setShowDropdown(false);
|
||||
// Set cursor after the inserted link
|
||||
setTimeout(() => {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(start + link.length, start + link.length);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-2 mb-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-2 mb-4 relative">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => setCursorPosition(e.target.selectionStart)}
|
||||
onKeyUp={(e) => setCursorPosition(e.target.selectionStart)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t("common.addNotePlaceholder")}
|
||||
className="border p-2 w-full"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full left-0 right-0 bg-white border border-gray-300 rounded shadow-lg z-10 max-h-40 overflow-y-auto">
|
||||
{filteredProjects.map((project, index) => (
|
||||
<div
|
||||
key={project.project_id}
|
||||
className={`p-2 cursor-pointer ${index === selectedIndex ? 'bg-blue-100' : 'hover:bg-gray-100'}`}
|
||||
onClick={() => selectProject(project)}
|
||||
>
|
||||
{triggerChar}{project.project_name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||
|
||||
@@ -14,6 +14,40 @@ export default function TaskCommentsModal({ task, isOpen, onClose }) {
|
||||
const [newNote, setNewNote] = useState("");
|
||||
const [loadingAdd, setLoadingAdd] = useState(false);
|
||||
|
||||
const parseNoteText = (text) => {
|
||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = linkRegex.exec(text)) !== null) {
|
||||
// Add text before the link
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
// Add the link
|
||||
parts.push(
|
||||
<a
|
||||
key={match.index}
|
||||
href={match[2]}
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && task) {
|
||||
fetchNotes();
|
||||
@@ -302,9 +336,9 @@ export default function TaskCommentsModal({ task, isOpen, onClose }) {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
|
||||
{note.note}
|
||||
</p>
|
||||
<div className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
|
||||
{parseNoteText(note.note)}
|
||||
</div>
|
||||
</div>
|
||||
{!note.is_system && (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user