diff --git a/ghostwriter/api/views.py b/ghostwriter/api/views.py
index d80ab7514..fa338c9dc 100644
--- a/ghostwriter/api/views.py
+++ b/ghostwriter/api/views.py
@@ -53,6 +53,7 @@
from ghostwriter.reporting.views2.report_finding_link import get_position
from ghostwriter.rolodex.models import (
Project,
+ ProjectCollabNote,
ProjectContact,
ProjectObjective,
ProjectSubTask,
@@ -1373,6 +1374,8 @@ class CheckEditPermissions(JwtRequiredMixin, HasuraActionView):
"report_finding_link": ReportFindingLink,
"report": Report,
"project": Project,
+ "project_collab_note": ProjectCollabNote,
+ "project_tree_sync": Project,
}
def post(self, request):
diff --git a/ghostwriter/factories.py b/ghostwriter/factories.py
index 7b54396d1..5a55911b0 100644
--- a/ghostwriter/factories.py
+++ b/ghostwriter/factories.py
@@ -579,6 +579,32 @@ class Meta:
operator = factory.SubFactory(UserFactory)
+class ProjectCollabNoteFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = "rolodex.ProjectCollabNote"
+
+ title = Faker("sentence", nb_words=3)
+ node_type = "note"
+ content = Faker("rich_text")
+ position = factory.Sequence(lambda n: n * 1000)
+ project = factory.SubFactory(ProjectFactory)
+
+
+class ProjectCollabNoteFolderFactory(ProjectCollabNoteFactory):
+ node_type = "folder"
+ content = ""
+
+
+class ProjectCollabNoteFieldFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = "rolodex.ProjectCollabNoteField"
+
+ field_type = "rich_text"
+ content = Faker("rich_text")
+ position = factory.Sequence(lambda n: n)
+ note = factory.SubFactory(ProjectCollabNoteFactory)
+
+
class ClientInviteFactory(factory.django.DjangoModelFactory):
class Meta:
model = "rolodex.ClientInvite"
diff --git a/ghostwriter/rolodex/migrations/0062_projectcollabnote_projectcollabnotefield.py b/ghostwriter/rolodex/migrations/0062_projectcollabnote_projectcollabnotefield.py
new file mode 100644
index 000000000..8d587f3ec
--- /dev/null
+++ b/ghostwriter/rolodex/migrations/0062_projectcollabnote_projectcollabnotefield.py
@@ -0,0 +1,194 @@
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("rolodex", "0061_projectrole_position_ordering"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ProjectCollabNote",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "title",
+ models.CharField(
+ help_text="Title of the note or folder",
+ max_length=255,
+ verbose_name="Title",
+ ),
+ ),
+ (
+ "node_type",
+ models.CharField(
+ choices=[("folder", "Folder"), ("note", "Note")],
+ default="note",
+ help_text="Whether this is a folder or a note",
+ max_length=10,
+ verbose_name="Type",
+ ),
+ ),
+ (
+ "content",
+ models.TextField(
+ blank=True,
+ default="",
+ help_text="Rich text content (for notes only, empty for folders)",
+ verbose_name="Content",
+ ),
+ ),
+ (
+ "position",
+ models.PositiveIntegerField(
+ default=0,
+ help_text="Order within parent (lower values first)",
+ verbose_name="Position",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "parent",
+ models.ForeignKey(
+ blank=True,
+ help_text="Parent folder (null for root-level items)",
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="children",
+ to="rolodex.projectcollabnote",
+ ),
+ ),
+ (
+ "project",
+ models.ForeignKey(
+ help_text="The project this note belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="collab_notes",
+ to="rolodex.project",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Project collaborative note",
+ "verbose_name_plural": "Project collaborative notes",
+ "ordering": ["position", "title"],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="projectcollabnote",
+ constraint=models.CheckConstraint(
+ check=models.Q(("node_type", "note"), ("content", ""), _connector="OR"),
+ name="folder_has_no_content",
+ ),
+ ),
+ migrations.CreateModel(
+ name="ProjectCollabNoteField",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "field_type",
+ models.CharField(
+ choices=[("rich_text", "Rich Text"), ("image", "Image")],
+ default="rich_text",
+ help_text="Type of content in this field",
+ max_length=10,
+ verbose_name="Field Type",
+ ),
+ ),
+ (
+ "content",
+ models.TextField(
+ blank=True,
+ default="",
+ help_text="HTML content for rich text fields",
+ verbose_name="Content",
+ ),
+ ),
+ (
+ "image_width",
+ models.IntegerField(
+ blank=True,
+ editable=False,
+ null=True,
+ verbose_name="Image Width",
+ ),
+ ),
+ (
+ "image_height",
+ models.IntegerField(
+ blank=True,
+ editable=False,
+ null=True,
+ verbose_name="Image Height",
+ ),
+ ),
+ (
+ "image",
+ models.ImageField(
+ blank=True,
+ help_text="Image file for image fields",
+ height_field="image_height",
+ null=True,
+ upload_to="collab_note_images/%Y/%m/%d/",
+ verbose_name="Image",
+ width_field="image_width",
+ ),
+ ),
+ (
+ "position",
+ models.PositiveIntegerField(
+ default=0,
+ help_text="Order within note (lower values first)",
+ verbose_name="Position",
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "updated_at",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "note",
+ models.ForeignKey(
+ help_text="The note this field belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="fields",
+ to="rolodex.projectcollabnote",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Project collaborative note field",
+ "verbose_name_plural": "Project collaborative note fields",
+ "ordering": ["note", "position"],
+ },
+ ),
+ ]
diff --git a/ghostwriter/rolodex/migrations/0063_migrate_collab_notes.py b/ghostwriter/rolodex/migrations/0063_migrate_collab_notes.py
new file mode 100644
index 000000000..714f2c7df
--- /dev/null
+++ b/ghostwriter/rolodex/migrations/0063_migrate_collab_notes.py
@@ -0,0 +1,39 @@
+"""Data migration to move content from Project.collab_note to ProjectCollabNote."""
+
+from django.db import migrations
+
+
+def migrate_collab_notes(apps, schema_editor):
+ Project = apps.get_model("rolodex", "Project")
+ ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote")
+ ProjectCollabNoteField = apps.get_model("rolodex", "ProjectCollabNoteField")
+
+ for project in Project.objects.exclude(collab_note=""):
+ note = ProjectCollabNote.objects.create(
+ project=project,
+ title="Notes",
+ node_type="note",
+ content=project.collab_note,
+ position=0,
+ )
+ ProjectCollabNoteField.objects.create(
+ note=note,
+ field_type="rich_text",
+ content=project.collab_note,
+ position=0,
+ )
+
+
+def reverse_migrate(apps, schema_editor):
+ ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote")
+ ProjectCollabNote.objects.all().delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("rolodex", "0062_projectcollabnote_projectcollabnotefield"),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_collab_notes, reverse_migrate),
+ ]
diff --git a/ghostwriter/rolodex/migrations/0064_alter_projectcollabnote_id_and_more.py b/ghostwriter/rolodex/migrations/0064_alter_projectcollabnote_id_and_more.py
new file mode 100644
index 000000000..454663257
--- /dev/null
+++ b/ghostwriter/rolodex/migrations/0064_alter_projectcollabnote_id_and_more.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.16 on 2026-03-31 21:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("rolodex", "0063_migrate_collab_notes"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="projectcollabnote",
+ name="id",
+ field=models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="projectcollabnotefield",
+ name="id",
+ field=models.BigAutoField(
+ auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+ ),
+ ),
+ ]
diff --git a/ghostwriter/rolodex/migrations/0065_set_collabnote_timestamp_defaults.py b/ghostwriter/rolodex/migrations/0065_set_collabnote_timestamp_defaults.py
new file mode 100644
index 000000000..02a7eea4a
--- /dev/null
+++ b/ghostwriter/rolodex/migrations/0065_set_collabnote_timestamp_defaults.py
@@ -0,0 +1,33 @@
+"""Set database-level defaults for collab note timestamp columns.
+
+Django 4.2's ``default=timezone.now`` only applies at the ORM level.
+Hasura and the collab server insert rows directly, bypassing the ORM,
+so database-level defaults are required.
+"""
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("rolodex", "0064_alter_projectcollabnote_id_and_more"),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN created_at SET DEFAULT NOW();",
+ reverse_sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN created_at DROP DEFAULT;",
+ ),
+ migrations.RunSQL(
+ sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN updated_at SET DEFAULT NOW();",
+ reverse_sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN updated_at DROP DEFAULT;",
+ ),
+ migrations.RunSQL(
+ sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN created_at SET DEFAULT NOW();",
+ reverse_sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN created_at DROP DEFAULT;",
+ ),
+ migrations.RunSQL(
+ sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN updated_at SET DEFAULT NOW();",
+ reverse_sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN updated_at DROP DEFAULT;",
+ ),
+ ]
diff --git a/ghostwriter/rolodex/models.py b/ghostwriter/rolodex/models.py
index 7729dc39d..0ec759f6c 100644
--- a/ghostwriter/rolodex/models.py
+++ b/ghostwriter/rolodex/models.py
@@ -3,6 +3,8 @@
# Standard Libraries
from datetime import time, timedelta
+from django.utils import timezone
+
# Django Imports
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -847,6 +849,178 @@ def __str__(self):
return f"{self.project}: {self.timestamp} - {self.note}"
+class ProjectCollabNoteType(models.TextChoices):
+ """Choices for the type of collaborative note node."""
+
+ FOLDER = "folder", "Folder"
+ NOTE = "note", "Note"
+
+
+class ProjectCollabNote(models.Model):
+ """
+ Stores hierarchical collaborative notes for a project.
+
+ Folders are containers only; notes are leaf nodes with rich text content.
+ Related to :model:`rolodex.Project`.
+ """
+
+ project = models.ForeignKey(
+ Project,
+ on_delete=models.CASCADE,
+ related_name="collab_notes",
+ help_text="The project this note belongs to",
+ )
+ parent = models.ForeignKey(
+ "self",
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name="children",
+ help_text="Parent folder (null for root-level items)",
+ )
+ title = models.CharField(
+ "Title",
+ max_length=255,
+ help_text="Title of the note or folder",
+ )
+ node_type = models.CharField(
+ "Type",
+ max_length=10,
+ choices=ProjectCollabNoteType.choices,
+ default=ProjectCollabNoteType.NOTE,
+ help_text="Whether this is a folder or a note",
+ )
+ content = models.TextField(
+ "Content",
+ default="",
+ blank=True,
+ help_text="Rich text content (for notes only, empty for folders)",
+ )
+ position = models.PositiveIntegerField(
+ "Position",
+ default=0,
+ help_text="Order within parent (lower values first)",
+ )
+ created_at = models.DateTimeField(default=timezone.now)
+ updated_at = models.DateTimeField(default=timezone.now)
+
+ class Meta:
+ ordering = ["position", "title"]
+ verbose_name = "Project collaborative note"
+ verbose_name_plural = "Project collaborative notes"
+ constraints = [
+ models.CheckConstraint(
+ check=Q(node_type="note") | Q(content=""),
+ name="folder_has_no_content",
+ ),
+ ]
+
+ def __str__(self):
+ return f"{self.title} ({self.node_type})"
+
+ def get_absolute_url(self):
+ return reverse("rolodex:project_detail", args=[str(self.project.id)])
+
+ def save(self, *args, **kwargs):
+ self.updated_at = timezone.now()
+ super().save(*args, **kwargs)
+
+ def user_can_view(self, user) -> bool:
+ return self.project.user_can_view(user)
+
+ def user_can_edit(self, user) -> bool:
+ return self.project.user_can_edit(user)
+
+ def user_can_delete(self, user) -> bool:
+ return self.project.user_can_delete(user)
+
+
+class ProjectCollabNoteFieldType(models.TextChoices):
+ """Choices for the type of collaborative note field."""
+
+ RICH_TEXT = "rich_text", "Rich Text"
+ IMAGE = "image", "Image"
+
+
+class ProjectCollabNoteField(models.Model):
+ """
+ Stores individual fields within a collaborative note.
+
+ Each ProjectCollabNote can have multiple fields that are reorderable.
+ Fields can be rich text or images.
+ Related to :model:`rolodex.ProjectCollabNote`.
+ """
+
+ note = models.ForeignKey(
+ ProjectCollabNote,
+ on_delete=models.CASCADE,
+ related_name="fields",
+ help_text="The note this field belongs to",
+ )
+ field_type = models.CharField(
+ "Field Type",
+ max_length=10,
+ choices=ProjectCollabNoteFieldType.choices,
+ default=ProjectCollabNoteFieldType.RICH_TEXT,
+ help_text="Type of content in this field",
+ )
+ content = models.TextField(
+ "Content",
+ default="",
+ blank=True,
+ help_text="HTML content for rich text fields",
+ )
+ image_width = models.IntegerField(
+ "Image Width",
+ blank=True,
+ null=True,
+ editable=False,
+ )
+ image_height = models.IntegerField(
+ "Image Height",
+ blank=True,
+ null=True,
+ editable=False,
+ )
+ image = models.ImageField(
+ "Image",
+ upload_to="collab_note_images/%Y/%m/%d/",
+ blank=True,
+ null=True,
+ width_field="image_width",
+ height_field="image_height",
+ help_text="Image file for image fields",
+ )
+ position = models.PositiveIntegerField(
+ "Position",
+ default=0,
+ help_text="Order within note (lower values first)",
+ )
+ created_at = models.DateTimeField(default=timezone.now)
+ updated_at = models.DateTimeField(default=timezone.now)
+
+ class Meta:
+ ordering = ["note", "position"]
+ verbose_name = "Project collaborative note field"
+ verbose_name_plural = "Project collaborative note fields"
+
+ def __str__(self):
+ return f"{self.note.title} - {self.field_type} field #{self.position}"
+
+ def save(self, *args, **kwargs):
+ self.updated_at = timezone.now()
+ super().save(*args, **kwargs)
+
+ def user_can_view(self, user) -> bool:
+ return self.note.user_can_view(user)
+
+ def user_can_edit(self, user) -> bool:
+ return self.note.user_can_edit(user)
+
+ def user_can_delete(self, user) -> bool:
+ return self.note.user_can_delete(user)
+
+
class ProjectScope(models.Model):
"""Stores an individual scope list, related to an individual :model:`rolodex.Project`."""
diff --git a/ghostwriter/rolodex/templates/rolodex/project_detail.html b/ghostwriter/rolodex/templates/rolodex/project_detail.html
index 91a3798cc..c3c5d8b9f 100644
--- a/ghostwriter/rolodex/templates/rolodex/project_detail.html
+++ b/ghostwriter/rolodex/templates/rolodex/project_detail.html
@@ -2152,5 +2152,7 @@
Related Log Entri
{% endfor %}
{% include "collab_editing/attrs_snippet.html" %}
+
+
{% endblock %}
diff --git a/ghostwriter/rolodex/tests/test_models.py b/ghostwriter/rolodex/tests/test_models.py
index ffdff49dc..1c333443c 100644
--- a/ghostwriter/rolodex/tests/test_models.py
+++ b/ghostwriter/rolodex/tests/test_models.py
@@ -21,6 +21,9 @@
OplogEntryFactory,
OplogFactory,
ProjectAssignmentFactory,
+ ProjectCollabNoteFactory,
+ ProjectCollabNoteFieldFactory,
+ ProjectCollabNoteFolderFactory,
ProjectContactFactory,
ProjectFactory,
ProjectInviteFactory,
@@ -38,7 +41,7 @@
WhiteCardFactory,
)
from ghostwriter.rolodex.admin import ProjectRoleAdminForm
-from ghostwriter.rolodex.models import Client, Project
+from ghostwriter.rolodex.models import Client, Project, ProjectCollabNote, ProjectCollabNoteField
logging.disable(logging.CRITICAL)
@@ -891,3 +894,87 @@ def test_crud_finding(self):
# Delete
contact.delete()
assert not self.ProjectContact.objects.all().exists()
+
+
+class ProjectCollabNoteModelTests(TestCase):
+ """Collection of tests for :model:`rolodex.ProjectCollabNote`."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = UserFactory(password="test")
+ cls.project = ProjectFactory()
+ cls.assignment = ProjectAssignmentFactory(project=cls.project, operator=cls.user)
+
+ def test_crud(self):
+ note = ProjectCollabNoteFactory(project=self.project, title="Test Note")
+ self.assertEqual(note.title, "Test Note")
+ self.assertEqual(note.node_type, "note")
+
+ note.title = "Updated"
+ note.save()
+ note.refresh_from_db()
+ self.assertEqual(note.title, "Updated")
+
+ note.delete()
+ self.assertFalse(ProjectCollabNote.objects.filter(pk=note.pk).exists())
+
+ def test_folder_creation(self):
+ folder = ProjectCollabNoteFolderFactory(project=self.project, title="Folder")
+ self.assertEqual(folder.node_type, "folder")
+ self.assertEqual(folder.content, "")
+
+ def test_hierarchical_structure(self):
+ folder = ProjectCollabNoteFolderFactory(project=self.project)
+ note = ProjectCollabNoteFactory(project=self.project, parent=folder)
+ self.assertEqual(note.parent, folder)
+ self.assertIn(note, folder.children.all())
+
+ def test_cascade_delete(self):
+ folder = ProjectCollabNoteFolderFactory(project=self.project)
+ child = ProjectCollabNoteFactory(project=self.project, parent=folder)
+ child_id = child.pk
+ folder.delete()
+ self.assertFalse(ProjectCollabNote.objects.filter(pk=child_id).exists())
+
+ def test_user_can_edit_delegates_to_project(self):
+ note = ProjectCollabNoteFactory(project=self.project)
+ self.assertEqual(note.user_can_edit(self.user), self.project.user_can_edit(self.user))
+
+ def test_str(self):
+ note = ProjectCollabNoteFactory(title="My Note")
+ self.assertEqual(str(note), "My Note (note)")
+
+
+class ProjectCollabNoteFieldModelTests(TestCase):
+ """Collection of tests for :model:`rolodex.ProjectCollabNoteField`."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.note = ProjectCollabNoteFactory()
+
+ def test_crud(self):
+ field = ProjectCollabNoteFieldFactory(note=self.note, content="
Hello
")
+ self.assertEqual(field.field_type, "rich_text")
+ self.assertEqual(field.content, "Hello
")
+
+ field.content = "Updated
"
+ field.save()
+ field.refresh_from_db()
+ self.assertEqual(field.content, "Updated
")
+
+ field.delete()
+ self.assertFalse(ProjectCollabNoteField.objects.filter(pk=field.pk).exists())
+
+ def test_cascade_delete_with_note(self):
+ note = ProjectCollabNoteFactory()
+ field = ProjectCollabNoteFieldFactory(note=note)
+ field_id = field.pk
+ note.delete()
+ self.assertFalse(ProjectCollabNoteField.objects.filter(pk=field_id).exists())
+
+ def test_ordering(self):
+ f1 = ProjectCollabNoteFieldFactory(note=self.note, position=2)
+ f2 = ProjectCollabNoteFieldFactory(note=self.note, position=0)
+ f3 = ProjectCollabNoteFieldFactory(note=self.note, position=1)
+ fields = list(self.note.fields.all())
+ self.assertEqual(fields, [f2, f3, f1])
diff --git a/ghostwriter/rolodex/tests/test_views.py b/ghostwriter/rolodex/tests/test_views.py
index 07a973918..a3ee72ef9 100644
--- a/ghostwriter/rolodex/tests/test_views.py
+++ b/ghostwriter/rolodex/tests/test_views.py
@@ -20,6 +20,8 @@
ClientNoteFactory,
HistoryFactory,
ObjectiveStatusFactory,
+ ProjectCollabNoteFactory,
+ ProjectCollabNoteFieldFactory,
ProjectContactFactory,
ProjectRoleFactory,
ProjectFactory,
@@ -1155,3 +1157,116 @@ def test_default_download_has_nosniff_but_no_csp(self):
# CSP is only added for inline responses
self.assertIsNone(response.get("Content-Security-Policy"))
+
+class CollabNoteImageUploadTests(TestCase):
+ """Collection of tests for :view:`rolodex.ajax_upload_note_field_image`."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = UserFactory(password=PASSWORD)
+ cls.project = ProjectFactory()
+ cls.assignment = ProjectAssignmentFactory(project=cls.project, operator=cls.user)
+ cls.note = ProjectCollabNoteFactory(project=cls.project)
+ cls.uri = reverse(
+ "rolodex:ajax_upload_note_field_image", kwargs={"pk": cls.note.pk}
+ )
+
+ def setUp(self):
+ self.client = Client()
+ self.client_auth = Client()
+ self.assertTrue(
+ self.client_auth.login(username=self.user.username, password=PASSWORD)
+ )
+
+ def test_view_requires_login(self):
+ response = self.client.post(self.uri)
+ self.assertEqual(response.status_code, 302)
+
+ def test_rejects_get_method(self):
+ response = self.client_auth.get(self.uri)
+ self.assertEqual(response.status_code, 405)
+
+ def test_rejects_missing_image(self):
+ response = self.client_auth.post(self.uri)
+ self.assertEqual(response.status_code, 400)
+
+ def test_upload_creates_field(self):
+ from django.core.files.uploadedfile import SimpleUploadedFile
+ import io
+ from PIL import Image
+
+ img = Image.new("RGB", (10, 10), color="red")
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+
+ image_file = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
+ response = self.client_auth.post(self.uri, {"image": image_file})
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["result"], "success")
+ self.assertIn("imageUrl", data)
+ self.assertIn("id", data)
+
+ def test_upload_to_existing_field(self):
+ from django.core.files.uploadedfile import SimpleUploadedFile
+ import io
+ from PIL import Image
+
+ field = ProjectCollabNoteFieldFactory(note=self.note, field_type="image")
+ uri = reverse(
+ "rolodex:ajax_upload_to_existing_field",
+ kwargs={"pk": self.note.pk, "field_pk": field.pk},
+ )
+
+ img = Image.new("RGB", (10, 10), color="blue")
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+
+ image_file = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
+ response = self.client_auth.post(uri, {"image": image_file})
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(data["result"], "success")
+ self.assertIn("imageUrl", data)
+
+
+class CollabNotesExportTests(TestCase):
+ """Collection of tests for :view:`rolodex.export_collab_notes_zip`."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = UserFactory(password=PASSWORD)
+ cls.project = ProjectFactory()
+ cls.assignment = ProjectAssignmentFactory(project=cls.project, operator=cls.user)
+ cls.uri = reverse(
+ "rolodex:ajax_export_collab_notes", kwargs={"pk": cls.project.pk}
+ )
+
+ def setUp(self):
+ self.client = Client()
+ self.client_auth = Client()
+ self.assertTrue(
+ self.client_auth.login(username=self.user.username, password=PASSWORD)
+ )
+
+ def test_view_requires_login(self):
+ response = self.client.get(self.uri)
+ self.assertEqual(response.status_code, 302)
+
+ def test_export_empty_project(self):
+ response = self.client_auth.get(self.uri)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response["Content-Type"], "application/zip")
+
+ def test_export_with_notes(self):
+ note = ProjectCollabNoteFactory(project=self.project, title="Test Note")
+ ProjectCollabNoteFieldFactory(
+ note=note, field_type="rich_text", content="Hello world
"
+ )
+ response = self.client_auth.get(self.uri)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response["Content-Type"], "application/zip")
+
+
diff --git a/ghostwriter/rolodex/urls.py b/ghostwriter/rolodex/urls.py
index 39a782931..e271cfc50 100644
--- a/ghostwriter/rolodex/urls.py
+++ b/ghostwriter/rolodex/urls.py
@@ -168,6 +168,26 @@
views.BloodhoundApiFetchView.as_view(),
name="ajax_bloodhound_fetch",
),
+ path(
+ "ajax/note/field//image/serve",
+ views.serve_note_field_image,
+ name="ajax_serve_note_image",
+ ),
+ path(
+ "ajax/note//field/image",
+ views.ajax_upload_note_field_image,
+ name="ajax_upload_note_field_image",
+ ),
+ path(
+ "ajax/note//field//image",
+ views.ajax_upload_note_field_image,
+ name="ajax_upload_to_existing_field",
+ ),
+ path(
+ "ajax/project//notes/export",
+ views.export_collab_notes_zip,
+ name="ajax_export_collab_notes",
+ ),
]
# URLs for :model:`Client` Class Based Views
diff --git a/ghostwriter/rolodex/views.py b/ghostwriter/rolodex/views.py
index 49525a76f..0336e673a 100644
--- a/ghostwriter/rolodex/views.py
+++ b/ghostwriter/rolodex/views.py
@@ -2,10 +2,14 @@
# Standard Libraries
import datetime
+import io
import json
import logging
import mimetypes
import os
+import re
+import zipfile
+from pathlib import Path
from urllib.parse import urlparse
# Django Imports
@@ -21,7 +25,8 @@
HttpResponse,
HttpResponseRedirect,
FileResponse,
- JsonResponse
+ JsonResponse,
+ StreamingHttpResponse,
)
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
@@ -34,6 +39,7 @@
from django.db.models import Exists, OuterRef
# 3rd Party Libraries
+from markdownify import markdownify as md
from taggit.models import Tag
# Ghostwriter Libraries
@@ -82,6 +88,8 @@
ObjectiveStatus,
Project,
ProjectAssignment,
+ ProjectCollabNote,
+ ProjectCollabNoteField,
ProjectContact,
ProjectInvite,
ProjectNote,
@@ -2289,6 +2297,207 @@ def get_context_data(self, **kwargs):
return ctx
+@login_required
+def serve_note_field_image(request, pk):
+ """Serve an image from a :model:`rolodex.ProjectCollabNoteField` with auth check."""
+ field = get_object_or_404(ProjectCollabNoteField, pk=pk)
+ if not field.note.user_can_view(request.user):
+ return ForbiddenJsonResponse()
+ if not field.image:
+ raise Http404
+ try:
+ return FileResponse(field.image.open("rb"), content_type="image/png")
+ except FileNotFoundError as exc:
+ raise Http404 from exc
+
+
+class NoteImageUploadForm(forms.Form):
+ """Django form for validating image uploads to collab note fields."""
+
+ image = forms.ImageField(
+ help_text="Image file (PNG, JPG, GIF, WebP, max 10 MB)",
+ )
+
+ def clean_image(self):
+ image = self.cleaned_data["image"]
+ allowed_types = {"image/png", "image/jpeg", "image/gif", "image/webp"}
+ if image.content_type not in allowed_types:
+ raise forms.ValidationError(
+ f"Invalid file type: {image.content_type}. "
+ "Allowed types: png, jpg, gif, webp"
+ )
+ max_size = 10 * 1024 * 1024
+ if image.size > max_size:
+ raise forms.ValidationError(
+ f"File too large: {image.size / 1024 / 1024:.1f} MB. Maximum: 10 MB"
+ )
+ return image
+
+
+@login_required
+def ajax_upload_note_field_image(request, pk, field_pk=None):
+ """
+ Upload an image for a :model:`rolodex.ProjectCollabNoteField`.
+
+ If ``field_pk`` is provided, updates an existing image field.
+ Otherwise, creates a new image field attached to the specified note.
+ """
+ if request.method != "POST":
+ return JsonResponse(
+ {"result": "error", "message": "Invalid request method"}, status=405
+ )
+
+ note = get_object_or_404(ProjectCollabNote, pk=pk)
+ if not note.user_can_edit(request.user):
+ return ForbiddenJsonResponse()
+
+ form = NoteImageUploadForm(request.POST, request.FILES)
+ if not form.is_valid():
+ return JsonResponse(
+ {"result": "error", "message": form.errors.as_text()}, status=400
+ )
+
+ image_file = form.cleaned_data["image"]
+
+ try:
+ if field_pk is not None:
+ return _update_existing_image_field(request, note, field_pk, image_file)
+ return _create_new_image_field(request, note, image_file)
+ except (OSError, IntegrityError):
+ logger.exception("Failed to upload image for note %s", pk)
+ return JsonResponse(
+ {"result": "error", "message": "Failed to upload image"}, status=500
+ )
+
+
+def _update_existing_image_field(request, note, field_pk, image_file):
+ """Update an existing image field with a new image."""
+ field = get_object_or_404(ProjectCollabNoteField, pk=field_pk, note=note)
+ if field.field_type != "image":
+ return JsonResponse(
+ {"result": "error", "message": "Field is not an image field"},
+ status=400,
+ )
+ field.image = image_file
+ field.save()
+ image_url = reverse("rolodex:ajax_serve_note_image", kwargs={"pk": field.pk})
+ logger.info(
+ "User %s uploaded image to existing field %s for note %s",
+ request.user, field.id, note.id,
+ )
+ return JsonResponse({
+ "result": "success",
+ "imageUrl": image_url,
+ })
+
+
+def _create_new_image_field(request, note, image_file):
+ """Create a new image field attached to a note."""
+ max_position = (
+ ProjectCollabNoteField.objects.filter(note=note)
+ .order_by("-position")
+ .values_list("position", flat=True)
+ .first()
+ ) or 0
+
+ field = ProjectCollabNoteField.objects.create(
+ note=note,
+ field_type="image",
+ image=image_file,
+ position=max_position + 1,
+ )
+ image_url = reverse("rolodex:ajax_serve_note_image", kwargs={"pk": field.pk})
+ logger.info(
+ "User %s uploaded image field %s for note %s",
+ request.user, field.id, note.id,
+ )
+ return JsonResponse({
+ "result": "success",
+ "id": field.id,
+ "imageUrl": image_url,
+ "position": field.position,
+ })
+
+
+def _sanitize_filename(name):
+ """Sanitize a string for use as a filename."""
+ sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
+ sanitized = re.sub(r"_+", "_", sanitized)
+ sanitized = sanitized.strip(" _")
+ if len(sanitized) > 200:
+ sanitized = sanitized[:200]
+ return sanitized or "untitled"
+
+
+@login_required
+def export_collab_notes_zip(request, pk):
+ """Export all collab notes for a project as a streaming ZIP response."""
+
+ project = get_object_or_404(Project, pk=pk)
+
+ if not project.user_can_edit(request.user):
+ return ForbiddenJsonResponse()
+
+ notes = ProjectCollabNote.objects.filter(project=project).prefetch_related("fields")
+
+ notes_by_parent = {}
+ for note in notes:
+ notes_by_parent.setdefault(note.parent_id, []).append(note)
+ for _parent_id, children in notes_by_parent.items():
+ children.sort(key=lambda n: n.position)
+
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ def process_node(note, path_prefix=""):
+ safe_title = _sanitize_filename(note.title)
+ if note.node_type == "folder":
+ folder_path = f"{path_prefix}{safe_title}/"
+ for child in notes_by_parent.get(note.id, []):
+ process_node(child, folder_path)
+ else:
+ md_content = f"# {note.title}\n\n"
+ image_num = 1
+ fields = sorted(note.fields.all(), key=lambda f: f.position)
+ for field in fields:
+ if field.field_type == "rich_text":
+ converted = md(field.content or "")
+ md_content += converted + "\n\n"
+ elif field.field_type == "image" and field.image:
+ try:
+ img_filename = (
+ f"image_{image_num}{Path(field.image.name).suffix}"
+ )
+ img_folder = f"{path_prefix}{safe_title}_images/"
+ zf.write(
+ field.image.path, f"{img_folder}{img_filename}"
+ )
+ md_content += (
+ f"![Image {image_num}]"
+ f"(./{safe_title}_images/{img_filename})\n\n"
+ )
+ image_num += 1
+ except (FileNotFoundError, OSError):
+ logger.exception(
+ "Could not include image for field %s", field.id
+ )
+ md_content += f"\n\n"
+ image_num += 1
+
+ zf.writestr(f"{path_prefix}{safe_title}.md", md_content)
+
+ for note in notes_by_parent.get(None, []):
+ process_node(note)
+
+ buffer.seek(0)
+
+ safe_codename = _sanitize_filename(project.codename)
+ response = StreamingHttpResponse(
+ buffer, content_type="application/zip"
+ )
+ add_content_disposition_header(response, f"{safe_codename}_notes.zip")
+ return response
+
+
class BloodhoundApiBaseView(RoleBasedAccessControlMixin, View):
project: Project | None
bh_api: Project | BloodHoundConfiguration
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_project.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_project.yaml
index 4832d1e4d..dced821e8 100644
--- a/hasura-docker/metadata/databases/default/tables/public_rolodex_project.yaml
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_project.yaml
@@ -65,6 +65,13 @@ array_relationships:
table:
name: shepherd_transientserver
schema: public
+ - name: collabNotes
+ using:
+ foreign_key_constraint_on:
+ column: project_id
+ table:
+ name: rolodex_projectcollabnote
+ schema: public
- name: comments
using:
foreign_key_constraint_on:
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
new file mode 100644
index 000000000..10b19787c
--- /dev/null
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnote.yaml
@@ -0,0 +1,171 @@
+table:
+ name: rolodex_projectcollabnote
+ schema: public
+configuration:
+ column_config:
+ node_type:
+ custom_name: nodeType
+ parent_id:
+ custom_name: parentId
+ project_id:
+ custom_name: projectId
+ created_at:
+ custom_name: createdAt
+ updated_at:
+ custom_name: updatedAt
+ custom_column_names:
+ node_type: nodeType
+ parent_id: parentId
+ project_id: projectId
+ created_at: createdAt
+ updated_at: updatedAt
+ custom_name: projectCollabNote
+ custom_root_fields: {}
+object_relationships:
+ - name: project
+ using:
+ foreign_key_constraint_on: project_id
+ - name: parent
+ using:
+ foreign_key_constraint_on: parent_id
+array_relationships:
+ - name: children
+ using:
+ foreign_key_constraint_on:
+ column: parent_id
+ table:
+ name: rolodex_projectcollabnote
+ schema: public
+ - name: fields
+ using:
+ foreign_key_constraint_on:
+ column: note_id
+ table:
+ name: rolodex_projectcollabnotefield
+ schema: public
+insert_permissions:
+ - role: manager
+ permission:
+ check: {}
+ set:
+ created_at: now()
+ updated_at: now()
+ columns:
+ - title
+ - node_type
+ - content
+ - position
+ - parent_id
+ - project_id
+ - role: user
+ permission:
+ check:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ set:
+ created_at: now()
+ updated_at: now()
+ columns:
+ - title
+ - node_type
+ - content
+ - position
+ - parent_id
+ - project_id
+select_permissions:
+ - role: manager
+ permission:
+ columns: '*'
+ filter: {}
+ allow_aggregations: true
+ - role: user
+ permission:
+ columns: '*'
+ allow_aggregations: true
+ filter:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+update_permissions:
+ - role: manager
+ permission:
+ columns:
+ - title
+ - node_type
+ - content
+ - position
+ - parent_id
+ filter: {}
+ check: {}
+ - role: user
+ permission:
+ columns:
+ - title
+ - node_type
+ - content
+ - position
+ - parent_id
+ filter:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ check:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+delete_permissions:
+ - role: manager
+ permission:
+ filter: {}
+ - role: user
+ permission:
+ filter:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
diff --git a/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
new file mode 100644
index 000000000..a8f7ca4a7
--- /dev/null
+++ b/hasura-docker/metadata/databases/default/tables/public_rolodex_projectcollabnotefield.yaml
@@ -0,0 +1,153 @@
+table:
+ name: rolodex_projectcollabnotefield
+ schema: public
+configuration:
+ column_config:
+ field_type:
+ custom_name: fieldType
+ note_id:
+ custom_name: noteId
+ image_width:
+ custom_name: imageWidth
+ image_height:
+ custom_name: imageHeight
+ created_at:
+ custom_name: createdAt
+ updated_at:
+ custom_name: updatedAt
+ custom_column_names:
+ field_type: fieldType
+ note_id: noteId
+ image_width: imageWidth
+ image_height: imageHeight
+ created_at: createdAt
+ updated_at: updatedAt
+ custom_name: projectCollabNoteField
+ custom_root_fields: {}
+object_relationships:
+ - name: note
+ using:
+ foreign_key_constraint_on: note_id
+insert_permissions:
+ - role: manager
+ permission:
+ check: {}
+ set:
+ created_at: now()
+ updated_at: now()
+ columns:
+ - field_type
+ - content
+ - position
+ - note_id
+ - role: user
+ permission:
+ check:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ set:
+ created_at: now()
+ updated_at: now()
+ columns:
+ - field_type
+ - content
+ - position
+ - note_id
+select_permissions:
+ - role: manager
+ permission:
+ columns: '*'
+ filter: {}
+ allow_aggregations: true
+ - role: user
+ permission:
+ columns: '*'
+ allow_aggregations: true
+ filter:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+update_permissions:
+ - role: manager
+ permission:
+ columns:
+ - field_type
+ - content
+ - position
+ filter: {}
+ check: {}
+ - role: user
+ permission:
+ columns:
+ - field_type
+ - content
+ - position
+ filter:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ check:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+delete_permissions:
+ - role: manager
+ permission:
+ filter: {}
+ - role: user
+ permission:
+ filter:
+ note:
+ project:
+ _or:
+ - assignments:
+ operator_id:
+ _eq: X-Hasura-User-Id
+ - invites:
+ user_id:
+ _eq: X-Hasura-User-Id
+ - client:
+ invites:
+ user_id:
+ _eq: X-Hasura-User-Id
diff --git a/hasura-docker/metadata/databases/default/tables/tables.yaml b/hasura-docker/metadata/databases/default/tables/tables.yaml
index 0fe147351..05653d394 100644
--- a/hasura-docker/metadata/databases/default/tables/tables.yaml
+++ b/hasura-docker/metadata/databases/default/tables/tables.yaml
@@ -32,6 +32,8 @@
- "!include public_rolodex_objectivepriority.yaml"
- "!include public_rolodex_objectivestatus.yaml"
- "!include public_rolodex_project.yaml"
+- "!include public_rolodex_projectcollabnote.yaml"
+- "!include public_rolodex_projectcollabnotefield.yaml"
- "!include public_rolodex_projectassignment.yaml"
- "!include public_rolodex_projectcontact.yaml"
- "!include public_rolodex_projectinvite.yaml"
diff --git a/javascript/package-lock.json b/javascript/package-lock.json
index 95bbfa65b..a8ab7a786 100644
--- a/javascript/package-lock.json
+++ b/javascript/package-lock.json
@@ -6,6 +6,9 @@
"": {
"dependencies": {
"@apollo/client": "^3.13.1",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
@@ -412,6 +415,59 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@envelop/core": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.3.2.tgz",
diff --git a/javascript/package.json b/javascript/package.json
index bfe29ab78..588f3c812 100644
--- a/javascript/package.json
+++ b/javascript/package.json
@@ -28,6 +28,9 @@
},
"dependencies": {
"@apollo/client": "^3.13.1",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
diff --git a/javascript/src/collab_server/handlers/project_collab_note.ts b/javascript/src/collab_server/handlers/project_collab_note.ts
new file mode 100644
index 000000000..2b2b7a486
--- /dev/null
+++ b/javascript/src/collab_server/handlers/project_collab_note.ts
@@ -0,0 +1,135 @@
+import { type ModelHandler } from "../base_handler";
+import * as Y from "yjs";
+import { htmlToYjs, yjsToHtml } from "../yjs_converters";
+import { ApolloClient, gql as rawGql } from "@apollo/client/core";
+
+interface FieldData {
+ id: string;
+ fieldType: string;
+ image: string | null;
+ position: number;
+}
+
+const GET_QUERY = rawGql`
+ query GET_PROJECT_COLLAB_NOTE($id: bigint!) {
+ projectCollabNote_by_pk(id: $id) {
+ content
+ title
+ nodeType
+ fields(order_by: {position: asc}) {
+ id
+ fieldType
+ content
+ image
+ position
+ }
+ }
+ }
+`;
+
+const SET_MUTATION = rawGql`
+ mutation SET_PROJECT_COLLAB_NOTE_FIELDS($updates: [projectCollabNoteField_updates!]!) {
+ update_projectCollabNoteField_many(updates: $updates) {
+ affected_rows
+ }
+ }
+`;
+
+const ProjectCollabNoteItemHandler: ModelHandler = {
+ async load(client: ApolloClient, id: number) {
+ const res = await client.query({
+ query: GET_QUERY,
+ variables: { id },
+ });
+ if (res.error || res.errors) throw res.error || res.errors;
+
+ const obj = res.data.projectCollabNote_by_pk;
+ if (!obj) throw new Error("No object");
+ if (obj.nodeType !== "note") throw new Error("Cannot edit folder content");
+
+ const doc = new Y.Doc();
+ let fieldDataArr: FieldData[];
+
+ doc.transact(() => {
+ const meta = doc.get("meta", Y.Map);
+ meta.set("title", obj.title);
+
+ const fieldsArray = new Y.Array();
+
+ if (obj.fields.length === 0 && obj.content) {
+ const legacyField: FieldData = {
+ id: "legacy",
+ fieldType: "rich_text",
+ image: null,
+ position: 0,
+ };
+ fieldsArray.push([legacyField]);
+ htmlToYjs(obj.content, doc.get("field_legacy", Y.XmlFragment));
+ fieldDataArr = [legacyField];
+ } else {
+ fieldDataArr = obj.fields.map((field: any) => {
+ const imageUrl = field.image ? `/rolodex/ajax/note/field/${field.id}/image/serve` : null;
+ const fieldData: FieldData = {
+ id: field.id.toString(),
+ fieldType: field.fieldType,
+ image: imageUrl,
+ position: field.position,
+ };
+ fieldsArray.push([fieldData]);
+
+ if (field.fieldType === "rich_text") {
+ htmlToYjs(
+ field.content,
+ doc.get(`field_${field.id}`, Y.XmlFragment)
+ );
+ }
+ return fieldData;
+ });
+ }
+
+ meta.set("fields", fieldsArray);
+ });
+
+ return [doc, fieldDataArr!];
+ },
+
+ async save(client: ApolloClient, id: number, doc: Y.Doc, _data: FieldData[]) {
+ let queryVars: any;
+ doc.transact(() => {
+ const meta = doc.get("meta", Y.Map) as Y.Map;
+ const fieldsArray = meta.get("fields") as Y.Array | undefined;
+
+ if (!fieldsArray) {
+ queryVars = { updates: [] };
+ return;
+ }
+
+ const currentFields: FieldData[] = [];
+ for (let i = 0; i < fieldsArray.length; i++) {
+ currentFields.push(fieldsArray.get(i));
+ }
+
+ const updates = currentFields
+ .filter((f) => f.fieldType === "rich_text" && f.id !== "legacy")
+ .map((f) => ({
+ where: { id: { _eq: parseInt(f.id) } },
+ _set: {
+ content: yjsToHtml(
+ doc.get(`field_${f.id}`, Y.XmlFragment)
+ ),
+ },
+ }));
+ queryVars = { updates };
+ });
+
+ if (queryVars.updates.length > 0) {
+ const res = await client.mutate({
+ mutation: SET_MUTATION,
+ variables: queryVars,
+ });
+ if (res.errors) throw res.errors;
+ }
+ },
+};
+
+export default ProjectCollabNoteItemHandler;
diff --git a/javascript/src/collab_server/handlers/project_tree_sync.ts b/javascript/src/collab_server/handlers/project_tree_sync.ts
new file mode 100644
index 000000000..ecccf6c82
--- /dev/null
+++ b/javascript/src/collab_server/handlers/project_tree_sync.ts
@@ -0,0 +1,54 @@
+import { type ModelHandler } from "../base_handler";
+import * as Y from "yjs";
+import { ApolloClient, gql as rawGql } from "@apollo/client/core";
+
+const GET_TREE_QUERY = rawGql`
+ query GET_PROJECT_TREE($id: bigint!) {
+ projectCollabNote(
+ where: { projectId: { _eq: $id } }
+ order_by: [{ position: asc }, { title: asc }]
+ ) {
+ id
+ title
+ nodeType
+ parentId
+ position
+ }
+ }
+`;
+
+interface TreeNode {
+ id: number;
+ title: string;
+ nodeType: string;
+ parentId: number | null;
+ position: number;
+}
+
+const ProjectTreeSyncHandler: ModelHandler = {
+ async load(client: ApolloClient, id: number) {
+ const res = await client.query({
+ query: GET_TREE_QUERY,
+ variables: { id },
+ });
+ if (res.error || res.errors) throw res.error || res.errors;
+
+ const doc = new Y.Doc();
+ doc.transact(() => {
+ const treeArray = doc.get("tree", Y.Array) as Y.Array;
+ const nodes: TreeNode[] = res.data.projectCollabNote || [];
+ if (nodes.length > 0) {
+ treeArray.push(nodes);
+ }
+ });
+
+ return [doc, null];
+ },
+
+ async save() {
+ // Tree data is persisted via GraphQL mutations from the frontend.
+ // The Yjs doc here acts as a real-time sync cache only.
+ },
+};
+
+export default ProjectTreeSyncHandler;
diff --git a/javascript/src/collab_server/index.ts b/javascript/src/collab_server/index.ts
index 54b0504cf..03e63fac6 100644
--- a/javascript/src/collab_server/index.ts
+++ b/javascript/src/collab_server/index.ts
@@ -21,6 +21,8 @@ import FindingHandler from "./handlers/finding";
import ReportFindingLinkHandler from "./handlers/report_finding_link";
import ReportHandler from "./handlers/report";
import ProjectHandler from "./handlers/project";
+import ProjectCollabNoteItemHandler from "./handlers/project_collab_note";
+import ProjectTreeSyncHandler from "./handlers/project_tree_sync";
// Extend this with your model handlers. See how-to-collab.md.
const HANDLERS_ARR: [string, ModelHandler][] = [
@@ -30,6 +32,8 @@ const HANDLERS_ARR: [string, ModelHandler][] = [
["report_finding_link", ReportFindingLinkHandler],
["report", ReportHandler],
["project", ProjectHandler],
+ ["project_collab_note", ProjectCollabNoteItemHandler],
+ ["project_tree_sync", ProjectTreeSyncHandler],
];
const HANDLERS: Map> = new Map(HANDLERS_ARR);
@@ -260,6 +264,13 @@ const server = new Server({
async afterUnloadDocument(data) {
documentData.delete(data.documentName);
},
+
+ async onStateless(data) {
+ const { documentName, document, payload } = data;
+ if (documentName.startsWith("project_tree_sync/")) {
+ document.broadcastStateless(payload);
+ }
+ },
});
server.listen();
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnote.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnote.tsx
deleted file mode 100644
index 268ef4bc3..000000000
--- a/javascript/src/frontend/collab_forms/forms/project_collabnote.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import ReactModal from "react-modal";
-import { ConnectionStatus, usePageConnection } from "../connection";
-import { createRoot, Root } from "react-dom/client";
-import RichTextEditor from "../rich_text_editor";
-import ErrorBoundary from "../error_boundary";
-
-function ProjectCollabNoteForm() {
- const { provider, status, connected } = usePageConnection({
- model: "project",
- });
-
- return (
- <>
-
-
- >
- );
-}
-
-document.addEventListener("DOMContentLoaded", () => {
- ReactModal.setAppElement(
- document.querySelector("div.wrapper") as HTMLElement
- );
-
- const $ = (window as any).$;
- let root: Root | null = null;
-
- $("#id_collab_notes").on("shown.bs.tab", () => {
- if (root !== null) return;
- root = createRoot(document.getElementById("collab_notes_container")!);
- root.render(
-
-
-
- );
- });
-
- $("#id_collab_notes").on("hidden.bs.tab", () => {
- if (root !== null) root.unmount();
- root = null;
- });
-});
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/AddFieldToolbar.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/AddFieldToolbar.tsx
new file mode 100644
index 000000000..0581d27ad
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/AddFieldToolbar.tsx
@@ -0,0 +1,28 @@
+interface AddFieldToolbarProps {
+ onAddRichText: () => void;
+ onAddImage: () => void;
+}
+
+export default function AddFieldToolbar({
+ onAddRichText,
+ onAddImage,
+}: AddFieldToolbarProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx
new file mode 100644
index 000000000..b6444c991
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/CreateModal.tsx
@@ -0,0 +1,115 @@
+import { useState } from "react";
+import ReactModal from "react-modal";
+
+interface CreateModalProps {
+ isOpen: boolean;
+ type: "note" | "folder";
+ onClose: () => void;
+ onCreate: (title: string) => void;
+}
+
+const OVERLAY_STYLE: ReactModal.Styles = {
+ overlay: {
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: 1050,
+ },
+ content: {
+ position: "relative",
+ inset: "auto",
+ border: "none",
+ background: "none",
+ padding: 0,
+ maxWidth: "500px",
+ width: "100%",
+ },
+};
+
+export default function CreateModal({
+ isOpen,
+ type,
+ onClose,
+ onCreate,
+}: CreateModalProps) {
+ const [title, setTitle] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim()) return;
+
+ setIsSubmitting(true);
+ try {
+ await onCreate(title.trim());
+ setTitle("");
+ onClose();
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleClose = () => {
+ setTitle("");
+ onClose();
+ };
+
+ return (
+
+
+
+
+ Create New {type === "folder" ? "Folder" : "Note"}
+
+
+
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx
new file mode 100644
index 000000000..edc180011
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/DeleteConfirmModal.tsx
@@ -0,0 +1,102 @@
+import ReactModal from "react-modal";
+
+type DeleteItemType = "rich_text" | "image" | "note" | "folder" | null;
+
+const TYPE_LABELS: Record = {
+ rich_text: "text field",
+ image: "image",
+ note: "note",
+ folder: "folder",
+};
+
+const OVERLAY_STYLE: ReactModal.Styles = {
+ overlay: {
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: 1050,
+ },
+ content: {
+ position: "relative",
+ inset: "auto",
+ border: "none",
+ background: "none",
+ padding: 0,
+ maxWidth: "500px",
+ width: "100%",
+ },
+};
+
+interface DeleteConfirmModalProps {
+ isOpen: boolean;
+ itemType: DeleteItemType;
+ itemTitle?: string;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+export default function DeleteConfirmModal({
+ isOpen,
+ itemType,
+ itemTitle,
+ onClose,
+ onConfirm,
+}: DeleteConfirmModalProps) {
+ const typeLabel = (itemType && TYPE_LABELS[itemType]) || "item";
+ const isFolder = itemType === "folder";
+
+ return (
+
+
+
+
+
+ Confirm Delete
+
+
+
+
+
+ Are you sure you want to delete {itemTitle ? (
+ <>the {typeLabel} "{itemTitle}">
+ ) : (
+ <>this {typeLabel}>
+ )}?
+
+ {isFolder && (
+
+
+ This will also delete all notes and subfolders inside.
+
+ )}
+
+ This action cannot be undone.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageField.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageField.tsx
new file mode 100644
index 000000000..d5b12fbca
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageField.tsx
@@ -0,0 +1,61 @@
+import { useState } from "react";
+import ImageFieldPlaceholder from "./ImageFieldPlaceholder";
+
+interface ImageFieldProps {
+ imageUrl: string | null | undefined;
+ onDelete: () => void;
+ onUpload?: (file: File) => void;
+ uploading?: boolean;
+}
+
+export default function ImageField({
+ imageUrl,
+ onDelete,
+ onUpload,
+ uploading = false,
+}: ImageFieldProps) {
+ const [showDeleteOverlay, setShowDeleteOverlay] = useState(false);
+
+ if (!imageUrl) {
+ return (
+ {})}
+ uploading={uploading}
+ />
+ );
+ }
+
+ return (
+ setShowDeleteOverlay(true)}
+ onMouseLeave={() => setShowDeleteOverlay(false)}
+ style={{ position: "relative", marginBottom: "1rem" }}
+ >
+

+ {showDeleteOverlay && (
+
+
+
+ )}
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageFieldPlaceholder.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageFieldPlaceholder.tsx
new file mode 100644
index 000000000..f1e40423e
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/ImageFieldPlaceholder.tsx
@@ -0,0 +1,118 @@
+import { useRef, useState, useCallback, useEffect } from "react";
+
+interface ImageFieldPlaceholderProps {
+ onUpload: (file: File) => void;
+ uploading: boolean;
+}
+
+export default function ImageFieldPlaceholder({
+ onUpload,
+ uploading,
+}: ImageFieldPlaceholderProps) {
+ const fileInputRef = useRef(null);
+ const [dragActive, setDragActive] = useState(false);
+ const containerRef = useRef(null);
+
+ const handleFileSelect = useCallback(
+ (file: File) => {
+ if (file && file.type.startsWith("image/")) {
+ onUpload(file);
+ }
+ },
+ [onUpload]
+ );
+
+ const handleFileInputChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) handleFileSelect(file);
+ e.target.value = "";
+ };
+
+ const handleDrag = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.type === "dragenter" || e.type === "dragover") {
+ setDragActive(true);
+ } else if (e.type === "dragleave") {
+ setDragActive(false);
+ }
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragActive(false);
+ const file = e.dataTransfer.files?.[0];
+ if (file) handleFileSelect(file);
+ };
+
+ const handlePaste = useCallback(
+ (event: ClipboardEvent) => {
+ const items = event.clipboardData?.items;
+ if (!items) return;
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].type.startsWith("image/")) {
+ event.preventDefault();
+ const file = items[i].getAsFile();
+ if (file) handleFileSelect(file);
+ break;
+ }
+ }
+ },
+ [handleFileSelect]
+ );
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ container.addEventListener("paste", handlePaste);
+ return () => container.removeEventListener("paste", handlePaste);
+ }, [handlePaste]);
+
+ const handleClick = () => {
+ if (!uploading) fileInputRef.current?.click();
+ };
+
+ const className = [
+ "image-field-placeholder",
+ dragActive && "drag-active",
+ uploading && "uploading",
+ ].filter(Boolean).join(" ");
+
+ return (
+
+ {uploading ? (
+ <>
+
+ Uploading...
+ >
+ ) : (
+ <>
+
+
+ {dragActive ? "Drop image here" : "Paste, drag-drop, or click to upload"}
+
+
+ PNG, JPG, GIF, WebP (max 10 MB)
+
+ >
+ )}
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteEditor.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteEditor.tsx
new file mode 100644
index 000000000..1770897e5
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteEditor.tsx
@@ -0,0 +1,301 @@
+import { useEffect, useState, useCallback } from "react";
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import * as Y from "yjs";
+import {
+ ConnectionStatus,
+ usePageConnection,
+} from "../../connection";
+import NoteFieldEditor from "./NoteFieldEditor";
+import AddFieldToolbar from "./AddFieldToolbar";
+import DeleteConfirmModal from "./DeleteConfirmModal";
+import { useFieldMutations } from "./hooks/useFieldMutations";
+import { useImageUpload } from "./hooks/useImageUpload";
+import type { NoteField } from "./types";
+
+interface NoteEditorProps {
+ noteId: number;
+}
+
+/**
+ * NoteEditor derives fields directly from the Yjs document via observeDeep.
+ * The Yjs meta.fields Y.Array is the single source of truth for field state.
+ * React state is updated only via Yjs observation callbacks.
+ */
+export default function NoteEditor({ noteId }: NoteEditorProps) {
+ const { provider, status, connected } = usePageConnection({
+ model: "project_collab_note",
+ id: noteId.toString(),
+ });
+
+ const [fields, setFields] = useState([]);
+ const [pendingDeleteField, setPendingDeleteField] = useState(null);
+ const { createRichTextField, createImageField, deleteField, reorderFields } = useFieldMutations();
+ const { uploading, uploadingFieldId, error, uploadImage, uploadToField, handlePaste } = useImageUpload();
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 8 },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ // Derive fields from Yjs document via observeDeep (Comment #17)
+ // The Yjs doc is the single source of truth; React state is read-only derived.
+ useEffect(() => {
+ if (!connected) return;
+
+ const meta = provider.document.getMap("meta");
+
+ const syncFieldsFromYjs = () => {
+ const fieldsArray = meta.get("fields") as Y.Array | undefined;
+ if (!fieldsArray) {
+ setFields([]);
+ return;
+ }
+ const fieldsList: NoteField[] = [];
+ for (let i = 0; i < fieldsArray.length; i++) {
+ fieldsList.push(fieldsArray.get(i));
+ }
+ setFields(fieldsList);
+ };
+
+ // Initial sync
+ syncFieldsFromYjs();
+
+ // Observe all deep changes to meta (handles fields array creation,
+ // item additions/removals, and nested property changes)
+ meta.observeDeep(syncFieldsFromYjs);
+
+ return () => {
+ meta.unobserveDeep(syncFieldsFromYjs);
+ };
+ }, [provider, connected]);
+
+ // Helper: mutate the Yjs fields array (all field changes go through here)
+ const getFieldsArray = useCallback((): Y.Array | null => {
+ if (!connected) return null;
+ const meta = provider.document.getMap("meta");
+ return (meta.get("fields") as Y.Array) || null;
+ }, [provider, connected]);
+
+ const ensureFieldsArray = useCallback((): Y.Array => {
+ const meta = provider.document.getMap("meta");
+ let arr = meta.get("fields") as Y.Array | undefined;
+ if (!arr) {
+ arr = new Y.Array();
+ meta.set("fields", arr);
+ }
+ return arr;
+ }, [provider]);
+
+ // Clipboard paste for images
+ useEffect(() => {
+ const handlePasteEvent = async (event: ClipboardEvent) => {
+ const result = await handlePaste(noteId, event);
+ if (result) {
+ const newField: NoteField = {
+ id: result.id,
+ fieldType: "image",
+ image: result.imageUrl,
+ position: result.position,
+ };
+ const arr = ensureFieldsArray();
+ arr.push([newField]);
+ }
+ };
+
+ document.addEventListener("paste", handlePasteEvent);
+ return () => document.removeEventListener("paste", handlePasteEvent);
+ }, [noteId, handlePaste, ensureFieldsArray]);
+
+ const handleAddRichText = useCallback(async () => {
+ try {
+ const result = await createRichTextField(noteId);
+ const newField: NoteField = {
+ id: result.id,
+ fieldType: "rich_text",
+ image: null,
+ position: result.position,
+ };
+ const arr = ensureFieldsArray();
+ arr.push([newField]);
+ } catch (err) {
+ console.error("Failed to create rich text field:", err);
+ }
+ }, [noteId, createRichTextField, ensureFieldsArray]);
+
+ const handleAddImage = useCallback(async () => {
+ try {
+ const result = await createImageField(noteId);
+ const newField: NoteField = {
+ id: result.id,
+ fieldType: "image",
+ image: null,
+ position: result.position,
+ };
+ const arr = ensureFieldsArray();
+ arr.push([newField]);
+ } catch (err) {
+ console.error("Failed to create image field:", err);
+ }
+ }, [noteId, createImageField, ensureFieldsArray]);
+
+ const handleUploadToField = useCallback(
+ async (fieldId: string, file: File) => {
+ try {
+ const result = await uploadToField(noteId, fieldId, file);
+ if (result) {
+ const arr = getFieldsArray();
+ if (arr) {
+ provider.document.transact(() => {
+ for (let i = 0; i < arr.length; i++) {
+ const f = arr.get(i);
+ if (f.id === fieldId) {
+ arr.delete(i, 1);
+ arr.insert(i, [{ ...f, image: result.imageUrl }]);
+ break;
+ }
+ }
+ });
+ }
+ }
+ } catch (err) {
+ console.error("Failed to upload image to field:", err);
+ }
+ },
+ [noteId, uploadToField, getFieldsArray, provider]
+ );
+
+ const requestDeleteField = useCallback((field: NoteField) => {
+ setPendingDeleteField(field);
+ }, []);
+
+ const handleConfirmDelete = useCallback(async () => {
+ if (!pendingDeleteField) return;
+ const fieldId = pendingDeleteField.id;
+ setPendingDeleteField(null);
+ try {
+ await deleteField(fieldId);
+ const arr = getFieldsArray();
+ if (arr) {
+ for (let i = 0; i < arr.length; i++) {
+ if (arr.get(i).id === fieldId) {
+ arr.delete(i, 1);
+ break;
+ }
+ }
+ }
+ } catch (err) {
+ console.error("Failed to delete field:", err);
+ }
+ }, [pendingDeleteField, deleteField, getFieldsArray]);
+
+ const handleCancelDelete = useCallback(() => {
+ setPendingDeleteField(null);
+ }, []);
+
+ const handleDragEnd = useCallback(
+ async (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ const oldIndex = fields.findIndex((f) => f.id === active.id);
+ const newIndex = fields.findIndex((f) => f.id === over.id);
+ if (oldIndex === -1 || newIndex === -1) return;
+
+ const newFields = arrayMove(fields, oldIndex, newIndex);
+
+ const updates = newFields.map((field, index) => ({
+ id: field.id,
+ position: index * 1000,
+ noteId,
+ }));
+
+ // Update Yjs (which triggers observeDeep → setFields)
+ const arr = getFieldsArray();
+ if (arr) {
+ provider.document.transact(() => {
+ arr.delete(0, arr.length);
+ const reordered = newFields.map((f, i) => ({ ...f, position: i * 1000 }));
+ arr.push(reordered);
+ });
+ }
+
+ try {
+ await reorderFields(updates);
+ } catch (err) {
+ console.error("Failed to reorder fields:", err);
+ }
+ },
+ [fields, noteId, reorderFields, getFieldsArray, provider]
+ );
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ f.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {fields.map((field) => (
+ requestDeleteField(field)}
+ onUploadImage={handleUploadToField}
+ uploadingFieldId={uploadingFieldId}
+ />
+ ))}
+
+
+
+ {fields.length === 0 && connected && (
+
+ No fields yet. Add a text field or image above to get started.
+
+ )}
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteFieldEditor.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteFieldEditor.tsx
new file mode 100644
index 000000000..8a1eafaee
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteFieldEditor.tsx
@@ -0,0 +1,87 @@
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import RichTextEditor from "../../rich_text_editor";
+import ImageField from "./ImageField";
+import type { NoteField } from "./types";
+import type { HocuspocusProvider } from "@hocuspocus/provider";
+
+interface NoteFieldEditorProps {
+ field: NoteField;
+ provider: HocuspocusProvider;
+ connected: boolean;
+ onDelete: () => void;
+ onUploadImage?: (fieldId: string, file: File) => void;
+ uploadingFieldId?: string | null;
+}
+
+export default function NoteFieldEditor({
+ field,
+ provider,
+ connected,
+ onDelete,
+ onUploadImage,
+ uploadingFieldId,
+}: NoteFieldEditorProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: field.id,
+ data: { type: "field", field },
+ });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+
+
+
+
+
+ {field.fieldType === "rich_text" ? "Text" : "Image"}
+
+
+ {field.fieldType === "rich_text" ? (
+
+
+
+ ) : (
+
onUploadImage(field.id, file) : undefined
+ }
+ uploading={uploadingFieldId === field.id}
+ />
+ )}
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
new file mode 100644
index 000000000..6d88a6de0
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/NoteTreeView.tsx
@@ -0,0 +1,331 @@
+import { useState, useMemo, useCallback } from "react";
+import {
+ DndContext,
+ DragOverlay,
+ pointerWithin,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ useDroppable,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { useNoteMutations } from "./hooks/useNoteMutations";
+import { useTreeDnd } from "./hooks/useTreeDnd";
+import { useTreeSync } from "./hooks/useTreeSync";
+import SortableTreeItem from "./SortableTreeItem";
+import TreeItem from "./TreeItem";
+import CreateModal from "./CreateModal";
+import DeleteConfirmModal from "./DeleteConfirmModal";
+import type { NoteTreeNode, FlatNote } from "./types";
+import "./tree.css";
+
+interface NoteTreeViewProps {
+ projectId: number;
+ selectedId: number | null;
+ onSelect: (id: number | null) => void;
+}
+
+export default function NoteTreeView({
+ projectId,
+ selectedId,
+ onSelect,
+}: NoteTreeViewProps) {
+ const {
+ tree,
+ flatNodes,
+ loading,
+ addNode,
+ removeNodes,
+ updateNode,
+ } = useTreeSync({ projectId });
+
+ const { createNote, createFolder, deleteNote, renameNote, moveNote } =
+ useNoteMutations();
+
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [createType, setCreateType] = useState<"note" | "folder">("note");
+ const [createParentId, setCreateParentId] = useState(null);
+ const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: { distance: 8 },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ /** Called after any tree mutation to sync Yjs state */
+ const onTreeMutated = useCallback(() => {
+ // Tree changes propagate through Yjs automatically via addNode/removeNodes/updateNode
+ }, []);
+
+ const {
+ dragState,
+ handleDragStart,
+ handleDragOver,
+ handleDragEnd: rawDragEnd,
+ handleDragCancel,
+ calculateNewPosition,
+ } = useTreeDnd({ flatNodes, moveNote, onTreeMutated });
+
+ // Wrap handleDragEnd to also update Yjs tree with both parent and position
+ const handleDragEnd = useCallback(
+ async (event: any) => {
+ const { active, over } = event;
+ const { overId, dropPosition } = dragState;
+
+ if (over && overId !== null && dropPosition !== null) {
+ const activeId = active.id as number;
+
+ if (overId === "tree-top" || overId === "tree-bottom") {
+ const rootNodes = flatNodes
+ .filter((n) => n.parentId === null)
+ .sort((a, b) => a.position - b.position);
+ const newPosition = overId === "tree-top"
+ ? (rootNodes.length > 0 ? rootNodes[0].position - 1000 : 0)
+ : (rootNodes.length > 0 ? rootNodes[rootNodes.length - 1].position + 1000 : 0);
+ updateNode(activeId, { parentId: null, position: newPosition });
+ } else {
+ const overNode = flatNodes.find((n) => n.id === overId);
+ if (overNode) {
+ const newParentId = dropPosition === "inside" ? (overId as number) : overNode.parentId;
+ const newPosition = calculateNewPosition(overId as number, dropPosition, newParentId);
+ updateNode(activeId, { parentId: newParentId, position: newPosition });
+ }
+ }
+ }
+
+ await rawDragEnd(event);
+ },
+ [dragState, flatNodes, rawDragEnd, updateNode, calculateNewPosition]
+ );
+
+ const allItemIds = useMemo(() => flatNodes.map((n) => n.id), [flatNodes]);
+
+ const activeItem = useMemo(() => {
+ if (!dragState.activeId) return null;
+ const findItem = (nodes: NoteTreeNode[]): NoteTreeNode | null => {
+ for (const node of nodes) {
+ if (node.id === dragState.activeId) return node;
+ if (node.children.length > 0) {
+ const found = findItem(node.children);
+ if (found) return found;
+ }
+ }
+ return null;
+ };
+ return findItem(tree);
+ }, [dragState.activeId, tree]);
+
+ const handleCreate = async (title: string) => {
+ let newId: number;
+ if (createType === "folder") {
+ newId = await createFolder(projectId, createParentId, title);
+ } else {
+ newId = await createNote(projectId, createParentId, title);
+ onSelect(newId);
+ }
+
+ // Add node to Yjs tree for real-time sync
+ const position = flatNodes
+ .filter((n) => n.parentId === createParentId)
+ .reduce((max, n) => Math.max(max, n.position), -1000) + 1000;
+
+ addNode({
+ id: newId,
+ title,
+ nodeType: createType,
+ parentId: createParentId,
+ position,
+ });
+
+ setShowCreateModal(false);
+ };
+
+ const requestDelete = (item: NoteTreeNode) => {
+ setPendingDeleteItem(item);
+ };
+
+ const handleConfirmDelete = async () => {
+ if (!pendingDeleteItem) return;
+ const id = pendingDeleteItem.id;
+ setPendingDeleteItem(null);
+ if (selectedId === id) onSelect(null);
+
+ // Collect all descendant IDs
+ const idsToRemove = new Set();
+ const collectIds = (nodes: NoteTreeNode[]) => {
+ for (const node of nodes) {
+ idsToRemove.add(node.id);
+ collectIds(node.children);
+ }
+ };
+ const findNode = (nodes: NoteTreeNode[]): NoteTreeNode | null => {
+ for (const n of nodes) {
+ if (n.id === id) return n;
+ const found = findNode(n.children);
+ if (found) return found;
+ }
+ return null;
+ };
+ const targetNode = findNode(tree);
+ if (targetNode) collectIds([targetNode]);
+ else idsToRemove.add(id);
+
+ await deleteNote(id);
+ removeNodes(idsToRemove);
+ };
+
+ const handleCancelDelete = () => {
+ setPendingDeleteItem(null);
+ };
+
+ const handleRename = async (id: number, title: string) => {
+ await renameNote(id, title);
+ updateNode(id, { title });
+ };
+
+ const handleCreateChild = (parentId: number, type: "note" | "folder") => {
+ setCreateType(type);
+ setCreateParentId(parentId);
+ setShowCreateModal(true);
+ };
+
+ const openCreateModal = (type: "note" | "folder") => {
+ setCreateType(type);
+ setCreateParentId(null);
+ setShowCreateModal(true);
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {tree.map((item) => (
+
+ ))}
+ {tree.length === 0 && (
+
+ No notes yet. Create one using the buttons above.
+
+ )}
+
+
+
+
+
+
+ {activeItem && (
+
+ {}}
+ onRequestDelete={() => {}}
+ onRename={() => {}}
+ onCreateChild={() => {}}
+ />
+
+ )}
+
+
+
+
setShowCreateModal(false)}
+ onCreate={handleCreate}
+ />
+
+
+
+ );
+}
+
+function TreeDropSentinel({ id }: { id: string }) {
+ const { setNodeRef, isOver } = useDroppable({ id });
+ return (
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/SortableTreeItem.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/SortableTreeItem.tsx
new file mode 100644
index 000000000..98996e373
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/SortableTreeItem.tsx
@@ -0,0 +1,74 @@
+import { useSortable } from "@dnd-kit/sortable";
+import TreeItem from "./TreeItem";
+import { NoteTreeNode, DropPosition, DragState } from "./types";
+
+interface SortableTreeItemProps {
+ item: NoteTreeNode;
+ depth: number;
+ selectedId: number | null;
+ onSelect: (id: number | null) => void;
+ onRequestDelete: (item: NoteTreeNode) => void;
+ onRename: (id: number, title: string) => void;
+ onCreateChild: (parentId: number, type: "note" | "folder") => void;
+ dragState: DragState;
+}
+
+export default function SortableTreeItem({
+ item,
+ depth,
+ selectedId,
+ onSelect,
+ onRequestDelete,
+ onRename,
+ onCreateChild,
+ dragState,
+}: SortableTreeItemProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ isDragging,
+ } = useSortable({ id: item.id });
+
+ const style = { opacity: isDragging ? 0 : 1 };
+
+ const isDropTarget = dragState.overId === item.id;
+ const dropPosition: DropPosition | null = isDropTarget
+ ? dragState.dropPosition
+ : null;
+
+ const renderChildren = (children: NoteTreeNode[], childDepth: number) => {
+ return children.map((child) => (
+
+ ));
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
new file mode 100644
index 000000000..6bf1aec25
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/TreeItem.tsx
@@ -0,0 +1,203 @@
+import { useState } from "react";
+import { NoteTreeNode, DropPosition } from "./types";
+
+interface TreeItemProps {
+ item: NoteTreeNode;
+ depth: number;
+ selectedId: number | null;
+ onSelect: (id: number | null) => void;
+ onRequestDelete: (item: NoteTreeNode) => void;
+ onRename: (id: number, title: string) => void;
+ onCreateChild: (parentId: number, type: "note" | "folder") => void;
+ isDragging?: boolean;
+ dropPosition?: DropPosition | null;
+ dragHandleProps?: Record;
+ dragAttributes?: Record;
+ renderChildren?: (children: NoteTreeNode[], depth: number) => React.ReactNode;
+}
+
+function TreeItemIcon({ isFolder, expanded }: { isFolder: boolean; expanded: boolean }) {
+ if (isFolder) {
+ return ;
+ }
+ return ;
+}
+
+function TreeItemChevron({ isFolder, hasChildren, expanded }: { isFolder: boolean; hasChildren: boolean; expanded: boolean }) {
+ if (!isFolder || !hasChildren) return null;
+ return expanded
+ ?
+ : ;
+}
+
+interface ActionButtonsProps {
+ isFolder: boolean;
+ buttonClass: string;
+ deleteClass: string;
+ onAddNote: (e: React.MouseEvent) => void;
+ onAddFolder: (e: React.MouseEvent) => void;
+ onRename: (e: React.MouseEvent) => void;
+ onDelete: (e: React.MouseEvent) => void;
+}
+
+function TreeItemActions({ isFolder, buttonClass, deleteClass, onAddNote, onAddFolder, onRename, onDelete }: ActionButtonsProps) {
+ return (
+
+ {isFolder && (
+ <>
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+export default function TreeItem({
+ item,
+ depth,
+ selectedId,
+ onSelect,
+ onRequestDelete,
+ onRename,
+ onCreateChild,
+ isDragging = false,
+ dropPosition = null,
+ dragHandleProps,
+ dragAttributes,
+ renderChildren,
+}: TreeItemProps) {
+ const [expanded, setExpanded] = useState(true);
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [renameValue, setRenameValue] = useState(item.title);
+
+ const isFolder = item.nodeType === "folder";
+ const isSelected = selectedId === item.id;
+
+ const handleClick = () => {
+ if (isFolder) setExpanded(!expanded);
+ else onSelect(item.id);
+ };
+
+ const handleRenameSubmit = () => {
+ if (renameValue.trim() && renameValue !== item.title) {
+ onRename(item.id, renameValue.trim());
+ }
+ setIsRenaming(false);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") handleRenameSubmit();
+ else if (e.key === "Escape") {
+ setRenameValue(item.title);
+ setIsRenaming(false);
+ }
+ };
+
+ const dropClass = dropPosition ? `tree-item-drop-${dropPosition}` : "";
+
+ const dropIndicatorStyle: React.CSSProperties = {};
+ if (dropPosition === "before") {
+ dropIndicatorStyle.borderTop = "3px solid #0d6efd";
+ } else if (dropPosition === "after") {
+ dropIndicatorStyle.borderBottom = "3px solid #0d6efd";
+ } else if (dropPosition === "inside") {
+ dropIndicatorStyle.outline = "2px solid #0d6efd";
+ dropIndicatorStyle.outlineOffset = "-2px";
+ dropIndicatorStyle.borderRadius = "4px";
+ dropIndicatorStyle.backgroundColor = "rgba(13, 110, 253, 0.1)";
+ }
+
+ const actionButtonClass = isSelected
+ ? "btn btn-sm p-0 me-1 text-white"
+ : "btn btn-link btn-sm p-0 me-1";
+ const deleteButtonClass = isSelected
+ ? "btn btn-sm p-0 text-white"
+ : "btn btn-link btn-sm p-0 text-danger";
+
+ return (
+
+
+
+ 0} expanded={expanded} />
+
+
+
+
+
+
+ {isRenaming ? (
+ setRenameValue(e.target.value)}
+ onBlur={handleRenameSubmit}
+ onKeyDown={handleKeyDown}
+ onClick={(e) => e.stopPropagation()}
+ autoFocus
+ style={{ maxWidth: "150px" }}
+ />
+ ) : (
+
+ {item.title}
+
+ )}
+
+ {!isRenaming && (
+ { e.stopPropagation(); onCreateChild(item.id, "note"); setExpanded(true); }}
+ onAddFolder={(e) => { e.stopPropagation(); onCreateChild(item.id, "folder"); setExpanded(true); }}
+ onRename={(e) => { e.stopPropagation(); setRenameValue(item.title); setIsRenaming(true); }}
+ onDelete={(e) => { e.stopPropagation(); onRequestDelete(item); }}
+ />
+ )}
+
+
+ {isFolder && expanded && item.children.length > 0 && (
+
+ {renderChildren
+ ? renderChildren(item.children, depth + 1)
+ : item.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useFieldMutations.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useFieldMutations.ts
new file mode 100644
index 000000000..9451f6d35
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useFieldMutations.ts
@@ -0,0 +1,162 @@
+import { useCallback } from "react";
+
+const CREATE_RICH_TEXT_FIELD_MUTATION = `
+ mutation CreateRichTextField($noteId: bigint!, $position: Int!) {
+ insert_projectCollabNoteField_one(object: {
+ noteId: $noteId,
+ fieldType: "rich_text",
+ content: "",
+ position: $position
+ }) {
+ id
+ position
+ }
+ }
+`;
+
+const CREATE_IMAGE_FIELD_MUTATION = `
+ mutation CreateImageField($noteId: bigint!, $position: Int!) {
+ insert_projectCollabNoteField_one(object: {
+ noteId: $noteId,
+ fieldType: "image",
+ content: "",
+ position: $position
+ }) {
+ id
+ position
+ }
+ }
+`;
+
+const DELETE_FIELD_MUTATION = `
+ mutation DeleteProjectCollabNoteField($id: bigint!) {
+ delete_projectCollabNoteField_by_pk(id: $id) {
+ id
+ }
+ }
+`;
+
+function buildReorderMutation(
+ fields: Array<{ id: string; position: number }>
+): { query: string; variables: Record } {
+ const mutations = fields.map(
+ (field, index) =>
+ `field${index}: update_projectCollabNoteField_by_pk(
+ pk_columns: { id: $id${index} },
+ _set: { position: $position${index} }
+ ) { id }`
+ );
+
+ const variables: Record = {};
+ const variableDeclarations = fields.map((field, index) => {
+ variables[`id${index}`] = parseInt(field.id);
+ variables[`position${index}`] = field.position;
+ return `$id${index}: bigint!, $position${index}: Int!`;
+ });
+
+ const query = `
+ mutation ReorderFields(${variableDeclarations.join(", ")}) {
+ ${mutations.join("\n ")}
+ }
+ `;
+
+ return { query, variables };
+}
+
+const GET_MAX_POSITION = `
+ query GetMaxFieldPosition($noteId: bigint!) {
+ projectCollabNoteField_aggregate(
+ where: { noteId: { _eq: $noteId } }
+ ) {
+ aggregate {
+ max {
+ position
+ }
+ }
+ }
+ }
+`;
+
+function getJwt(): string {
+ return document.getElementById("yjs-jwt")?.innerHTML ?? "";
+}
+
+async function graphqlMutate(query: string, variables: Record) {
+ const response = await fetch("/v1/graphql", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${getJwt()}`,
+ },
+ body: JSON.stringify({ query, variables }),
+ });
+ const data = await response.json();
+ if (data.errors) {
+ throw new Error(data.errors[0]?.message || "GraphQL error");
+ }
+ return data.data;
+}
+
+export function useFieldMutations() {
+ const getNextPosition = useCallback(
+ async (noteId: number): Promise => {
+ const data = await graphqlMutate(GET_MAX_POSITION, { noteId });
+ const maxPos =
+ data.projectCollabNoteField_aggregate.aggregate.max.position;
+ return maxPos !== null ? maxPos + 1 : 0;
+ },
+ []
+ );
+
+ const createRichTextField = useCallback(
+ async (noteId: number): Promise<{ id: string; position: number }> => {
+ const position = await getNextPosition(noteId);
+ const data = await graphqlMutate(CREATE_RICH_TEXT_FIELD_MUTATION, {
+ noteId,
+ position,
+ });
+ return {
+ id: data.insert_projectCollabNoteField_one.id.toString(),
+ position: data.insert_projectCollabNoteField_one.position,
+ };
+ },
+ [getNextPosition]
+ );
+
+ const createImageField = useCallback(
+ async (noteId: number): Promise<{ id: string; position: number }> => {
+ const position = await getNextPosition(noteId);
+ const data = await graphqlMutate(CREATE_IMAGE_FIELD_MUTATION, {
+ noteId,
+ position,
+ });
+ return {
+ id: data.insert_projectCollabNoteField_one.id.toString(),
+ position: data.insert_projectCollabNoteField_one.position,
+ };
+ },
+ [getNextPosition]
+ );
+
+ const deleteField = useCallback(async (id: string): Promise => {
+ await graphqlMutate(DELETE_FIELD_MUTATION, { id: parseInt(id) });
+ }, []);
+
+ const reorderFields = useCallback(
+ async (
+ fields: Array<{ id: string; position: number; noteId: number }>
+ ): Promise => {
+ if (fields.length === 0) return;
+ const { query, variables } = buildReorderMutation(fields);
+ await graphqlMutate(query, variables);
+ },
+ []
+ );
+
+ return {
+ createRichTextField,
+ createImageField,
+ deleteField,
+ reorderFields,
+ };
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useImageUpload.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useImageUpload.ts
new file mode 100644
index 000000000..5620615b3
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useImageUpload.ts
@@ -0,0 +1,132 @@
+import { useState } from "react";
+
+interface UploadImageResponse {
+ result: string;
+ id: string;
+ imageUrl: string;
+ position: number;
+ message?: string;
+}
+
+interface UploadToFieldResponse {
+ result: string;
+ imageUrl: string;
+ message?: string;
+}
+
+export function useImageUpload() {
+ const [uploading, setUploading] = useState(false);
+ const [uploadingFieldId, setUploadingFieldId] = useState(null);
+ const [error, setError] = useState(null);
+
+ const uploadImage = async (noteId: number, file: File): Promise => {
+ setUploading(true);
+ setError(null);
+
+ try {
+ if (!file.type.startsWith("image/")) {
+ throw new Error(`Invalid file type: ${file.type}`);
+ }
+
+ const maxSize = 10 * 1024 * 1024;
+ if (file.size > maxSize) {
+ throw new Error(`File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: 10 MB`);
+ }
+
+ const formData = new FormData();
+ formData.append("image", file);
+
+ const csrf = document.cookie
+ .split("; ")
+ .find((row) => row.startsWith("csrftoken="))
+ ?.split("=")[1];
+
+ const response = await fetch(`/rolodex/ajax/note/${noteId}/field/image`, {
+ method: "POST",
+ body: formData,
+ headers: { "X-CSRFToken": csrf || "" },
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.message || "Failed to upload image");
+ }
+
+ const data: UploadImageResponse = await response.json();
+ if (data.result !== "success") throw new Error(data.message || "Upload failed");
+ return data;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error");
+ return null;
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const uploadToField = async (
+ noteId: number,
+ fieldId: string,
+ file: File
+ ): Promise => {
+ setUploading(true);
+ setUploadingFieldId(fieldId);
+ setError(null);
+
+ try {
+ if (!file.type.startsWith("image/")) {
+ throw new Error(`Invalid file type: ${file.type}`);
+ }
+
+ const maxSize = 10 * 1024 * 1024;
+ if (file.size > maxSize) {
+ throw new Error(`File too large: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: 10 MB`);
+ }
+
+ const formData = new FormData();
+ formData.append("image", file);
+
+ const csrf = document.cookie
+ .split("; ")
+ .find((row) => row.startsWith("csrftoken="))
+ ?.split("=")[1];
+
+ const response = await fetch(`/rolodex/ajax/note/${noteId}/field/${fieldId}/image`, {
+ method: "POST",
+ body: formData,
+ headers: { "X-CSRFToken": csrf || "" },
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.message || "Failed to upload image");
+ }
+
+ const data: UploadToFieldResponse = await response.json();
+ if (data.result !== "success") throw new Error(data.message || "Upload failed");
+ return data;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error");
+ return null;
+ } finally {
+ setUploading(false);
+ setUploadingFieldId(null);
+ }
+ };
+
+ const handlePaste = async (noteId: number, event: ClipboardEvent): Promise => {
+ const items = event.clipboardData?.items;
+ if (!items) return null;
+
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ if (item.type.startsWith("image/")) {
+ event.preventDefault();
+ const file = item.getAsFile();
+ if (file) return await uploadImage(noteId, file);
+ }
+ }
+ return null;
+ };
+
+ return { uploading, uploadingFieldId, error, uploadImage, uploadToField, handlePaste };
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useNoteMutations.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useNoteMutations.ts
new file mode 100644
index 000000000..286fc5f43
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useNoteMutations.ts
@@ -0,0 +1,258 @@
+import { useCallback } from "react";
+
+const CREATE_ROOT_MUTATION = `
+ mutation CreateRootProjectCollabNote(
+ $projectId: bigint!,
+ $title: String!,
+ $nodeType: String!,
+ $position: Int!
+ ) {
+ insert_projectCollabNote_one(object: {
+ projectId: $projectId,
+ title: $title,
+ nodeType: $nodeType,
+ content: "",
+ position: $position
+ }) {
+ id
+ }
+ }
+`;
+
+const CREATE_CHILD_MUTATION = `
+ mutation CreateChildProjectCollabNote(
+ $projectId: bigint!,
+ $parentId: bigint!,
+ $title: String!,
+ $nodeType: String!,
+ $position: Int!
+ ) {
+ insert_projectCollabNote_one(object: {
+ projectId: $projectId,
+ parentId: $parentId,
+ title: $title,
+ nodeType: $nodeType,
+ content: "",
+ position: $position
+ }) {
+ id
+ }
+ }
+`;
+
+const UPDATE_TITLE_MUTATION = `
+ mutation UpdateProjectCollabNoteTitle($id: bigint!, $title: String!) {
+ update_projectCollabNote_by_pk(
+ pk_columns: { id: $id },
+ _set: { title: $title }
+ ) {
+ id
+ }
+ }
+`;
+
+const DELETE_MUTATION = `
+ mutation DeleteProjectCollabNote($id: bigint!) {
+ delete_projectCollabNote_by_pk(id: $id) {
+ id
+ }
+ }
+`;
+
+const DELETE_NOTE_FIELDS_MUTATION = `
+ mutation DeleteNoteFields($noteId: bigint!) {
+ delete_projectCollabNoteField(where: { noteId: { _eq: $noteId } }) {
+ affected_rows
+ }
+ }
+`;
+
+const GET_DESCENDANTS_QUERY = `
+ query GetDescendants($id: bigint!) {
+ projectCollabNote_by_pk(id: $id) {
+ id
+ children {
+ id
+ children {
+ id
+ children {
+ id
+ children {
+ id
+ children {
+ id
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+const MOVE_MUTATION = `
+ mutation MoveProjectCollabNote($id: bigint!, $parentId: bigint, $position: Int!) {
+ update_projectCollabNote_by_pk(
+ pk_columns: { id: $id },
+ _set: { parentId: $parentId, position: $position }
+ ) {
+ id
+ }
+ }
+`;
+
+const GET_MAX_POSITION_ROOT = `
+ query GetMaxPositionRoot($projectId: bigint!) {
+ projectCollabNote_aggregate(
+ where: {
+ projectId: { _eq: $projectId },
+ parentId: { _is_null: true }
+ }
+ ) {
+ aggregate {
+ max {
+ position
+ }
+ }
+ }
+ }
+`;
+
+const GET_MAX_POSITION_CHILD = `
+ query GetMaxPositionChild($projectId: bigint!, $parentId: bigint!) {
+ projectCollabNote_aggregate(
+ where: {
+ projectId: { _eq: $projectId },
+ parentId: { _eq: $parentId }
+ }
+ ) {
+ aggregate {
+ max {
+ position
+ }
+ }
+ }
+ }
+`;
+
+function getJwt(): string {
+ return document.getElementById("yjs-jwt")?.innerHTML ?? "";
+}
+
+async function graphqlMutate(query: string, variables: Record) {
+ const response = await fetch("/v1/graphql", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${getJwt()}`,
+ },
+ body: JSON.stringify({ query, variables }),
+ });
+ const data = await response.json();
+ if (data.errors) {
+ throw new Error(data.errors[0]?.message || "GraphQL error");
+ }
+ return data.data;
+}
+
+export function useNoteMutations() {
+ const getNextPosition = useCallback(
+ async (projectId: number, parentId: number | null): Promise => {
+ const query = parentId === null ? GET_MAX_POSITION_ROOT : GET_MAX_POSITION_CHILD;
+ const variables = parentId === null
+ ? { projectId }
+ : { projectId, parentId };
+ const data = await graphqlMutate(query, variables);
+ const maxPos =
+ data.projectCollabNote_aggregate.aggregate.max.position;
+ return maxPos !== null ? maxPos + 1000 : 0;
+ },
+ []
+ );
+
+ const createNote = useCallback(
+ async (
+ projectId: number,
+ parentId: number | null,
+ title: string
+ ): Promise => {
+ const position = await getNextPosition(projectId, parentId);
+ const mutation = parentId === null ? CREATE_ROOT_MUTATION : CREATE_CHILD_MUTATION;
+ const variables = parentId === null
+ ? { projectId, title, nodeType: "note", position }
+ : { projectId, parentId, title, nodeType: "note", position };
+ const data = await graphqlMutate(mutation, variables);
+ return data.insert_projectCollabNote_one.id;
+ },
+ [getNextPosition]
+ );
+
+ const createFolder = useCallback(
+ async (
+ projectId: number,
+ parentId: number | null,
+ title: string
+ ): Promise => {
+ const position = await getNextPosition(projectId, parentId);
+ const mutation = parentId === null ? CREATE_ROOT_MUTATION : CREATE_CHILD_MUTATION;
+ const variables = parentId === null
+ ? { projectId, title, nodeType: "folder", position }
+ : { projectId, parentId, title, nodeType: "folder", position };
+ const data = await graphqlMutate(mutation, variables);
+ return data.insert_projectCollabNote_one.id;
+ },
+ [getNextPosition]
+ );
+
+ const renameNote = useCallback(
+ async (id: number, title: string): Promise => {
+ await graphqlMutate(UPDATE_TITLE_MUTATION, { id, title });
+ },
+ []
+ );
+
+ const deleteNote = useCallback(async (id: number): Promise => {
+ interface NoteNode {
+ id: number;
+ children?: NoteNode[];
+ }
+
+ const collectIds = (node: NoteNode | null, ids: number[] = []): number[] => {
+ if (!node) return ids;
+ if (node.children) {
+ for (const child of node.children) {
+ collectIds(child, ids);
+ }
+ }
+ ids.push(node.id);
+ return ids;
+ };
+
+ const data = await graphqlMutate(GET_DESCENDANTS_QUERY, { id });
+ const idsToDelete = collectIds(data.projectCollabNote_by_pk);
+
+ for (const noteId of idsToDelete) {
+ await graphqlMutate(DELETE_NOTE_FIELDS_MUTATION, { noteId });
+ await graphqlMutate(DELETE_MUTATION, { id: noteId });
+ }
+ }, []);
+
+ const moveNote = useCallback(
+ async (
+ id: number,
+ parentId: number | null,
+ position: number
+ ): Promise => {
+ await graphqlMutate(MOVE_MUTATION, { id, parentId, position });
+ },
+ []
+ );
+
+ return {
+ createNote,
+ createFolder,
+ renameNote,
+ deleteNote,
+ moveNote,
+ };
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
new file mode 100644
index 000000000..45654afe2
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeDnd.ts
@@ -0,0 +1,223 @@
+import { useState, useCallback, useRef } from "react";
+import { DragEndEvent, DragOverEvent, DragStartEvent } from "@dnd-kit/core";
+import { FlatNote, DropPosition, DragState } from "../types";
+
+interface UseTreeDndProps {
+ flatNodes: FlatNote[];
+ moveNote: (id: number, parentId: number | null, position: number) => Promise;
+ onTreeMutated: () => void;
+}
+
+interface ClientRect {
+ top: number;
+ height: number;
+}
+
+/** Determine before/inside/after based on pointer Y within an item rect. */
+function computeDropPosition(
+ nodeType: "folder" | "note",
+ pointerY: number | null,
+ overRect: ClientRect | null,
+ deltaY: number,
+): DropPosition {
+ if (nodeType === "folder") {
+ if (pointerY != null && overRect && overRect.height > 0) {
+ const ratio = (pointerY - overRect.top) / overRect.height;
+ if (ratio < 0.25) return "before";
+ if (ratio > 0.75) return "after";
+ return "inside";
+ }
+ return "inside";
+ }
+ if (pointerY != null && overRect && overRect.height > 0) {
+ return (pointerY - overRect.top) < overRect.height * 0.5 ? "before" : "after";
+ }
+ return deltaY > 0 ? "after" : "before";
+}
+
+/** Calculate position for sentinel (tree-top / tree-bottom) drops. */
+function sentinelPosition(sentinelId: string, rootNodes: FlatNote[]): number {
+ if (rootNodes.length === 0) return 0;
+ const sorted = [...rootNodes].sort((a, b) => a.position - b.position);
+ return sentinelId === "tree-top"
+ ? sorted[0].position - 1000
+ : sorted[sorted.length - 1].position + 1000;
+}
+
+export function useTreeDnd({ flatNodes, moveNote, onTreeMutated }: UseTreeDndProps) {
+ const [dragState, setDragState] = useState({
+ activeId: null,
+ overId: null,
+ dropPosition: null,
+ });
+ const lastOverId = useRef(null);
+
+ const isDescendant = useCallback(
+ (nodeId: number, ancestorId: number): boolean => {
+ let current = flatNodes.find((n) => n.id === nodeId);
+ while (current?.parentId !== null) {
+ if (current.parentId === ancestorId) return true;
+ current = flatNodes.find((n) => n.id === current!.parentId);
+ }
+ return false;
+ },
+ [flatNodes]
+ );
+
+ const isValidDrop = useCallback(
+ (activeId: number, overId: number, dropPosition: DropPosition): boolean => {
+ if (activeId === overId) return false;
+ const activeNode = flatNodes.find((n) => n.id === activeId);
+ if (!activeNode) return false;
+
+ if (dropPosition === "inside") {
+ const overNode = flatNodes.find((n) => n.id === overId);
+ if (!overNode || overNode.nodeType !== "folder") return false;
+ if (isDescendant(overId, activeId)) return false;
+ }
+
+ return true;
+ },
+ [flatNodes, isDescendant]
+ );
+
+ const calculateNewPosition = useCallback(
+ (overId: number, dropPosition: DropPosition, newParentId: number | null): number => {
+ const siblings = flatNodes
+ .filter((n) => n.parentId === newParentId)
+ .sort((a, b) => a.position - b.position);
+
+ if (siblings.length === 0) return 0;
+
+ const overIndex = siblings.findIndex((n) => n.id === overId);
+
+ if (dropPosition === "inside") {
+ const children = flatNodes
+ .filter((n) => n.parentId === overId)
+ .sort((a, b) => a.position - b.position);
+ if (children.length === 0) return 0;
+ return children[children.length - 1].position + 1000;
+ }
+
+ if (dropPosition === "before") {
+ if (overIndex === 0) return siblings[0].position - 1000;
+ const prevPos = siblings[overIndex - 1].position;
+ const currPos = siblings[overIndex].position;
+ return Math.floor((prevPos + currPos) / 2);
+ }
+
+ if (overIndex === siblings.length - 1) return siblings[overIndex].position + 1000;
+ const currPos = siblings[overIndex].position;
+ const nextPos = siblings[overIndex + 1].position;
+ return Math.floor((currPos + nextPos) / 2);
+ },
+ [flatNodes]
+ );
+
+ const handleDragStart = useCallback((event: DragStartEvent) => {
+ lastOverId.current = null;
+ setDragState({ activeId: event.active.id as number, overId: null, dropPosition: null });
+ }, []);
+
+ const handleDragOver = useCallback(
+ (event: DragOverEvent) => {
+ const { active, over } = event;
+
+ if (!over) {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ lastOverId.current = null;
+ return;
+ }
+
+ const activeId = active.id as number;
+ const overIdRaw = over.id;
+
+ // Handle sentinel drop zones at top/bottom of tree
+ if (overIdRaw === "tree-top" || overIdRaw === "tree-bottom") {
+ const dropPosition: DropPosition = overIdRaw === "tree-top" ? "before" : "after";
+ setDragState((prev) => ({ ...prev, overId: overIdRaw as any, dropPosition }));
+ return;
+ }
+
+ const overId = overIdRaw as number;
+
+ if (activeId === overId) {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ return;
+ }
+
+ const overNode = flatNodes.find((n) => n.id === overId);
+ if (!overNode) {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ return;
+ }
+
+ const activatorY = (event.activatorEvent as PointerEvent)?.clientY;
+ const pointerY = activatorY != null ? activatorY + event.delta.y : null;
+ let dropPosition = computeDropPosition(
+ overNode.nodeType, pointerY, over.rect, event.delta.y
+ );
+
+ if (!isValidDrop(activeId, overId, dropPosition)) {
+ if (dropPosition === "inside") {
+ dropPosition = event.delta.y > 0 ? "after" : "before";
+ if (!isValidDrop(activeId, overId, dropPosition)) {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ return;
+ }
+ } else {
+ setDragState((prev) => ({ ...prev, overId: null, dropPosition: null }));
+ return;
+ }
+ }
+
+ lastOverId.current = overId;
+ setDragState((prev) => ({ ...prev, overId, dropPosition }));
+ },
+ [flatNodes, isValidDrop]
+ );
+
+ const handleDragEnd = useCallback(
+ async (event: DragEndEvent) => {
+ const { active, over } = event;
+ const { overId, dropPosition } = dragState;
+
+ setDragState({ activeId: null, overId: null, dropPosition: null });
+ lastOverId.current = null;
+
+ if (!over || overId === null || dropPosition === null) return;
+
+ const activeId = active.id as number;
+
+ // Handle sentinel drop zones
+ let newParentId: number | null;
+ let newPosition: number;
+
+ if (overId === "tree-top" || overId === "tree-bottom") {
+ const rootNodes = flatNodes.filter((n) => n.parentId === null);
+ newParentId = null;
+ newPosition = sentinelPosition(overId as string, rootNodes);
+ } else {
+ const overNode = flatNodes.find((n) => n.id === overId);
+ if (!overNode) return;
+ newParentId = dropPosition === "inside" ? (overId as number) : overNode.parentId;
+ newPosition = calculateNewPosition(overId as number, dropPosition, newParentId);
+ }
+
+ try {
+ await moveNote(activeId, newParentId, newPosition);
+ onTreeMutated();
+ } catch (error) {
+ console.error("Failed to move note:", error);
+ }
+ },
+ [dragState, flatNodes, calculateNewPosition, moveNote, onTreeMutated]
+ );
+
+ const handleDragCancel = useCallback(() => {
+ setDragState({ activeId: null, overId: null, dropPosition: null });
+ lastOverId.current = null;
+ }, []);
+
+ return { dragState, handleDragStart, handleDragOver, handleDragEnd, handleDragCancel, calculateNewPosition };
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts
new file mode 100644
index 000000000..1ccb0a3d8
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/hooks/useTreeSync.ts
@@ -0,0 +1,196 @@
+import { useEffect, useRef, useCallback, useState } from "react";
+import {
+ HocuspocusProvider,
+ HocuspocusProviderWebsocket,
+} from "@hocuspocus/provider";
+import * as Y from "yjs";
+import type { FlatNote, NoteTreeNode } from "../types";
+
+interface UseTreeSyncOptions {
+ projectId: number;
+}
+
+/**
+ * Hook for real-time tree synchronization across clients using Yjs.
+ *
+ * Instead of broadcasting "tree-changed" messages and re-fetching from GraphQL,
+ * the tree structure is stored in a Yjs shared Y.Array. When any client modifies
+ * the tree (create/delete/rename/move), it updates both the database (via GraphQL)
+ * and the Yjs array. Other clients see changes via Yjs sync automatically.
+ */
+export function useTreeSync({ projectId }: UseTreeSyncOptions) {
+ const providerRef = useRef(null);
+ const [flatNodes, setFlatNodes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [connected, setConnected] = useState(false);
+
+ useEffect(() => {
+ const url = document.getElementById("yjs-url")?.innerHTML;
+ const jwt = document.getElementById("yjs-jwt")?.innerHTML;
+
+ if (!url || !jwt) {
+ console.warn("useTreeSync: Missing yjs-url or yjs-jwt elements");
+ setLoading(false);
+ return;
+ }
+
+ const websocketProvider = new HocuspocusProviderWebsocket({
+ url,
+ autoConnect: false,
+ });
+
+ const provider = new HocuspocusProvider({
+ websocketProvider,
+ name: `project_tree_sync/${projectId}`,
+ token: jwt,
+ onSynced() {
+ setConnected(true);
+ setLoading(false);
+ // Read initial tree from Yjs doc
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ const nodes: FlatNote[] = [];
+ for (let i = 0; i < treeArray.length; i++) {
+ nodes.push(treeArray.get(i));
+ }
+ setFlatNodes(nodes);
+ },
+ });
+
+ providerRef.current = provider;
+
+ // Observe changes to the tree array
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ const observer = () => {
+ const nodes: FlatNote[] = [];
+ for (let i = 0; i < treeArray.length; i++) {
+ nodes.push(treeArray.get(i));
+ }
+ setFlatNodes(nodes);
+ };
+ treeArray.observeDeep(observer);
+
+ provider.attach();
+ websocketProvider.connect();
+
+ return () => {
+ treeArray.unobserveDeep(observer);
+ provider.destroy();
+ providerRef.current = null;
+ };
+ }, [projectId]);
+
+ /** Replace the entire tree array in the Yjs doc with new nodes. */
+ const updateTree = useCallback(
+ (newNodes: FlatNote[]) => {
+ const provider = providerRef.current;
+ if (!provider) return;
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ provider.document.transact(() => {
+ treeArray.delete(0, treeArray.length);
+ if (newNodes.length > 0) {
+ treeArray.push(newNodes);
+ }
+ });
+ },
+ []
+ );
+
+ /** Add a single node to the tree array. */
+ const addNode = useCallback(
+ (node: FlatNote) => {
+ const provider = providerRef.current;
+ if (!provider) return;
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ treeArray.push([node]);
+ },
+ []
+ );
+
+ /** Remove a node (and optionally its descendants) from the tree array. */
+ const removeNodes = useCallback(
+ (ids: Set) => {
+ const provider = providerRef.current;
+ if (!provider) return;
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ provider.document.transact(() => {
+ // Iterate backwards to safely delete by index
+ for (let i = treeArray.length - 1; i >= 0; i--) {
+ const node = treeArray.get(i);
+ if (ids.has(node.id)) {
+ treeArray.delete(i, 1);
+ }
+ }
+ });
+ },
+ []
+ );
+
+ /** Update a node's properties in the tree array. */
+ const updateNode = useCallback(
+ (id: number, updates: Partial) => {
+ const provider = providerRef.current;
+ if (!provider) return;
+ const treeArray = provider.document.get("tree", Y.Array) as Y.Array;
+ provider.document.transact(() => {
+ for (let i = 0; i < treeArray.length; i++) {
+ const node = treeArray.get(i);
+ if (node.id === id) {
+ treeArray.delete(i, 1);
+ treeArray.insert(i, [{ ...node, ...updates }]);
+ break;
+ }
+ }
+ });
+ },
+ []
+ );
+
+ // Build tree structure from flat nodes
+ const tree = buildTree(flatNodes);
+
+ return {
+ tree,
+ flatNodes,
+ loading,
+ connected,
+ updateTree,
+ addNode,
+ removeNodes,
+ updateNode,
+ };
+}
+
+function buildTree(flatNodes: FlatNote[]): NoteTreeNode[] {
+ const nodeMap = new Map();
+ const roots: NoteTreeNode[] = [];
+
+ for (const node of flatNodes) {
+ nodeMap.set(node.id, { ...node, children: [] });
+ }
+
+ for (const node of flatNodes) {
+ const treeNode = nodeMap.get(node.id)!;
+ if (node.parentId === null) {
+ roots.push(treeNode);
+ } else {
+ const parent = nodeMap.get(node.parentId);
+ if (parent) {
+ parent.children.push(treeNode);
+ } else {
+ roots.push(treeNode);
+ }
+ }
+ }
+
+ // Sort by position then title at every level
+ const sortNodes = (a: NoteTreeNode, b: NoteTreeNode) =>
+ a.position !== b.position ? a.position - b.position : a.title.localeCompare(b.title);
+ roots.sort(sortNodes);
+ for (const node of nodeMap.values()) {
+ if (node.children.length > 1) {
+ node.children.sort(sortNodes);
+ }
+ }
+
+ return roots;
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx b/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx
new file mode 100644
index 000000000..2592e6e69
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/index.tsx
@@ -0,0 +1,86 @@
+import ReactModal from "react-modal";
+import { createRoot, Root } from "react-dom/client";
+import { useState } from "react";
+import ErrorBoundary from "../../error_boundary";
+import NoteTreeView from "./NoteTreeView";
+import NoteEditor from "./NoteEditor";
+
+function ProjectCollabNotesContainer() {
+ const [selectedNoteId, setSelectedNoteId] = useState(null);
+
+ const projectId = parseInt(
+ document.getElementById("yjs-object-id")!.innerHTML
+ );
+
+ return (
+
+
+
+
+
+
+ {selectedNoteId ? (
+
+
+
+ ) : (
+
+
+
+
Select a note to edit, or create a new one.
+
+
+ )}
+
+
+ );
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ ReactModal.setAppElement(
+ document.querySelector("div.wrapper") as HTMLElement
+ );
+
+ const $ = (window as any).$;
+ let root: Root | null = null;
+
+ $("#id_collab_notes").on("shown.bs.tab", () => {
+ if (root !== null) return;
+ root = createRoot(document.getElementById("collab_notes_container")!);
+ root.render(
+
+
+
+ );
+ });
+
+ $("#id_collab_notes").on("hidden.bs.tab", () => {
+ if (root !== null) root.unmount();
+ root = null;
+ });
+});
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css b/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css
new file mode 100644
index 000000000..54e6e6416
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/tree.css
@@ -0,0 +1,138 @@
+.tree-item-dragging {
+ visibility: hidden;
+}
+
+.tree-item-container {
+ position: relative;
+}
+
+.tree-item-drop-before::before {
+ content: "";
+ position: absolute;
+ top: -1px;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background-color: #0d6efd;
+ border-radius: 2px;
+ z-index: 10;
+ box-shadow: 0 0 0 1px rgba(13, 110, 253, 0.3);
+}
+
+.tree-item-drop-after::after {
+ content: "";
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background-color: #0d6efd;
+ border-radius: 2px;
+ z-index: 10;
+ box-shadow: 0 0 0 1px rgba(13, 110, 253, 0.3);
+}
+
+.tree-item-drop-inside > .tree-item {
+ background-color: #cfe2ff;
+ border-radius: 4px;
+ outline: 2px solid #0d6efd;
+ outline-offset: -2px;
+}
+
+.tree-drag-overlay {
+ background: white;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ border-radius: 4px;
+ padding: 2px;
+ opacity: 0.9;
+}
+
+.tree-item {
+ cursor: grab;
+}
+
+.tree-item:active {
+ cursor: grabbing;
+}
+
+/* Image field placeholder - addresses PR comment #16 and #27 (use CSS not inline styles) */
+.image-field-placeholder {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ min-height: 150px;
+ border: 2px dashed var(--bs-border-color);
+ border-radius: 8px;
+ background-color: var(--bs-tertiary-bg);
+ cursor: pointer;
+ transition: all 0.2s ease;
+ outline: none;
+}
+
+.image-field-placeholder:focus,
+.image-field-placeholder.drag-active {
+ border-color: var(--bs-primary);
+ background-color: var(--bs-primary-bg-subtle);
+}
+
+.image-field-placeholder.uploading {
+ cursor: wait;
+}
+
+.image-field-placeholder__icon {
+ font-size: 32px;
+ color: var(--bs-secondary-color);
+ margin-bottom: 12px;
+}
+
+.image-field-placeholder.drag-active .image-field-placeholder__icon {
+ color: var(--bs-primary);
+}
+
+.image-field-placeholder__text {
+ color: var(--bs-secondary-color);
+ text-align: center;
+}
+
+.image-field-placeholder__hint {
+ font-size: 12px;
+ color: var(--bs-secondary-color);
+ margin-top: 4px;
+}
+
+/* Delete confirm modal - addresses PR comment #13 and #26 (use CSS not inline styles) */
+.collab-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1050;
+}
+
+.collab-modal-content {
+ position: relative;
+ inset: auto;
+ border: none;
+ background: none;
+ padding: 0;
+ max-width: 500px;
+ width: 100%;
+}
+
+/* Add field toolbar */
+.add-field-toolbar {
+ display: flex;
+ gap: 8px;
+ padding: 12px;
+ border-top: 1px solid var(--bs-border-color);
+ border-bottom: 1px solid var(--bs-border-color);
+ margin-bottom: 16px;
+ background-color: var(--bs-tertiary-bg);
+}
diff --git a/javascript/src/frontend/collab_forms/forms/project_collabnotes/types.ts b/javascript/src/frontend/collab_forms/forms/project_collabnotes/types.ts
new file mode 100644
index 000000000..cb9f74218
--- /dev/null
+++ b/javascript/src/frontend/collab_forms/forms/project_collabnotes/types.ts
@@ -0,0 +1,32 @@
+export interface NoteTreeNode {
+ id: number;
+ title: string;
+ nodeType: "folder" | "note";
+ parentId: number | null;
+ position: number;
+ children: NoteTreeNode[];
+}
+
+export interface FlatNote {
+ id: number;
+ title: string;
+ nodeType: "folder" | "note";
+ parentId: number | null;
+ position: number;
+}
+
+export type DropPosition = "before" | "after" | "inside";
+
+export interface DragState {
+ activeId: number | null;
+ overId: number | null;
+ dropPosition: DropPosition | null;
+}
+
+export interface NoteField {
+ id: string;
+ fieldType: "rich_text" | "image";
+ content?: string;
+ image?: string | null;
+ position: number;
+}
diff --git a/javascript/vite.config.frontend.ts b/javascript/vite.config.frontend.ts
index 1f1529ed8..975ec783c 100644
--- a/javascript/vite.config.frontend.ts
+++ b/javascript/vite.config.frontend.ts
@@ -19,7 +19,7 @@ export default defineConfig(({ mode }) => {
collab_forms_report_field:
"./src/frontend/collab_forms/forms/report_field.tsx",
collab_forms_project_collabnote:
- "./src/frontend/collab_forms/forms/project_collabnote.tsx",
+ "./src/frontend/collab_forms/forms/project_collabnotes/index.tsx",
admin_tiptap: "./src/frontend/admin_tiptap.tsx",
},
output: {
diff --git a/requirements/base.txt b/requirements/base.txt
index 2620c73c3..dd0b8c028 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -49,3 +49,4 @@ django-taggit==6.1.0
croniter==3.0.3
cvss==3.2
markdown==3.9
+markdownify==0.14.1
diff --git a/scripts/seed_test_data.py b/scripts/seed_test_data.py
new file mode 100644
index 000000000..f554bb34c
--- /dev/null
+++ b/scripts/seed_test_data.py
@@ -0,0 +1,255 @@
+"""
+Seed script for populating Ghostwriter with test data.
+
+Run via: docker compose -f local.yml exec django python manage.py shell < scripts/seed_test_data.py
+"""
+
+from datetime import date, timedelta
+from django.contrib.auth import get_user_model
+from ghostwriter.rolodex.models import (
+ Client,
+ ClientContact,
+ ClientInvite,
+ Project,
+ ProjectAssignment,
+ ProjectCollabNote,
+ ProjectCollabNoteField,
+ ProjectInvite,
+ ProjectScope,
+ ProjectTarget,
+ ProjectType,
+ ProjectRole,
+)
+
+User = get_user_model()
+
+
+def create_notes_tree(nodes, project, parent=None, pos_start=0):
+ """Recursively create a tree of collab notes from a list of dicts."""
+ for idx, node in enumerate(nodes):
+ position = (pos_start + idx) * 1000
+ note, _ = ProjectCollabNote.objects.get_or_create(
+ project=project,
+ title=node["title"],
+ parent=parent,
+ defaults={
+ "node_type": node["type"],
+ "content": node.get("content", ""),
+ "position": position,
+ },
+ )
+ if node["type"] == "note" and node.get("content"):
+ ProjectCollabNoteField.objects.get_or_create(
+ note=note,
+ position=0,
+ defaults={
+ "field_type": "rich_text",
+ "content": node["content"],
+ },
+ )
+ if "children" in node:
+ create_notes_tree(node["children"], project, parent=note)
+
+
+def seed_users():
+ """Create test users and return a dict of username -> User."""
+ users = {}
+ user_specs = [
+ ("alice", "alice@example.com", "Alice Rivera"),
+ ("bob", "bob@example.com", "Bob Chen"),
+ ("charlie", "charlie@example.com", "Charlie Okafor"),
+ ]
+ for username, email, name in user_specs:
+ user, created = User.objects.get_or_create(
+ username=username,
+ defaults={"email": email, "name": name, "role": "user"},
+ )
+ if created:
+ user.set_password("TestPass123!")
+ user.save()
+ print(f" Created user: {username} / TestPass123!")
+ else:
+ print(f" User already exists: {username}")
+ users[username] = user
+
+ mgr, created = User.objects.get_or_create(
+ username="manager",
+ defaults={"email": "mgr@example.com", "name": "Morgan Taylor", "role": "manager"},
+ )
+ if created:
+ mgr.set_password("TestPass123!")
+ mgr.save()
+ print(" Created manager: manager / TestPass123!")
+ else:
+ print(" Manager already exists: manager")
+ users["manager"] = mgr
+ return users
+
+
+def seed_clients(users):
+ """Create test clients, projects, assignments, and collab notes."""
+ pt_pentest = ProjectType.objects.get(project_type="Penetration Test")
+ pt_webapp = ProjectType.objects.get(project_type="Web Application Assessment")
+ pt_redteam = ProjectType.objects.get(project_type="Red Team")
+
+ role_lead = ProjectRole.objects.get(project_role="Assessment Lead")
+ role_operator = ProjectRole.objects.get(project_role="Operator")
+
+ clients_data = [
+ {
+ "name": "Acme Corporation",
+ "short_name": "ACME",
+ "codename": "THUNDERBOLT",
+ "contacts": [
+ ("Jane Doe", "jane.doe@acme.example.com", "CISO", "555-0101"),
+ ("John Smith", "john.smith@acme.example.com", "IT Director", "555-0102"),
+ ],
+ "projects": [
+ {
+ "codename": "IRON FALCON",
+ "project_type": pt_pentest,
+ "lead": "alice",
+ "operators": ["bob"],
+ "start": date.today() - timedelta(days=14),
+ "end": date.today() + timedelta(days=16),
+ "scopes": ["10.10.0.0/16", "web.acme.example.com", "api.acme.example.com"],
+ "targets": ["Domain Controller (DC01)", "Exchange Server", "VPN Gateway"],
+ "notes_tree": [
+ {"title": "Reconnaissance", "type": "folder", "children": [
+ {"title": "External Recon", "type": "note", "content": "Ran subdomain enumeration against acme.example.com. Found 14 subdomains.
"},
+ {"title": "OSINT Findings", "type": "note", "content": "LinkedIn reveals 3 IT admins. Password policy doc leaked on Pastebin (expired).
"},
+ ]},
+ {"title": "Exploitation", "type": "folder", "children": [
+ {"title": "Initial Access", "type": "note", "content": "Phishing payload delivered via macro-enabled doc. User jane.doe clicked.
"},
+ {"title": "Lateral Movement", "type": "note", "content": "Used Pass-the-Hash from workstation WS-042 to reach DC01.
"},
+ ]},
+ {"title": "Meeting Notes", "type": "note", "content": "Kickoff call 2026-03-17. Client wants focus on AD attack paths.
"},
+ ],
+ },
+ ],
+ },
+ {
+ "name": "Globex Industries",
+ "short_name": "GLOBEX",
+ "codename": "NIGHTSHADE",
+ "contacts": [
+ ("Hank Scorpio", "hank@globex.example.com", "CEO", "555-0200"),
+ ],
+ "projects": [
+ {
+ "codename": "SHADOW NEXUS",
+ "project_type": pt_webapp,
+ "lead": "bob",
+ "operators": ["charlie"],
+ "start": date.today() - timedelta(days=7),
+ "end": date.today() + timedelta(days=23),
+ "scopes": ["https://portal.globex.example.com", "https://api.globex.example.com/v2"],
+ "targets": ["Customer Portal", "REST API v2", "Admin Panel"],
+ "notes_tree": [
+ {"title": "API Testing", "type": "folder", "children": [
+ {"title": "Authentication Bypass", "type": "note", "content": "JWT validation can be bypassed by setting alg=none. Critical finding.
"},
+ {"title": "IDOR on /users endpoint", "type": "note", "content": "Changing user_id in request allows access to other users' data.
"},
+ ]},
+ {"title": "Portal XSS", "type": "note", "content": "Stored XSS in profile bio field. Payload: <img src=x onerror=alert(1)>
"},
+ ],
+ },
+ ],
+ },
+ {
+ "name": "Initech",
+ "short_name": "INIT",
+ "codename": "REDSTAPLER",
+ "contacts": [
+ ("Bill Lumbergh", "bill@initech.example.com", "VP", "555-0300"),
+ ("Milton Waddams", "milton@initech.example.com", "Facilities", "555-0301"),
+ ],
+ "projects": [
+ {
+ "codename": "CRIMSON TIDE",
+ "project_type": pt_redteam,
+ "lead": "charlie",
+ "operators": ["alice", "bob"],
+ "start": date.today(),
+ "end": date.today() + timedelta(days=30),
+ "scopes": ["*.initech.example.com", "10.20.0.0/16", "Physical: HQ Building"],
+ "targets": ["CEO Laptop", "Financial Database", "Badge System"],
+ "notes_tree": [
+ {"title": "Planning", "type": "folder", "children": [
+ {"title": "Rules of Engagement", "type": "note", "content": "No DoS. No production DB modification. Physical access authorized M-F 8am-6pm only.
"},
+ {"title": "C2 Infrastructure", "type": "note", "content": "Cobalt Strike teamserver on AWS. Redirector chain: CloudFront → Nginx → TS.
"},
+ ]},
+ {"title": "Execution Log", "type": "folder", "children": []},
+ ],
+ },
+ ],
+ },
+ ]
+
+ for cdata in clients_data:
+ client, created = Client.objects.get_or_create(
+ name=cdata["name"],
+ defaults={"short_name": cdata["short_name"], "codename": cdata["codename"]},
+ )
+ print(f"\n{'Created' if created else 'Exists'} client: {client.name}")
+
+ for cname, cemail, ctitle, cphone in cdata["contacts"]:
+ ClientContact.objects.get_or_create(
+ client=client,
+ email=cemail,
+ defaults={"name": cname, "job_title": ctitle, "phone": cphone},
+ )
+
+ for user in users.values():
+ ClientInvite.objects.get_or_create(client=client, user=user)
+
+ for pdata in cdata["projects"]:
+ proj, proj_created = Project.objects.get_or_create(
+ codename=pdata["codename"],
+ defaults={
+ "client": client,
+ "project_type": pdata["project_type"],
+ "start_date": pdata["start"],
+ "end_date": pdata["end"],
+ "complete": False,
+ },
+ )
+ print(f" {'Created' if proj_created else 'Exists'} project: {proj.codename}")
+
+ lead_user = users[pdata["lead"]]
+ ProjectAssignment.objects.get_or_create(
+ project=proj, operator=lead_user,
+ defaults={"role": role_lead, "start_date": pdata["start"], "end_date": pdata["end"]},
+ )
+ for op_name in pdata["operators"]:
+ ProjectAssignment.objects.get_or_create(
+ project=proj, operator=users[op_name],
+ defaults={"role": role_operator, "start_date": pdata["start"], "end_date": pdata["end"]},
+ )
+
+ ProjectInvite.objects.get_or_create(project=proj, user=users["manager"])
+
+ for scope_name in pdata["scopes"]:
+ ProjectScope.objects.get_or_create(
+ project=proj, name=scope_name,
+ defaults={"scope": scope_name},
+ )
+
+ for target_name in pdata["targets"]:
+ ProjectTarget.objects.get_or_create(project=proj, hostname=target_name)
+
+ if proj_created:
+ create_notes_tree(pdata["notes_tree"], proj)
+
+
+users = seed_users()
+seed_clients(users)
+
+print("\n" + "=" * 60)
+print("SEED DATA COMPLETE")
+print("=" * 60)
+print(f"Users: {User.objects.count()} (login with TestPass123!)")
+print(f"Clients: {Client.objects.count()}")
+print(f"Projects: {Project.objects.count()}")
+print(f"Collab Notes: {ProjectCollabNote.objects.count()}")
+print(f"Collab Note Fields: {ProjectCollabNoteField.objects.count()}")
+print("=" * 60)