feat: Add link parsing functionality to notes in ProjectViewPage and TaskCommentsModal

This commit is contained in:
2025-10-02 11:36:36 +02:00
parent 31736ccc78
commit 79238dd643
3 changed files with 194 additions and 15 deletions

View File

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

View File

@@ -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">
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t("common.addNotePlaceholder")}
className="border p-2 w-full"
rows={3}
required
/>
<form onSubmit={handleSubmit} className="space-y-2 mb-4 relative">
<div className="relative">
<textarea
ref={textareaRef}
value={note}
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"

View File

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