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 [loading, setLoading] = useState(true);
|
||||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
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
|
// Helper function to add a new note to the list
|
||||||
const addNote = (newNote) => {
|
const addNote = (newNote) => {
|
||||||
setNotes(prevNotes => [newNote, ...prevNotes]);
|
setNotes(prevNotes => [newNote, ...prevNotes]);
|
||||||
@@ -758,7 +793,9 @@ export default function ProjectViewPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-900 leading-relaxed">{n.note}</p>
|
<div className="text-gray-900 leading-relaxed">
|
||||||
|
{parseNoteText(n.note)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,56 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
export default function NoteForm({ projectId, onNoteAdded }) {
|
export default function NoteForm({ projectId, onNoteAdded }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [status, setStatus] = useState(null);
|
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) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -37,19 +81,83 @@ export default function NoteForm({ projectId, onNoteAdded }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit(e);
|
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 (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-2 mb-4">
|
<form onSubmit={handleSubmit} className="space-y-2 mb-4 relative">
|
||||||
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onClick={(e) => setCursorPosition(e.target.selectionStart)}
|
||||||
|
onKeyUp={(e) => setCursorPosition(e.target.selectionStart)}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
placeholder={t("common.addNotePlaceholder")}
|
placeholder={t("common.addNotePlaceholder")}
|
||||||
className="border p-2 w-full"
|
className="border p-2 w-full"
|
||||||
rows={3}
|
rows={3}
|
||||||
required
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
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 [newNote, setNewNote] = useState("");
|
||||||
const [loadingAdd, setLoadingAdd] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen && task) {
|
if (isOpen && task) {
|
||||||
fetchNotes();
|
fetchNotes();
|
||||||
@@ -302,9 +336,9 @@ export default function TaskCommentsModal({ task, isOpen, onClose }) {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
|
<div className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
|
||||||
{note.note}
|
{parseNoteText(note.note)}
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!note.is_system && (
|
{!note.is_system && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user