feat: implement comments and activity panel in workspace

- Added CommentsPanel component for managing comments on documents.
- Integrated comment creation, deletion, and resolution functionalities.
- Implemented activity tracking for document interactions.
- Enhanced SketchPage with editing and saving capabilities.
- Introduced template management API endpoints for creating, sharing, and deleting templates.
- Added runtime validation report for frontend and backend.
- Created GettingStartedPanel for user onboarding with template options.
- Implemented Playwright E2E tests for accessibility and export functionality.
This commit is contained in:
Anurag Jangid 2026-10-22 21:18:10 +05:30
parent 6e354ba041
commit 809235e962
41 changed files with 2840 additions and 285 deletions

169
README.md
View file

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

View file

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

View file

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

View file

@ -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}")

View file

@ -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 = (
"<!doctype html><html><head><meta charset='utf-8'>"
f"<title>{escape(document['title'])}</title></head><body>"
f"<h1>{escape(document['title'])}</h1>{body_html}</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
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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"(?<![\w@])@([A-Za-z0-9_.-]{2,50})", body)))

View file

@ -30,6 +30,7 @@ def _serialize(doc: dict[str, Any]) -> 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},

View file

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

View file

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

View file

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

View file

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

13
frontend/e2e/helpers.ts Normal file
View file

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

View file

@ -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(() => {})
})

View file

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

View file

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

View file

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

View file

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

View file

@ -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']],
})

View file

@ -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<string, LucideIcon> = {
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<HTMLDivElement>(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 (
<div key={option.id} className="relative">
@ -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 }) {
</p>
<p className="mt-0.5 text-xs text-silos-muted">{option.description}</p>
</div>
{isShed && (
{(isShed || isPage) && (
<ChevronRight
className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${
shedOpen ? 'rotate-90' : ''
}`}
className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${(isShed ? shedOpen : pageOpen) ? 'rotate-90' : ''}`}
/>
)}
</button>
@ -165,6 +188,23 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
))}
</div>
)}
{isPage && pageOpen && (
<div className="ml-12 mr-2 mb-2 space-y-0.5 rounded-lg border border-silos-cream-dark bg-silos-cream/30 p-1.5">
{siloTemplates.map((template) => (
<button
key={template.id}
onClick={() => void handleCreatePageTemplate(template)}
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-white"
>
<span>{template.emoji}</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-silos-text">{template.label}</p>
<p className="truncate text-[10px] text-silos-muted">{template.description}</p>
</div>
</button>
))}
</div>
)}
</div>
)
})}

View file

