diff --git a/backend/app/database.py b/backend/app/database.py
index 86ca965..2426afb 100644
--- a/backend/app/database.py
+++ b/backend/app/database.py
@@ -5,6 +5,7 @@ from pathlib import Path
def init_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
@@ -14,6 +15,19 @@ def init_db(db_path: Path) -> None:
value TEXT NOT NULL
)
""")
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS pages (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ slug TEXT NOT NULL UNIQUE,
+ sort_order INTEGER NOT NULL,
+ is_system INTEGER NOT NULL DEFAULT 0,
+ is_hidden INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_pages_order ON pages(sort_order)")
conn.execute("""
CREATE TABLE IF NOT EXISTS segments (
id TEXT PRIMARY KEY,
@@ -27,11 +41,106 @@ def init_db(db_path: Path) -> None:
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_segments_order ON segments(sort_order)")
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS team_members (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ student_number TEXT NOT NULL,
+ sort_order INTEGER NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """)
+
+ # Migrate existing table schemas (add columns if missing)
+ _migrate_columns(conn)
+
+ # Remove legacy segment types
+ conn.execute("DELETE FROM segments WHERE type IN ('link', 'team')")
+
conn.commit()
+
+ # Ensure default pages exist
+ _migrate_default_page(conn)
+ _ensure_team_page(conn)
finally:
conn.close()
+def _migrate_columns(conn: sqlite3.Connection) -> None:
+ """Add any missing columns to existing tables."""
+ seg_cols = [row["name"] for row in conn.execute("PRAGMA table_info(segments)").fetchall()]
+ if "page_id" not in seg_cols:
+ conn.execute("ALTER TABLE segments ADD COLUMN page_id TEXT REFERENCES pages(id)")
+
+ page_cols = [row["name"] for row in conn.execute("PRAGMA table_info(pages)").fetchall()]
+ if "is_system" not in page_cols:
+ conn.execute("ALTER TABLE pages ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0")
+ if "is_hidden" not in page_cols:
+ conn.execute("ALTER TABLE pages ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0")
+
+
+def _migrate_default_page(conn: sqlite3.Connection) -> None:
+ """Ensure a Home page exists and all segments have a page_id."""
+ from datetime import UTC, datetime
+ from uuid import uuid4
+
+ home_id: str | None = None
+ home = conn.execute("SELECT id FROM pages WHERE slug = 'home'").fetchone()
+ if not home:
+ # Only create Home if no pages exist at all
+ page_count = conn.execute(
+ "SELECT COUNT(*) AS cnt FROM pages WHERE is_system = 0"
+ ).fetchone()["cnt"]
+ if page_count == 0:
+ now = datetime.now(UTC).isoformat()
+ default_id = str(uuid4())
+ conn.execute(
+ "INSERT INTO pages"
+ " (id, name, slug, sort_order, is_system, is_hidden, created_at, updated_at)"
+ " VALUES (?, ?, ?, ?, 1, 0, ?, ?)",
+ (default_id, "Home", "home", 0, now, now),
+ )
+ conn.commit()
+ home_id = default_id
+ else:
+ first = conn.execute(
+ "SELECT id FROM pages WHERE is_system = 0 ORDER BY sort_order ASC LIMIT 1"
+ ).fetchone()
+ home_id = first["id"] if first else None
+ else:
+ home_id = home["id"]
+
+ if home_id:
+ conn.execute("UPDATE segments SET page_id = ? WHERE page_id IS NULL", (home_id,))
+ conn.commit()
+
+ conn.execute("UPDATE pages SET is_system = 1 WHERE slug = 'home'")
+ conn.commit()
+
+
+def _ensure_team_page(conn: sqlite3.Connection) -> None:
+ """Ensure the system Team page exists (always pinned last)."""
+ from datetime import UTC, datetime
+ from uuid import uuid4
+
+ existing = conn.execute("SELECT id FROM pages WHERE slug = 'team'").fetchone()
+ if existing:
+ # Make sure it's marked as system
+ conn.execute("UPDATE pages SET is_system = 1 WHERE slug = 'team'")
+ conn.commit()
+ return
+
+ now = datetime.now(UTC).isoformat()
+ conn.execute(
+ "INSERT INTO pages"
+ " (id, name, slug, sort_order, is_system, is_hidden, created_at, updated_at)"
+ " VALUES (?, ?, ?, ?, 1, 0, ?, ?)",
+ (str(uuid4()), "Team", "team", 9999, now, now),
+ )
+ conn.commit()
+
+
def get_connection(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
diff --git a/backend/app/main.py b/backend/app/main.py
index 4bb9d75..5e99f6b 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -9,8 +9,10 @@ from app.config import get_settings
from app.database import init_db
from app.routes.assets import router as assets_router
from app.routes.auth import router as auth_router
+from app.routes.pages import router as pages_router
from app.routes.segments import router as segments_router
from app.routes.site import router as site_router
+from app.routes.team import router as team_router
@asynccontextmanager
@@ -25,9 +27,11 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(title="Handin Website", lifespan=lifespan)
app.include_router(auth_router)
+app.include_router(pages_router)
app.include_router(segments_router)
app.include_router(site_router)
app.include_router(assets_router)
+app.include_router(team_router)
@app.get("/api/health")
diff --git a/backend/app/models.py b/backend/app/models.py
index 32299fd..43aa10d 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -12,7 +12,7 @@ class SegmentType(StrEnum):
AUDIO = "audio"
IFRAME = "iframe"
GALLERY = "gallery"
- LINK = "link"
+ TEAM = "team"
class Segment(BaseModel):
@@ -22,13 +22,15 @@ class Segment(BaseModel):
title: str
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
+ page_id: str | None = None
created_at: datetime
updated_at: datetime
class SegmentCreateRequest(BaseModel):
type: SegmentType
- title: str
+ title: str = ""
+ page_id: str | None = None
content: str = ""
metadata: dict[str, object] = Field(default_factory=dict)
@@ -54,9 +56,54 @@ class SegmentResponse(BaseModel):
title: str
content: str
metadata: dict[str, object]
+ page_id: str | None = None
created_at: datetime
updated_at: datetime
class SiteResponse(BaseModel):
title: str
+
+
+class Page(BaseModel):
+ id: str
+ name: str
+ slug: str
+ sort_order: int
+ is_system: bool = False
+ is_hidden: bool = False
+ created_at: datetime
+ updated_at: datetime
+
+
+class PageCreateRequest(BaseModel):
+ name: str
+ slug: str
+
+
+class PageUpdateRequest(BaseModel):
+ name: str | None = None
+ slug: str | None = None
+ is_hidden: bool | None = None
+
+
+class PageReorderRequest(BaseModel):
+ page_ids: list[str]
+
+
+class TeamMember(BaseModel):
+ id: str
+ name: str
+ student_number: str
+ sort_order: int
+ created_at: datetime
+ updated_at: datetime
+
+
+class TeamMemberCreateRequest(BaseModel):
+ name: str
+ student_number: str
+
+
+class TeamMemberReorderRequest(BaseModel):
+ member_ids: list[str]
diff --git a/backend/app/routes/pages.py b/backend/app/routes/pages.py
new file mode 100644
index 0000000..6e45a65
--- /dev/null
+++ b/backend/app/routes/pages.py
@@ -0,0 +1,104 @@
+import threading
+from pathlib import Path
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, Response
+
+from app.auth import require_admin
+from app.config import get_settings
+from app.database import init_db
+from app.models import (
+ Page,
+ PageCreateRequest,
+ PageReorderRequest,
+ PageUpdateRequest,
+ SegmentResponse,
+)
+from app.services import page_service, segment_service
+
+router = APIRouter(prefix="/api/pages", tags=["pages"])
+
+_write_lock = threading.Lock()
+
+
+def get_db_path() -> Path:
+ data_dir = get_settings().data_dir
+ db_path = Path(data_dir) / "handin.db"
+ init_db(db_path)
+ return db_path
+
+
+DbPath = Annotated[Path, Depends(get_db_path)]
+Admin = Annotated[None, Depends(require_admin)]
+
+
+@router.get("/")
+def list_pages(db_path: DbPath) -> list[Page]:
+ return page_service.list_pages(db_path)
+
+
+@router.post("/", status_code=201)
+def create_page(request: PageCreateRequest, _: Admin, db_path: DbPath) -> Page:
+ with _write_lock:
+ # Check slug uniqueness
+ existing = page_service.get_page_by_slug(db_path, request.slug)
+ if existing:
+ raise HTTPException(status_code=409, detail="A page with this slug already exists")
+ return page_service.create_page(db_path, request)
+
+
+@router.put("/reorder")
+def reorder_pages(request: PageReorderRequest, _: Admin, db_path: DbPath) -> list[Page]:
+ with _write_lock:
+ try:
+ return page_service.reorder_pages(db_path, request.page_ids)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+
+
+@router.get("/{page_id}")
+def get_page(page_id: str, db_path: DbPath) -> Page:
+ page = page_service.get_page(db_path, page_id)
+ if page is None:
+ raise HTTPException(status_code=404, detail="Page not found")
+ return page
+
+
+@router.get("/by-slug/{slug}")
+def get_page_by_slug(slug: str, db_path: DbPath) -> Page:
+ page = page_service.get_page_by_slug(db_path, slug)
+ if page is None:
+ raise HTTPException(status_code=404, detail="Page not found")
+ return page
+
+
+@router.patch("/{page_id}")
+def update_page(page_id: str, request: PageUpdateRequest, _: Admin, db_path: DbPath) -> Page:
+ if request.slug is not None:
+ existing = page_service.get_page_by_slug(db_path, request.slug)
+ if existing and existing.id != page_id:
+ raise HTTPException(status_code=409, detail="A page with this slug already exists")
+ page = page_service.update_page(db_path, page_id, request)
+ if page is None:
+ raise HTTPException(status_code=404, detail="Page not found")
+ return page
+
+
+@router.delete("/{page_id}", status_code=204)
+def delete_page(page_id: str, _: Admin, db_path: DbPath) -> Response:
+ deleted, error = page_service.delete_page(db_path, page_id)
+ if not deleted:
+ if error == "not_found":
+ raise HTTPException(status_code=404, detail="Page not found")
+ if error == "system_page":
+ raise HTTPException(status_code=403, detail="System pages cannot be deleted")
+ return Response(status_code=204)
+
+
+@router.get("/{page_id}/segments")
+def list_page_segments(page_id: str, db_path: DbPath) -> list[SegmentResponse]:
+ page = page_service.get_page(db_path, page_id)
+ if page is None:
+ raise HTTPException(status_code=404, detail="Page not found")
+ segments = segment_service.list_segments(db_path, page_id=page_id)
+ return [SegmentResponse.model_validate(s.model_dump()) for s in segments]
diff --git a/backend/app/routes/segments.py b/backend/app/routes/segments.py
index e2a150b..eefcdac 100644
--- a/backend/app/routes/segments.py
+++ b/backend/app/routes/segments.py
@@ -3,7 +3,7 @@ from pathlib import Path
from typing import Annotated
from uuid import UUID
-from fastapi import APIRouter, Depends, HTTPException, Response
+from fastapi import APIRouter, Depends, HTTPException, Query, Response
from app.auth import require_admin
from app.config import get_settings
@@ -34,8 +34,11 @@ Admin = Annotated[None, Depends(require_admin)]
@router.get("/")
-def list_segments(db_path: DbPath) -> list[SegmentResponse]:
- segments = segment_service.list_segments(db_path)
+def list_segments(
+ db_path: DbPath,
+ page_id: Annotated[str | None, Query()] = None,
+) -> list[SegmentResponse]:
+ segments = segment_service.list_segments(db_path, page_id=page_id)
return [SegmentResponse.model_validate(s.model_dump()) for s in segments]
diff --git a/backend/app/routes/team.py b/backend/app/routes/team.py
new file mode 100644
index 0000000..197c7ac
--- /dev/null
+++ b/backend/app/routes/team.py
@@ -0,0 +1,56 @@
+import threading
+from pathlib import Path
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, Response
+
+from app.auth import require_admin
+from app.config import get_settings
+from app.database import init_db
+from app.models import TeamMember, TeamMemberCreateRequest, TeamMemberReorderRequest
+from app.services import team_service
+
+router = APIRouter(prefix="/api/team", tags=["team"])
+
+_write_lock = threading.Lock()
+
+
+def get_db_path() -> Path:
+ data_dir = get_settings().data_dir
+ db_path = Path(data_dir) / "handin.db"
+ init_db(db_path)
+ return db_path
+
+
+DbPath = Annotated[Path, Depends(get_db_path)]
+Admin = Annotated[None, Depends(require_admin)]
+
+
+@router.get("/")
+def list_members(db_path: DbPath) -> list[TeamMember]:
+ return team_service.list_members(db_path)
+
+
+@router.post("/", status_code=201)
+def add_member(request: TeamMemberCreateRequest, _: Admin, db_path: DbPath) -> TeamMember:
+ with _write_lock:
+ return team_service.add_member(db_path, request)
+
+
+@router.put("/reorder")
+def reorder_members(
+ request: TeamMemberReorderRequest, _: Admin, db_path: DbPath
+) -> list[TeamMember]:
+ with _write_lock:
+ try:
+ return team_service.reorder_members(db_path, request.member_ids)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+
+
+@router.delete("/{member_id}", status_code=204)
+def delete_member(member_id: str, _: Admin, db_path: DbPath) -> Response:
+ deleted = team_service.delete_member(db_path, member_id)
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Team member not found")
+ return Response(status_code=204)
diff --git a/backend/app/services/page_service.py b/backend/app/services/page_service.py
new file mode 100644
index 0000000..4df4afb
--- /dev/null
+++ b/backend/app/services/page_service.py
@@ -0,0 +1,149 @@
+import sqlite3
+from datetime import UTC, datetime
+from pathlib import Path
+from uuid import uuid4
+
+from app.database import get_connection
+from app.models import Page, PageCreateRequest, PageUpdateRequest
+
+
+def _row_to_page(row: sqlite3.Row) -> Page:
+ return Page(
+ id=row["id"],
+ name=row["name"],
+ slug=row["slug"],
+ sort_order=row["sort_order"],
+ is_system=bool(row["is_system"]),
+ is_hidden=bool(row["is_hidden"]),
+ created_at=datetime.fromisoformat(row["created_at"]),
+ updated_at=datetime.fromisoformat(row["updated_at"]),
+ )
+
+
+def list_pages(db_path: Path) -> list[Page]:
+ conn = get_connection(db_path)
+ try:
+ rows = conn.execute(
+ "SELECT * FROM pages ORDER BY is_system ASC, sort_order ASC"
+ ).fetchall()
+ return [_row_to_page(row) for row in rows]
+ finally:
+ conn.close()
+
+
+def get_page(db_path: Path, page_id: str) -> Page | None:
+ conn = get_connection(db_path)
+ try:
+ row = conn.execute("SELECT * FROM pages WHERE id = ?", (page_id,)).fetchone()
+ return _row_to_page(row) if row else None
+ finally:
+ conn.close()
+
+
+def get_page_by_slug(db_path: Path, slug: str) -> Page | None:
+ conn = get_connection(db_path)
+ try:
+ row = conn.execute("SELECT * FROM pages WHERE slug = ?", (slug,)).fetchone()
+ return _row_to_page(row) if row else None
+ finally:
+ conn.close()
+
+
+def create_page(db_path: Path, request: PageCreateRequest) -> Page:
+ conn = get_connection(db_path)
+ try:
+ row = conn.execute(
+ "SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM pages WHERE is_system = 0"
+ ).fetchone()
+ sort_order: int = row["next_order"] if row else 0
+
+ page_id = str(uuid4())
+ now = datetime.now(UTC).isoformat()
+
+ conn.execute(
+ "INSERT INTO pages"
+ " (id, name, slug, sort_order, is_system, is_hidden, created_at, updated_at)"
+ " VALUES (?, ?, ?, ?, 0, 0, ?, ?)",
+ (page_id, request.name, request.slug, sort_order, now, now),
+ )
+ conn.commit()
+
+ return Page(
+ id=page_id,
+ name=request.name,
+ slug=request.slug,
+ sort_order=sort_order,
+ is_system=False,
+ is_hidden=False,
+ created_at=datetime.fromisoformat(now),
+ updated_at=datetime.fromisoformat(now),
+ )
+ finally:
+ conn.close()
+
+
+def update_page(db_path: Path, page_id: str, request: PageUpdateRequest) -> Page | None:
+ conn = get_connection(db_path)
+ try:
+ existing = conn.execute("SELECT * FROM pages WHERE id = ?", (page_id,)).fetchone()
+ if existing is None:
+ return None
+
+ now = datetime.now(UTC).isoformat()
+ name = request.name if request.name is not None else existing["name"]
+ slug = request.slug if request.slug is not None else existing["slug"]
+ is_hidden = (
+ int(request.is_hidden) if request.is_hidden is not None else existing["is_hidden"]
+ )
+
+ conn.execute(
+ "UPDATE pages SET name = ?, slug = ?, is_hidden = ?, updated_at = ? WHERE id = ?",
+ (name, slug, is_hidden, now, page_id),
+ )
+ conn.commit()
+ return get_page(db_path, page_id)
+ finally:
+ conn.close()
+
+
+def delete_page(db_path: Path, page_id: str) -> tuple[bool, str | None]:
+ """Cascade-deletes the page and all its segments."""
+ conn = get_connection(db_path)
+ try:
+ existing = conn.execute("SELECT * FROM pages WHERE id = ?", (page_id,)).fetchone()
+ if existing is None:
+ return False, "not_found"
+ if existing["is_system"]:
+ return False, "system_page"
+
+ conn.execute("DELETE FROM segments WHERE page_id = ?", (page_id,))
+ conn.execute("DELETE FROM pages WHERE id = ?", (page_id,))
+ conn.commit()
+ return True, None
+ finally:
+ conn.close()
+
+
+def reorder_pages(db_path: Path, page_ids: list[str]) -> list[Page]:
+ """Reorders only non-system pages."""
+ conn = get_connection(db_path)
+ try:
+ existing_ids = {
+ row["id"]
+ for row in conn.execute("SELECT id FROM pages WHERE is_system = 0").fetchall()
+ }
+ requested_ids = set(page_ids)
+ missing = requested_ids - existing_ids
+ if missing:
+ raise ValueError(f"Page IDs not found: {missing}")
+
+ now = datetime.now(UTC).isoformat()
+ for index, pid in enumerate(page_ids):
+ conn.execute(
+ "UPDATE pages SET sort_order = ?, updated_at = ? WHERE id = ?",
+ (index, now, pid),
+ )
+ conn.commit()
+ return list_pages(db_path)
+ finally:
+ conn.close()
diff --git a/backend/app/services/segment_service.py b/backend/app/services/segment_service.py
index dae3cca..0e8c985 100644
--- a/backend/app/services/segment_service.py
+++ b/backend/app/services/segment_service.py
@@ -16,6 +16,7 @@ def _row_to_segment(row: sqlite3.Row) -> Segment:
title=row["title"],
content=row["content"],
metadata=json.loads(row["metadata"]),
+ page_id=dict(row).get("page_id"),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
@@ -25,7 +26,9 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
conn = get_connection(db_path)
try:
row = conn.execute(
- "SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM segments"
+ "SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order"
+ " FROM segments WHERE page_id = ?",
+ (request.page_id,),
).fetchone()
sort_order: int = row["next_order"] if row else 0
@@ -33,9 +36,9 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
now = datetime.now(UTC).isoformat()
metadata_json = json.dumps(request.metadata)
- cols = "id, type, sort_order, title, content, metadata, created_at, updated_at"
+ cols = "id, type, sort_order, title, content, metadata, page_id, created_at, updated_at"
conn.execute(
- f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ f"INSERT INTO segments ({cols}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
str(segment_id),
request.type.value,
@@ -43,6 +46,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
request.title,
request.content,
metadata_json,
+ request.page_id,
now,
now,
),
@@ -56,6 +60,7 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
title=request.title,
content=request.content,
metadata=request.metadata,
+ page_id=request.page_id,
created_at=datetime.fromisoformat(now),
updated_at=datetime.fromisoformat(now),
)
@@ -63,10 +68,16 @@ def create_segment(db_path: Path, request: SegmentCreateRequest) -> Segment:
conn.close()
-def list_segments(db_path: Path) -> list[Segment]:
+def list_segments(db_path: Path, page_id: str | None = None) -> list[Segment]:
conn = get_connection(db_path)
try:
- rows = conn.execute("SELECT * FROM segments ORDER BY sort_order ASC").fetchall()
+ if page_id is not None:
+ rows = conn.execute(
+ "SELECT * FROM segments WHERE page_id = ? ORDER BY sort_order ASC",
+ (page_id,),
+ ).fetchall()
+ else:
+ rows = conn.execute("SELECT * FROM segments ORDER BY sort_order ASC").fetchall()
return [_row_to_segment(row) for row in rows]
finally:
conn.close()
@@ -140,6 +151,13 @@ def reorder_segments(db_path: Path, segment_ids: list[UUID]) -> list[Segment]:
)
conn.commit()
- return list_segments(db_path)
+ # Return segments scoped to the same page as the first segment in the list
+ if segment_ids:
+ row = conn.execute(
+ "SELECT page_id FROM segments WHERE id = ?", (str(segment_ids[0]),)
+ ).fetchone()
+ page_id = row["page_id"] if row else None
+ return list_segments(db_path, page_id=page_id)
+ return []
finally:
conn.close()
diff --git a/backend/app/services/team_service.py b/backend/app/services/team_service.py
new file mode 100644
index 0000000..e4d8f30
--- /dev/null
+++ b/backend/app/services/team_service.py
@@ -0,0 +1,91 @@
+import sqlite3
+from datetime import UTC, datetime
+from pathlib import Path
+from uuid import uuid4
+
+from app.database import get_connection
+from app.models import TeamMember, TeamMemberCreateRequest
+
+
+def _row_to_member(row: sqlite3.Row) -> TeamMember:
+ return TeamMember(
+ id=row["id"],
+ name=row["name"],
+ student_number=row["student_number"],
+ sort_order=row["sort_order"],
+ created_at=datetime.fromisoformat(row["created_at"]),
+ updated_at=datetime.fromisoformat(row["updated_at"]),
+ )
+
+
+def list_members(db_path: Path) -> list[TeamMember]:
+ conn = get_connection(db_path)
+ try:
+ rows = conn.execute("SELECT * FROM team_members ORDER BY sort_order ASC").fetchall()
+ return [_row_to_member(row) for row in rows]
+ finally:
+ conn.close()
+
+
+def add_member(db_path: Path, request: TeamMemberCreateRequest) -> TeamMember:
+ conn = get_connection(db_path)
+ try:
+ row = conn.execute(
+ "SELECT COALESCE(MAX(sort_order) + 1, 0) AS next_order FROM team_members"
+ ).fetchone()
+ sort_order: int = row["next_order"] if row else 0
+ member_id = str(uuid4())
+ now = datetime.now(UTC).isoformat()
+ conn.execute(
+ "INSERT INTO team_members"
+ " (id, name, student_number, sort_order, created_at, updated_at)"
+ " VALUES (?, ?, ?, ?, ?, ?)",
+ (member_id, request.name, request.student_number, sort_order, now, now),
+ )
+ conn.commit()
+ return TeamMember(
+ id=member_id,
+ name=request.name,
+ student_number=request.student_number,
+ sort_order=sort_order,
+ created_at=datetime.fromisoformat(now),
+ updated_at=datetime.fromisoformat(now),
+ )
+ finally:
+ conn.close()
+
+
+def delete_member(db_path: Path, member_id: str) -> bool:
+ conn = get_connection(db_path)
+ try:
+ existing = conn.execute(
+ "SELECT id FROM team_members WHERE id = ?", (member_id,)
+ ).fetchone()
+ if existing is None:
+ return False
+ conn.execute("DELETE FROM team_members WHERE id = ?", (member_id,))
+ conn.commit()
+ return True
+ finally:
+ conn.close()
+
+
+def reorder_members(db_path: Path, member_ids: list[str]) -> list[TeamMember]:
+ conn = get_connection(db_path)
+ try:
+ existing_ids = {
+ row["id"] for row in conn.execute("SELECT id FROM team_members").fetchall()
+ }
+ missing = set(member_ids) - existing_ids
+ if missing:
+ raise ValueError(f"Member IDs not found: {missing}")
+ now = datetime.now(UTC).isoformat()
+ for index, mid in enumerate(member_ids):
+ conn.execute(
+ "UPDATE team_members SET sort_order = ?, updated_at = ? WHERE id = ?",
+ (index, now, mid),
+ )
+ conn.commit()
+ return list_members(db_path)
+ finally:
+ conn.close()
diff --git a/frontend/index.html b/frontend/index.html
index d7e5f05..c4c7429 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,6 +4,7 @@
Handin
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 221e9d2..25230dd 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,8 +8,12 @@
"name": "handin-frontend",
"version": "0.1.0",
"dependencies": {
+ "@tiptap/starter-kit": "^3.20.0",
+ "@tiptap/vue-3": "^3.20.0",
"marked": "15.0.7",
+ "tiptap-markdown": "^0.9.0",
"vue": "3.5.13",
+ "vue-router": "^5.0.3",
"vuedraggable": "4.1.0"
},
"devDependencies": {
@@ -22,6 +26,22 @@
"vue-tsc": "2.2.8"
}
},
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -510,12 +530,82 @@
"node": ">=18"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@remirror/core-constants": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
+ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
+ "license": "MIT"
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -1151,6 +1241,433 @@
"vite": "^5.2.0 || ^6"
}
},
+ "node_modules/@tiptap/core": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz",
+ "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-blockquote": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.0.tgz",
+ "integrity": "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-bold": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.0.tgz",
+ "integrity": "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-bubble-menu": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.0.tgz",
+ "integrity": "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0",
+ "@tiptap/pm": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.0.tgz",
+ "integrity": "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-code": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.0.tgz",
+ "integrity": "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz",
+ "integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0",
+ "@tiptap/pm": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-document": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.0.tgz",
+ "integrity": "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-dropcursor": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.0.tgz",
+ "integrity": "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-floating-menu": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.0.tgz",
+ "integrity": "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==",
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@floating-ui/dom": "^1.0.0",
+ "@tiptap/core": "^3.20.0",
+ "@tiptap/pm": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-gapcursor": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.0.tgz",
+ "integrity": "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.0.tgz",
+ "integrity": "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-heading": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.0.tgz",
+ "integrity": "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-horizontal-rule": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.0.tgz",
+ "integrity": "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0",
+ "@tiptap/pm": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-italic": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz",
+ "integrity": "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz",
+ "integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.3.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0",
+ "@tiptap/pm": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-list": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz",
+ "integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0",
+ "@tiptap/pm": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-list-item": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.0.tgz",
+ "integrity": "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-list-keymap": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.0.tgz",
+ "integrity": "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-ordered-list": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.0.tgz",
+ "integrity": "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-paragraph": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.0.tgz",
+ "integrity": "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-strike": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.0.tgz",
+ "integrity": "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-text": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz",
+ "integrity": "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.0.tgz",
+ "integrity": "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/extensions": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz",
+ "integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.20.0",
+ "@tiptap/pm": "^3.20.0"
+ }
+ },
+ "node_modules/@tiptap/pm": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz",
+ "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-changeset": "^2.3.0",
+ "prosemirror-collab": "^1.3.1",
+ "prosemirror-commands": "^1.6.2",
+ "prosemirror-dropcursor": "^1.8.1",
+ "prosemirror-gapcursor": "^1.3.2",
+ "prosemirror-history": "^1.4.1",
+ "prosemirror-inputrules": "^1.4.0",
+ "prosemirror-keymap": "^1.2.2",
+ "prosemirror-markdown": "^1.13.1",
+ "prosemirror-menu": "^1.2.4",
+ "prosemirror-model": "^1.24.1",
+ "prosemirror-schema-basic": "^1.2.3",
+ "prosemirror-schema-list": "^1.5.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.6.4",
+ "prosemirror-trailing-node": "^3.0.0",
+ "prosemirror-transform": "^1.10.2",
+ "prosemirror-view": "^1.38.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/starter-kit": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz",
+ "integrity": "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/core": "^3.20.0",
+ "@tiptap/extension-blockquote": "^3.20.0",
+ "@tiptap/extension-bold": "^3.20.0",
+ "@tiptap/extension-bullet-list": "^3.20.0",
+ "@tiptap/extension-code": "^3.20.0",
+ "@tiptap/extension-code-block": "^3.20.0",
+ "@tiptap/extension-document": "^3.20.0",
+ "@tiptap/extension-dropcursor": "^3.20.0",
+ "@tiptap/extension-gapcursor": "^3.20.0",
+ "@tiptap/extension-hard-break": "^3.20.0",
+ "@tiptap/extension-heading": "^3.20.0",
+ "@tiptap/extension-horizontal-rule": "^3.20.0",
+ "@tiptap/extension-italic": "^3.20.0",
+ "@tiptap/extension-link": "^3.20.0",
+ "@tiptap/extension-list": "^3.20.0",
+ "@tiptap/extension-list-item": "^3.20.0",
+ "@tiptap/extension-list-keymap": "^3.20.0",
+ "@tiptap/extension-ordered-list": "^3.20.0",
+ "@tiptap/extension-paragraph": "^3.20.0",
+ "@tiptap/extension-strike": "^3.20.0",
+ "@tiptap/extension-text": "^3.20.0",
+ "@tiptap/extension-underline": "^3.20.0",
+ "@tiptap/extensions": "^3.20.0",
+ "@tiptap/pm": "^3.20.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/vue-3": {
+ "version": "3.20.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.20.0.tgz",
+ "integrity": "sha512-u8UfDKsbIOF+mVsXwJ946p1jfrLGFUyqp9i/DAeGGg2I85DPOkhZgz67bUPVXkpossoEk+jKCkRN0eBHl9+eZQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "optionalDependencies": {
+ "@tiptap/extension-bubble-menu": "^3.20.0",
+ "@tiptap/extension-floating-menu": "^3.20.0"
+ },
+ "peerDependencies": {
+ "@floating-ui/dom": "^1.0.0",
+ "@tiptap/core": "^3.20.0",
+ "@tiptap/pm": "^3.20.0",
+ "vue": "^3.0.0"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1158,6 +1675,28 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "license": "MIT"
+ },
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.3.tgz",
@@ -1201,6 +1740,33 @@
"vscode-uri": "^3.0.8"
}
},
+ "node_modules/@vue-macros/common": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz",
+ "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-sfc": "^3.5.22",
+ "ast-kit": "^2.1.2",
+ "local-pkg": "^1.1.2",
+ "magic-string-ast": "^1.0.2",
+ "unplugin-utils": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/vue-macros"
+ },
+ "peerDependencies": {
+ "vue": "^2.7.0 || ^3.2.25"
+ },
+ "peerDependenciesMeta": {
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vue/compiler-core": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
@@ -1225,20 +1791,71 @@
}
},
"node_modules/@vue/compiler-sfc": {
- "version": "3.5.13",
- "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz",
- "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==",
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
+ "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.25.3",
- "@vue/compiler-core": "3.5.13",
- "@vue/compiler-dom": "3.5.13",
- "@vue/compiler-ssr": "3.5.13",
- "@vue/shared": "3.5.13",
+ "@babel/parser": "^7.29.0",
+ "@vue/compiler-core": "3.5.29",
+ "@vue/compiler-dom": "3.5.29",
+ "@vue/compiler-ssr": "3.5.29",
+ "@vue/shared": "3.5.29",
"estree-walker": "^2.0.2",
- "magic-string": "^0.30.11",
- "postcss": "^8.4.48",
- "source-map-js": "^1.2.0"
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.6",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-sfc/node_modules/@vue/compiler-core": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
+ "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@vue/shared": "3.5.29",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-sfc/node_modules/@vue/compiler-dom": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
+ "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.29",
+ "@vue/shared": "3.5.29"
+ }
+ },
+ "node_modules/@vue/compiler-sfc/node_modules/@vue/compiler-ssr": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
+ "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.29",
+ "@vue/shared": "3.5.29"
+ }
+ },
+ "node_modules/@vue/compiler-sfc/node_modules/@vue/shared": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
+ "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/compiler-sfc/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@vue/compiler-ssr": {
@@ -1262,6 +1879,33 @@
"he": "^1.2.0"
}
},
+ "node_modules/@vue/devtools-api": {
+ "version": "8.0.7",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.7.tgz",
+ "integrity": "sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-kit": "^8.0.7"
+ }
+ },
+ "node_modules/@vue/devtools-kit": {
+ "version": "8.0.7",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.7.tgz",
+ "integrity": "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-shared": "^8.0.7",
+ "birpc": "^2.6.1",
+ "hookable": "^5.5.3",
+ "perfect-debounce": "^2.0.0"
+ }
+ },
+ "node_modules/@vue/devtools-shared": {
+ "version": "8.0.7",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.7.tgz",
+ "integrity": "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==",
+ "license": "MIT"
+ },
"node_modules/@vue/language-core": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.8.tgz",
@@ -1337,6 +1981,18 @@
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
"license": "MIT"
},
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
@@ -1344,6 +2000,44 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/ast-kit": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
+ "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.5",
+ "pathe": "^2.0.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ },
+ "node_modules/ast-walker-scope": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
+ "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.4",
+ "ast-kit": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1351,6 +2045,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/birpc": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
+ "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@@ -1361,6 +2064,33 @@
"balanced-match": "^1.0.0"
}
},
+ "node_modules/chokidar": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
+ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "license": "MIT"
+ },
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1465,12 +2195,47 @@
"@esbuild/win32-x64": "0.25.12"
}
},
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
+ "node_modules/exsolve": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
+ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1503,6 +2268,12 @@
"he": "bin/he"
}
},
+ "node_modules/hookable": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
+ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+ "license": "MIT"
+ },
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1513,6 +2284,30 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
@@ -1752,6 +2547,38 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/linkifyjs": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
+ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
+ "license": "MIT"
+ },
+ "node_modules/local-pkg": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
+ "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.4",
+ "pkg-types": "^2.3.0",
+ "quansync": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
@@ -1782,6 +2609,44 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magic-string-ast": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz",
+ "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==",
+ "license": "MIT",
+ "dependencies": {
+ "magic-string": "^0.30.19"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ },
+ "node_modules/markdown-it": {
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
+ "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.0",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/markdown-it-task-lists": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
+ "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==",
+ "license": "ISC"
+ },
"node_modules/marked": {
"version": "15.0.7",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.7.tgz",
@@ -1794,6 +2659,12 @@
"node": ">= 18"
}
},
+ "node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "license": "MIT"
+ },
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
@@ -1810,11 +2681,39 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/mlly": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
+ "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.1"
+ }
+ },
+ "node_modules/mlly/node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "license": "MIT"
+ },
+ "node_modules/mlly/node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -1835,6 +2734,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1842,12 +2747,47 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "license": "MIT"
+ },
+ "node_modules/perfect-debounce": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
+ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
+ "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.2.2",
+ "exsolve": "^1.0.7",
+ "pathe": "^2.0.3"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1890,6 +2830,239 @@
"node": ">=4"
}
},
+ "node_modules/prosemirror-changeset": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
+ "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-collab": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
+ "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+ "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+ "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
+ "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+ "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-inputrules": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+ "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-markdown": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
+ "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/markdown-it": "^14.0.0",
+ "markdown-it": "^14.0.0",
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-menu": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz",
+ "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==",
+ "license": "MIT",
+ "dependencies": {
+ "crelt": "^1.0.0",
+ "prosemirror-commands": "^1.0.0",
+ "prosemirror-history": "^1.0.0",
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
+ "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-schema-basic": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
+ "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.4"
+ }
+ },
+ "node_modules/prosemirror-trailing-node": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
+ "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remirror/core-constants": "3.0.0",
+ "escape-string-regexp": "^4.0.0"
+ },
+ "peerDependencies": {
+ "prosemirror-model": "^1.22.1",
+ "prosemirror-state": "^1.4.2",
+ "prosemirror-view": "^1.33.8"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz",
+ "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.41.6",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
+ "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.20.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/quansync": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
+ "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/antfu"
+ },
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/readdirp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
+ "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -1935,6 +3108,18 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
+ "node_modules/scule": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
+ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
+ "license": "MIT"
+ },
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
@@ -1971,6 +3156,62 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tiptap-markdown": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
+ "integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
+ "license": "MIT",
+ "workspaces": [
+ "example"
+ ],
+ "dependencies": {
+ "@types/markdown-it": "^13.0.7",
+ "markdown-it": "^14.1.0",
+ "markdown-it-task-lists": "^2.1.1",
+ "prosemirror-markdown": "^1.11.1"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.1"
+ }
+ },
+ "node_modules/tiptap-markdown/node_modules/@types/linkify-it": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
+ "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
+ "license": "MIT"
+ },
+ "node_modules/tiptap-markdown/node_modules/@types/markdown-it": {
+ "version": "13.0.9",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
+ "integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/linkify-it": "^3",
+ "@types/mdurl": "^1"
+ }
+ },
+ "node_modules/tiptap-markdown/node_modules/@types/mdurl": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
+ "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==",
+ "license": "MIT"
+ },
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
@@ -1985,6 +3226,48 @@
"node": ">=14.17"
}
},
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ },
+ "node_modules/ufo": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
+ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
+ "license": "MIT"
+ },
+ "node_modules/unplugin": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
+ "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "picomatch": "^4.0.3",
+ "webpack-virtual-modules": "^0.6.2"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/unplugin-utils": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
+ "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -2092,6 +3375,51 @@
}
}
},
+ "node_modules/vue-router": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz",
+ "integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/generator": "^7.28.6",
+ "@vue-macros/common": "^3.1.1",
+ "@vue/devtools-api": "^8.0.6",
+ "ast-walker-scope": "^0.8.3",
+ "chokidar": "^5.0.0",
+ "json5": "^2.2.3",
+ "local-pkg": "^1.1.2",
+ "magic-string": "^0.30.21",
+ "mlly": "^1.8.0",
+ "muggle-string": "^0.4.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "scule": "^1.3.0",
+ "tinyglobby": "^0.2.15",
+ "unplugin": "^3.0.0",
+ "unplugin-utils": "^0.3.1",
+ "yaml": "^2.8.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "@pinia/colada": ">=0.21.2",
+ "@vue/compiler-sfc": "^3.5.17",
+ "pinia": "^3.0.4",
+ "vue": "^3.5.0"
+ },
+ "peerDependenciesMeta": {
+ "@pinia/colada": {
+ "optional": true
+ },
+ "@vue/compiler-sfc": {
+ "optional": true
+ },
+ "pinia": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vue-tsc": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.8.tgz",
@@ -2109,6 +3437,23 @@
"typescript": ">=5.0.0"
}
},
+ "node_modules/vue/node_modules/@vue/compiler-sfc": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz",
+ "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.3",
+ "@vue/compiler-core": "3.5.13",
+ "@vue/compiler-dom": "3.5.13",
+ "@vue/compiler-ssr": "3.5.13",
+ "@vue/shared": "3.5.13",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.11",
+ "postcss": "^8.4.48",
+ "source-map-js": "^1.2.0"
+ }
+ },
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
@@ -2120,6 +3465,33 @@
"peerDependencies": {
"vue": "^3.0.1"
}
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/webpack-virtual-modules": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
+ "license": "MIT"
+ },
+ "node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 7ce7e27..9d303f9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,13 +10,17 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
- "vue": "3.5.13",
+ "@tiptap/starter-kit": "^3.20.0",
+ "@tiptap/vue-3": "^3.20.0",
"marked": "15.0.7",
+ "tiptap-markdown": "^0.9.0",
+ "vue": "3.5.13",
+ "vue-router": "^5.0.3",
"vuedraggable": "4.1.0"
},
"devDependencies": {
- "@tailwindcss/vite": "4.1.4",
"@tailwindcss/typography": "0.5.16",
+ "@tailwindcss/vite": "4.1.4",
"@vitejs/plugin-vue": "5.2.3",
"tailwindcss": "4.1.4",
"typescript": "5.7.3",
diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png
new file mode 100644
index 0000000..d1a6c63
Binary files /dev/null and b/frontend/public/favicon.png differ
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 57c300f..3e54ee9 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,7 +1,9 @@
-
+
+
+
diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png
new file mode 100644
index 0000000..d1a6c63
Binary files /dev/null and b/frontend/src/assets/logo.png differ
diff --git a/frontend/src/components/admin/AddSegmentModal.vue b/frontend/src/components/admin/AddSegmentModal.vue
index b23f1db..7c5d4c4 100644
--- a/frontend/src/components/admin/AddSegmentModal.vue
+++ b/frontend/src/components/admin/AddSegmentModal.vue
@@ -2,29 +2,27 @@
import { ref, computed } from "vue";
import type { SegmentType } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
+import RichTextEditor from "@/components/admin/RichTextEditor.vue";
const emit = defineEmits<{
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record }];
cancel: [];
}>();
-const segmentTypes: { value: SegmentType; label: string }[] = [
- { value: "markdown", label: "Markdown" },
- { value: "pdf", label: "PDF" },
- { value: "video", label: "Video" },
- { value: "audio", label: "Audio" },
- { value: "iframe", label: "Iframe" },
- { value: "gallery", label: "Gallery" },
- { value: "link", label: "Link" },
+const segmentTypes: { value: SegmentType; label: string; description: string; icon: string }[] = [
+ { value: "markdown", label: "Text", description: "Write formatted text, headings, and lists", icon: '' },
+ { value: "gallery", label: "Images", description: "Display a photo gallery", icon: '' },
+ { value: "pdf", label: "PDF", description: "Show a PDF document", icon: '' },
+ { value: "video", label: "Video", description: "Embed a video file", icon: '' },
+ { value: "audio", label: "Audio", description: "Embed an audio file", icon: '' },
+ { value: "iframe", label: "Embed", description: "Embed an external website or tool", icon: '' },
];
const selectedType = ref("markdown");
const title = ref("");
const content = ref("");
const galleryImages = ref([]);
-
const canSubmit = computed(() => {
- if (!title.value.trim()) return false;
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
if (selectedType.value === "markdown") return true;
return content.value.trim().length > 0;
@@ -67,82 +65,83 @@ const assetAcceptMap: Record = {
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="$emit('cancel')"
>
-
-
Add New Segment
+
+
Add a new section
-
-
-
-
+
+
+
-
+
-
-
+
+
-
-
+
+
-
-
+
+
+
Paste the URL of the website or tool you want to embed.
+
+
-
+
Upload one or more photos. You can add more later.
-
+
diff --git a/frontend/src/components/admin/AssetUploader.vue b/frontend/src/components/admin/AssetUploader.vue
index 7ec227d..7b1d7b3 100644
--- a/frontend/src/components/admin/AssetUploader.vue
+++ b/frontend/src/components/admin/AssetUploader.vue
@@ -1,11 +1,12 @@
@@ -47,15 +81,19 @@ function onFileSelect(e: Event) {
@drop.prevent="onDrop"
@click="fileInput?.click()"
>
+
-
Uploading...
+
+ {{ progressLabel }}
+
-
{{ error }}
+
{{ uploadError }}
diff --git a/frontend/src/components/admin/RichTextEditor.vue b/frontend/src/components/admin/RichTextEditor.vue
new file mode 100644
index 0000000..98317ea
--- /dev/null
+++ b/frontend/src/components/admin/RichTextEditor.vue
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/SegmentEditor.vue b/frontend/src/components/admin/SegmentEditor.vue
index c60db08..1857fb7 100644
--- a/frontend/src/components/admin/SegmentEditor.vue
+++ b/frontend/src/components/admin/SegmentEditor.vue
@@ -2,6 +2,7 @@
import { ref } from "vue";
import type { Segment } from "@/types/segment";
import AssetUploader from "@/components/admin/AssetUploader.vue";
+import RichTextEditor from "@/components/admin/RichTextEditor.vue";
const props = defineProps<{
segment: Segment;
@@ -17,11 +18,21 @@ const title = ref(props.segment.title);
const content = ref(props.segment.content);
const metadata = ref
>({ ...props.segment.metadata });
const showDeleteConfirm = ref(false);
+const teamMembers = ref<{ name: string; student_number: string }[]>(
+ Array.isArray(props.segment.metadata.members)
+ ? [...(props.segment.metadata.members as { name: string; student_number: string }[])]
+ : [],
+);
+const newMemberName = ref("");
+const newMemberNumber = ref("");
function handleSave() {
const updates: { title?: string; content?: string; metadata?: Record } = {};
if (title.value !== props.segment.title) updates.title = title.value;
if (content.value !== props.segment.content) updates.content = content.value;
+ if (props.segment.type === "team") {
+ metadata.value = { ...metadata.value, members: teamMembers.value };
+ }
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
updates.metadata = metadata.value;
}
@@ -71,20 +82,15 @@ const assetAcceptMap: Record = {
-
-
+
+
-
+
@@ -125,10 +131,47 @@ const assetAcceptMap: Record = {
+
+
+
+
+
+ {{ member.name }}
+ {{ member.student_number }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/layout/AppShell.vue b/frontend/src/components/layout/AppShell.vue
index 997e2b5..79ed674 100644
--- a/frontend/src/components/layout/AppShell.vue
+++ b/frontend/src/components/layout/AppShell.vue
@@ -1,135 +1,175 @@
-
-
-
-
-
- {{ siteTitle }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
+
+
+
+
+
+
+
{{ siteTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {}" />
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/layout/MobileHeader.vue b/frontend/src/components/layout/MobileHeader.vue
deleted file mode 100644
index a1d798f..0000000
--- a/frontend/src/components/layout/MobileHeader.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ siteTitle }}
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/components/layout/Sidebar.vue b/frontend/src/components/layout/Sidebar.vue
new file mode 100644
index 0000000..85e3058
--- /dev/null
+++ b/frontend/src/components/layout/Sidebar.vue
@@ -0,0 +1,411 @@
+
+
+
+
+
diff --git a/frontend/src/components/layout/SidebarNav.vue b/frontend/src/components/layout/SidebarNav.vue
deleted file mode 100644
index c40e70f..0000000
--- a/frontend/src/components/layout/SidebarNav.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
diff --git a/frontend/src/components/segments/GallerySegment.vue b/frontend/src/components/segments/GallerySegment.vue
index bb0cbe6..65f5ee4 100644
--- a/frontend/src/components/segments/GallerySegment.vue
+++ b/frontend/src/components/segments/GallerySegment.vue
@@ -1,5 +1,5 @@
-
-
![]()
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/segments/PdfSegment.vue b/frontend/src/components/segments/PdfSegment.vue
index 7333f5e..d1c12e0 100644
--- a/frontend/src/components/segments/PdfSegment.vue
+++ b/frontend/src/components/segments/PdfSegment.vue
@@ -9,7 +9,7 @@ defineProps<{
diff --git a/frontend/src/components/segments/SegmentList.vue b/frontend/src/components/segments/SegmentList.vue
index df846b2..5eea882 100644
--- a/frontend/src/components/segments/SegmentList.vue
+++ b/frontend/src/components/segments/SegmentList.vue
@@ -1,5 +1,5 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
{{ segment.title }}
-
-
-
-
-
-
- External link: {{ segment.content }}
-
-
-
-
-
handleSave(segment.id, updates)"
- @cancel="editingId = null"
- @delete="handleDelete(segment.id)"
- />
+
+
+
{{ segment.title }}
+
+
-
-
-
-
-
-
-
- {{ segment.title }}
-
+
+ handleSave(segment.id, updates)"
+ @cancel="editingId = null"
+ @delete="handleDelete(segment.id)"
+ />
+
+
+
+
+
+
+
+
+ {{ segment.title }}
+
+
+
+
diff --git a/frontend/src/components/segments/TeamSegment.vue b/frontend/src/components/segments/TeamSegment.vue
new file mode 100644
index 0000000..2f5cf92
--- /dev/null
+++ b/frontend/src/components/segments/TeamSegment.vue
@@ -0,0 +1,31 @@
+
+
+
+
+ -
+ {{ member.name }}
+ {{ member.student_number }}
+
+
+
diff --git a/frontend/src/composables/usePages.ts b/frontend/src/composables/usePages.ts
new file mode 100644
index 0000000..d1cef29
--- /dev/null
+++ b/frontend/src/composables/usePages.ts
@@ -0,0 +1,110 @@
+import { ref } from "vue";
+import type { Page } from "@/types/segment";
+import { useAdmin } from "@/composables/useAdmin";
+
+const pages = ref
([]);
+const loading = ref(false);
+const error = ref(null);
+
+export function usePages() {
+ const { authHeaders } = useAdmin();
+
+ async function fetchPages(): Promise {
+ loading.value = true;
+ error.value = null;
+ try {
+ const res = await fetch("/api/pages/");
+ if (!res.ok) throw new Error(`Failed to fetch pages: ${res.status}`);
+ pages.value = (await res.json()) as Page[];
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : "Unknown error";
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ async function createPage(name: string, slug: string): Promise {
+ try {
+ const res = await fetch("/api/pages/", {
+ method: "POST",
+ headers: { ...authHeaders.value, "Content-Type": "application/json" },
+ body: JSON.stringify({ name, slug }),
+ });
+ if (!res.ok) {
+ const data = (await res.json()) as { detail?: string };
+ throw new Error(data.detail ?? `Create failed: ${res.status}`);
+ }
+ const page = (await res.json()) as Page;
+ await fetchPages();
+ return page;
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : "Unknown error";
+ return null;
+ }
+ }
+
+ async function updatePage(id: string, data: { name?: string; slug?: string }): Promise {
+ try {
+ const res = await fetch(`/api/pages/${id}`, {
+ method: "PATCH",
+ headers: { ...authHeaders.value, "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ const body = (await res.json()) as { detail?: string };
+ throw new Error(body.detail ?? `Update failed: ${res.status}`);
+ }
+ const page = (await res.json()) as Page;
+ await fetchPages();
+ return page;
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : "Unknown error";
+ return null;
+ }
+ }
+
+ async function deletePage(id: string): Promise<{ ok: boolean; error?: string }> {
+ try {
+ const res = await fetch(`/api/pages/${id}`, {
+ method: "DELETE",
+ headers: authHeaders.value,
+ });
+ if (!res.ok) {
+ const body = (await res.json()) as { detail?: string };
+ return { ok: false, error: body.detail ?? `Delete failed: ${res.status}` };
+ }
+ await fetchPages();
+ return { ok: true };
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : "Unknown error";
+ return { ok: false, error: msg };
+ }
+ }
+
+ async function toggleHidden(id: string, hidden: boolean): Promise {
+ try {
+ await fetch(`/api/pages/${id}`, {
+ method: "PATCH",
+ headers: { ...authHeaders.value, "Content-Type": "application/json" },
+ body: JSON.stringify({ is_hidden: hidden }),
+ });
+ await fetchPages();
+ } catch { /* Silently fail */ }
+ }
+
+ async function reorderPages(ids: string[]): Promise {
+ try {
+ const res = await fetch("/api/pages/reorder", {
+ method: "PUT",
+ headers: { ...authHeaders.value, "Content-Type": "application/json" },
+ body: JSON.stringify({ page_ids: ids }),
+ });
+ if (!res.ok) throw new Error(`Reorder failed: ${res.status}`);
+ pages.value = (await res.json()) as Page[];
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : "Unknown error";
+ }
+ }
+
+ return { pages, loading, error, fetchPages, createPage, updatePage, deletePage, reorderPages, toggleHidden };
+}
diff --git a/frontend/src/composables/useSegments.ts b/frontend/src/composables/useSegments.ts
index 3b27a6f..62a250c 100644
--- a/frontend/src/composables/useSegments.ts
+++ b/frontend/src/composables/useSegments.ts
@@ -1,9 +1,8 @@
-import { ref, onMounted } from "vue";
-import type { Segment, SiteInfo } from "@/types/segment";
+import { ref } from "vue";
+import type { Segment } from "@/types/segment";
-export function useSegments() {
+export function useSegments(pageId: string) {
const segments = ref([]);
- const siteTitle = ref("Handin");
const loading = ref(true);
const error = ref(null);
@@ -11,18 +10,10 @@ export function useSegments() {
loading.value = true;
error.value = null;
try {
- const [siteRes, segmentsRes] = await Promise.all([
- fetch("/api/site/"),
- fetch("/api/segments/"),
- ]);
- if (!siteRes.ok) throw new Error(`Site fetch failed: ${siteRes.status}`);
- if (!segmentsRes.ok) throw new Error(`Segments fetch failed: ${segmentsRes.status}`);
-
- const siteData = (await siteRes.json()) as SiteInfo;
- const segmentsData = (await segmentsRes.json()) as Segment[];
-
- siteTitle.value = siteData.title;
- segments.value = segmentsData.sort((a, b) => a.sort_order - b.sort_order);
+ const res = await fetch(`/api/segments/?page_id=${encodeURIComponent(pageId)}`);
+ if (!res.ok) throw new Error(`Segments fetch failed: ${res.status}`);
+ const data = (await res.json()) as Segment[];
+ segments.value = data.sort((a, b) => a.sort_order - b.sort_order);
} catch (e) {
error.value = e instanceof Error ? e.message : "Unknown error";
} finally {
@@ -30,9 +21,5 @@ export function useSegments() {
}
}
- onMounted(() => {
- void refresh();
- });
-
- return { segments, siteTitle, loading, error, refresh };
+ return { segments, loading, error, refresh };
}
diff --git a/frontend/src/composables/useTeamMembers.ts b/frontend/src/composables/useTeamMembers.ts
new file mode 100644
index 0000000..a073746
--- /dev/null
+++ b/frontend/src/composables/useTeamMembers.ts
@@ -0,0 +1,57 @@
+import { ref } from "vue";
+import type { TeamMember } from "@/types/segment";
+import { useAdmin } from "@/composables/useAdmin";
+
+export function useTeamMembers() {
+ const { authHeaders } = useAdmin();
+ const members = ref([]);
+ const loading = ref(false);
+ const error = ref(null);
+
+ async function fetchMembers(): Promise {
+ loading.value = true;
+ error.value = null;
+ try {
+ const res = await fetch("/api/team/");
+ if (!res.ok) throw new Error(`Failed to fetch team: ${res.status}`);
+ members.value = (await res.json()) as TeamMember[];
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : "Unknown error";
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ async function addMember(name: string, student_number: string): Promise {
+ try {
+ const res = await fetch("/api/team/", {
+ method: "POST",
+ headers: { ...authHeaders.value, "Content-Type": "application/json" },
+ body: JSON.stringify({ name, student_number }),
+ });
+ if (!res.ok) throw new Error(`Add failed: ${res.status}`);
+ const member = (await res.json()) as TeamMember;
+ await fetchMembers();
+ return member;
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : "Unknown error";
+ return null;
+ }
+ }
+
+ async function deleteMember(id: string): Promise {
+ try {
+ const res = await fetch(`/api/team/${id}`, {
+ method: "DELETE",
+ headers: authHeaders.value,
+ });
+ if (!res.ok) return false;
+ await fetchMembers();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ return { members, loading, error, fetchMembers, addMember, deleteMember };
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index de275e7..01b3dd4 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,5 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
+import router from "./router";
import "./style.css";
-createApp(App).mount("#app");
+createApp(App).use(router).mount("#app");
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
new file mode 100644
index 0000000..dcd2e58
--- /dev/null
+++ b/frontend/src/router/index.ts
@@ -0,0 +1,22 @@
+import { createRouter, createWebHistory } from "vue-router";
+import PageView from "@/views/PageView.vue";
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ {
+ path: "/",
+ redirect: () => {
+ // Will be redirected to first page after pages load
+ return "/";
+ },
+ },
+ {
+ path: "/:slug",
+ name: "page",
+ component: PageView,
+ },
+ ],
+});
+
+export default router;
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 2754b4c..4dd8e00 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -4,6 +4,7 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
+ /* Brand yellow — unchanged */
--color-primary-50: #fffbeb;
--color-primary-100: #fff3c4;
--color-primary-200: #fce588;
@@ -12,4 +13,17 @@
--color-primary-500: #cc9900;
--color-primary-600: #997300;
--color-primary-700: #664d00;
+
+ /* Soft dark gray palette — used throughout dark mode */
+ --color-gray-950: #0d0d0d;
+ --color-gray-900: #171717;
+ --color-gray-800: #1e1e1e;
+ --color-gray-700: #2a2a2a;
+ --color-gray-600: #3d3d3d;
+ --color-gray-500: #6b6b6b;
+ --color-gray-400: #9e9e9e;
+ --color-gray-300: #c2c2c2;
+ --color-gray-200: #e0e0e0;
+ --color-gray-100: #f0f0f0;
+ --color-gray-50: #f8f8f8;
}
diff --git a/frontend/src/types/segment.ts b/frontend/src/types/segment.ts
index d8eb4d3..8edc4f0 100644
--- a/frontend/src/types/segment.ts
+++ b/frontend/src/types/segment.ts
@@ -1,4 +1,4 @@
-export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "link";
+export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "team";
export interface Segment {
id: string;
@@ -7,6 +7,7 @@ export interface Segment {
title: string;
content: string;
metadata: Record;
+ page_id: string | null;
created_at: string;
updated_at: string;
}
@@ -14,3 +15,23 @@ export interface Segment {
export interface SiteInfo {
title: string;
}
+
+export interface Page {
+ id: string;
+ name: string;
+ slug: string;
+ sort_order: number;
+ is_system: boolean;
+ is_hidden: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface TeamMember {
+ id: string;
+ name: string;
+ student_number: string;
+ sort_order: number;
+ created_at: string;
+ updated_at: string;
+}
diff --git a/frontend/src/views/HomePage.vue b/frontend/src/views/HomePage.vue
deleted file mode 100644
index e0c95af..0000000
--- a/frontend/src/views/HomePage.vue
+++ /dev/null
@@ -1,165 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/views/PageView.vue b/frontend/src/views/PageView.vue
new file mode 100644
index 0000000..c02d61f
--- /dev/null
+++ b/frontend/src/views/PageView.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
This page is empty
+
Use the + Add section button to add your first section.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/TeamPage.vue b/frontend/src/views/TeamPage.vue
new file mode 100644
index 0000000..1d39925
--- /dev/null
+++ b/frontend/src/views/TeamPage.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
Team Members
+
+
+
+
+
+
+
+
+
+
+
No team members yet.
+
+
+
+
+
+
+
+ | Name |
+ Student number |
+ |
+
+
+
+
+ | {{ member.name }} |
+ {{ member.student_number }} |
+
+
+ |
+
+
+
+
+
+
+
+ {{ addError }}
+
+
+