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:

  • Attendees
  • Agenda
  • Actions
', - }, - { - id: 'plan', - label: 'Project plan', - description: 'Silo starter for a project plan', - html: '

Project plan

Goals

  • Milestone 1
  • Milestone 2
', - }, -] - 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 && ( +
void saveTemplate(event)} className="mb-4 rounded-xl border border-silos-cream-dark bg-silos-cream/40 p-3"> +

New page template

+
+ setEmoji(event.target.value)} className="w-12 rounded-lg border border-silos-cream-dark bg-white px-2 py-2 text-center" aria-label="Template emoji" /> + setName(event.target.value)} placeholder="Template name" required className="min-w-0 flex-1 rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" /> +
+ setDescription(event.target.value)} placeholder="Description" className="mt-2 w-full rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" /> + setTemplateCategory(event.target.value)} placeholder="Category, e.g. Product" className="mt-2 w-full rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" /> +