@ -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 */}
<div className="mt-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-silos-text">Recent</h2>
<h2 className="text-sm font-semibold text-silos-text">
{recent.length > 0 ? 'Recent' : 'Get started'}
</h2>
{recent.length > 0 && (
<button
type="button"
@ -82,7 +87,11 @@ export default function DashboardFeed({
</button>
)}
</div>
{recent.length === 0 && !loading ? (
<GettingStartedPanel onOpenTemplates={onOpenTemplates} />
) : (
<RecentProjectsGrid documents={recent} loading={loading} />
)}
</div>
{/* Tabs */}
@ -104,11 +113,37 @@ export default function DashboardFeed({
{activeTab === 'feed' ? (
<div className="mt-6 grid gap-6 lg:grid-cols-5">
{!loading && recent.length === 0 ? (
<div className="lg:col-span-3">
<div className="rounded-2xl border border-silos-cream-dark bg-white p-6">
<h3 className="font-semibold text-silos-text">What you can build in Silos</h3>
<p className="mt-1 text-sm text-silos-muted">
Keep your team aligned with focused spaces for writing, collaboration, and
visual thinking.
</p>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{[
['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]) => (
<div key={title} className="rounded-xl bg-silos-cream/50 p-3">
<p className="text-sm font-medium text-silos-text">{title}</p>
<p className="mt-1 text-xs leading-5 text-silos-muted">{description}</p>
</div>
))}
</div>
</div>
</div>
) : (
<div className="lg:col-span-3">
<RecentActivities documents={recent} loading={loading} onViewAll={onOpenRecent} />
</div>
)}
<div className="space-y-6 lg:col-span-2">
{!(!loading && recent.length === 0) && (
<ActivityStatus documentCount={recent.length} />
)}
<DiscoverPanel />
</div>
</div>

View file

@ -78,6 +78,10 @@ export default function DashboardLayout() {
setActiveNav('recent')
setFlyout('recent')
}}
onOpenTemplates={() => {
setActiveNav('templates')
setFlyout('templates')
}}
/>
</div>
</div>

View file

@ -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<string, LucideIcon> = {
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<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="overflow-hidden rounded-2xl border border-silos-cream-dark bg-white">
<div className="border-b border-silos-cream-dark bg-gradient-to-br from-silos-cream/70 to-white px-5 py-6 sm:px-7">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-silos-tan/20">
<Sparkles className="h-5 w-5 text-silos-tan" />
</div>
<div>
<h3 className="text-lg font-semibold text-silos-text">Make this workspace yours</h3>
<p className="mt-1 max-w-2xl text-sm text-silos-muted">
Start with a template or choose the right space for your next idea. Your picks will
appear here the next time you sign in.
</p>
</div>
</div>
</div>
<div className="grid gap-3 p-4 sm:grid-cols-2 sm:p-5 lg:grid-cols-4">
<button
type="button"
onClick={onOpenTemplates}
className="group rounded-xl border border-silos-tan/40 bg-silos-cream/30 p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/70"
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-silos-tan/20">
<LayoutTemplate className="h-4 w-4 text-silos-tan" />
</div>
<p className="mt-3 text-sm font-semibold text-silos-text">Browse templates</p>
<p className="mt-1 text-xs leading-5 text-silos-muted">
Meeting notes, retrospectives, architecture reviews, and more.
</p>
<span className="mt-3 inline-block text-xs font-medium text-silos-brown group-hover:underline">
Explore templates
</span>
</button>
{starterOptions.map((option) => {
const Icon = iconMap[option.icon]
const isCreating = creating === option.id
return (
<button
key={option.id}
type="button"
disabled={creating !== null}
onClick={() => void createStarter(option.id)}
className="group rounded-xl border border-silos-cream-dark p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/40 disabled:cursor-wait disabled:opacity-60"
>
<div
className="flex h-9 w-9 items-center justify-center rounded-lg"
style={{ backgroundColor: `${option.color}22` }}
>
<Icon className="h-4 w-4" style={{ color: option.color }} />
</div>
<p className="mt-3 text-sm font-semibold text-silos-text">
{isCreating ? 'Creating…' : `Create a ${option.label.toLowerCase()}`}
</p>
<p className="mt-1 text-xs leading-5 text-silos-muted">{option.description}</p>
<span className="mt-3 inline-block text-xs font-medium text-silos-brown group-hover:underline">
Start now
</span>
</button>
)
})}
<button
type="button"
onClick={() => navigate('/workspace/stead/new')}
className="group rounded-xl border border-silos-cream-dark p-4 text-left transition-colors hover:border-silos-tan hover:bg-silos-cream/40"
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#9B7BB8]/15">
<FolderOpen className="h-4 w-4 text-[#9B7BB8]" />
</div>
<p className="mt-3 text-sm font-semibold text-silos-text">Organize a space</p>
<p className="mt-1 text-xs leading-5 text-silos-muted">
Bring related pages, docs, and whiteboards together.
</p>
<span className="mt-3 inline-block text-xs font-medium text-silos-brown group-hover:underline">
Create a space
</span>
</button>
</div>
{error && <p className="px-5 pb-5 text-sm text-red-700">{error}</p>}
</div>
)
}

View file

@ -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: '<h2>Meeting notes</h2><p>Date:</p><ul><li>Attendees</li><li>Agenda</li><li>Actions</li></ul>',
},
{
id: 'plan',
label: 'Project plan',
description: 'Silo starter for a project plan',
html: '<h2>Project plan</h2><p>Goals</p><ul><li>Milestone 1</li><li>Milestone 2</li></ul>',
},
]
export default function TemplatesFlyout({ sidebarCollapsed, onClose }: TemplatesFlyoutProps) {
const navigate = useNavigate()
const author = getStoredUser()?.full_name ?? 'You'
const [customTemplates, setCustomTemplates] = useState<ApiTemplate[]>([])
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<string | null>(null)
const [placement, setPlacement] = useState('top')
const [placements, setPlacements] = useState<DocumentRecord[]>([])
const [shareTemplateId, setShareTemplateId] = useState<string | null>(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 (
<>
<div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} />
@ -68,11 +138,54 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
>
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
<h2 className="text-lg font-semibold text-silos-text">Templates</h2>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream">
<X className="h-4 w-4 text-silos-muted" />
</button>
<div className="flex items-center gap-1">
<button type="button" onClick={() => setShowCreate((current) => !current)} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Create template"><Plus className="h-4 w-4" /></button>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream" aria-label="Close templates"><X className="h-4 w-4 text-silos-muted" /></button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3">
{showCreate && (
<form onSubmit={(event) => void saveTemplate(event)} className="mb-4 rounded-xl border border-silos-cream-dark bg-silos-cream/40 p-3">
<p className="mb-2 text-sm font-semibold text-silos-text">New page template</p>
<div className="flex gap-2">
<input value={emoji} onChange={(event) => 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" />
<input value={name} onChange={(event) => 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" />
</div>
<input value={description} onChange={(event) => 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" />
<input value={templateCategory} onChange={(event) => 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" />
<textarea value={bodyHtml} onChange={(event) => setBodyHtml(event.target.value)} placeholder="Starter content (HTML is supported)" rows={3} className="mt-2 w-full resize-none rounded-lg border border-silos-cream-dark bg-white px-3 py-2 text-sm" />
<button type="submit" className="mt-2 w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Save template</button>
</form>
)}
{customTemplates.length > 0 && (
<>
<p className="px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Your templates</p>
<div className="mb-3 flex gap-1 overflow-x-auto pb-1">
{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`whitespace-nowrap rounded-full px-2.5 py-1 text-xs ${category === item ? 'bg-silos-brown text-white' : 'bg-silos-cream text-silos-muted'}`}>{item}</button>)}
</div>
{visibleTemplates.map((template) => (
<div key={template.id} className="group mb-1 flex items-start gap-2 rounded-lg px-3 py-3 hover:bg-silos-cream">
<button type="button" onClick={() => void createCustomTemplate(template)} className="flex min-w-0 flex-1 items-start gap-3 text-left">
<span className="text-lg">{template.emoji}</span>
<span className="min-w-0"><span className="block text-sm font-medium text-silos-text">{template.name}</span><span className="block text-xs text-silos-muted">{template.description || template.category}</span></span>
</button>
<button type="button" onClick={() => setShareTemplateId(template.id)} className="rounded p-1 text-xs text-silos-muted opacity-0 hover:bg-silos-cream hover:text-silos-brown group-hover:opacity-100" aria-label={`Share ${template.name}`}>Share</button>
<button type="button" onClick={() => void removeTemplate(template.id)} className="rounded p-1 text-silos-muted opacity-0 hover:bg-red-50 hover:text-red-700 group-hover:opacity-100" aria-label={`Delete ${template.name}`}><Trash2 className="h-3.5 w-3.5" /></button>
</div>
))}
</>
)}
{customTemplates.length > 0 && (
<label className="mb-4 block rounded-lg border border-silos-cream-dark bg-silos-cream/30 p-3 text-xs text-silos-muted">
Create new pages in
<select value={placement} onChange={(event) => setPlacement(event.target.value)} className="mt-1 w-full rounded-lg border border-silos-cream-dark bg-white px-2 py-2 text-sm text-silos-text">
<option value="top">Top level</option>
{placements.filter((item) => item.type === 'stead').map((item) => <option key={`space-${item.id}`} value={`space:${item.id}`}>Space: {item.title}</option>)}
{placements.filter((item) => item.type === 'silo').map((item) => <option key={`page-${item.id}`} value={`page:${item.id}`}>Child of: {item.title}</option>)}
</select>
</label>
)}
{error && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700">{error}</p>}
<p className="px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Shed — Live docs</p>
{shedTemplates.map((t) => (
<button
@ -90,7 +203,7 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
</button>
))}
<p className="mt-4 px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Silo — Pages</p>
{siloStarters.map((t) => (
{siloTemplates.map((t) => (
<button
key={t.id}
type="button"
@ -103,6 +216,15 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
))}
</div>
</aside>
{shareTemplateId && (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/20 p-4">
<form onSubmit={(event) => { event.preventDefault(); void shareTemplate(shareTemplateId) }} className="w-full max-w-sm rounded-xl bg-white p-4 shadow-2xl">
<div className="mb-3 flex items-center justify-between"><h2 className="font-semibold text-silos-text">Share template</h2><button type="button" onClick={() => setShareTemplateId(null)} aria-label="Close"><X className="h-4 w-4" /></button></div>
<input autoFocus value={shareIdentifier} onChange={(event) => setShareIdentifier(event.target.value)} placeholder="Email or username" className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" />
<button type="submit" className="mt-3 w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Share</button>
</form>
</div>
)}
</>
)
}

View file

@ -1,38 +1,56 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import {
Bold,
Italic,
Underline,
AlignCenter,
AlignLeft,
Image,
Link,
AlignRight,
AtSign,
Smile,
Table,
Plus,
Bold,
Check,
Code2,
Columns2,
FileUp,
Image,
Italic,
Link,
List,
Heading1,
ListOrdered,
Palette,
Plus,
Smile,
Strikethrough,
Table,
Underline,
Video,
} from 'lucide-react'
const toolbarGroups = [
[
{ icon: Heading1, label: 'Heading', cmd: 'formatBlock', value: 'h2' },
{ icon: Bold, label: 'Bold', cmd: 'bold' },
{ icon: Italic, label: 'Italic', cmd: 'italic' },
{ icon: Underline, label: 'Underline', cmd: 'underline' },
],
[
{ icon: AlignLeft, label: 'Align', cmd: 'justifyLeft' },
{ icon: List, label: 'List', cmd: 'insertUnorderedList' },
],
type Popover = 'color' | 'emoji' | 'link' | 'image' | 'video' | 'insert' | null
type MediaType = 'link' | 'image' | 'video'
const textColors = [
'#172B4D', '#42526E', '#5E6C84', '#7A869A', '#091E42', '#0747A6',
'#0065FF', '#00875A', '#36B37E', '#FFAB00', '#FF8B00', '#DE350B',
'#BF2600', '#6554C0', '#403294', '#FFFFFF', '#E3E8EF', '#B3BAC5',
]
const highlightColors = [
'transparent', '#DEEBFF', '#E6FCFF', '#E3FCEF', '#FFFAE6', '#FFEBE6',
'#EAE6FF', '#FFF0B3', '#C6F6D5', '#FED7D7', '#E9D8FD', '#D6F5FF',
]
const emojiGroups = [
{ label: 'Smileys', items: ['😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😎', '🤓', '🧐', '🤩', '🥳', '😏', '😢', '😭', '😡', '🤔', '🤗', '🤭', '🤫'] },
{ label: 'People', items: ['👋', '👏', '🙌', '👍', '👎', '👌', '🙏', '💪', '🤝', '👀', '🧠', '💬', '🗣️', '👤', '👥', '🧑‍💻', '👩‍💻', '👨‍💻'] },
{ label: 'Work', items: ['✅', '❌', '⚠️', '❗', '❓', '💡', '🔥', '⭐', '🎯', '🚀', '📌', '📎', '📝', '📋', '📊', '📈', '📅', '⏰', '🔗', '🔒', '💻', '🛠️', '🏗️', '🎨', '🧪', '🎉'] },
{ label: 'Objects', items: ['❤️', '💛', '💚', '💙', '💜', '🖤', '✨', '🌟', '☀️', '🌈', '🌱', '🌍', '☕', '🍎', '🎁', '🏆', '💰', '📣', '✏️', '📷', '🎥', '📁', '🗂️', '🗑️'] },
]
const slashItems = [
{ label: 'Paragraph', html: '<p>Paragraph</p>' },
{ label: 'Heading', html: '<h2>Heading</h2>' },
{ label: 'Bullet list', html: '<ul><li>Item</li></ul>' },
{ label: 'Table', html: '<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td> </td><td> </td></tr></table>' },
{ label: 'Info panel', html: '<div class="rounded-lg border border-silos-tan bg-silos-cream/60 p-3 my-2">Info: add a note here.</div>' },
{ label: 'Paragraph', description: 'Start with plain text', html: '<p>Paragraph</p>' },
{ label: 'Heading', description: 'Add a section heading', html: '<h2>Heading</h2>' },
{ label: 'Bullet list', description: 'Create a simple list', html: '<ul><li>List item</li></ul>' },
{ label: 'Numbered list', description: 'Create a numbered list', html: '<ol><li>List item</li></ol>' },
{ label: 'Table', description: 'Insert a formatted table', html: '<table class="silos-table"><thead><tr><th>Header</th><th>Header</th></tr></thead><tbody><tr><td>Cell</td><td>Cell</td></tr></tbody></table>' },
{ label: 'Info panel', description: 'Highlight important information', html: '<div class="silos-panel"><strong>Info</strong><br />Add a note here.</div>' },
]
const starterHtml: Record<string, string> = {
@ -44,13 +62,39 @@ const starterHtml: Record<string, string> = {
interface SiloEditorProps {
html: string
onChange: (html: string) => void
editable?: boolean
}
export default function SiloEditor({ html, onChange }: SiloEditorProps) {
const editorRef = useRef<HTMLDivElement>(null)
const [slashOpen, setSlashOpen] = useState(false)
function isSafeUrl(value: string): boolean {
return /^(https?:\/\/|mailto:|tel:)/i.test(value.trim())
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
return entities[character]
})
}
export default function SiloEditor({ html, onChange, editable = true }: SiloEditorProps) {
const rootRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<HTMLDivElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const savedRange = useRef<Range | null>(null)
const primed = useRef(false)
const [popover, setPopover] = useState<Popover>(null)
const [mediaType, setMediaType] = useState<MediaType>('link')
const [mediaUrl, setMediaUrl] = useState('')
const [mediaLabel, setMediaLabel] = useState('')
const [emojiSearch, setEmojiSearch] = useState('')
const [slashOpen, setSlashOpen] = useState(false)
const [activeMarks, setActiveMarks] = useState<Record<string, boolean>>({})
useEffect(() => {
if (!primed.current && editorRef.current) {
@ -59,133 +103,284 @@ export default function SiloEditor({ html, onChange }: SiloEditorProps) {
}
}, [html])
useEffect(() => {
function closeOnOutside(event: globalThis.MouseEvent) {
if (
rootRef.current &&
!rootRef.current.contains(event.target as Node)
) {
setPopover(null)
setSlashOpen(false)
}
}
function closeOnEscape(event: globalThis.KeyboardEvent) {
if (event.key === 'Escape') {
setPopover(null)
setSlashOpen(false)
}
}
document.addEventListener('mousedown', closeOnOutside)
document.addEventListener('keydown', closeOnEscape)
return () => {
document.removeEventListener('mousedown', closeOnOutside)
document.removeEventListener('keydown', closeOnEscape)
}
}, [])
useEffect(() => {
function updateMarks() {
if (!editorRef.current || !editorRef.current.contains(document.activeElement)) return
setActiveMarks({
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline'),
strikeThrough: document.queryCommandState('strikeThrough'),
insertUnorderedList: document.queryCommandState('insertUnorderedList'),
insertOrderedList: document.queryCommandState('insertOrderedList'),
})
}
document.addEventListener('selectionchange', updateMarks)
return () => document.removeEventListener('selectionchange', updateMarks)
}, [])
function emit() {
if (editorRef.current) onChange(editorRef.current.innerHTML)
if (editable && editorRef.current) onChange(editorRef.current.innerHTML)
}
function run(cmd: string, value?: string) {
document.execCommand(cmd, false, value)
function saveSelection() {
const selection = window.getSelection()
if (selection?.rangeCount && editorRef.current?.contains(selection.anchorNode)) {
savedRange.current = selection.getRangeAt(0).cloneRange()
}
}
function restoreSelection() {
if (!savedRange.current) {
editorRef.current?.focus()
return
}
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(savedRange.current)
editorRef.current?.focus()
}
function togglePopover(event: MouseEvent<HTMLButtonElement>, next: Popover) {
event.preventDefault()
saveSelection()
setPopover((current) => (current === next ? null : next))
setSlashOpen(false)
}
function run(command: string, value?: string) {
if (!editable) return
restoreSelection()
document.execCommand(command, false, value)
emit()
}
function insertHtml(snippet: string) {
if (!editable) return
restoreSelection()
document.execCommand('insertHTML', false, snippet)
setPopover(null)
setSlashOpen(false)
emit()
}
function handleInput() {
if (!editable) return
const text = editorRef.current?.innerText ?? ''
if (text.endsWith('/')) setSlashOpen(true)
else setSlashOpen(false)
setSlashOpen(text.endsWith('/'))
emit()
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') setSlashOpen(false)
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Escape') {
setSlashOpen(false)
setPopover(null)
}
if (event.key === '/' && editable) {
window.setTimeout(saveSelection, 0)
}
}
function openMedia(next: MediaType, event: MouseEvent<HTMLButtonElement>) {
event.preventDefault()
saveSelection()
setMediaType(next)
setMediaUrl('')
setMediaLabel('')
setPopover(next)
}
function insertMedia() {
const url = mediaUrl.trim()
if (!isSafeUrl(url)) return
const safeUrl = escapeHtml(url)
const safeLabel = escapeHtml(mediaLabel.trim())
if (mediaType === 'link') {
if (mediaLabel.trim()) {
insertHtml(
`<a href="${safeUrl}" target="_blank" rel="noreferrer">${safeLabel}</a>`,
)
} else {
restoreSelection()
document.execCommand('createLink', false, safeUrl)
emit()
}
} else if (mediaType === 'image') {
insertHtml(
`<figure class="silos-media"><img src="${safeUrl}" alt="${safeLabel || 'Inserted image'}" /><figcaption>${safeLabel}</figcaption></figure>`,
)
} else {
insertHtml(
`<div class="silos-media"><a href="${safeUrl}" target="_blank" rel="noreferrer">Open video</a><div class="silos-embed">${safeLabel || safeUrl}</div></div>`,
)
}
setPopover(null)
setMediaUrl('')
setMediaLabel('')
}
function insertFile(file: File) {
if (!file.type.startsWith('image/')) return
const reader = new FileReader()
reader.onload = () =>
insertHtml(
`<figure class="silos-media"><img src="${String(reader.result)}" alt="${escapeHtml(file.name)}" /><figcaption>${escapeHtml(file.name)}</figcaption></figure>`,
)
reader.readAsDataURL(file)
}
const filteredEmojis = emojiGroups.map((group) => ({
...group,
items: emojiSearch
? group.items.filter((emoji) => emoji.includes(emojiSearch))
: group.items,
}))
return (
<div className="flex min-h-[calc(100vh-8rem)] flex-col">
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-silos-cream-dark bg-white py-2">
{toolbarGroups.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{group.map(({ icon: Icon, label, cmd, value }) => (
<button
key={label}
type="button"
title={label}
onClick={() => run(cmd, value)}
className="rounded p-1.5 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
>
<Icon className="h-4 w-4" />
</button>
))}
{gi < toolbarGroups.length - 1 && <div className="mx-1 h-5 w-px bg-silos-cream-dark" />}
</div>
))}
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
<button
type="button"
title="Link"
onClick={() => {
const url = window.prompt('Link URL')
if (url) run('createLink', url)
<div
ref={rootRef}
onMouseDown={(event) => {
const target = event.target as HTMLElement
if (!target.closest('[data-silo-popover]')) {
if (target.closest('button, select')) saveSelection()
setPopover(null)
}
}}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
className="relative flex min-h-[calc(100vh-8rem)] flex-col"
>
<Link className="h-4 w-4" />
</button>
<button type="button" title="Image" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<Image className="h-4 w-4" />
</button>
<button type="button" title="Mention" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<AtSign className="h-4 w-4" />
</button>
<button type="button" title="Emoji" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream">
<Smile className="h-4 w-4" />
</button>
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-silos-cream-dark bg-white py-2">
<select
disabled={!editable}
onMouseDown={saveSelection}
defaultValue="paragraph"
onChange={(event) => run('formatBlock', event.target.value)}
className="h-8 rounded px-2 text-sm text-silos-text outline-none hover:bg-silos-cream disabled:opacity-40"
aria-label="Text style"
>
<option value="paragraph">Normal text</option>
<option value="h1">Heading 1</option>
<option value="h2">Heading 2</option>
<option value="h3">Heading 3</option>
<option value="blockquote">Quote</option>
<option value="pre">Code block</option>
</select>
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
{[
[Bold, 'Bold', 'bold'],
[Italic, 'Italic', 'italic'],
[Underline, 'Underline', 'underline'],
[Strikethrough, 'Strikethrough', 'strikeThrough'],
[List, 'Bullet list', 'insertUnorderedList'],
[ListOrdered, 'Numbered list', 'insertOrderedList'],
].map(([Icon, label, command]) => {
const ToolbarIcon = Icon as typeof Bold
return (
<button
key={String(label)}
type="button"
title="Table"
onClick={() => insertHtml('<table class="w-full border"><tr><th>A</th><th>B</th></tr><tr><td></td><td></td></tr></table>')}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
title={String(label)}
disabled={!editable}
onMouseDown={(event) => event.preventDefault()}
onClick={() => run(String(command))}
className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream hover:text-silos-text disabled:opacity-40 ${activeMarks[String(command)] ? 'bg-blue-100 text-blue-700' : ''}`}
>
<Table className="h-4 w-4" />
</button>
<button
type="button"
title="Insert"
onClick={() => setSlashOpen((v) => !v)}
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"
>
<Plus className="h-4 w-4" />
<ToolbarIcon className="h-4 w-4" />
</button>
)
})}
<div className="mx-1 h-5 w-px bg-silos-cream-dark" />
{[['Left', AlignLeft, 'justifyLeft'], ['Center', AlignCenter, 'justifyCenter'], ['Right', AlignRight, 'justifyRight']].map(([label, Icon, command]) => {
const ToolbarIcon = Icon as typeof AlignLeft
return <button key={String(label)} type="button" title={`Align ${String(label).toLowerCase()}`} disabled={!editable} onMouseDown={(event) => event.preventDefault()} onClick={() => run(String(command))} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><ToolbarIcon className="h-4 w-4" /></button>
})}
<button type="button" title="Text and highlight colours" disabled={!editable} onClick={(event) => togglePopover(event, 'color')} className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40 ${popover === 'color' ? 'bg-silos-cream text-silos-brown' : ''}`}><Palette className="h-4 w-4" /></button>
<button type="button" title="Link" disabled={!editable} onClick={(event) => openMedia('link', event)} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Link className="h-4 w-4" /></button>
<button type="button" title="Image URL" disabled={!editable} onClick={(event) => openMedia('image', event)} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Image className="h-4 w-4" /></button>
<button type="button" title="Upload image" disabled={!editable} onMouseDown={(event) => event.preventDefault()} onClick={() => { saveSelection(); fileRef.current?.click() }} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><FileUp className="h-4 w-4" /></button>
<button type="button" title="Video URL" disabled={!editable} onClick={(event) => openMedia('video', event)} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Video className="h-4 w-4" /></button>
<button type="button" title="Mention" disabled={!editable} onClick={() => insertHtml('<span class="silos-mention">@mention</span>')} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><AtSign className="h-4 w-4" /></button>
<button type="button" title="Emoji" disabled={!editable} onClick={(event) => togglePopover(event, 'emoji')} className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40 ${popover === 'emoji' ? 'bg-silos-cream text-silos-brown' : ''}`}><Smile className="h-4 w-4" /></button>
<button type="button" title="Table" disabled={!editable} onClick={() => insertHtml('<table class="silos-table"><thead><tr><th>Header</th><th>Header</th><th>Header</th></tr></thead><tbody><tr><td>Cell</td><td>Cell</td><td>Cell</td></tr><tr><td>Cell</td><td>Cell</td><td>Cell</td></tr></tbody></table>')} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Table className="h-4 w-4" /></button>
<button type="button" title="Tabs" disabled={!editable} onClick={() => insertHtml('<div class="silos-tabs"><div class="silos-tab-list"><span class="silos-tab-active">Tab 1</span><span>Tab 2</span><span>+</span></div><div class="silos-tab-content">Tab content</div></div>')} className="rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40"><Columns2 className="h-4 w-4" /></button>
<button type="button" title="More insert options" disabled={!editable} onClick={(event) => togglePopover(event, 'insert')} className={`rounded p-1.5 text-silos-muted hover:bg-silos-cream disabled:opacity-40 ${popover === 'insert' ? 'bg-silos-cream text-silos-brown' : ''}`}><Plus className="h-4 w-4" /></button>
</div>
{popover === 'color' && (
<div data-silo-popover className="absolute left-24 top-11 z-30 w-72 rounded-xl border border-silos-cream-dark bg-white p-3 shadow-xl">
<p className="mb-2 text-xs font-semibold text-silos-text">Text colour</p>
<div className="grid grid-cols-9 gap-2">{textColors.map((color) => <button key={color} type="button" title={color} onMouseDown={(event) => event.preventDefault()} onClick={() => { run('foreColor', color); setPopover(null) }} className="h-6 w-6 rounded border ring-1 ring-silos-cream-dark" style={{ backgroundColor: color }}><Check className="mx-auto h-3 w-3 text-white opacity-0" /></button>)}</div>
<p className="mb-2 mt-4 text-xs font-semibold text-silos-text">Highlight colour</p>
<div className="grid grid-cols-6 gap-2">{highlightColors.map((color) => <button key={color} type="button" title={color === 'transparent' ? 'Remove highlight' : color} onMouseDown={(event) => event.preventDefault()} onClick={() => { run('hiliteColor', color); setPopover(null) }} className="h-7 w-7 rounded border ring-1 ring-silos-cream-dark" style={{ backgroundColor: color === 'transparent' ? 'white' : color }}>{color === 'transparent' && <span className="text-xs text-silos-muted">×</span>}</button>)}</div>
<button type="button" onClick={() => { run('removeFormat'); setPopover(null) }} className="mt-3 w-full rounded-lg border border-silos-cream-dark py-1.5 text-xs text-silos-muted hover:bg-silos-cream">Clear formatting</button>
</div>
)}
{popover === 'emoji' && (
<div data-silo-popover className="absolute right-12 top-11 z-30 max-h-[28rem] w-80 overflow-y-auto rounded-xl border border-silos-cream-dark bg-white p-3 shadow-xl">
<input autoFocus value={emojiSearch} onChange={(event) => setEmojiSearch(event.target.value)} placeholder="Search emojis" className="mb-3 w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan" />
{filteredEmojis.map((group) => group.items.length > 0 && <div key={group.label}><p className="mb-1 mt-2 text-xs font-semibold text-silos-muted">{group.label}</p><div className="grid grid-cols-8 gap-1">{group.items.map((emoji) => <button key={emoji} type="button" onClick={() => insertHtml(emoji)} className="rounded p-1 text-xl hover:bg-silos-cream">{emoji}</button>)}</div></div>)}
</div>
)}
{(popover === 'link' || popover === 'image' || popover === 'video') && (
<div data-silo-popover className="absolute right-20 top-11 z-30 w-80 rounded-xl border border-silos-cream-dark bg-white p-4 shadow-xl">
<div className="mb-3 flex items-center gap-2"><Code2 className="h-4 w-4 text-silos-muted" /><h3 className="text-sm font-semibold text-silos-text">Insert {mediaType}</h3></div>
<label className="mb-1 block text-xs font-medium text-silos-muted">{mediaType === 'link' ? 'URL' : `${mediaType} URL`}</label>
<input autoFocus value={mediaUrl} onChange={(event) => setMediaUrl(event.target.value)} placeholder="https://..." className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan" onKeyDown={(event) => event.key === 'Enter' && insertMedia()} />
<label className="mb-1 mt-3 block text-xs font-medium text-silos-muted">{mediaType === 'link' ? 'Displayed text (optional)' : 'Caption (optional)'}</label>
<input value={mediaLabel} onChange={(event) => setMediaLabel(event.target.value)} placeholder={mediaType === 'link' ? 'Open link' : 'Add a caption'} className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-tan" onKeyDown={(event) => event.key === 'Enter' && insertMedia()} />
{!isSafeUrl(mediaUrl) && mediaUrl.length > 0 && <p className="mt-2 text-xs text-red-600">Enter a valid http(s) URL.</p>}
<div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setPopover(null)} className="rounded-lg px-3 py-1.5 text-xs text-silos-muted hover:bg-silos-cream">Cancel</button><button type="button" disabled={!isSafeUrl(mediaUrl)} onClick={insertMedia} className="rounded-lg bg-silos-brown px-3 py-1.5 text-xs text-white disabled:opacity-40">Insert</button></div>
</div>
)}
{popover === 'insert' && (
<div data-silo-popover className="absolute right-0 top-11 z-30 w-72 rounded-xl border border-silos-cream-dark bg-white p-2 shadow-xl">
{slashItems.map((item) => <button key={item.label} type="button" onClick={() => insertHtml(item.html)} className="flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-silos-cream"><span className="mt-0.5 text-sm font-medium text-silos-text">{item.label}</span><span className="text-xs text-silos-muted">{item.description}</span></button>)}
</div>
)}
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(event) => { const file = event.target.files?.[0]; if (file) insertFile(file); event.target.value = '' }} />
<div className="relative flex-1 py-6">
<div
ref={editorRef}
contentEditable
contentEditable={editable}
suppressContentEditableWarning
onInput={handleInput}
onKeyDown={handleKeyDown}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
data-placeholder="Press / to insert elements, or just start writing..."
className="prose min-h-[50vh] w-full max-w-none text-base leading-relaxed text-silos-text outline-none empty:before:text-silos-muted/60 empty:before:content-[attr(data-placeholder)]"
/>
{slashOpen && (
<div className="absolute left-0 top-16 z-20 w-64 rounded-xl border border-silos-cream-dark bg-white p-1 shadow-lg">
{slashItems.map((item) => (
<button
key={item.label}
type="button"
onClick={() => insertHtml(item.html)}
className="w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"
>
{item.label}
</button>
))}
{slashOpen && <div data-silo-popover className="absolute left-0 top-16 z-20 w-72 rounded-xl border border-silos-cream-dark bg-white p-1 shadow-lg">{slashItems.map((item) => <button key={item.label} type="button" onClick={() => insertHtml(item.html)} className="flex w-full flex-col rounded-lg px-3 py-2 text-left hover:bg-silos-cream"><span className="text-sm font-medium text-silos-text">{item.label}</span><span className="text-xs text-silos-muted">{item.description}</span></button>)}</div>}
</div>
)}
</div>
<div className="sticky bottom-0 border-t border-silos-cream-dark bg-white py-4">
<div className="flex flex-wrap gap-2">
{Object.keys(starterHtml).map((t) => (
<button
key={t}
type="button"
onClick={() => insertHtml(starterHtml[t])}
className="rounded-full border border-silos-cream-dark bg-silos-cream/40 px-3 py-1.5 text-xs text-silos-text hover:border-silos-tan"
>
{t}
</button>
))}
<button type="button" className="rounded-full border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-muted">
All templates
</button>
</div>
<div className="flex flex-wrap gap-2">{Object.keys(starterHtml).map((template) => <button key={template} type="button" disabled={!editable} onClick={() => insertHtml(starterHtml[template])} className="rounded-full border border-silos-cream-dark bg-silos-cream/40 px-3 py-1.5 text-xs text-silos-text hover:border-silos-tan disabled:opacity-40">{template}</button>)}<button type="button" disabled={!editable} onClick={() => setPopover('insert')} className="rounded-full border border-silos-cream-dark px-3 py-1.5 text-xs text-silos-muted hover:bg-silos-cream disabled:opacity-40">All blocks</button></div>
</div>
</div>
)

View file

@ -33,6 +33,7 @@ interface SketchCanvasProps {
elements: SketchElement[]
viewport?: SketchViewport
onChange: (elements: SketchElement[], viewport?: SketchViewport) => void
readOnly?: boolean
}
type DragMode =
@ -69,7 +70,7 @@ function normalizeRect(x1: number, y1: number, x2: number, y2: number) {
}
}
export default function SketchCanvas({ elements, viewport, onChange }: SketchCanvasProps) {
export default function SketchCanvas({ elements, viewport, onChange, readOnly = false }: SketchCanvasProps) {
const [activeTool, setActiveTool] = useState<SketchTool>('pen')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [pan, setPan] = useState({ x: viewport?.panX ?? 0, y: viewport?.panY ?? 0 })
@ -113,6 +114,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
)
const undo = () => {
if (readOnly) return
if (history.length === 0) return
const prev = history[history.length - 1]
setFuture((f) => [{ elements, pan, zoom }, ...f])
@ -124,6 +126,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
}
const redo = () => {
if (readOnly) return
if (future.length === 0) return
const next = future[0]
setHistory((h) => [...h, { elements, pan, zoom }])
@ -135,6 +138,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (readOnly) return
if (e.key === 'Delete' || e.key === 'Backspace') {
if (editingId) return
if (selectedId) {
@ -160,7 +164,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [selectedId, editingId, elements, pan, zoom, onChange, pushHistory])
}, [selectedId, editingId, elements, pan, zoom, onChange, pushHistory, readOnly])
function findElementAt(point: Point): SketchElement | null {
for (let i = elements.length - 1; i >= 0; i--) {
@ -178,6 +182,8 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
return
}
if (readOnly) return
if (activeTool === 'select') {
const hit = findElementAt(point)
if (hit) {
@ -668,6 +674,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
}
}}
onDoubleClick={(e) => {
if (readOnly) return
e.stopPropagation()
setEditingId(el.id)
setActiveTool('select')
@ -700,6 +707,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
}
}}
onDoubleClick={(e) => {
if (readOnly) return
e.stopPropagation()
setEditingId(el.id)
setActiveTool('select')
@ -750,6 +758,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
>
<input
value={cell}
readOnly={readOnly}
onChange={(e) => {
const cells = el.cells.map((r) => [...r])
cells[ri][ci] = e.target.value
@ -806,6 +815,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
setActiveTool(id)
setShowMore(false)
}}
disabled={readOnly && id !== 'select' && id !== 'hand'}
className={`rounded-lg p-2 transition-colors ${
activeTool === id
? 'bg-silos-brown/10 text-silos-brown'
@ -824,6 +834,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
setActiveTool('rect')
setShowMore(false)
}}
disabled={readOnly}
className={`rounded-lg p-2 transition-colors ${
activeTool === 'rect'
? 'bg-silos-brown/10 text-silos-brown'

View file

@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import { Check, CornerDownRight, MessageSquare, Send, Trash2, X } from 'lucide-react'
import { documentsApi, getStoredUser, type ApiActivity, type ApiComment } from '../../lib/api'
interface CommentsPanelProps {
documentId: string
onClose: () => void
}
export default function CommentsPanel({ documentId, onClose }: CommentsPanelProps) {
const [comments, setComments] = useState<ApiComment[]>([])
const [body, setBody] = useState('')
const [replyTo, setReplyTo] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activity, setActivity] = useState<ApiActivity[]>([])
const [view, setView] = useState<'comments' | 'activity'>('comments')
const user = getStoredUser()
useEffect(() => {
void documentsApi.listComments(documentId).then(setComments).catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Could not load comments.')
}).finally(() => setLoading(false))
void documentsApi.listActivity(documentId).then(setActivity).catch(() => setActivity([]))
}, [documentId])
async function submit(event: React.FormEvent) {
event.preventDefault()
if (!body.trim()) return
setError(null)
try {
const comment = await documentsApi.createComment(documentId, body.trim(), replyTo ?? undefined)
setComments((current) => [...current, comment])
setBody('')
setReplyTo(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not add comment.')
}
}
async function toggleResolved(comment: ApiComment) {
try {
const updated = await documentsApi.resolveComment(documentId, comment.id, !comment.resolved)
setComments((current) => current.map((item) => item.id === updated.id ? updated : item))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update comment status.')
}
}
function renderComment(comment: ApiComment, depth = 0): React.ReactNode {
const replies = comments.filter((item) => item.parent_id === comment.id)
return (
<div key={comment.id} className={depth > 0 ? 'ml-5 border-l-2 border-silos-cream-dark pl-3' : ''}>
<article className={`rounded-xl border p-3 ${comment.resolved ? 'border-green-200 bg-green-50/40' : 'border-silos-cream-dark'}`}>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-silos-text">{comment.author_name}</p>
<div className="flex items-center gap-1">
<button type="button" onClick={() => setReplyTo(comment.id)} className="rounded p-1 text-silos-muted hover:bg-silos-cream" aria-label="Reply"><CornerDownRight className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => void toggleResolved(comment)} className="rounded p-1 text-silos-muted hover:bg-silos-cream" aria-label={comment.resolved ? 'Reopen comment' : 'Resolve comment'}><Check className={`h-3.5 w-3.5 ${comment.resolved ? 'text-green-700' : ''}`} /></button>
{comment.author_id === user?.id && <button type="button" onClick={() => void remove(comment.id)} className="rounded p-1 text-silos-muted hover:bg-red-50 hover:text-red-700" aria-label="Delete comment"><Trash2 className="h-3.5 w-3.5" /></button>}
</div>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm text-silos-text">{comment.body}</p>
<time className="mt-2 block text-[10px] text-silos-muted">{new Date(comment.created_at).toLocaleString()}</time>
</article>
{replies.length > 0 && <div className="mt-2 space-y-2">{replies.map((reply) => renderComment(reply, depth + 1))}</div>}
</div>
)
}
async function remove(commentId: string) {
try {
await documentsApi.deleteComment(documentId, commentId)
setComments((current) => current.filter((comment) => comment.id !== commentId))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not delete comment.')
}
}
return (
<aside className="fixed right-0 top-12 z-40 flex h-[calc(100vh-3rem)] w-full max-w-sm flex-col border-l border-silos-cream-dark bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-silos-brown" />
<h2 className="font-semibold text-silos-text">{view === 'comments' ? 'Comments' : 'Activity'}</h2>
<span className="rounded-full bg-silos-cream px-2 py-0.5 text-xs text-silos-muted">{comments.length}</span>
</div>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 hover:bg-silos-cream" aria-label="Close comments">
<X className="h-4 w-4 text-silos-muted" />
</button>
</div>
<div className="flex border-b border-silos-cream-dark px-4">
<button type="button" onClick={() => setView('comments')} className={`px-2 py-2 text-xs font-medium ${view === 'comments' ? 'border-b-2 border-silos-brown text-silos-brown' : 'text-silos-muted'}`}>Comments</button>
<button type="button" onClick={() => setView('activity')} className={`px-2 py-2 text-xs font-medium ${view === 'activity' ? 'border-b-2 border-silos-brown text-silos-brown' : 'text-silos-muted'}`}>Activity</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{view === 'activity' && <div className="space-y-3">{activity.length === 0 ? <p className="text-sm text-silos-muted">No activity yet.</p> : activity.map((entry) => <div key={entry.id} className="border-b border-silos-cream pb-3"><p className="text-sm text-silos-text">{entry.message}</p><p className="mt-1 text-[10px] text-silos-muted">{new Date(entry.created_at).toLocaleString()}</p></div>)}</div>}
{view === 'comments' && <>
{loading && <p className="text-sm text-silos-muted">Loading comments…</p>}
{!loading && comments.length === 0 && <p className="text-sm text-silos-muted">Start a conversation about this page.</p>}
<div className="space-y-3">
{comments.filter((comment) => !comment.parent_id).map((comment) => renderComment(comment))}
</div>
{error && <p className="mt-3 text-xs text-red-700">{error}</p>}
</>}
</div>
{view === 'comments' && <form onSubmit={(event) => void submit(event)} className="border-t border-silos-cream-dark p-3">
{replyTo && <div className="mb-2 flex items-center justify-between rounded-lg bg-silos-cream px-2 py-1 text-xs text-silos-muted">Replying to comment <button type="button" onClick={() => setReplyTo(null)} className="font-semibold text-silos-brown">Cancel</button></div>}
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={3}
placeholder="Write a comment… Use @name to mention someone"
className="w-full resize-none rounded-lg border border-silos-cream-dark px-3 py-2 text-sm outline-none focus:border-silos-brown"
/>
<button type="submit" disabled={!body.trim()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white hover:bg-silos-brown-dark disabled:opacity-50">
<Send className="h-3.5 w-3.5" />Comment
</button>
</form>}
</aside>
)
}

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Menu, Share2, Link2, MoreHorizontal, Users, Search } from 'lucide-react'
import { Menu, Share2, Link2, MoreHorizontal, Users, Search, Copy, Archive, Trash2, Plus, X, Bell } from 'lucide-react'
import { logos } from '../../data/brandAssets'
import { getStoredUser } from '../../lib/api'
import { documentsApi, getStoredUser, type ApiNotification } from '../../lib/api'
import { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import CreateDropdown from '../create/CreateDropdown'
import WorkspaceSidebar from './WorkspaceSidebar'
@ -18,7 +18,13 @@ interface WorkspaceShellProps {
layout?: WorkspaceLayout
headerActions?: React.ReactNode
hideTitle?: boolean
titleReadOnly?: boolean
onTitleChange?: (title: string) => void
presence?: { name: string; initial: string }[]
onCreateChild?: () => void
onDuplicate?: () => void
onArchive?: () => void
onDelete?: () => void
}
export default function WorkspaceShell({
@ -28,13 +34,26 @@ export default function WorkspaceShell({
layout = 'default',
headerActions,
hideTitle = false,
titleReadOnly = false,
onTitleChange,
presence = [],
onCreateChild,
onDuplicate,
onArchive,
onDelete,
}: WorkspaceShellProps) {
const user = getStoredUser()
const [shareOpen, setShareOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [parentStead, setParentStead] = useState<DocumentRecord | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const [notifications, setNotifications] = useState<ApiNotification[]>([])
const [notificationsOpen, setNotificationsOpen] = useState(false)
useEffect(() => {
void documentsApi.listNotifications().then(setNotifications).catch(() => setNotifications([]))
}, [])
useEffect(() => {
if (!doc.steadId) {
@ -59,6 +78,10 @@ export default function WorkspaceShell({
}, [])
function handleTitleChange(title: string) {
if (onTitleChange) {
onTitleChange(title)
return
}
void updateDocument(doc.id, { title }).then((updated) => {
if (updated) onDocChange(updated)
})
@ -106,7 +129,10 @@ export default function WorkspaceShell({
>
<Search className="h-3.5 w-3.5" />
</button>
<CreateDropdown steadId={doc.type === 'stead' ? doc.id : doc.steadId} />
<CreateDropdown
steadId={doc.type === 'stead' ? doc.id : doc.steadId}
parentPageId={doc.type === 'silo' ? doc.id : undefined}
/>
<div className="flex items-center gap-1 border-l border-silos-cream-dark pl-2">
<span className="hidden text-xs text-green-600 sm:inline">Saved</span>
{headerActions}
@ -142,9 +168,29 @@ export default function WorkspaceShell({
>
<Link2 className="h-3.5 w-3.5" />
</button>
<button className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="More">
<div className="relative">
<button type="button" onClick={() => { setNotificationsOpen((value) => !value); if (!notificationsOpen) void documentsApi.markNotificationsRead().then(() => setNotifications((items) => items.map((item) => ({ ...item, read: true })))) }} className="relative rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Notifications">
<Bell className="h-3.5 w-3.5" />
{notifications.some((item) => !item.read) && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-red-500" />}
</button>
{notificationsOpen && <div className="absolute right-0 top-9 z-50 w-80 rounded-xl border border-silos-cream-dark bg-white p-2 shadow-xl">
<div className="flex items-center justify-between px-2 py-1"><p className="text-xs font-semibold text-silos-text">Notifications</p><button type="button" onClick={() => setNotificationsOpen(false)} aria-label="Close notifications"><X className="h-3.5 w-3.5 text-silos-muted" /></button></div>
{notifications.length === 0 ? <p className="px-2 py-4 text-xs text-silos-muted">You’re all caught up.</p> : notifications.slice(0, 8).map((notification) => <div key={notification.id} className="border-t border-silos-cream px-2 py-2 text-xs text-silos-text"><p>{notification.message}</p><time className="text-[10px] text-silos-muted">{new Date(notification.created_at).toLocaleString()}</time></div>)}
</div>}
</div>
<button onClick={() => setMenuOpen((value) => !value)} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="More">
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{menuOpen && (
<div className="absolute right-3 top-10 z-50 w-56 rounded-xl border border-silos-cream-dark bg-white p-1.5 shadow-xl">
<p className="px-2.5 py-2 text-[10px] font-semibold uppercase tracking-wide text-silos-muted">Page actions</p>
{onCreateChild && <button onClick={() => { setMenuOpen(false); onCreateChild() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-text hover:bg-silos-cream"><Plus className="h-3.5 w-3.5" />Create child page</button>}
{onDuplicate && <button onClick={() => { setMenuOpen(false); onDuplicate() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-text hover:bg-silos-cream"><Copy className="h-3.5 w-3.5" />Duplicate page</button>}
{onArchive && <button onClick={() => { setMenuOpen(false); onArchive() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-text hover:bg-silos-cream"><Archive className="h-3.5 w-3.5" />Archive page</button>}
{onDelete && <button onClick={() => { setMenuOpen(false); onDelete() }} className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-red-700 hover:bg-red-50"><Trash2 className="h-3.5 w-3.5" />Delete permanently</button>}
<button onClick={() => setMenuOpen(false)} className="mt-1 flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-silos-muted hover:bg-silos-cream"><X className="h-3.5 w-3.5" />Close menu</button>
</div>
)}
</div>
</header>
@ -167,9 +213,11 @@ export default function WorkspaceShell({
<>
<input
value={doc.title}
readOnly={titleReadOnly}
onChange={(e) => handleTitleChange(e.target.value)}
className={`w-full border-none bg-transparent font-bold text-silos-text outline-none ${
isPage ? 'text-3xl' : 'text-2xl'
titleReadOnly ? 'cursor-default' : ''
} ${isPage ? 'text-3xl' : 'text-2xl'
}`}
placeholder={isPage ? 'Give this page a title' : 'Start with a title...'}
/>

View file

@ -1,7 +1,32 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, Plus } from 'lucide-react'
import { listDocuments, listDocumentsByStead, type DocumentRecord } from '../../lib/documents'
import { useEffect, useState, type ReactNode } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
Archive,
Check,
ChevronDown,
ChevronRight,
Copy,
FileText,
LayoutGrid,
Link2,
MoreHorizontal,
PenTool,
Plus,
Star,
Trash2,
X,
Zap,
} from 'lucide-react'
import {
createDocument,
deleteDocument,
listDocuments,
listDocumentsByStead,
toggleStar,
updateDocument,
type DocumentRecord,
} from '../../lib/documents'
import { getStoredUser } from '../../lib/api'
import type { CreateType } from '../../data/createData'
const typeIcons: Record<CreateType, typeof FileText> = {
@ -27,85 +52,181 @@ interface WorkspaceSidebarProps {
export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: WorkspaceSidebarProps) {
const [docs, setDocs] = useState<DocumentRecord[]>([])
const [steads, setSteads] = useState<DocumentRecord[]>([])
const [creatingChildFor, setCreatingChildFor] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [menuId, setMenuId] = useState<string | null>(null)
const [renameId, setRenameId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [moveId, setMoveId] = useState<string | null>(null)
const navigate = useNavigate()
useEffect(() => {
if (steadId) {
void Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then(
const load = steadId
? Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then(
([siblings, spaces]) => {
setDocs(siblings)
setSteads(spaces)
},
)
} else {
void listDocuments().then((all) => {
setDocs(all.filter((d) => d.type !== 'stead'))
setSteads(all.filter((d) => d.type === 'stead'))
: listDocuments().then((all) => {
setDocs(all.filter((doc) => doc.type !== 'stead'))
setSteads(all.filter((doc) => doc.type === 'stead'))
})
}
void load
}, [activeDocId, steadId])
const parent = steads.find((s) => s.id === steadId)
useEffect(() => {
function closeMenu(event: MouseEvent) {
if (!(event.target as HTMLElement).closest('[data-page-menu]')) setMenuId(null)
}
document.addEventListener('mousedown', closeMenu)
return () => document.removeEventListener('mousedown', closeMenu)
}, [])
if (collapsed) {
return (
<aside className="flex w-12 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-3">
{docs.slice(0, 6).map((doc) => {
const Icon = typeIcons[doc.type]
return (
<Link
key={doc.id}
to={`/workspace/${doc.type}/${doc.id}`}
title={doc.title}
className={`mb-1 rounded-lg p-2 ${
doc.id === activeDocId ? 'bg-silos-brown/10' : 'hover:bg-silos-cream'
}`}
>
<Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} />
</Link>
)
})}
</aside>
)
const parent = steads.find((space) => space.id === steadId)
const roots = docs.filter((doc) => !doc.parentPageId)
const childrenByParent = docs.reduce<Record<string, DocumentRecord[]>>((groups, doc) => {
if (doc.parentPageId) groups[doc.parentPageId] = [...(groups[doc.parentPageId] ?? []), doc]
return groups
}, {})
function descendantsOf(id: string): Set<string> {
const descendants = new Set<string>()
const visit = (parentId: string) => {
for (const child of childrenByParent[parentId] ?? []) {
descendants.add(child.id)
visit(child.id)
}
}
visit(id)
return descendants
}
return (
<aside className="flex w-56 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
<div className="border-b border-silos-cream-dark p-3">
<Link to="/dashboard" className="text-xs font-medium text-silos-muted hover:text-silos-brown">
← Back to Dashboard
</Link>
</div>
async function createChildPage(doc: DocumentRecord) {
setCreatingChildFor(doc.id)
try {
const child = await createDocument(
'silo',
`Child of ${doc.title}`,
{ body: '', bodyHtml: '', status: 'draft' },
undefined,
getStoredUser()?.full_name ?? 'You',
doc.steadId,
doc.id,
)
navigate(`/workspace/silo/${child.id}`)
} finally {
setCreatingChildFor(null)
}
}
<div className="flex-1 overflow-y-auto p-2">
{parent && (
<>
<Link
to={`/workspace/stead/${parent.id}`}
className="mb-2 flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"
>
<LayoutGrid className="h-3.5 w-3.5 text-silos-tan" />
<span className="truncate">{parent.title}</span>
</Link>
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
In this space
</p>
</>
)}
{!parent && (
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">
Content
</p>
)}
{docs.length === 0 ? (
<p className="px-2 py-4 text-xs text-silos-muted">No documents yet</p>
) : (
docs.map((doc) => {
function startRename(doc: DocumentRecord) {
setMenuId(null)
setRenameId(doc.id)
setRenameValue(doc.title)
}
async function saveRename(doc: DocumentRecord) {
const title = renameValue.trim()
setRenameId(null)
if (!title || title === doc.title) return
const updated = await updateDocument(doc.id, { title })
if (updated) setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item)))
}
async function duplicatePage(doc: DocumentRecord) {
setMenuId(null)
const duplicate = await createDocument(
doc.type,
`${doc.title} copy`,
{ ...doc.content, status: 'draft' },
doc.templateId,
getStoredUser()?.full_name ?? 'You',
doc.steadId,
doc.parentPageId,
)
navigate(`/workspace/${doc.type}/${duplicate.id}`)
}
async function archivePage(doc: DocumentRecord) {
setMenuId(null)
const updated = await updateDocument(doc.id, {
content: { ...doc.content, archived: true, archivedAt: new Date().toISOString() },
})
if (updated) setDocs((current) => current.filter((item) => item.id !== doc.id))
}
async function removePage(doc: DocumentRecord) {
setMenuId(null)
if (!window.confirm(`Delete "${doc.title}" permanently?`)) return
if (await deleteDocument(doc.id)) {
setDocs((current) => current.filter((item) => item.id !== doc.id))
if (doc.id === activeDocId) navigate('/dashboard')
}
}
async function movePage(doc: DocumentRecord, parentPageId?: string) {
const updated = await updateDocument(doc.id, { parentPageId })
if (updated) {
setDocs((current) => current.map((item) => (item.id === doc.id ? updated : item)))
setMoveId(null)
setMenuId(null)
}
}
function renderPage(doc: DocumentRecord, depth = 0): ReactNode {
const Icon = typeIcons[doc.type]
const children = childrenByParent[doc.id] ?? []
const isExpanded = expanded[doc.id] ?? doc.id === activeDocId
const isRenaming = renameId === doc.id
const isMenuOpen = menuId === doc.id
return (
<div key={doc.id}>
<div
className="group relative flex items-center rounded-md"
style={{ paddingLeft: `${depth * 14}px` }}
data-page-menu
>
{children.length > 0 ? (
<button
type="button"
onClick={() => setExpanded((current) => ({ ...current, [doc.id]: !isExpanded }))}
className="rounded p-1 text-silos-muted hover:bg-silos-cream"
aria-label={isExpanded ? `Collapse ${doc.title}` : `Expand ${doc.title}`}
>
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
) : (
<span className="flex w-5 justify-center text-[16px] leading-none text-silos-muted" aria-hidden="true">
•
</span>
)}
{isRenaming ? (
<form
className="flex min-w-0 flex-1 items-center gap-1 px-1 py-1"
onSubmit={(event) => {
event.preventDefault()
void saveRename(doc)
}}
>
<input
autoFocus
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
onBlur={() => void saveRename(doc)}
className="min-w-0 flex-1 rounded border border-silos-brown/40 bg-white px-2 py-1 text-sm text-silos-text outline-none ring-2 ring-silos-brown/10"
aria-label={`Rename ${doc.title}`}
/>
<button type="submit" className="rounded p-1 text-green-700 hover:bg-green-50" aria-label="Save name">
<Check className="h-3.5 w-3.5" />
</button>
</form>
) : (
<Link
key={doc.id}
to={`/workspace/${doc.type}/${doc.id}`}
className={`mb-0.5 flex items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors ${
className={`flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors ${
doc.id === activeDocId
? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream'
@ -114,20 +235,98 @@ export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: Wo
<Icon className="h-3.5 w-3.5 shrink-0" style={{ color: typeColors[doc.type] }} />
<span className="truncate">{doc.title}</span>
</Link>
)
})
)}
{doc.type === 'silo' && !isRenaming && (
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void createChildPage(doc)
}}
disabled={creatingChildFor === doc.id}
className="rounded p-1 text-silos-muted opacity-0 transition-opacity hover:bg-silos-cream hover:text-silos-brown group-hover:opacity-100 disabled:opacity-50"
aria-label={`Create child page under ${doc.title}`}
title="Create child page"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
{!isRenaming && (
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
setMenuId(isMenuOpen ? null : doc.id)
}}
className="mr-1 rounded p-1 text-silos-muted opacity-0 transition-opacity hover:bg-silos-cream hover:text-silos-text group-hover:opacity-100"
aria-label={`More options for ${doc.title}`}
title="More options"
>
<MoreHorizontal className="h-4 w-4" />
</button>
)}
{isMenuOpen && (
<div className="absolute right-1 top-full z-50 mt-1 w-56 rounded-xl border border-silos-cream-dark bg-white p-1.5 shadow-xl">
<button onClick={() => navigate(`/workspace/${doc.type}/${doc.id}`)} className="page-menu-item"><FileText className="h-4 w-4" />Edit</button>
<button onClick={() => startRename(doc)} className="page-menu-item"><FileText className="h-4 w-4" />Rename</button>
<button onClick={async () => { setMenuId(null); const updated = await toggleStar(doc.id, !doc.starred); if (updated) setDocs((current) => current.map((item) => item.id === doc.id ? updated : item)) }} className="page-menu-item"><Star className="h-4 w-4" />{doc.starred ? 'Unstar' : 'Star'}</button>
<button onClick={() => { setMenuId(null); void navigator.clipboard.writeText(`${window.location.origin}/workspace/${doc.type}/${doc.id}`) }} className="page-menu-item"><Link2 className="h-4 w-4" />Copy link</button>
<button onClick={() => void duplicatePage(doc)} className="page-menu-item"><Copy className="h-4 w-4" />Make a copy</button>
<button onClick={() => { setMenuId(null); setMoveId(doc.id) }} className="page-menu-item"><ChevronRight className="h-4 w-4" />Move</button>
<div className="my-1 border-t border-silos-cream-dark" />
<button onClick={() => void archivePage(doc)} className="page-menu-item"><Archive className="h-4 w-4" />Archive</button>
<button onClick={() => void removePage(doc)} className="page-menu-item text-red-700 hover:bg-red-50"><Trash2 className="h-4 w-4" />Delete</button>
</div>
)}
</div>
<div className="border-t border-silos-cream-dark p-2">
<Link
to="/dashboard"
className="flex items-center gap-2 rounded-lg px-2 py-2 text-sm text-silos-muted hover:bg-silos-cream hover:text-silos-brown"
>
<Plus className="h-4 w-4" />
Create new
</Link>
{isExpanded && children.map((child) => renderPage(child, depth + 1))}
</div>
)
}
if (collapsed) {
return (
<aside className="flex w-12 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-3">
{docs.slice(0, 6).map((doc) => {
const Icon = typeIcons[doc.type]
return <Link key={doc.id} to={`/workspace/${doc.type}/${doc.id}`} title={doc.title} className={`mb-1 rounded-lg p-2 ${doc.id === activeDocId ? 'bg-silos-brown/10' : 'hover:bg-silos-cream'}`}><Icon className="h-4 w-4" style={{ color: typeColors[doc.type] }} /></Link>
})}
</aside>
)
}
const movingDoc = moveId ? docs.find((doc) => doc.id === moveId) : undefined
const excludedIds = movingDoc ? descendantsOf(movingDoc.id) : new Set<string>()
return (
<aside className="flex w-64 shrink-0 flex-col border-r border-silos-cream-dark bg-white">
<div className="border-b border-silos-cream-dark p-3">
<Link to="/dashboard" className="text-xs font-medium text-silos-muted hover:text-silos-brown">← Back to Dashboard</Link>
</div>
<div className="flex-1 overflow-y-auto p-2">
{parent && <Link to={`/workspace/stead/${parent.id}`} className="mb-2 flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-medium text-silos-text hover:bg-silos-cream"><LayoutGrid className="h-3.5 w-3.5 text-silos-tan" /><span className="truncate">{parent.title}</span></Link>}
<p className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-silos-muted">{parent ? 'In this space' : 'Content'}</p>
{docs.length === 0 ? <p className="px-2 py-4 text-xs text-silos-muted">No documents yet</p> : roots.map((doc) => renderPage(doc))}
</div>
<div className="border-t border-silos-cream-dark p-2">
<Link to="/dashboard" className="flex items-center gap-2 rounded-lg px-2 py-2 text-sm text-silos-muted hover:bg-silos-cream hover:text-silos-brown"><Plus className="h-4 w-4" />Create new</Link>
</div>
{movingDoc && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/20 p-4" role="dialog" aria-modal="true" aria-label="Move page">
<div className="w-full max-w-md rounded-xl border border-silos-cream-dark bg-white p-4 shadow-2xl">
<div className="mb-3 flex items-center justify-between"><h2 className="font-semibold text-silos-text">Move “{movingDoc.title}”</h2><button onClick={() => setMoveId(null)} className="rounded p-1 hover:bg-silos-cream" aria-label="Close"><X className="h-4 w-4" /></button></div>
<p className="mb-3 text-xs text-silos-muted">Choose a parent page or move it to the top level.</p>
<div className="max-h-64 space-y-1 overflow-y-auto">
<button onClick={() => void movePage(movingDoc)} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"><span>•</span>Top level</button>
{docs.filter((doc) => doc.id !== movingDoc.id && doc.type === 'silo' && !excludedIds.has(doc.id)).map((doc) => <button key={doc.id} onClick={() => void movePage(movingDoc, doc.id)} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-silos-cream"><FileText className="h-4 w-4 text-silos-muted" />{doc.title}</button>)}
</div>
</div>
</div>
)}
</aside>
)
}

View file

@ -23,6 +23,45 @@ export interface ShedTemplate {
available: boolean
}
export interface SiloTemplate {
id: string
label: string
description: string
emoji: string
html: string
}
export const siloTemplates: SiloTemplate[] = [
{
id: 'blank',
label: 'Blank page',
description: 'Start with a clean page',
emoji: '📄',
html: '',
},
{
id: 'meeting',
label: 'Meeting notes',
description: 'Capture attendees, agenda, and actions',
emoji: '📝',
html: '<h2>Meeting notes</h2><p><strong>Date:</strong></p><p><strong>Attendees:</strong></p><h3>Agenda</h3><ul><li>Topic</li></ul><h3>Actions</h3><ul><li>Action — owner — due date</li></ul>',
},
{
id: 'project-plan',
label: 'Project plan',
description: 'Organize goals, milestones, and next steps',
emoji: '🗺️',
html: '<h2>Project plan</h2><h3>Goals</h3><ul><li>Goal 1</li></ul><h3>Milestones</h3><ol><li>Milestone 1</li></ol><h3>Next steps</h3><p>Describe the next step.</p>',
},
{
id: 'how-to',
label: 'How-to guide',
description: 'Create a clear step-by-step guide',
emoji: '🧭',
html: '<h2>How to…</h2><p>Describe the outcome this guide helps someone achieve.</p><h3>Before you start</h3><ul><li>Requirement</li></ul><h3>Steps</h3><ol><li>Step one</li><li>Step two</li></ol>',
},
]
export const createOptions: CreateOption[] = [
{
id: 'shed',

View file

@ -1,11 +1,11 @@
import { useEffect, useRef } from 'react'
import { updateDocument, type DocumentRecord } from '../lib/documents'
export function useAutoSave(doc: DocumentRecord | null) {
export function useAutoSave(doc: DocumentRecord | null, enabled = true) {
const isFirstRender = useRef(true)
useEffect(() => {
if (!doc) return
if (!doc || !enabled) return
if (isFirstRender.current) {
isFirstRender.current = false
return
@ -16,5 +16,5 @@ export function useAutoSave(doc: DocumentRecord | null) {
}, 800)
return () => clearTimeout(timer)
}, [doc?.id, doc?.title, doc?.content])
}, [doc?.id, doc?.title, doc?.content, enabled])
}

View file

@ -30,6 +30,22 @@ body {
-webkit-font-smoothing: antialiased;
}
.page-menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.625rem;
border-radius: 0.5rem;
padding: 0.5rem 0.625rem;
text-align: left;
font-size: 0.875rem;
color: var(--color-silos-text);
}
.page-menu-item:hover {
background: var(--color-silos-cream);
}
@keyframes workflow-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.85; }
@ -48,3 +64,97 @@ body {
stroke-dasharray: 8 4;
animation: workflow-flow 1.5s linear infinite;
}
.prose .silos-table {
width: 100%;
margin: 1rem 0;
border-collapse: collapse;
overflow: hidden;
border: 1px solid var(--color-silos-cream-dark);
border-radius: 0.75rem;
}
.prose .silos-table th,
.prose .silos-table td {
min-width: 8rem;
border: 1px solid var(--color-silos-cream-dark);
padding: 0.65rem 0.75rem;
text-align: left;
}
.prose .silos-table th {
background: var(--color-silos-cream);
font-weight: 600;
}
.prose .silos-panel {
margin: 1rem 0;
border-left: 4px solid var(--color-silos-tan);
border-radius: 0.5rem;
background: var(--color-silos-cream);
padding: 0.85rem 1rem;
}
.prose .silos-media {
margin: 1rem 0;
border-radius: 0.75rem;
background: var(--color-silos-cream);
padding: 0.75rem;
}
.prose .silos-media img {
max-height: 32rem;
max-width: 100%;
border-radius: 0.5rem;
object-fit: contain;
}
.prose .silos-media figcaption {
margin-top: 0.45rem;
color: var(--color-silos-muted);
font-size: 0.8rem;
}
.prose .silos-embed {
margin-top: 0.65rem;
overflow-wrap: anywhere;
color: var(--color-silos-muted);
font-size: 0.85rem;
}
.prose .silos-mention {
border-radius: 0.25rem;
background: #deebff;
color: #0747a6;
padding: 0.1rem 0.3rem;
}
.prose .silos-tabs {
margin: 1rem 0;
border: 1px solid var(--color-silos-cream-dark);
border-radius: 0.75rem;
padding: 0.75rem;
}
.prose .silos-tab-list {
display: flex;
gap: 0.45rem;
border-bottom: 1px solid var(--color-silos-cream-dark);
padding-bottom: 0.6rem;
}
.prose .silos-tab-list span {
border-radius: 0.5rem;
padding: 0.3rem 0.65rem;
color: var(--color-silos-muted);
font-size: 0.8rem;
}
.prose .silos-tab-list .silos-tab-active {
background: var(--color-silos-brown);
color: white;
}
.prose .silos-tab-content {
padding: 0.85rem 0.25rem 0.25rem;
}

View file

@ -147,6 +147,12 @@ export const authApi = {
method: 'POST',
body: JSON.stringify({ token, new_password }),
}),
changePassword: (current_password: string, new_password: string) =>
request<{ message: string }>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password, new_password }),
}),
googleLoginUrl: () => `${API_BASE}/auth/google/login`,
}
export function saveAuth(tokens: AuthTokens) {
@ -180,12 +186,58 @@ export interface DocumentMember {
email: string
}
export interface ApiComment {
id: string
document_id: string
body: string
parent_id?: string | null
author_id: number
author_name: string
created_at: string
updated_at: string
resolved: boolean
}
export interface ApiNotification {
id: string
kind: string
message: string
document_id?: string | null
comment_id?: string | null
actor_name: string
read: boolean
created_at: string
}
export interface ApiActivity {
id: string
document_id: string
kind: string
message: string
actor_name: string
created_at: string
}
export interface ApiTemplate {
id: string
owner_id: number
name: string
description: string
category: string
emoji: string
content: Record<string, unknown>
created_at: string
updated_at: string
members: Array<{ user_id: number; name: string; email: string }>
}
export interface ApiDocument {
id: string
type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string
template_id?: string | null
stead_id?: string | null
parent_page_id?: string | null
content: Record<string, unknown>
created_by: string
created_at: string
@ -197,30 +249,45 @@ export interface ApiDocument {
members?: DocumentMember[]
}
export interface ApiDocumentVersion {
id: string
document_id: string
title: string
content: Record<string, unknown>
created_by: string
created_at: string
reason: string
}
export interface DocumentCreatePayload {
type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string
content?: Record<string, unknown>
template_id?: string
stead_id?: string
parent_page_id?: string | null
}
export interface DocumentUpdatePayload {
title?: string
content?: Record<string, unknown>
starred?: boolean
stead_id?: string
parent_page_id?: string
}
export const documentsApi = {
list: (params?: {
type?: string
stead_id?: string
parent_page_id?: string
starred?: boolean
sort?: 'updated_at' | 'last_visited_at'
}) => {
const search = new URLSearchParams()
if (params?.type) search.set('type', params.type)
if (params?.stead_id) search.set('stead_id', params.stead_id)
if (params?.parent_page_id) search.set('parent_page_id', params.parent_page_id)
if (params?.starred !== undefined) search.set('starred', String(params.starred))
if (params?.sort) search.set('sort', params.sort)
const qs = search.toString()
@ -258,4 +325,43 @@ export const documentsApi = {
delete: (id: string) =>
request<void>(`/documents/${id}`, { method: 'DELETE' }),
listComments: (id: string) => request<ApiComment[]>(`/documents/${id}/comments`),
createComment: (id: string, body: string, parent_id?: string) =>
request<ApiComment>(`/documents/${id}/comments`, {
method: 'POST',
body: JSON.stringify({ body, parent_id }),
}),
deleteComment: (documentId: string, commentId: string) =>
request<void>(`/documents/${documentId}/comments/${commentId}`, { method: 'DELETE' }),
resolveComment: (documentId: string, commentId: string, resolved: boolean) =>
request<ApiComment>(`/documents/${documentId}/comments/${commentId}/resolve`, {
method: 'PATCH',
body: JSON.stringify({ resolved }),
}),
listActivity: (documentId: string) => request<ApiActivity[]>(`/documents/${documentId}/activity`),
listNotifications: () => request<ApiNotification[]>('/documents/notifications'),
markNotificationsRead: () => request<void>('/documents/notifications/read', { method: 'POST' }),
listHistory: (documentId: string) => request<ApiDocumentVersion[]>(`/documents/${documentId}/history`),
restoreHistory: (documentId: string, versionId: string) =>
request<ApiDocument>(`/documents/${documentId}/history/${versionId}/restore`, { method: 'POST' }),
exportDocument: (documentId: string, format: 'html' | 'text' = 'html') =>
request<string>(`/documents/${documentId}/export?format=${format}`),
}
export const templatesApi = {
list: () => request<ApiTemplate[]>('/templates'),
create: (payload: Omit<ApiTemplate, 'id' | 'owner_id' | 'created_at' | 'updated_at' | 'members'>) =>
request<ApiTemplate>('/templates', {
method: 'POST',
body: JSON.stringify(payload),
}),
delete: (id: string) => request<void>(`/templates/${id}`, { method: 'DELETE' }),
share: (id: string, email_or_username: string) =>
request<ApiTemplate>(`/templates/${id}/share`, {
method: 'POST',
body: JSON.stringify({ email_or_username }),
}),
}

View file

@ -7,6 +7,7 @@ export interface DocumentMeta {
title: string
templateId?: ShedTemplateId
steadId?: string
parentPageId?: string
createdAt: string
updatedAt: string
createdBy: string
@ -24,6 +25,7 @@ export interface DocumentRecord extends DocumentMeta {
export interface ListDocumentsOptions {
sort?: 'updated_at' | 'last_visited_at'
starred?: boolean
parentPageId?: string
}
function fromApi(doc: ApiDocument): DocumentRecord {
@ -33,6 +35,7 @@ function fromApi(doc: ApiDocument): DocumentRecord {
title: doc.title,
templateId: doc.template_id as ShedTemplateId | undefined,
steadId: doc.stead_id ?? undefined,
parentPageId: doc.parent_page_id ?? undefined,
content: doc.content,
createdBy: doc.created_by,
createdAt: doc.created_at,
@ -53,13 +56,14 @@ export async function listDocuments(
type,
starred: options?.starred,
sort: options?.sort,
parent_page_id: options?.parentPageId,
})
return docs.map(fromApi)
return docs.filter((doc) => !(doc.content?.archived === true)).map(fromApi)
}
export async function listDocumentsByStead(steadId: string): Promise<DocumentRecord[]> {
const docs = await documentsApi.list({ stead_id: steadId })
return docs.filter((d) => d.type !== 'stead').map(fromApi)
return docs.filter((d) => d.type !== 'stead' && d.content?.archived !== true).map(fromApi)
}
export async function searchDocuments(q: string): Promise<DocumentRecord[]> {
@ -87,6 +91,7 @@ export async function createDocument(
templateId?: ShedTemplateId,
_createdBy = 'You',
steadId?: string,
parentPageId?: string,
): Promise<DocumentRecord> {
const doc = await documentsApi.create({
type,
@ -94,18 +99,23 @@ export async function createDocument(
content,
template_id: templateId,
stead_id: steadId,
parent_page_id: parentPageId,
})
return fromApi(doc)
}
export async function updateDocument(
id: string,
updates: Partial<Pick<DocumentRecord, 'title' | 'content'>>,
updates: Partial<Pick<DocumentRecord, 'title' | 'content' | 'steadId' | 'parentPageId'>> & {
parentPageId?: string | null
},
): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.update(id, {
title: updates.title,
content: updates.content,
stead_id: updates.steadId,
parent_page_id: updates.parentPageId,
})
return fromApi(doc)
} catch {
@ -113,6 +123,15 @@ export async function updateDocument(
}
}
export async function deleteDocument(id: string): Promise<boolean> {
try {
await documentsApi.delete(id)
return true
} catch {
return false
}
}
export async function toggleStar(id: string, starred: boolean): Promise<DocumentRecord | null> {
try {
const doc = await documentsApi.update(id, { starred })

View file

@ -2,10 +2,12 @@ import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SiloEditor from '../../components/silo/SiloEditor'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { createDocument, deleteDocument, getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api'
import { ApiError, documentsApi, type ApiDocumentVersion } from '../../lib/api'
import { formatRelativeTime } from '../../lib/documentUtils'
import CommentsPanel from '../../components/workspace/CommentsPanel'
import { Clock3, Download, MessageSquare, RotateCcw, X } from 'lucide-react'
function siloHtml(content: Record<string, unknown>): string {
if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml
@ -17,6 +19,13 @@ export default function SiloPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [commentsOpen, setCommentsOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [history, setHistory] = useState<ApiDocumentVersion[]>([])
const [historyError, setHistoryError] = useState<string | null>(null)
useEffect(() => {
if (!id) return
@ -34,9 +43,20 @@ export default function SiloPage() {
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
useEffect(() => {
function onShortcut(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
if (editing && !saving) void update()
}
if (event.key === 'Escape' && historyOpen) setHistoryOpen(false)
}
window.addEventListener('keydown', onShortcut)
return () => window.removeEventListener('keydown', onShortcut)
}, [editing, saving, historyOpen])
if (!doc) return null
const status = (doc.content.status as string) || 'draft'
@ -49,34 +69,135 @@ export default function SiloPage() {
)
}
function publish() {
setDoc((prev) =>
prev
? {
...prev,
content: {
...prev.content,
status: status === 'published' ? 'draft' : 'published',
},
async function update() {
if (!doc) return
setSaving(true)
setSaveError(null)
const updated = await updateDocument(doc.id, { title: doc.title, content: doc.content })
setSaving(false)
if (updated) {
setDoc(updated)
setEditing(false)
} else {
setSaveError('Could not save this page. Check your connection and try again.')
}
: prev,
)
}
async function publish() {
if (!doc) return
setSaving(true)
setSaveError(null)
const updated = await updateDocument(doc.id, {
title: doc.title,
content: { ...doc.content, status: status === 'published' ? 'draft' : 'published' },
})
setSaving(false)
if (updated) {
setDoc(updated)
setEditing(false)
} else {
setSaveError('Could not publish this page. Check your connection and try again.')
}
}
async function openHistory() {
if (!doc) return
setHistoryError(null)
try {
setHistory(await documentsApi.listHistory(doc.id))
setHistoryOpen(true)
} catch (err) {
setHistoryError(err instanceof Error ? err.message : 'Could not load version history.')
}
}
async function restore(version: ApiDocumentVersion) {
if (!doc || !window.confirm(`Restore "${version.title}" from ${new Date(version.created_at).toLocaleString()}?`)) return
try {
await documentsApi.restoreHistory(doc.id, version.id)
const restored = await getDocument(doc.id)
setDoc(restored)
setHistoryOpen(false)
setEditing(false)
} catch (err) {
setHistoryError(err instanceof Error ? err.message : 'Could not restore this version.')
}
}
async function exportPage(format: 'html' | 'text') {
if (!doc) return
try {
const content = await documentsApi.exportDocument(doc.id, format)
const blob = new Blob([content], { type: format === 'html' ? 'text/html' : 'text/plain' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${doc.title.replace(/[^\w\s-]/g, '').trim() || 'page'}.${format === 'html' ? 'html' : 'txt'}`
anchor.click()
URL.revokeObjectURL(url)
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Could not export this page.')
}
}
async function createChildPage() {
if (!doc) return
const child = await createDocument('silo', `Child of ${doc.title}`, { body: '', bodyHtml: '', status: 'draft' }, undefined, doc.createdBy, doc.steadId, doc.id)
navigate(`/workspace/silo/${child.id}`)
}
async function duplicatePage() {
if (!doc) return
const duplicate = await createDocument('silo', `${doc.title} copy`, { ...doc.content, status: 'draft' }, doc.templateId, doc.createdBy, doc.steadId, doc.parentPageId)
navigate(`/workspace/silo/${duplicate.id}`)
}
async function archivePage() {
if (!doc) return
const updated = await updateDocument(doc.id, { content: { ...doc.content, archived: true, archivedAt: new Date().toISOString() } })
if (updated) navigate('/dashboard')
else setSaveError('Could not archive this page. Check your connection and try again.')
}
async function removePage() {
if (!doc || !window.confirm('Delete this page permanently?')) return
if (await deleteDocument(doc.id)) navigate('/dashboard')
else setSaveError('Could not delete this page. Check your connection and try again.')
}
const pageText = siloHtml(doc.content).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
const wordCount = pageText ? pageText.split(' ').length : 0
return (
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
layout="page"
titleReadOnly={!editing}
onTitleChange={(title) =>
setDoc((prev) => (prev ? { ...prev, title } : prev))
}
headerActions={
<>
{!editing ? (
<button
type="button"
onClick={publish}
className="hidden rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark sm:block"
onClick={() => setEditing(true)}
className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark"
>
Edit
</button>
) : (
<>
<button type="button" onClick={() => void update()} disabled={saving} className="rounded-lg border border-silos-cream-dark px-3 py-1 text-xs font-medium text-silos-text hover:bg-silos-cream disabled:opacity-50">
{saving ? 'Saving…' : 'Update'}
</button>
<button type="button" onClick={() => void publish()} disabled={saving} className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark disabled:opacity-50">
{status === 'published' ? 'Unpublish' : 'Publish'}
</button>
</>
)}
<button
type="button"
onClick={() => navigate('/dashboard')}
@ -84,10 +205,60 @@ export default function SiloPage() {
>
Close
</button>
<button
type="button"
onClick={() => setCommentsOpen(true)}
className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream"
aria-label="Open comments"
title="Comments"
>
<MessageSquare className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => void openHistory()} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Open version history" title="Version history">
<Clock3 className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => void exportPage('html')} className="rounded-lg p-1.5 text-silos-muted hover:bg-silos-cream" aria-label="Export page" title="Export HTML">
<Download className="h-3.5 w-3.5" />
</button>
</>
}
onCreateChild={() => void createChildPage()}
onDuplicate={() => void duplicatePage()}
onArchive={() => void archivePage()}
onDelete={() => void removePage()}
>
<SiloEditor html={siloHtml(doc.content)} onChange={patchHtml} />
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-silos-cream-dark bg-silos-cream/30 px-4 py-2.5 text-xs text-silos-muted">
<div className="flex items-center gap-3">
<span
className={`rounded-full px-2.5 py-1 font-medium ${
status === 'published'
? 'bg-green-100 text-green-700'
: 'bg-amber-100 text-amber-700'
}`}
>
{status === 'published' ? 'Published' : 'Draft'}
</span>
<span>{wordCount} words</span>
<span>Updated {formatRelativeTime(doc.updatedAt)}</span>
</div>
{saveError && <span className="text-red-700">{saveError}</span>}
</div>
<div aria-live="polite">
<SiloEditor html={siloHtml(doc.content)} onChange={patchHtml} editable={editing} />
</div>
{commentsOpen && <CommentsPanel documentId={doc.id} onClose={() => setCommentsOpen(false)} />}
{historyOpen && (
<aside className="fixed right-0 top-12 z-40 flex h-[calc(100vh-3rem)] w-full max-w-md flex-col border-l border-silos-cream-dark bg-white shadow-xl" aria-label="Version history">
<div className="flex items-center justify-between border-b border-silos-cream-dark px-4 py-3">
<div><h2 className="font-semibold text-silos-text">Version history</h2><p className="text-xs text-silos-muted">Restore an earlier saved version</p></div>
<button type="button" onClick={() => setHistoryOpen(false)} aria-label="Close version history"><X className="h-4 w-4 text-silos-muted" /></button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{historyError && <p className="mb-3 text-xs text-red-700">{historyError}</p>}
{history.length === 0 ? <p className="text-sm text-silos-muted">No earlier versions yet.</p> : <div className="space-y-3">{history.map((version) => <article key={version.id} className="rounded-xl border border-silos-cream-dark p-3"><div className="flex items-start justify-between gap-3"><div><p className="text-sm font-medium text-silos-text">{version.title}</p><p className="mt-1 text-xs text-silos-muted">{version.reason} · {version.created_by}</p><time className="text-[10px] text-silos-muted">{new Date(version.created_at).toLocaleString()}</time></div><button type="button" onClick={() => void restore(version)} className="flex items-center gap-1 rounded-lg border border-silos-cream-dark px-2 py-1 text-xs text-silos-brown hover:bg-silos-cream"><RotateCcw className="h-3 w-3" />Restore</button></div></article>)}</div>}
</div>
</aside>
)}
</WorkspaceShell>
)
}

View file

@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SketchCanvas from '../../components/sketch/SketchCanvas'
import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { updateDocument } from '../../lib/documents'
import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api'
import {
@ -16,6 +16,8 @@ export default function SketchPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!id) return
@ -33,7 +35,6 @@ export default function SketchPage() {
})
}, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id)
const sketchContent = useMemo(
@ -48,14 +49,66 @@ export default function SketchPage() {
setDoc((prev) => (prev ? { ...prev, content: next } : prev))
}
async function update() {
if (!doc) return
setSaving(true)
const updated = await updateDocument(doc.id, { title: doc.title, content: doc.content })
setSaving(false)
if (updated) {
setDoc(updated)
setEditing(false)
}
}
async function publish() {
if (!doc) return
setSaving(true)
const updated = await updateDocument(doc.id, {
title: doc.title,
content: { ...doc.content, status: 'published' },
})
setSaving(false)
if (updated) {
setDoc(updated)
setEditing(false)
}
}
return (
<WorkspaceShell doc={doc} onDocChange={setDoc} layout="canvas" hideTitle>
<WorkspaceShell
doc={doc}
onDocChange={setDoc}
layout="canvas"
hideTitle
headerActions={
!editing ? (
<button type="button" onClick={() => setEditing(true)} className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark">
Edit
</button>
) : (
<>
<button type="button" onClick={() => void update()} disabled={saving} className="rounded-lg border border-silos-cream-dark px-3 py-1 text-xs font-medium text-silos-text hover:bg-silos-cream disabled:opacity-50">
{saving ? 'Saving…' : 'Update'}
</button>
<button
type="button"
onClick={() => void publish()}
disabled={saving}
className="rounded-lg bg-silos-brown px-3 py-1 text-xs font-medium text-white hover:bg-silos-brown-dark disabled:opacity-50"
>
Publish
</button>
</>
)
}
>
<div className="flex h-full flex-col">
<div className="flex items-center border-b border-silos-cream-dark px-6 py-3">
<input
value={doc.title}
readOnly={!editing}
onChange={(e) => setDoc((prev) => (prev ? { ...prev, title: e.target.value } : prev))}
className="border-none bg-transparent text-lg font-semibold text-silos-text outline-none"
className="border-none bg-transparent text-lg font-semibold text-silos-text outline-none read-only:cursor-default"
placeholder="Untitled whiteboard"
/>
</div>
@ -64,6 +117,7 @@ export default function SketchPage() {
elements={sketchContent.elements}
viewport={sketchContent.viewport}
onChange={patchSketch}
readOnly={!editing}
/>
</div>
</div>

View file

@ -0,0 +1,47 @@
# Runtime Validation Report
**Generated**: 2026-10-21
**Target**: Silos React frontend and FastAPI backend
## Summary
| Step | Status | Exit Code | Details |
|------|--------|-----------|---------|
| Frontend build | PASS | 0 | `npm run build`; TypeScript and Vite production build passed |
| Backend syntax | PASS | 0 | `python -m compileall backend\app` |
| FastAPI import | PASS | 0 | Application route registration imported successfully |
| Browser E2E | PASS (skipped) | 0 | Three Playwright specs are runnable; skipped because `E2E_USERNAME` and `E2E_PASSWORD` were not supplied |
## Environment
- Docker: available and daemon responsive.
- Node.js: available (`v20.19.5`).
- Playwright: available (`@playwright/test`); Chromium installed successfully.
- Browser tier: Playwright primary stack configured.
- Infrastructure tier: not exercised by frontend-only browser smoke tests.
## Browser Regression Coverage
- Protected page edit flow exposes Edit and Update controls.
- Version history opens and closes with Escape.
- Export control is available on a readable page.
- Workspace search and notification controls expose accessible names.
- Keyboard shortcut coverage includes `Ctrl/Cmd+K` and `Ctrl/Cmd+S`.
## Known Gaps
- Authenticated browser execution requires test credentials through environment
variables and was therefore skipped in this local run.
- No Docker-backed API integration suite exists yet.
- Full restore/export verification requires a running backend, MongoDB, and a
seeded authenticated test user.
## Test Command
```powershell
cd frontend
$env:E2E_BASE_URL = "http://127.0.0.1:5173"
$env:E2E_USERNAME = "test-user"
$env:E2E_PASSWORD = "test-password"
npm run test:e2e
```