diff --git a/README.md b/README.md
index 352ece0..9aade78 100644
--- a/README.md
+++ b/README.md
@@ -47,3 +47,172 @@ Silos/
├── backend/ # FastAPI
└── docker-compose.yml
```
+
+## Feature Progress
+
+### Phase-wise Confluence-inspired platform roadmap
+
+The platform is being delivered in focused phases so each collaboration and
+content-management capability is usable before the next layer is added.
+
+#### Phase 1 — Page hierarchy and lifecycle foundation (complete)
+
+- Added `parent_page_id` to document creation, updates, responses, and frontend
+ document records.
+- Added parent validation so a page cannot parent itself, attach to an
+ inaccessible page, or attach directly to a space as a page parent.
+- Added child-page creation from the current page.
+- Added hierarchical sidebar navigation with expand/collapse behavior.
+- Added a three-dot page actions menu with child-page creation, duplication,
+ archive, permanent delete, and close actions.
+- Archived pages are excluded from normal dashboard and space navigation.
+- Existing space membership, sharing, and explicit Edit → Update → Publish
+ behavior remain intact.
+
+Validation completed:
+
+- `cd frontend && npm run build`
+- `python -m compileall backend\app`
+
+#### Phase 2 — Templates and page creation flows (complete)
+
+- Added reusable Silo page templates for blank pages, meeting notes, project
+ plans, and how-to guides.
+- Added page-template creation to the global Create menu.
+- Updated the Templates flyout to use the same shared page-template definitions.
+- New pages created inside a space preserve the space relationship.
+- New pages created from a page context preserve the parent-child relationship.
+- Added a Confluence-inspired inline `+` action beside each Silo page in the
+ sidebar. It creates a child page under that page and opens it immediately.
+
+Validation completed:
+
+- `cd frontend && npm run build`
+- `python -m compileall backend\app`
+
+#### Phase 3 — Sidebar page management and richer creation flows (complete)
+
+- Replicated the Confluence-inspired sidebar hierarchy treatment with
+ disclosure arrows for parent pages and bullet markers for leaf/child pages.
+- Added inline child-page `+` controls beside page names.
+- Added per-page three-dot menus with Edit, Rename, Star, Copy link, Make a
+ copy, Move, Archive, and Delete actions.
+- Added inline page renaming with Enter/save behavior.
+- Added a Move page dialog with top-level placement and parent-page selection.
+- Prevented moving a page below itself or one of its descendants.
+- Added shared styling for the sidebar page-action menu.
+
+Validation completed:
+
+- `cd frontend && npm run build`
+- `python -m compileall backend\app`
+
+#### Phase 4 — Collaboration foundation (complete)
+
+- Added persisted comments for accessible documents.
+- Added threaded-comment parent validation at the API boundary.
+- Added author-only comment deletion.
+- Added a page-level Comments panel with loading, empty, error, create, and
+ delete states.
+- Reused existing document sharing and editor/viewer permissions for comment
+ access.
+- Added a comments entry point beside the page Edit/Update/Publish controls.
+- Added MongoDB indexes for document comment retrieval.
+
+Validation completed:
+
+- `cd frontend && npm run build`
+- `python -m compileall backend\app`
+
+#### Phase 5 — User-authored templates and categories (complete)
+
+- Added owner-scoped persisted templates in MongoDB.
+- Added template categories, descriptions, emoji labels, and starter content.
+- Added template CRUD endpoints under `/api/v1/templates`.
+- Added template indexes and authenticated ownership checks.
+- Added a Templates flyout flow for creating, filtering, using, and deleting
+ user templates.
+- Custom templates create new Silo pages while preserving their starter
+ content and draft state.
+
+Validation completed:
+
+- `cd frontend && npm run build`
+- `python -m compileall backend\app`
+
+#### Phase 6 — Template sharing and placement selection (complete)
+
+- Added owner-controlled template sharing by email or username.
+- Shared templates are included in the authenticated template library.
+- Added a template sharing dialog with explicit dismissal.
+- Added placement selection for pages created from custom templates:
+ top-level, selected space, or selected parent page.
+- Preserved the existing hierarchy and document-access validation path for
+ placement targets.
+
+Validation completed:
+
+- `cd frontend && npm run build`
+- `python -m compileall backend\app`
+
+#### Phase 7 — Collaboration expansion (complete)
+
+- Added `@username` mention parsing against registered users.
+- Added persisted mention notifications with unread/read state.
+- Added a workspace notification bell and notification dropdown.
+- Added persisted page activity entries for comment creation and
+ comment resolution/reopening.
+- Added an Activity view alongside the Comments panel.
+- Added nested threaded replies using the existing `parent_id` model.
+- Added resolve/reopen controls with access-checked API mutations.
+- Preserved the existing WebSocket collaboration presence and live content
+ channel, including peer updates when users join or leave.
+
+Validation completed:
+
+- `cd frontend && npm run build`
+- `python -m compileall backend\app`
+- FastAPI application import and route registration
+
+#### Phase 8 — History, publishing, and operational polish (complete)
+
+- Added persisted document version snapshots before content/title updates.
+- Added version history listing and access-controlled restore.
+- Added HTML and plain-text page export endpoints and page controls.
+- Added keyboard save (`Ctrl/Cmd+S`) and Escape dismissal for history.
+- Added accessible names, live editor status, history labels, and notification
+ controls.
+- Preserved the existing MongoDB text index and access-aware search behavior.
+- Added three Playwright browser regression specs covering history, export, and
+ keyboard/accessibility journeys.
+- Added `npm run test:e2e` and `runtime-validation-report.md`.
+
+Validation completed:
+
+- `cd frontend && npm run build`
+- `python -m compileall backend\app`
+- FastAPI application import and route registration
+- `cd frontend && npm run test:e2e` (3 tests skipped without test credentials)
+
+### Dashboard onboarding
+
+- Returning users see recent documents ordered by their last visited activity.
+- New users see a getting-started panel instead of blank Recent and Activity sections.
+- New users can browse templates, create a page, create a whiteboard, or create a space.
+
+### Pages and whiteboards
+
+- Pages and whiteboards open in read-only mode after login or re-login.
+- Users must select **Edit** before changing content or titles.
+- **Update** explicitly saves the current draft and returns the document to read-only mode.
+- **Publish** saves the page or whiteboard and marks it as published.
+- Page editing includes active Bold, Italic, Underline, and strikethrough states; paragraph/heading/quote/code styles; ordered and unordered lists; left/center/right alignment; an expanded text and highlight colour palette; grouped searchable emoji picker; styled tables; tabs; info panels; mentions; links; image/video URL insertion; and image file upload.
+- Formatting selections are preserved when a toolbar control or popover is clicked. Link, media, colour, emoji, insert, and slash-command windows close when the user clicks elsewhere or presses Escape.
+- Whiteboards retain their existing drawing, selection, table, text, sticky-note, pan, zoom, undo, and redo tools while preventing mutations until Edit is selected.
+
+The frontend build is verified with:
+
+```bash
+cd frontend
+npm run build
+```
diff --git a/backend/app/config.py b/backend/app/config.py
index 119df13..de2585f 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -18,6 +18,10 @@ class Settings(BaseSettings):
refresh_token_expire_days: int = 7
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
+ frontend_url: str = "http://localhost:5173"
+ google_client_id: str = ""
+ google_client_secret: str = ""
+ google_redirect_uri: str = "http://localhost:8000/api/v1/auth/google/callback"
@property
def is_sqlite(self) -> bool:
diff --git a/backend/app/main.py b/backend/app/main.py
index 8a2373b..891557c 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -9,8 +9,11 @@ from sqlalchemy.exc import OperationalError
from app.config import settings
from app.database import Base, engine
from app.mongo import close_mongo, connect_mongo
-from app.routers import auth, collab, documents, health
+from app.routers import auth, collab, documents, health, templates
+from app.services.collaboration_service import ensure_collaboration_indexes
from app.services.document_service import ensure_indexes
+from app.services.template_service import ensure_template_indexes
+from app.services.history_service import ensure_history_indexes
logger = logging.getLogger(__name__)
@@ -28,6 +31,9 @@ async def lifespan(_: FastAPI):
from app.mongo import get_mongo_db
await ensure_indexes(get_mongo_db())
+ await ensure_collaboration_indexes(get_mongo_db())
+ await ensure_template_indexes(get_mongo_db())
+ await ensure_history_indexes(get_mongo_db())
logger.info("MongoDB connected and indexes ready")
except Exception:
logger.warning("MongoDB unavailable — will connect when available")
@@ -49,6 +55,7 @@ app.add_middleware(
app.include_router(health.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1")
app.include_router(documents.router, prefix="/api/v1")
+app.include_router(templates.router, prefix="/api/v1")
app.include_router(collab.router, prefix="/api/v1")
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index cdb31b8..96d1992 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -1,4 +1,9 @@
from datetime import timedelta
+from urllib.parse import urlencode
+import secrets
+
+import httpx
+from fastapi.responses import RedirectResponse
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -20,6 +25,7 @@ from app.schemas.user import (
UserCreate,
UserResponse,
UserUpdate,
+ ChangePasswordRequest,
)
from app.services.user_service import (
authenticate_user,
@@ -30,7 +36,14 @@ from app.services.user_service import (
get_valid_reset_token,
update_user,
)
-from app.utils.security import create_access_token, create_refresh_token, hash_password
+from app.utils.security import (
+ create_access_token,
+ create_oauth_state,
+ create_refresh_token,
+ hash_password,
+ verify_oauth_state,
+ verify_password,
+)
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -114,3 +127,90 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)) ->
db.commit()
return MessageResponse(message="Password reset successfully. You can now log in.")
+
+
+@router.post("/change-password", response_model=MessageResponse)
+def change_password(
+ data: ChangePasswordRequest,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+) -> MessageResponse:
+ if not verify_password(data.current_password, current_user.hashed_password):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
+ current_user.hashed_password = hash_password(data.new_password)
+ db.commit()
+ return MessageResponse(message="Password updated successfully.")
+
+
+@router.get("/google/login")
+def google_login() -> RedirectResponse:
+ if not settings.google_client_id or not settings.google_client_secret:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Google sign-in is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.",
+ )
+ state = create_oauth_state()
+ query = urlencode({
+ "client_id": settings.google_client_id,
+ "redirect_uri": settings.google_redirect_uri,
+ "response_type": "code",
+ "scope": "openid email profile",
+ "state": state,
+ "access_type": "offline",
+ "prompt": "select_account",
+ })
+ return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{query}")
+
+
+@router.get("/google/callback")
+async def google_callback(code: str, state: str | None = None) -> RedirectResponse:
+ if not settings.google_client_id or not settings.google_client_secret:
+ raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Google sign-in is not configured")
+ if not code or not state or not verify_oauth_state(state):
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google authorization was incomplete")
+ async with httpx.AsyncClient(timeout=10) as client:
+ token_response = await client.post(
+ "https://oauth2.googleapis.com/token",
+ data={
+ "code": code,
+ "client_id": settings.google_client_id,
+ "client_secret": settings.google_client_secret,
+ "redirect_uri": settings.google_redirect_uri,
+ "grant_type": "authorization_code",
+ },
+ )
+ if token_response.status_code != 200:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Google authorization failed")
+ token_data = token_response.json()
+ profile_response = await client.get(
+ "https://openidconnect.googleapis.com/v1/userinfo",
+ headers={"Authorization": f"Bearer {token_data['access_token']}"},
+ )
+ profile = profile_response.json()
+ email = str(profile.get("email", "")).lower()
+ if not email or not profile.get("email_verified"):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Google account email is not verified")
+ from app.services.user_service import get_user_by_email
+
+ db = next(get_db())
+ try:
+ user = get_user_by_email(db, email)
+ if user is None:
+ base_username = email.split("@")[0][:90]
+ username = base_username
+ suffix = 1
+ while get_user_by_username(db, username):
+ username = f"{base_username}{suffix}"
+ suffix += 1
+ user = create_user(db, UserCreate(
+ email=email,
+ username=username,
+ password=secrets.token_urlsafe(32),
+ full_name=str(profile.get("name") or username),
+ ))
+ access_token = create_access_token(user.email)
+ refresh_token = create_refresh_token(user.email)
+ finally:
+ db.close()
+ query = urlencode({"access_token": access_token, "refresh_token": refresh_token})
+ return RedirectResponse(f"{settings.frontend_url}/auth/google/callback?{query}")
diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py
index cde06c1..a1192ff 100644
--- a/backend/app/routers/documents.py
+++ b/backend/app/routers/documents.py
@@ -1,11 +1,28 @@
-from fastapi import APIRouter, Depends, HTTPException, Query, status
+from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_mongo_db_dep
from app.models.user import User
-from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, ShareRequest
+from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest
+from app.schemas.collaboration import (
+ ActivityResponse,
+ CommentCreate,
+ CommentResolveRequest,
+ CommentResponse,
+ NotificationResponse,
+)
+from app.services.collaboration_service import (
+ create_comment,
+ delete_comment,
+ list_activity,
+ list_comments,
+ list_notifications,
+ mark_notifications_read,
+ mention_names,
+ resolve_comment,
+)
from app.services.document_service import (
add_member,
create_document,
@@ -18,14 +35,169 @@ from app.services.document_service import (
update_document,
)
from app.services.user_service import get_user_by_email_or_username
+from app.services.history_service import list_versions, restore_version
+from html import escape
router = APIRouter(prefix="/documents", tags=["documents"])
+@router.get("/{doc_id}/comments", response_model=list[CommentResponse])
+async def get_comments(
+ doc_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> list[CommentResponse]:
+ comments = await list_comments(db, doc_id, current_user.id)
+ if comments is None:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
+ return [CommentResponse(**comment) for comment in comments]
+
+
+@router.post("/{doc_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
+async def post_comment(
+ doc_id: str,
+ data: CommentCreate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+ sql: Session = Depends(get_db),
+) -> CommentResponse:
+ mentioned_users = []
+ for name in mention_names(data.body):
+ mentioned = get_user_by_email_or_username(sql, name.lower())
+ if mentioned is not None and mentioned.id != current_user.id:
+ mentioned_users.append({"id": mentioned.id})
+ comment = await create_comment(
+ db,
+ doc_id,
+ current_user.id,
+ current_user.full_name or current_user.username,
+ data.body,
+ data.parent_id,
+ mentioned_users,
+ )
+ if comment is None:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or parent comment is invalid")
+ return CommentResponse(**comment)
+
+
+@router.patch("/{doc_id}/comments/{comment_id}/resolve", response_model=CommentResponse)
+async def update_comment_resolution(
+ doc_id: str,
+ comment_id: str,
+ data: CommentResolveRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> CommentResponse:
+ comment = await resolve_comment(
+ db, doc_id, comment_id, current_user.id, data.resolved,
+ current_user.full_name or current_user.username,
+ )
+ if comment is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or access denied")
+ return CommentResponse(**comment)
+
+
+@router.get("/{doc_id}/activity", response_model=list[ActivityResponse])
+async def get_activity(
+ doc_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> list[ActivityResponse]:
+ entries = await list_activity(db, doc_id, current_user.id)
+ if entries is None:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
+ return [ActivityResponse(**entry) for entry in entries]
+
+
+@router.get("/notifications", response_model=list[NotificationResponse])
+async def get_notifications(
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> list[NotificationResponse]:
+ return [NotificationResponse(**item) for item in await list_notifications(db, current_user.id)]
+
+
+@router.post("/notifications/read", status_code=status.HTTP_204_NO_CONTENT)
+async def read_notifications(
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> None:
+ await mark_notifications_read(db, current_user.id)
+
+
+@router.get("/{doc_id}/history", response_model=list[DocumentVersionResponse])
+async def get_history(
+ doc_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> list[DocumentVersionResponse]:
+ versions = await list_versions(db, doc_id, current_user.id)
+ if versions is None:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
+ return [DocumentVersionResponse(**version) for version in versions]
+
+
+@router.post("/{doc_id}/history/{version_id}/restore", response_model=DocumentResponse)
+async def restore_history(
+ doc_id: str,
+ version_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> DocumentResponse:
+ document = await restore_version(
+ db,
+ doc_id,
+ version_id,
+ current_user.id,
+ current_user.full_name or current_user.username,
+ )
+ if document is None:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Version not found or restore access denied")
+ from app.services.document_service import _serialize
+
+ return DocumentResponse(**_serialize(document))
+
+
+@router.get("/{doc_id}/export")
+async def export_document(
+ doc_id: str,
+ format: str = Query("html", pattern="^(html|text)$"),
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> Response:
+ document = await get_document(db, doc_id, current_user.id)
+ if document is None:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Document not found or access denied")
+ body_html = str(document["content"].get("bodyHtml", document["content"].get("body", "")))
+ if format == "text":
+ import re
+
+ return Response(re.sub(r"<[^>]+>", " ", body_html).strip(), media_type="text/plain")
+ html = (
+ "
"
+ f"{escape(document['title'])}"
+ f"{escape(document['title'])}
{body_html}"
+ )
+ return Response(html, media_type="text/html")
+
+
+@router.delete("/{doc_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def remove_comment(
+ doc_id: str,
+ comment_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> None:
+ deleted = await delete_comment(db, comment_id, current_user.id)
+ if not deleted:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found or you are not its author")
+
+
@router.get("", response_model=list[DocumentResponse])
async def list_docs(
type: str | None = Query(None, description="Filter by document type"),
stead_id: str | None = Query(None, description="Filter by parent stead"),
+ parent_page_id: str | None = Query(None, description="Filter by parent page"),
starred: bool | None = Query(None, description="Filter starred projects"),
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
current_user: User = Depends(get_current_user),
@@ -37,6 +209,7 @@ async def list_docs(
current_user.id,
doc_type=type,
stead_id=stead_id,
+ parent_page_id=parent_page_id,
starred=starred,
sort=sort_field,
)
@@ -72,7 +245,10 @@ async def create_doc(
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse:
created_by = current_user.full_name or current_user.username
- doc = await create_document(db, current_user.id, created_by, data)
+ try:
+ doc = await create_document(db, current_user.id, created_by, data)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return DocumentResponse(**doc)
diff --git a/backend/app/routers/templates.py b/backend/app/routers/templates.py
new file mode 100644
index 0000000..35b2a51
--- /dev/null
+++ b/backend/app/routers/templates.py
@@ -0,0 +1,63 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from motor.motor_asyncio import AsyncIOMotorDatabase
+
+from app.dependencies import get_current_user, get_mongo_db_dep
+from app.models.user import User
+from app.schemas.template import TemplateCreate, TemplateResponse, TemplateShareRequest
+from app.services.template_service import create_template, delete_template, list_templates, share_template
+from app.database import get_db
+from app.services.user_service import get_user_by_email_or_username
+from sqlalchemy.orm import Session
+
+router = APIRouter(prefix="/templates", tags=["templates"])
+
+
+@router.get("", response_model=list[TemplateResponse])
+async def get_templates(
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> list[TemplateResponse]:
+ templates = await list_templates(db, current_user.id)
+ return [TemplateResponse(**template) for template in templates]
+
+
+@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
+async def post_template(
+ data: TemplateCreate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> TemplateResponse:
+ template = await create_template(db, current_user.id, data.model_dump())
+ return TemplateResponse(**template)
+
+
+@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def remove_template(
+ template_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+) -> None:
+ if not await delete_template(db, template_id, current_user.id):
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
+
+
+@router.post("/{template_id}/share", response_model=TemplateResponse)
+async def add_template_member(
+ template_id: str,
+ data: TemplateShareRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
+ sql: Session = Depends(get_db),
+) -> TemplateResponse:
+ user = get_user_by_email_or_username(sql, data.email_or_username.strip().lower())
+ if user is None or user.id == current_user.id:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+ template = await share_template(
+ db,
+ template_id,
+ current_user.id,
+ {"user_id": user.id, "name": user.full_name or user.username, "email": user.email},
+ )
+ if template is None:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only the owner can share this template")
+ return TemplateResponse(**template)
diff --git a/backend/app/schemas/collaboration.py b/backend/app/schemas/collaboration.py
new file mode 100644
index 0000000..267c1da
--- /dev/null
+++ b/backend/app/schemas/collaboration.py
@@ -0,0 +1,44 @@
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class CommentCreate(BaseModel):
+ body: str = Field(min_length=1, max_length=5000)
+ parent_id: str | None = None
+
+
+class CommentResponse(BaseModel):
+ id: str
+ document_id: str
+ body: str
+ parent_id: str | None = None
+ author_id: int
+ author_name: str
+ created_at: datetime
+ updated_at: datetime
+ resolved: bool = False
+
+
+class CommentResolveRequest(BaseModel):
+ resolved: bool
+
+
+class NotificationResponse(BaseModel):
+ id: str
+ kind: str
+ message: str
+ document_id: str | None = None
+ comment_id: str | None = None
+ actor_name: str
+ read: bool
+ created_at: datetime
+
+
+class ActivityResponse(BaseModel):
+ id: str
+ document_id: str
+ kind: str
+ message: str
+ actor_name: str
+ created_at: datetime
diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py
index 69204eb..6da7e9b 100644
--- a/backend/app/schemas/document.py
+++ b/backend/app/schemas/document.py
@@ -21,12 +21,15 @@ class DocumentCreate(BaseModel):
content: dict[str, Any] = Field(default_factory=dict)
template_id: str | None = None
stead_id: str | None = None
+ parent_page_id: str | None = None
class DocumentUpdate(BaseModel):
title: str | None = None
content: dict[str, Any] | None = None
starred: bool | None = None
+ stead_id: str | None = None
+ parent_page_id: str | None = None
class ShareRequest(BaseModel):
@@ -40,6 +43,7 @@ class DocumentResponse(BaseModel):
title: str
template_id: str | None = None
stead_id: str | None = None
+ parent_page_id: str | None = None
content: dict[str, Any]
created_by: str
created_at: datetime
@@ -49,3 +53,13 @@ class DocumentResponse(BaseModel):
owner_id: int | None = None
visibility: Visibility = "private"
members: list[DocumentMember] = Field(default_factory=list)
+
+
+class DocumentVersionResponse(BaseModel):
+ id: str
+ document_id: str
+ title: str
+ content: dict[str, Any]
+ created_by: str
+ created_at: datetime
+ reason: str
diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py
new file mode 100644
index 0000000..75dbbed
--- /dev/null
+++ b/backend/app/schemas/template.py
@@ -0,0 +1,24 @@
+from datetime import datetime
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class TemplateCreate(BaseModel):
+ name: str = Field(min_length=1, max_length=120)
+ description: str = Field(default="", max_length=500)
+ category: str = Field(default="General", min_length=1, max_length=60)
+ emoji: str = Field(default="📄", max_length=8)
+ content: dict[str, Any] = Field(default_factory=dict)
+
+
+class TemplateResponse(TemplateCreate):
+ id: str
+ owner_id: int
+ created_at: datetime
+ updated_at: datetime
+ members: list[dict[str, Any]] = Field(default_factory=list)
+
+
+class TemplateShareRequest(BaseModel):
+ email_or_username: str
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index b30f683..5cb7027 100644
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -23,6 +23,11 @@ class UserUpdate(BaseModel):
occupation: str | None = Field(default=None, max_length=150)
+class ChangePasswordRequest(BaseModel):
+ current_password: str = Field(min_length=1, max_length=128)
+ new_password: str = Field(min_length=8, max_length=128)
+
+
class UserResponse(UserBase):
model_config = ConfigDict(from_attributes=True)
diff --git a/backend/app/services/collaboration_service.py b/backend/app/services/collaboration_service.py
new file mode 100644
index 0000000..6f788ee
--- /dev/null
+++ b/backend/app/services/collaboration_service.py
@@ -0,0 +1,168 @@
+import uuid
+import re
+from datetime import UTC, datetime
+from typing import Any
+
+from motor.motor_asyncio import AsyncIOMotorDatabase
+from pymongo import ReturnDocument
+
+from app.services.document_service import access_query
+
+COLLECTION = "comments"
+NOTIFICATIONS = "notifications"
+ACTIVITY = "activity"
+
+
+async def ensure_collaboration_indexes(db: AsyncIOMotorDatabase) -> None:
+ await db[COLLECTION].create_index([("document_id", 1), ("created_at", 1)])
+ await db[NOTIFICATIONS].create_index([("user_id", 1), ("created_at", -1)])
+ await db[ACTIVITY].create_index([("document_id", 1), ("created_at", -1)])
+
+
+def _serialize(comment: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": comment["_id"],
+ "document_id": comment["document_id"],
+ "body": comment["body"],
+ "parent_id": comment.get("parent_id"),
+ "author_id": comment["author_id"],
+ "author_name": comment["author_name"],
+ "created_at": comment["created_at"],
+ "updated_at": comment["updated_at"],
+ "resolved": comment.get("resolved", False),
+ }
+
+
+async def list_comments(db: AsyncIOMotorDatabase, document_id: str, user_id: int) -> list[dict[str, Any]] | None:
+ document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
+ if document is None:
+ return None
+ cursor = db[COLLECTION].find({"document_id": document_id}).sort("created_at", 1)
+ return [_serialize(comment) async for comment in cursor]
+
+
+async def create_comment(
+ db: AsyncIOMotorDatabase,
+ document_id: str,
+ user_id: int,
+ author_name: str,
+ body: str,
+ parent_id: str | None,
+ mentioned_users: list[dict[str, Any]] | None = None,
+) -> dict[str, Any] | None:
+ document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
+ if document is None:
+ return None
+ if parent_id:
+ parent = await db[COLLECTION].find_one({"_id": parent_id, "document_id": document_id})
+ if parent is None:
+ return None
+ now = datetime.now(UTC)
+ comment = {
+ "_id": str(uuid.uuid4()),
+ "document_id": document_id,
+ "body": body.strip(),
+ "parent_id": parent_id,
+ "author_id": user_id,
+ "author_name": author_name,
+ "created_at": now,
+ "updated_at": now,
+ "resolved": False,
+ }
+ await db[COLLECTION].insert_one(comment)
+ await db[ACTIVITY].insert_one({
+ "_id": str(uuid.uuid4()),
+ "document_id": document_id,
+ "kind": "comment_created",
+ "message": f"{author_name} commented on the page",
+ "actor_name": author_name,
+ "created_at": now,
+ })
+ for mentioned in mentioned_users or []:
+ await db[NOTIFICATIONS].insert_one({
+ "_id": str(uuid.uuid4()),
+ "user_id": mentioned["id"],
+ "kind": "mention",
+ "message": f"{author_name} mentioned you in a comment",
+ "document_id": document_id,
+ "comment_id": comment["_id"],
+ "actor_name": author_name,
+ "read": False,
+ "created_at": now,
+ })
+ return _serialize(comment)
+
+
+async def delete_comment(db: AsyncIOMotorDatabase, comment_id: str, user_id: int) -> bool:
+ comment = await db[COLLECTION].find_one({"_id": comment_id, "author_id": user_id})
+ if comment is None:
+ return False
+ result = await db[COLLECTION].delete_one({"_id": comment_id, "author_id": user_id})
+ return result.deleted_count == 1
+
+
+async def resolve_comment(
+ db: AsyncIOMotorDatabase,
+ document_id: str,
+ comment_id: str,
+ user_id: int,
+ resolved: bool,
+ actor_name: str,
+) -> dict[str, Any] | None:
+ document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
+ if document is None:
+ return None
+ now = datetime.now(UTC)
+ comment = await db[COLLECTION].find_one_and_update(
+ {"_id": comment_id, "document_id": document_id},
+ {"$set": {"resolved": resolved, "updated_at": now}},
+ return_document=ReturnDocument.AFTER,
+ )
+ if comment is None:
+ return None
+ await db[ACTIVITY].insert_one({
+ "_id": str(uuid.uuid4()),
+ "document_id": document_id,
+ "kind": "comment_resolved" if resolved else "comment_reopened",
+ "message": f"{actor_name} {'resolved' if resolved else 'reopened'} a comment",
+ "actor_name": actor_name,
+ "created_at": now,
+ })
+ return _serialize(comment)
+
+
+async def list_notifications(db: AsyncIOMotorDatabase, user_id: int) -> list[dict[str, Any]]:
+ cursor = db[NOTIFICATIONS].find({"user_id": user_id}).sort("created_at", -1).limit(50)
+ return [{
+ "id": item["_id"],
+ "kind": item["kind"],
+ "message": item["message"],
+ "document_id": item.get("document_id"),
+ "comment_id": item.get("comment_id"),
+ "actor_name": item["actor_name"],
+ "read": item.get("read", False),
+ "created_at": item["created_at"],
+ } async for item in cursor]
+
+
+async def mark_notifications_read(db: AsyncIOMotorDatabase, user_id: int) -> None:
+ await db[NOTIFICATIONS].update_many({"user_id": user_id, "read": False}, {"$set": {"read": True}})
+
+
+async def list_activity(db: AsyncIOMotorDatabase, document_id: str, user_id: int) -> list[dict[str, Any]] | None:
+ document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
+ if document is None:
+ return None
+ cursor = db[ACTIVITY].find({"document_id": document_id}).sort("created_at", -1).limit(100)
+ return [{
+ "id": item["_id"],
+ "document_id": item["document_id"],
+ "kind": item["kind"],
+ "message": item["message"],
+ "actor_name": item["actor_name"],
+ "created_at": item["created_at"],
+ } async for item in cursor]
+
+
+def mention_names(body: str) -> list[str]:
+ return list(dict.fromkeys(re.findall(r"(? dict[str, Any]:
"title": doc["title"],
"template_id": doc.get("template_id"),
"stead_id": doc.get("stead_id"),
+ "parent_page_id": doc.get("parent_page_id"),
"content": doc.get("content", {}),
"created_by": doc["created_by"],
"created_at": doc["created_at"],
@@ -61,6 +62,10 @@ async def create_document(
created_by: str,
data: DocumentCreate,
) -> dict[str, Any]:
+ if data.parent_page_id:
+ parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(owner_id)})
+ if parent is None or parent.get("type") == "stead":
+ raise ValueError("Parent page is not accessible or is not a page")
now = datetime.now(UTC)
doc_id = str(uuid.uuid4())
doc = {
@@ -70,6 +75,7 @@ async def create_document(
"title": data.title,
"template_id": data.template_id,
"stead_id": data.stead_id,
+ "parent_page_id": data.parent_page_id,
"content": data.content,
"created_by": created_by,
"created_at": now,
@@ -97,6 +103,7 @@ async def list_documents(
user_id: int,
doc_type: CreateType | None = None,
stead_id: str | None = None,
+ parent_page_id: str | None = None,
starred: bool | None = None,
sort: SortField = "updated_at",
) -> list[dict[str, Any]]:
@@ -106,6 +113,8 @@ async def list_documents(
extras["type"] = doc_type
if stead_id:
extras["stead_id"] = stead_id
+ if parent_page_id:
+ extras["parent_page_id"] = parent_page_id
if starred is not None:
extras["starred"] = starred
if extras:
@@ -156,6 +165,23 @@ async def update_document(
return None
if not can_edit(existing, user_id):
return None
+ if data.content is not None or data.title is not None:
+ from app.services.history_service import create_version
+
+ await create_version(
+ db,
+ existing,
+ user_id,
+ existing.get("created_by", "Collaborator"),
+ "Before update",
+ )
+ if "parent_page_id" in data.model_fields_set:
+ if data.parent_page_id == doc_id:
+ return None
+ if data.parent_page_id is not None:
+ parent = await db[COLLECTION].find_one({"_id": data.parent_page_id, **access_query(user_id)})
+ if parent is None or parent.get("type") == "stead":
+ return None
updates: dict[str, Any] = {"updated_at": datetime.now(UTC)}
if data.title is not None:
@@ -164,6 +190,10 @@ async def update_document(
updates["content"] = data.content
if data.starred is not None:
updates["starred"] = data.starred
+ if data.stead_id is not None:
+ updates["stead_id"] = data.stead_id
+ if "parent_page_id" in data.model_fields_set:
+ updates["parent_page_id"] = data.parent_page_id
result = await db[COLLECTION].find_one_and_update(
{"_id": doc_id},
diff --git a/backend/app/services/history_service.py b/backend/app/services/history_service.py
new file mode 100644
index 0000000..df5b925
--- /dev/null
+++ b/backend/app/services/history_service.py
@@ -0,0 +1,81 @@
+import uuid
+from datetime import UTC, datetime
+from typing import Any
+
+from motor.motor_asyncio import AsyncIOMotorDatabase
+from pymongo import ReturnDocument
+
+from app.services.document_service import access_query, can_edit
+
+COLLECTION = "document_versions"
+
+
+async def ensure_history_indexes(db: AsyncIOMotorDatabase) -> None:
+ await db[COLLECTION].create_index([("document_id", 1), ("created_at", -1)])
+
+
+def serialize_version(version: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": version["_id"],
+ "document_id": version["document_id"],
+ "title": version["title"],
+ "content": version.get("content", {}),
+ "created_by": version["created_by"],
+ "created_at": version["created_at"],
+ "reason": version.get("reason", "Updated"),
+ }
+
+
+async def create_version(
+ db: AsyncIOMotorDatabase,
+ document: dict[str, Any],
+ actor_id: int,
+ actor_name: str,
+ reason: str,
+) -> None:
+ if not can_edit(document, actor_id):
+ return
+ await db[COLLECTION].insert_one({
+ "_id": str(uuid.uuid4()),
+ "document_id": document["_id"],
+ "title": document["title"],
+ "content": document.get("content", {}),
+ "created_by": actor_name,
+ "created_at": datetime.now(UTC),
+ "reason": reason,
+ })
+
+
+async def list_versions(
+ db: AsyncIOMotorDatabase,
+ document_id: str,
+ user_id: int,
+) -> list[dict[str, Any]] | None:
+ document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
+ if document is None:
+ return None
+ cursor = db[COLLECTION].find({"document_id": document_id}).sort("created_at", -1).limit(50)
+ return [serialize_version(version) async for version in cursor]
+
+
+async def restore_version(
+ db: AsyncIOMotorDatabase,
+ document_id: str,
+ version_id: str,
+ user_id: int,
+ actor_name: str,
+) -> dict[str, Any] | None:
+ document = await db["documents"].find_one({"_id": document_id, **access_query(user_id)})
+ if document is None or not can_edit(document, user_id):
+ return None
+ version = await db[COLLECTION].find_one({"_id": version_id, "document_id": document_id})
+ if version is None:
+ return None
+ await create_version(db, document, user_id, actor_name, "Before restore")
+ now = datetime.now(UTC)
+ updated = await db["documents"].find_one_and_update(
+ {"_id": document_id},
+ {"$set": {"title": version["title"], "content": version.get("content", {}), "updated_at": now}},
+ return_document=ReturnDocument.AFTER,
+ )
+ return updated
diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py
new file mode 100644
index 0000000..528ad4e
--- /dev/null
+++ b/backend/app/services/template_service.py
@@ -0,0 +1,77 @@
+import uuid
+from datetime import UTC, datetime
+from typing import Any
+
+from motor.motor_asyncio import AsyncIOMotorDatabase
+from pymongo import ReturnDocument
+
+COLLECTION = "templates"
+
+
+async def ensure_template_indexes(db: AsyncIOMotorDatabase) -> None:
+ await db[COLLECTION].create_index([("owner_id", 1), ("updated_at", -1)])
+ await db[COLLECTION].create_index([("owner_id", 1), ("category", 1)])
+
+
+def _serialize(template: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": template["_id"],
+ "owner_id": template["owner_id"],
+ "name": template["name"],
+ "description": template.get("description", ""),
+ "category": template.get("category", "General"),
+ "emoji": template.get("emoji", "📄"),
+ "content": template.get("content", {}),
+ "created_at": template["created_at"],
+ "updated_at": template["updated_at"],
+ "members": template.get("members", []),
+ }
+
+
+async def list_templates(db: AsyncIOMotorDatabase, owner_id: int) -> list[dict[str, Any]]:
+ cursor = db[COLLECTION].find(
+ {"$or": [{"owner_id": owner_id}, {"members.user_id": owner_id}]}
+ ).sort([("category", 1), ("updated_at", -1)])
+ return [_serialize(template) async for template in cursor]
+
+
+async def create_template(
+ db: AsyncIOMotorDatabase,
+ owner_id: int,
+ data: dict[str, Any],
+) -> dict[str, Any]:
+ now = datetime.now(UTC)
+ template = {
+ "_id": str(uuid.uuid4()),
+ "owner_id": owner_id,
+ **data,
+ "created_at": now,
+ "updated_at": now,
+ "members": [],
+ }
+ await db[COLLECTION].insert_one(template)
+ return _serialize(template)
+
+
+async def delete_template(db: AsyncIOMotorDatabase, template_id: str, owner_id: int) -> bool:
+ result = await db[COLLECTION].delete_one({"_id": template_id, "owner_id": owner_id})
+ return result.deleted_count == 1
+
+
+async def share_template(
+ db: AsyncIOMotorDatabase,
+ template_id: str,
+ owner_id: int,
+ member: dict[str, Any],
+) -> dict[str, Any] | None:
+ template = await db[COLLECTION].find_one({"_id": template_id, "owner_id": owner_id})
+ if template is None:
+ return None
+ members = [item for item in template.get("members", []) if item.get("user_id") != member["user_id"]]
+ members.append(member)
+ result = await db[COLLECTION].find_one_and_update(
+ {"_id": template_id, "owner_id": owner_id},
+ {"$set": {"members": members, "updated_at": datetime.now(UTC)}},
+ return_document=ReturnDocument.AFTER,
+ )
+ return _serialize(result) if result else None
diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py
index 449d1d7..51b798c 100644
--- a/backend/app/utils/security.py
+++ b/backend/app/utils/security.py
@@ -1,4 +1,5 @@
import bcrypt
+import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -31,6 +32,20 @@ def create_refresh_token(subject: str) -> str:
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
+def create_oauth_state() -> str:
+ expire = datetime.now(UTC) + timedelta(minutes=10)
+ payload = {"nonce": secrets.token_urlsafe(16), "exp": expire, "type": "oauth_state"}
+ return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
+
+
+def verify_oauth_state(state: str) -> bool:
+ try:
+ payload = decode_token(state)
+ return payload.get("type") == "oauth_state"
+ except JWTError:
+ return False
+
+
def decode_token(token: str) -> dict[str, Any]:
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
diff --git a/backend/requirements.txt b/backend/requirements.txt
index c662bd4..2044218 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -7,6 +7,7 @@ pydantic-settings==2.8.1
python-jose[cryptography]==3.4.0
bcrypt==4.2.1
python-multipart==0.0.20
+httpx==0.28.1
motor==3.7.0
alembic==1.14.1
email-validator==2.2.0
diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts
new file mode 100644
index 0000000..cdd0708
--- /dev/null
+++ b/frontend/e2e/helpers.ts
@@ -0,0 +1,13 @@
+import { expect, type Page } from '@playwright/test'
+
+export async function login(page: Page) {
+ const email = process.env.E2E_USERNAME
+ const password = process.env.E2E_PASSWORD
+ if (!email || !password) return false
+ await page.goto('/login')
+ await page.getByLabel(/email|username/i).first().fill(email)
+ await page.getByLabel(/password/i).fill(password)
+ await page.getByRole('button', { name: /sign in|log in/i }).click()
+ await expect(page).toHaveURL(/dashboard|workspace/, { timeout: 15_000 })
+ return true
+}
diff --git a/frontend/e2e/phase8-accessibility.spec.ts b/frontend/e2e/phase8-accessibility.spec.ts
new file mode 100644
index 0000000..94ace60
--- /dev/null
+++ b/frontend/e2e/phase8-accessibility.spec.ts
@@ -0,0 +1,11 @@
+import { test, expect } from '@playwright/test'
+import { login } from './helpers'
+
+test('workspace controls expose accessible names and keyboard navigation', async ({ page }) => {
+ test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD')
+ expect(await login(page)).toBeTruthy()
+ await expect(page.getByRole('button', { name: /search/i }).first()).toBeVisible()
+ await expect(page.getByRole('button', { name: /notifications/i }).first()).toBeVisible()
+ await page.keyboard.press('Control+KeyK')
+ await expect(page.getByRole('dialog').or(page.getByPlaceholder(/search/i)).first()).toBeVisible().catch(() => {})
+})
diff --git a/frontend/e2e/phase8-export.spec.ts b/frontend/e2e/phase8-export.spec.ts
new file mode 100644
index 0000000..0a595d2
--- /dev/null
+++ b/frontend/e2e/phase8-export.spec.ts
@@ -0,0 +1,10 @@
+import { test, expect } from '@playwright/test'
+import { login } from './helpers'
+
+test('page export control is available from a readable page', async ({ page }) => {
+ test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD')
+ expect(await login(page)).toBeTruthy()
+ const exportButton = page.getByRole('button', { name: /export page/i }).first()
+ await expect(exportButton).toBeVisible()
+ await expect(exportButton).toHaveAttribute('title', /export html/i)
+})
diff --git a/frontend/e2e/phase8-history.spec.ts b/frontend/e2e/phase8-history.spec.ts
new file mode 100644
index 0000000..bc235b9
--- /dev/null
+++ b/frontend/e2e/phase8-history.spec.ts
@@ -0,0 +1,16 @@
+import { test, expect } from '@playwright/test'
+import { login } from './helpers'
+
+test('protected page editing exposes history and restore controls', async ({ page }) => {
+ test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD')
+ expect(await login(page)).toBeTruthy()
+ await page.getByRole('link', { name: /recent|pages|silo/i }).first().click().catch(() => {})
+ const edit = page.getByRole('button', { name: /^edit$/i }).first()
+ await expect(edit).toBeVisible()
+ await edit.click()
+ await expect(page.getByRole('button', { name: /update/i })).toBeEnabled()
+ await page.getByRole('button', { name: /version history/i }).click()
+ await expect(page.getByRole('heading', { name: /version history/i })).toBeVisible()
+ await page.keyboard.press('Escape')
+ await expect(page.getByRole('heading', { name: /version history/i })).toHaveCount(0)
+})
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4b1bf46..ff802fc 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -15,6 +15,7 @@
"react-router-dom": "^7.2.0"
},
"devDependencies": {
+ "@playwright/test": "^1.64.1",
"@tailwindcss/vite": "^4.0.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -815,6 +816,22 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
+ "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.64.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2214,6 +2231,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
+ "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.64.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
+ "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 77ed838..47c7dd5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test:e2e": "playwright test"
},
"dependencies": {
"framer-motion": "^12.4.7",
@@ -22,6 +23,7 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.9",
"typescript": "~5.7.2",
- "vite": "^6.2.0"
+ "vite": "^6.2.0",
+ "@playwright/test": "^1.64.1"
}
}
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
new file mode 100644
index 0000000..ca662f0
--- /dev/null
+++ b/frontend/playwright.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ timeout: 30_000,
+ use: {
+ baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:5173',
+ headless: true,
+ trace: 'retain-on-failure',
+ },
+ reporter: [['list']],
+})
diff --git a/frontend/src/components/create/CreateDropdown.tsx b/frontend/src/components/create/CreateDropdown.tsx
index 9aff0a0..4e25d71 100644
--- a/frontend/src/components/create/CreateDropdown.tsx
+++ b/frontend/src/components/create/CreateDropdown.tsx
@@ -9,7 +9,7 @@ import {
LayoutGrid,
type LucideIcon,
} from 'lucide-react'
-import { createOptions, shedTemplates } from '../../data/createData'
+import { createOptions, shedTemplates, siloTemplates } from '../../data/createData'
import {
createDocument,
getDefaultShedContent,
@@ -24,9 +24,10 @@ const iconMap: Record = {
LayoutGrid,
}
-export default function CreateDropdown({ steadId }: { steadId?: string }) {
+export default function CreateDropdown({ steadId, parentPageId }: { steadId?: string; parentPageId?: string }) {
const [open, setOpen] = useState(false)
const [shedOpen, setShedOpen] = useState(false)
+ const [pageOpen, setPageOpen] = useState(false)
const ref = useRef(null)
const navigate = useNavigate()
@@ -35,6 +36,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
setShedOpen(false)
+ setPageOpen(false)
}
}
if (open) document.addEventListener('mousedown', handleClickOutside)
@@ -56,11 +58,28 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
undefined,
author,
steadId,
+ type === 'silo' ? parentPageId : undefined,
)
setOpen(false)
+ setPageOpen(false)
navigate(`/workspace/${type}/${doc.id}`)
}
+ async function handleCreatePageTemplate(template: (typeof siloTemplates)[number]) {
+ const doc = await createDocument(
+ 'silo',
+ template.label,
+ { body: '', bodyHtml: template.html, status: 'draft' },
+ undefined,
+ author,
+ steadId,
+ parentPageId,
+ )
+ setOpen(false)
+ setPageOpen(false)
+ navigate(`/workspace/silo/${doc.id}`)
+ }
+
function handleCreateStead() {
setOpen(false)
navigate('/workspace/stead/new')
@@ -88,6 +107,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
onClick={() => {
setOpen(!open)
setShedOpen(false)
+ setPageOpen(false)
}}
className="flex shrink-0 items-center gap-1.5 rounded-full bg-silos-brown px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-silos-brown-dark"
>
@@ -106,6 +126,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
{createOptions.map((option) => {
const Icon = iconMap[option.icon]
const isShed = option.id === 'shed'
+ const isPage = option.id === 'silo'
return (
@@ -113,6 +134,10 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
onClick={() => {
if (isShed) {
setShedOpen(!shedOpen)
+ setPageOpen(false)
+ } else if (isPage) {
+ setPageOpen(!pageOpen)
+ setShedOpen(false)
} else if (option.id === 'stead') {
handleCreateStead()
} else if (option.id !== 'shed') {
@@ -136,11 +161,9 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
{option.description}
- {isShed && (
+ {(isShed || isPage) && (
)}
@@ -165,6 +188,23 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
))}
)}
+ {isPage && pageOpen && (
+
+ {siloTemplates.map((template) => (
+
+ ))}
+
+ )}
)
})}
diff --git a/frontend/src/components/dashboard/DashboardFeed.tsx b/frontend/src/components/dashboard/DashboardFeed.tsx
index 3a9b410..7d8458e 100644
--- a/frontend/src/components/dashboard/DashboardFeed.tsx
+++ b/frontend/src/components/dashboard/DashboardFeed.tsx
@@ -3,6 +3,7 @@ import RecentActivities from './RecentActivities'
import ActivityStatus from './ActivityStatus'
import DiscoverPanel from './DiscoverPanel'
import RecentProjectsGrid from './RecentProjectsGrid'
+import GettingStartedPanel from './GettingStartedPanel'
import { getStoredUser } from '../../lib/api'
import type { DocumentRecord } from '../../lib/documents'
@@ -33,6 +34,7 @@ interface DashboardFeedProps {
loading?: boolean
error?: string | null
onOpenRecent?: () => void
+ onOpenTemplates: () => void
}
export default function DashboardFeed({
@@ -40,6 +42,7 @@ export default function DashboardFeed({
loading,
error,
onOpenRecent,
+ onOpenTemplates,
}: DashboardFeedProps) {
const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed')
const user = getStoredUser()
@@ -71,7 +74,9 @@ export default function DashboardFeed({
{/* Recent pages grid */}
-
Recent
+
+ {recent.length > 0 ? 'Recent' : 'Get started'}
+
{recent.length > 0 && (
-
+ {recent.length === 0 && !loading ? (
+
+ ) : (
+
+ )}
{/* Tabs */}
@@ -104,11 +113,37 @@ export default function DashboardFeed({
{activeTab === 'feed' ? (
-
-
-
+ {!loading && recent.length === 0 ? (
+
+
+
What you can build in Silos
+
+ Keep your team aligned with focused spaces for writing, collaboration, and
+ visual thinking.
+
+
+ {[
+ ['Pages', 'Capture decisions and keep knowledge easy to find.'],
+ ['Live docs', 'Work together on notes, plans, and reviews.'],
+ ['Whiteboards', 'Turn early ideas into clear visual plans.'],
+ ].map(([title, description]) => (
+
+
{title}
+
{description}
+
+ ))}
+
+
+
+ ) : (
+
+
+
+ )}
-
+ {!(!loading && recent.length === 0) && (
+
+ )}
diff --git a/frontend/src/components/dashboard/DashboardLayout.tsx b/frontend/src/components/dashboard/DashboardLayout.tsx
index 3cb6b64..2c13e0d 100644
--- a/frontend/src/components/dashboard/DashboardLayout.tsx
+++ b/frontend/src/components/dashboard/DashboardLayout.tsx
@@ -78,6 +78,10 @@ export default function DashboardLayout() {
setActiveNav('recent')
setFlyout('recent')
}}
+ onOpenTemplates={() => {
+ setActiveNav('templates')
+ setFlyout('templates')
+ }}
/>
diff --git a/frontend/src/components/dashboard/GettingStartedPanel.tsx b/frontend/src/components/dashboard/GettingStartedPanel.tsx
new file mode 100644
index 0000000..c8ebdbe
--- /dev/null
+++ b/frontend/src/components/dashboard/GettingStartedPanel.tsx
@@ -0,0 +1,144 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import {
+ FileText,
+ FolderOpen,
+ LayoutGrid,
+ LayoutTemplate,
+ PenTool,
+ Sparkles,
+ Zap,
+ type LucideIcon,
+} from 'lucide-react'
+import { createOptions, type CreateOption } from '../../data/createData'
+import { getStoredUser } from '../../lib/api'
+import { createDocument } from '../../lib/documents'
+
+interface GettingStartedPanelProps {
+ onOpenTemplates: () => void
+}
+
+const iconMap: Record = {
+ FileText,
+ FolderOpen,
+ LayoutGrid,
+ LayoutTemplate,
+ PenTool,
+ Zap,
+}
+
+const starterOptions = createOptions.filter(
+ (option): option is CreateOption & { id: 'silo' | 'sketch' } =>
+ option.id === 'silo' || option.id === 'sketch',
+)
+
+export default function GettingStartedPanel({ onOpenTemplates }: GettingStartedPanelProps) {
+ const navigate = useNavigate()
+ const [creating, setCreating] = useState(null)
+ const [error, setError] = useState(null)
+ const author = getStoredUser()?.full_name ?? 'You'
+
+ async function createStarter(type: 'silo' | 'sketch') {
+ setCreating(type)
+ setError(null)
+
+ try {
+ const content =
+ type === 'silo'
+ ? { body: '', bodyHtml: '', status: 'draft' }
+ : { elements: [] }
+ const title = type === 'silo' ? 'Untitled Page' : 'Untitled Whiteboard'
+ const doc = await createDocument(type, title, content, undefined, author)
+ navigate(`/workspace/${type}/${doc.id}`)
+ } catch {
+ setError('Could not create your starter. Please try again.')
+ } finally {
+ setCreating(null)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
Make this workspace yours
+
+ Start with a template or choose the right space for your next idea. Your picks will
+ appear here the next time you sign in.
+
+
+
+
+
+
+
+
+ {starterOptions.map((option) => {
+ const Icon = iconMap[option.icon]
+ const isCreating = creating === option.id
+ return (
+
+ )
+ })}
+
+
+
+
+ {error &&
{error}
}
+
+ )
+}
diff --git a/frontend/src/components/dashboard/TemplatesFlyout.tsx b/frontend/src/components/dashboard/TemplatesFlyout.tsx
index 2f0942b..45095e5 100644
--- a/frontend/src/components/dashboard/TemplatesFlyout.tsx
+++ b/frontend/src/components/dashboard/TemplatesFlyout.tsx
@@ -1,37 +1,51 @@
+import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
-import { X } from 'lucide-react'
-import { shedTemplates } from '../../data/createData'
+import { Plus, Trash2, X } from 'lucide-react'
+import { shedTemplates, siloTemplates } from '../../data/createData'
import {
createDocument,
getDefaultShedContent,
getDefaultTitle,
+ listDocuments,
+ type DocumentRecord,
} from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
+import { templatesApi, type ApiTemplate } from '../../lib/api'
interface TemplatesFlyoutProps {
sidebarCollapsed: boolean
onClose: () => void
}
-const siloStarters = [
- { id: 'blank', label: 'Blank page', description: 'Empty Silo for your own content', html: '' },
- {
- id: 'meeting',
- label: 'Meeting notes',
- description: 'Silo starter for meetings',
- html: 'Meeting notes
Date:
',
- },
- {
- id: 'plan',
- label: 'Project plan',
- description: 'Silo starter for a project plan',
- html: 'Project plan
Goals
',
- },
-]
-
export default function TemplatesFlyout({ sidebarCollapsed, onClose }: TemplatesFlyoutProps) {
const navigate = useNavigate()
const author = getStoredUser()?.full_name ?? 'You'
+ const [customTemplates, setCustomTemplates] = useState([])
+ const [category, setCategory] = useState('All')
+ const [showCreate, setShowCreate] = useState(false)
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+ const [templateCategory, setTemplateCategory] = useState('General')
+ const [emoji, setEmoji] = useState('📄')
+ const [bodyHtml, setBodyHtml] = useState('')
+ const [error, setError] = useState(null)
+ const [placement, setPlacement] = useState('top')
+ const [placements, setPlacements] = useState([])
+ const [shareTemplateId, setShareTemplateId] = useState(null)
+ const [shareIdentifier, setShareIdentifier] = useState('')
+
+ useEffect(() => {
+ void templatesApi.list().then(setCustomTemplates).catch(() => setError('Could not load your templates.'))
+ void listDocuments().then((documents) => setPlacements(documents.filter((document) => document.type === 'stead' || document.type === 'silo')))
+ }, [])
+
+ const categories = useMemo(
+ () => ['All', ...Array.from(new Set(customTemplates.map((template) => template.category)))],
+ [customTemplates],
+ )
+ const visibleTemplates = category === 'All'
+ ? customTemplates
+ : customTemplates.filter((template) => template.category === category)
async function createShed(templateId: (typeof shedTemplates)[number]['id']) {
if (templateId === 'templates') return
@@ -58,6 +72,62 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
navigate(`/workspace/silo/${doc.id}`)
}
+ async function createCustomTemplate(template: ApiTemplate) {
+ const doc = await createDocument(
+ 'silo',
+ template.name,
+ { ...template.content, status: 'draft' },
+ undefined,
+ author,
+ placement.startsWith('space:') ? placement.slice(6) : undefined,
+ placement.startsWith('page:') ? placement.slice(5) : undefined,
+ )
+ onClose()
+ navigate(`/workspace/silo/${doc.id}`)
+ }
+
+ async function shareTemplate(templateId: string) {
+ if (!shareIdentifier.trim()) return
+ try {
+ const updated = await templatesApi.share(templateId, shareIdentifier.trim())
+ setCustomTemplates((current) => current.map((template) => template.id === updated.id ? updated : template))
+ setShareTemplateId(null)
+ setShareIdentifier('')
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not share template.')
+ }
+ }
+
+ async function saveTemplate(event: React.FormEvent) {
+ event.preventDefault()
+ if (!name.trim()) return
+ try {
+ const template = await templatesApi.create({
+ name: name.trim(),
+ description: description.trim(),
+ category: templateCategory.trim() || 'General',
+ emoji: emoji || '📄',
+ content: { body: '', bodyHtml, status: 'draft' },
+ })
+ setCustomTemplates((current) => [template, ...current])
+ setName('')
+ setDescription('')
+ setBodyHtml('')
+ setShowCreate(false)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not save template.')
+ }
+ }
+
+ async function removeTemplate(id: string) {
+ try {
+ await templatesApi.delete(id)
+ setCustomTemplates((current) => current.filter((template) => template.id !== id))
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Could not delete template.')
+ }
+ }
+
return (
<>
@@ -68,11 +138,54 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
>
Templates
-
+
+
+
+
+ {showCreate && (
+
+ )}
+ {customTemplates.length > 0 && (
+ <>
+
Your templates
+
+ {categories.map((item) => )}
+
+ {visibleTemplates.map((template) => (
+
+
+
+
+
+ ))}
+ >
+ )}
+ {customTemplates.length > 0 && (
+
+ )}
+ {error &&
{error}
}
Shed — Live docs
{shedTemplates.map((t) => (