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 ├── backend/ # FastAPI
└── docker-compose.yml └── 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 refresh_token_expire_days: int = 7
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173" 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 @property
def is_sqlite(self) -> bool: def is_sqlite(self) -> bool:

View file

@ -9,8 +9,11 @@ from sqlalchemy.exc import OperationalError
from app.config import settings from app.config import settings
from app.database import Base, engine from app.database import Base, engine
from app.mongo import close_mongo, connect_mongo 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.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__) logger = logging.getLogger(__name__)
@ -28,6 +31,9 @@ async def lifespan(_: FastAPI):
from app.mongo import get_mongo_db from app.mongo import get_mongo_db
await ensure_indexes(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") logger.info("MongoDB connected and indexes ready")
except Exception: except Exception:
logger.warning("MongoDB unavailable — will connect when available") 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(health.router, prefix="/api/v1")
app.include_router(auth.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1")
app.include_router(documents.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") app.include_router(collab.router, prefix="/api/v1")

View file

@ -1,4 +1,9 @@
from datetime import timedelta 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 fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -20,6 +25,7 @@ from app.schemas.user import (
UserCreate, UserCreate,
UserResponse, UserResponse,
UserUpdate, UserUpdate,
ChangePasswordRequest,
) )
from app.services.user_service import ( from app.services.user_service import (
authenticate_user, authenticate_user,
@ -30,7 +36,14 @@ from app.services.user_service import (
get_valid_reset_token, get_valid_reset_token,
update_user, 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"]) router = APIRouter(prefix="/auth", tags=["auth"])
@ -114,3 +127,90 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)) ->
db.commit() db.commit()
return MessageResponse(message="Password reset successfully. You can now log in.") 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 motor.motor_asyncio import AsyncIOMotorDatabase
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.dependencies import get_current_user, get_mongo_db_dep from app.dependencies import get_current_user, get_mongo_db_dep
from app.models.user import User 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 ( from app.services.document_service import (
add_member, add_member,
create_document, create_document,
@ -18,14 +35,169 @@ from app.services.document_service import (
update_document, update_document,
) )
from app.services.user_service import get_user_by_email_or_username 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 = 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]) @router.get("", response_model=list[DocumentResponse])
async def list_docs( async def list_docs(
type: str | None = Query(None, description="Filter by document type"), type: str | None = Query(None, description="Filter by document type"),
stead_id: str | None = Query(None, description="Filter by parent stead"), 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"), starred: bool | None = Query(None, description="Filter starred projects"),
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"), sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@ -37,6 +209,7 @@ async def list_docs(
current_user.id, current_user.id,
doc_type=type, doc_type=type,
stead_id=stead_id, stead_id=stead_id,
parent_page_id=parent_page_id,
starred=starred, starred=starred,
sort=sort_field, sort=sort_field,
) )
@ -72,7 +245,10 @@ async def create_doc(
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
) -> DocumentResponse: ) -> DocumentResponse:
created_by = current_user.full_name or current_user.username created_by = current_user.full_name or current_user.username
doc = await create_document(db, current_user.id, created_by, data) try:
doc = await create_document(db, current_user.id, created_by, data)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return DocumentResponse(**doc) 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) content: dict[str, Any] = Field(default_factory=dict)
template_id: str | None = None template_id: str | None = None
stead_id: str | None = None stead_id: str | None = None
parent_page_id: str | None = None
class DocumentUpdate(BaseModel): class DocumentUpdate(BaseModel):
title: str | None = None title: str | None = None
content: dict[str, Any] | None = None content: dict[str, Any] | None = None
starred: bool | None = None starred: bool | None = None
stead_id: str | None = None
parent_page_id: str | None = None
class ShareRequest(BaseModel): class ShareRequest(BaseModel):
@ -40,6 +43,7 @@ class DocumentResponse(BaseModel):
title: str title: str
template_id: str | None = None template_id: str | None = None
stead_id: str | None = None stead_id: str | None = None
parent_page_id: str | None = None
content: dict[str, Any] content: dict[str, Any]
created_by: str created_by: str
created_at: datetime created_at: datetime
@ -49,3 +53,13 @@ class DocumentResponse(BaseModel):
owner_id: int | None = None owner_id: int | None = None
visibility: Visibility = "private" visibility: Visibility = "private"
members: list[DocumentMember] = Field(default_factory=list) 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) 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): class UserResponse(UserBase):
model_config = ConfigDict(from_attributes=True) 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"], "title": doc["title"],
"template_id": doc.get("template_id"), "template_id": doc.get("template_id"),
"stead_id": doc.get("stead_id"), "stead_id": doc.get("stead_id"),
"parent_page_id": doc.get("parent_page_id"),
"content": doc.get("content", {}), "content": doc.get("content", {}),
"created_by": doc["created_by"], "created_by": doc["created_by"],
"created_at": doc["created_at"], "created_at": doc["created_at"],
@ -61,6 +62,10 @@ async def create_document(
created_by: str, created_by: str,
data: DocumentCreate, data: DocumentCreate,
) -> dict[str, Any]: ) -> 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) now = datetime.now(UTC)
doc_id = str(uuid.uuid4()) doc_id = str(uuid.uuid4())
doc = { doc = {
@ -70,6 +75,7 @@ async def create_document(
"title": data.title, "title": data.title,
"template_id": data.template_id, "template_id": data.template_id,
"stead_id": data.stead_id, "stead_id": data.stead_id,
"parent_page_id": data.parent_page_id,
"content": data.content, "content": data.content,
"created_by": created_by, "created_by": created_by,
"created_at": now, "created_at": now,
@ -97,6 +103,7 @@ async def list_documents(
user_id: int, user_id: int,
doc_type: CreateType | None = None, doc_type: CreateType | None = None,
stead_id: str | None = None, stead_id: str | None = None,
parent_page_id: str | None = None,
starred: bool | None = None, starred: bool | None = None,
sort: SortField = "updated_at", sort: SortField = "updated_at",
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@ -106,6 +113,8 @@ async def list_documents(
extras["type"] = doc_type extras["type"] = doc_type
if stead_id: if stead_id:
extras["stead_id"] = stead_id extras["stead_id"] = stead_id
if parent_page_id:
extras["parent_page_id"] = parent_page_id
if starred is not None: if starred is not None:
extras["starred"] = starred extras["starred"] = starred
if extras: if extras:
@ -156,6 +165,23 @@ async def update_document(
return None return None
if not can_edit(existing, user_id): if not can_edit(existing, user_id):
return None 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)} updates: dict[str, Any] = {"updated_at": datetime.now(UTC)}
if data.title is not None: if data.title is not None:
@ -164,6 +190,10 @@ async def update_document(
updates["content"] = data.content updates["content"] = data.content
if data.starred is not None: if data.starred is not None:
updates["starred"] = data.starred 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( result = await db[COLLECTION].find_one_and_update(
{"_id": doc_id}, {"_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 bcrypt
import secrets
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
@ -31,6 +32,20 @@ def create_refresh_token(subject: str) -> str:
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) 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]: def decode_token(token: str) -> dict[str, Any]:
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) 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 python-jose[cryptography]==3.4.0
bcrypt==4.2.1 bcrypt==4.2.1
python-multipart==0.0.20 python-multipart==0.0.20
httpx==0.28.1
motor==3.7.0 motor==3.7.0
alembic==1.14.1 alembic==1.14.1
email-validator==2.2.0 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" "react-router-dom": "^7.2.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.1",
"@tailwindcss/vite": "^4.0.9", "@tailwindcss/vite": "^4.0.9",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
@ -815,6 +816,22 @@
"node": "^22.20 || ^24.12 || >=25" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -2214,6 +2231,35 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.28", "version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",

View file

@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"framer-motion": "^12.4.7", "framer-motion": "^12.4.7",
@ -22,6 +23,7 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.9", "tailwindcss": "^4.0.9",
"typescript": "~5.7.2", "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, LayoutGrid,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { createOptions, shedTemplates } from '../../data/createData' import { createOptions, shedTemplates, siloTemplates } from '../../data/createData'
import { import {
createDocument, createDocument,
getDefaultShedContent, getDefaultShedContent,
@ -24,9 +24,10 @@ const iconMap: Record<string, LucideIcon> = {
LayoutGrid, LayoutGrid,
} }
export default function CreateDropdown({ steadId }: { steadId?: string }) { export default function CreateDropdown({ steadId, parentPageId }: { steadId?: string; parentPageId?: string }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [shedOpen, setShedOpen] = useState(false) const [shedOpen, setShedOpen] = useState(false)
const [pageOpen, setPageOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const navigate = useNavigate() const navigate = useNavigate()
@ -35,6 +36,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
if (ref.current && !ref.current.contains(e.target as Node)) { if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false) setOpen(false)
setShedOpen(false) setShedOpen(false)
setPageOpen(false)
} }
} }
if (open) document.addEventListener('mousedown', handleClickOutside) if (open) document.addEventListener('mousedown', handleClickOutside)
@ -56,11 +58,28 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
undefined, undefined,
author, author,
steadId, steadId,
type === 'silo' ? parentPageId : undefined,
) )
setOpen(false) setOpen(false)
setPageOpen(false)
navigate(`/workspace/${type}/${doc.id}`) 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() { function handleCreateStead() {
setOpen(false) setOpen(false)
navigate('/workspace/stead/new') navigate('/workspace/stead/new')
@ -88,6 +107,7 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
onClick={() => { onClick={() => {
setOpen(!open) setOpen(!open)
setShedOpen(false) 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" 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) => { {createOptions.map((option) => {
const Icon = iconMap[option.icon] const Icon = iconMap[option.icon]
const isShed = option.id === 'shed' const isShed = option.id === 'shed'
const isPage = option.id === 'silo'
return ( return (
<div key={option.id} className="relative"> <div key={option.id} className="relative">
@ -113,6 +134,10 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
onClick={() => { onClick={() => {
if (isShed) { if (isShed) {
setShedOpen(!shedOpen) setShedOpen(!shedOpen)
setPageOpen(false)
} else if (isPage) {
setPageOpen(!pageOpen)
setShedOpen(false)
} else if (option.id === 'stead') { } else if (option.id === 'stead') {
handleCreateStead() handleCreateStead()
} else if (option.id !== 'shed') { } else if (option.id !== 'shed') {
@ -136,11 +161,9 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
</p> </p>
<p className="mt-0.5 text-xs text-silos-muted">{option.description}</p> <p className="mt-0.5 text-xs text-silos-muted">{option.description}</p>
</div> </div>
{isShed && ( {(isShed || isPage) && (
<ChevronRight <ChevronRight
className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${ className={`mt-1 h-4 w-4 shrink-0 text-silos-muted transition-transform ${(isShed ? shedOpen : pageOpen) ? 'rotate-90' : ''}`}
shedOpen ? 'rotate-90' : ''
}`}
/> />
)} )}
</button> </button>
@ -165,6 +188,23 @@ export default function CreateDropdown({ steadId }: { steadId?: string }) {
))} ))}
</div> </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> </div>
) )
})} })}

View file

@ -3,6 +3,7 @@ import RecentActivities from './RecentActivities'
import ActivityStatus from './ActivityStatus' import ActivityStatus from './ActivityStatus'
import DiscoverPanel from './DiscoverPanel' import DiscoverPanel from './DiscoverPanel'
import RecentProjectsGrid from './RecentProjectsGrid' import RecentProjectsGrid from './RecentProjectsGrid'
import GettingStartedPanel from './GettingStartedPanel'
import { getStoredUser } from '../../lib/api' import { getStoredUser } from '../../lib/api'
import type { DocumentRecord } from '../../lib/documents' import type { DocumentRecord } from '../../lib/documents'
@ -33,6 +34,7 @@ interface DashboardFeedProps {
loading?: boolean loading?: boolean
error?: string | null error?: string | null
onOpenRecent?: () => void onOpenRecent?: () => void
onOpenTemplates: () => void
} }
export default function DashboardFeed({ export default function DashboardFeed({
@ -40,6 +42,7 @@ export default function DashboardFeed({
loading, loading,
error, error,
onOpenRecent, onOpenRecent,
onOpenTemplates,
}: DashboardFeedProps) { }: DashboardFeedProps) {
const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed') const [activeTab, setActiveTab] = useState<'feed' | 'discover'>('feed')
const user = getStoredUser() const user = getStoredUser()
@ -71,7 +74,9 @@ export default function DashboardFeed({
{/* Recent pages grid */} {/* Recent pages grid */}
<div className="mt-6"> <div className="mt-6">
<div className="mb-4 flex items-center justify-between"> <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 && ( {recent.length > 0 && (
<button <button
type="button" type="button"
@ -82,7 +87,11 @@ export default function DashboardFeed({
</button> </button>
)} )}
</div> </div>
<RecentProjectsGrid documents={recent} loading={loading} /> {recent.length === 0 && !loading ? (
<GettingStartedPanel onOpenTemplates={onOpenTemplates} />
) : (
<RecentProjectsGrid documents={recent} loading={loading} />
)}
</div> </div>
{/* Tabs */} {/* Tabs */}
@ -104,11 +113,37 @@ export default function DashboardFeed({
{activeTab === 'feed' ? ( {activeTab === 'feed' ? (
<div className="mt-6 grid gap-6 lg:grid-cols-5"> <div className="mt-6 grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3"> {!loading && recent.length === 0 ? (
<RecentActivities documents={recent} loading={loading} onViewAll={onOpenRecent} /> <div className="lg:col-span-3">
</div> <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"> <div className="space-y-6 lg:col-span-2">
<ActivityStatus documentCount={recent.length} /> {!(!loading && recent.length === 0) && (
<ActivityStatus documentCount={recent.length} />
)}
<DiscoverPanel /> <DiscoverPanel />
</div> </div>
</div> </div>

View file

@ -78,6 +78,10 @@ export default function DashboardLayout() {
setActiveNav('recent') setActiveNav('recent')
setFlyout('recent') setFlyout('recent')
}} }}
onOpenTemplates={() => {
setActiveNav('templates')
setFlyout('templates')
}}
/> />
</div> </div>
</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 { useNavigate } from 'react-router-dom'
import { X } from 'lucide-react' import { Plus, Trash2, X } from 'lucide-react'
import { shedTemplates } from '../../data/createData' import { shedTemplates, siloTemplates } from '../../data/createData'
import { import {
createDocument, createDocument,
getDefaultShedContent, getDefaultShedContent,
getDefaultTitle, getDefaultTitle,
listDocuments,
type DocumentRecord,
} from '../../lib/documents' } from '../../lib/documents'
import { getStoredUser } from '../../lib/api' import { getStoredUser } from '../../lib/api'
import { templatesApi, type ApiTemplate } from '../../lib/api'
interface TemplatesFlyoutProps { interface TemplatesFlyoutProps {
sidebarCollapsed: boolean sidebarCollapsed: boolean
onClose: () => void 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) { export default function TemplatesFlyout({ sidebarCollapsed, onClose }: TemplatesFlyoutProps) {
const navigate = useNavigate() const navigate = useNavigate()
const author = getStoredUser()?.full_name ?? 'You' 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']) { async function createShed(templateId: (typeof shedTemplates)[number]['id']) {
if (templateId === 'templates') return if (templateId === 'templates') return
@ -58,6 +72,62 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
navigate(`/workspace/silo/${doc.id}`) 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 ( return (
<> <>
<div className="fixed inset-0 top-12 z-40 bg-black/10" onClick={onClose} /> <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"> <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> <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"> <div className="flex items-center gap-1">
<X className="h-4 w-4 text-silos-muted" /> <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> <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>
<div className="flex-1 overflow-y-auto p-3"> <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> <p className="px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Shed — Live docs</p>
{shedTemplates.map((t) => ( {shedTemplates.map((t) => (
<button <button
@ -90,7 +203,7 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
</button> </button>
))} ))}
<p className="mt-4 px-1 pb-2 text-xs font-semibold uppercase text-silos-muted">Silo — Pages</p> <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 <button
key={t.id} key={t.id}
type="button" type="button"
@ -103,6 +216,15 @@ export default function TemplatesFlyout({ sidebarCollapsed, onClose }: Templates
))} ))}
</div> </div>
</aside> </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 { import {
Bold, AlignCenter,
Italic,
Underline,
AlignLeft, AlignLeft,
Image, AlignRight,
Link,
AtSign, AtSign,
Smile, Bold,
Table, Check,
Plus, Code2,
Columns2,
FileUp,
Image,
Italic,
Link,
List, List,
Heading1, ListOrdered,
Palette,
Plus,
Smile,
Strikethrough,
Table,
Underline,
Video,
} from 'lucide-react' } from 'lucide-react'
const toolbarGroups = [ type Popover = 'color' | 'emoji' | 'link' | 'image' | 'video' | 'insert' | null
[ type MediaType = 'link' | 'image' | 'video'
{ icon: Heading1, label: 'Heading', cmd: 'formatBlock', value: 'h2' },
{ icon: Bold, label: 'Bold', cmd: 'bold' }, const textColors = [
{ icon: Italic, label: 'Italic', cmd: 'italic' }, '#172B4D', '#42526E', '#5E6C84', '#7A869A', '#091E42', '#0747A6',
{ icon: Underline, label: 'Underline', cmd: 'underline' }, '#0065FF', '#00875A', '#36B37E', '#FFAB00', '#FF8B00', '#DE350B',
], '#BF2600', '#6554C0', '#403294', '#FFFFFF', '#E3E8EF', '#B3BAC5',
[ ]
{ icon: AlignLeft, label: 'Align', cmd: 'justifyLeft' },
{ icon: List, label: 'List', cmd: 'insertUnorderedList' }, 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 = [ const slashItems = [
{ label: 'Paragraph', html: '<p>Paragraph</p>' }, { label: 'Paragraph', description: 'Start with plain text', html: '<p>Paragraph</p>' },
{ label: 'Heading', html: '<h2>Heading</h2>' }, { label: 'Heading', description: 'Add a section heading', html: '<h2>Heading</h2>' },
{ label: 'Bullet list', html: '<ul><li>Item</li></ul>' }, { label: 'Bullet list', description: 'Create a simple list', html: '<ul><li>List 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: 'Numbered list', description: 'Create a numbered list', html: '<ol><li>List item</li></ol>' },
{ 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: '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> = { const starterHtml: Record<string, string> = {
@ -44,13 +62,39 @@ const starterHtml: Record<string, string> = {
interface SiloEditorProps { interface SiloEditorProps {
html: string html: string
onChange: (html: string) => void onChange: (html: string) => void
editable?: boolean
} }
export default function SiloEditor({ html, onChange }: SiloEditorProps) { function isSafeUrl(value: string): boolean {
const editorRef = useRef<HTMLDivElement>(null) return /^(https?:\/\/|mailto:|tel:)/i.test(value.trim())
const [slashOpen, setSlashOpen] = useState(false) }
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 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(() => { useEffect(() => {
if (!primed.current && editorRef.current) { if (!primed.current && editorRef.current) {
@ -59,133 +103,284 @@ export default function SiloEditor({ html, onChange }: SiloEditorProps) {
} }
}, [html]) }, [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() { function emit() {
if (editorRef.current) onChange(editorRef.current.innerHTML) if (editable && editorRef.current) onChange(editorRef.current.innerHTML)
} }
function run(cmd: string, value?: string) { function saveSelection() {
document.execCommand(cmd, false, value) 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() 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() emit()
} }
function insertHtml(snippet: string) { function insertHtml(snippet: string) {
if (!editable) return
restoreSelection()
document.execCommand('insertHTML', false, snippet) document.execCommand('insertHTML', false, snippet)
setPopover(null)
setSlashOpen(false) setSlashOpen(false)
emit() emit()
} }
function handleInput() { function handleInput() {
if (!editable) return
const text = editorRef.current?.innerText ?? '' const text = editorRef.current?.innerText ?? ''
if (text.endsWith('/')) setSlashOpen(true) setSlashOpen(text.endsWith('/'))
else setSlashOpen(false)
emit() emit()
} }
function handleKeyDown(e: React.KeyboardEvent) { function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (e.key === 'Escape') setSlashOpen(false) 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 ( return (
<div className="flex min-h-[calc(100vh-8rem)] flex-col"> <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="relative 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"> <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) => ( <select
<div key={gi} className="flex items-center gap-0.5"> disabled={!editable}
{group.map(({ icon: Icon, label, cmd, value }) => ( onMouseDown={saveSelection}
<button defaultValue="paragraph"
key={label} onChange={(event) => run('formatBlock', event.target.value)}
type="button" className="h-8 rounded px-2 text-sm text-silos-text outline-none hover:bg-silos-cream disabled:opacity-40"
title={label} aria-label="Text style"
onClick={() => run(cmd, value)} >
className="rounded p-1.5 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text" <option value="paragraph">Normal text</option>
> <option value="h1">Heading 1</option>
<Icon className="h-4 w-4" /> <option value="h2">Heading 2</option>
</button> <option value="h3">Heading 3</option>
))} <option value="blockquote">Quote</option>
{gi < toolbarGroups.length - 1 && <div className="mx-1 h-5 w-px bg-silos-cream-dark" />} <option value="pre">Code block</option>
</div> </select>
))}
<div className="mx-1 h-5 w-px bg-silos-cream-dark" /> <div className="mx-1 h-5 w-px bg-silos-cream-dark" />
<button {[
type="button" [Bold, 'Bold', 'bold'],
title="Link" [Italic, 'Italic', 'italic'],
onClick={() => { [Underline, 'Underline', 'underline'],
const url = window.prompt('Link URL') [Strikethrough, 'Strikethrough', 'strikeThrough'],
if (url) run('createLink', url) [List, 'Bullet list', 'insertUnorderedList'],
}} [ListOrdered, 'Numbered list', 'insertOrderedList'],
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream" ].map(([Icon, label, command]) => {
> const ToolbarIcon = Icon as typeof Bold
<Link className="h-4 w-4" /> return (
</button> <button
<button type="button" title="Image" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"> key={String(label)}
<Image className="h-4 w-4" /> type="button"
</button> title={String(label)}
<button type="button" title="Mention" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"> disabled={!editable}
<AtSign className="h-4 w-4" /> onMouseDown={(event) => event.preventDefault()}
</button> onClick={() => run(String(command))}
<button type="button" title="Emoji" className="rounded p-1.5 text-silos-muted hover:bg-silos-cream"> 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' : ''}`}
<Smile className="h-4 w-4" /> >
</button> <ToolbarIcon className="h-4 w-4" />
<button </button>
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>')} <div className="mx-1 h-5 w-px bg-silos-cream-dark" />
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream" {[['Left', AlignLeft, 'justifyLeft'], ['Center', AlignCenter, 'justifyCenter'], ['Right', AlignRight, 'justifyRight']].map(([label, Icon, command]) => {
> const ToolbarIcon = Icon as typeof AlignLeft
<Table className="h-4 w-4" /> 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> })}
<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>
type="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>
title="Insert" <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>
onClick={() => setSlashOpen((v) => !v)} <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>
className="rounded p-1.5 text-silos-muted hover:bg-silos-cream" <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>
<Plus className="h-4 w-4" /> <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> <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> </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 className="relative flex-1 py-6">
<div <div
ref={editorRef} ref={editorRef}
contentEditable contentEditable={editable}
suppressContentEditableWarning suppressContentEditableWarning
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
data-placeholder="Press / to insert elements, or just start writing..." 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)]" 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 && ( {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 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>
))}
</div>
)}
</div> </div>
<div className="sticky bottom-0 border-t border-silos-cream-dark bg-white py-4"> <div className="sticky bottom-0 border-t border-silos-cream-dark bg-white py-4">
<div className="flex flex-wrap gap-2"> <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>
{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> </div>
</div> </div>
) )

View file

@ -33,6 +33,7 @@ interface SketchCanvasProps {
elements: SketchElement[] elements: SketchElement[]
viewport?: SketchViewport viewport?: SketchViewport
onChange: (elements: SketchElement[], viewport?: SketchViewport) => void onChange: (elements: SketchElement[], viewport?: SketchViewport) => void
readOnly?: boolean
} }
type DragMode = 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 [activeTool, setActiveTool] = useState<SketchTool>('pen')
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [pan, setPan] = useState({ x: viewport?.panX ?? 0, y: viewport?.panY ?? 0 }) 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 = () => { const undo = () => {
if (readOnly) return
if (history.length === 0) return if (history.length === 0) return
const prev = history[history.length - 1] const prev = history[history.length - 1]
setFuture((f) => [{ elements, pan, zoom }, ...f]) setFuture((f) => [{ elements, pan, zoom }, ...f])
@ -124,6 +126,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
} }
const redo = () => { const redo = () => {
if (readOnly) return
if (future.length === 0) return if (future.length === 0) return
const next = future[0] const next = future[0]
setHistory((h) => [...h, { elements, pan, zoom }]) setHistory((h) => [...h, { elements, pan, zoom }])
@ -135,6 +138,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
useEffect(() => { useEffect(() => {
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (readOnly) return
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
if (editingId) return if (editingId) return
if (selectedId) { if (selectedId) {
@ -160,7 +164,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
} }
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('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 { function findElementAt(point: Point): SketchElement | null {
for (let i = elements.length - 1; i >= 0; i--) { for (let i = elements.length - 1; i >= 0; i--) {
@ -178,6 +182,8 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
return return
} }
if (readOnly) return
if (activeTool === 'select') { if (activeTool === 'select') {
const hit = findElementAt(point) const hit = findElementAt(point)
if (hit) { if (hit) {
@ -668,6 +674,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
} }
}} }}
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (readOnly) return
e.stopPropagation() e.stopPropagation()
setEditingId(el.id) setEditingId(el.id)
setActiveTool('select') setActiveTool('select')
@ -700,6 +707,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
} }
}} }}
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (readOnly) return
e.stopPropagation() e.stopPropagation()
setEditingId(el.id) setEditingId(el.id)
setActiveTool('select') setActiveTool('select')
@ -750,6 +758,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
> >
<input <input
value={cell} value={cell}
readOnly={readOnly}
onChange={(e) => { onChange={(e) => {
const cells = el.cells.map((r) => [...r]) const cells = el.cells.map((r) => [...r])
cells[ri][ci] = e.target.value cells[ri][ci] = e.target.value
@ -806,6 +815,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
setActiveTool(id) setActiveTool(id)
setShowMore(false) setShowMore(false)
}} }}
disabled={readOnly && id !== 'select' && id !== 'hand'}
className={`rounded-lg p-2 transition-colors ${ className={`rounded-lg p-2 transition-colors ${
activeTool === id activeTool === id
? 'bg-silos-brown/10 text-silos-brown' ? 'bg-silos-brown/10 text-silos-brown'
@ -824,6 +834,7 @@ export default function SketchCanvas({ elements, viewport, onChange }: SketchCan
setActiveTool('rect') setActiveTool('rect')
setShowMore(false) setShowMore(false)
}} }}
disabled={readOnly}
className={`rounded-lg p-2 transition-colors ${ className={`rounded-lg p-2 transition-colors ${
activeTool === 'rect' activeTool === 'rect'
? 'bg-silos-brown/10 text-silos-brown' ? '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 { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' 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 { 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 { getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import CreateDropdown from '../create/CreateDropdown' import CreateDropdown from '../create/CreateDropdown'
import WorkspaceSidebar from './WorkspaceSidebar' import WorkspaceSidebar from './WorkspaceSidebar'
@ -18,7 +18,13 @@ interface WorkspaceShellProps {
layout?: WorkspaceLayout layout?: WorkspaceLayout
headerActions?: React.ReactNode headerActions?: React.ReactNode
hideTitle?: boolean hideTitle?: boolean
titleReadOnly?: boolean
onTitleChange?: (title: string) => void
presence?: { name: string; initial: string }[] presence?: { name: string; initial: string }[]
onCreateChild?: () => void
onDuplicate?: () => void
onArchive?: () => void
onDelete?: () => void
} }
export default function WorkspaceShell({ export default function WorkspaceShell({
@ -28,13 +34,26 @@ export default function WorkspaceShell({
layout = 'default', layout = 'default',
headerActions, headerActions,
hideTitle = false, hideTitle = false,
titleReadOnly = false,
onTitleChange,
presence = [], presence = [],
onCreateChild,
onDuplicate,
onArchive,
onDelete,
}: WorkspaceShellProps) { }: WorkspaceShellProps) {
const user = getStoredUser() const user = getStoredUser()
const [shareOpen, setShareOpen] = useState(false) const [shareOpen, setShareOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [parentStead, setParentStead] = useState<DocumentRecord | null>(null) 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(() => { useEffect(() => {
if (!doc.steadId) { if (!doc.steadId) {
@ -59,6 +78,10 @@ export default function WorkspaceShell({
}, []) }, [])
function handleTitleChange(title: string) { function handleTitleChange(title: string) {
if (onTitleChange) {
onTitleChange(title)
return
}
void updateDocument(doc.id, { title }).then((updated) => { void updateDocument(doc.id, { title }).then((updated) => {
if (updated) onDocChange(updated) if (updated) onDocChange(updated)
}) })
@ -106,7 +129,10 @@ export default function WorkspaceShell({
> >
<Search className="h-3.5 w-3.5" /> <Search className="h-3.5 w-3.5" />
</button> </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"> <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> <span className="hidden text-xs text-green-600 sm:inline">Saved</span>
{headerActions} {headerActions}
@ -142,9 +168,29 @@ export default function WorkspaceShell({
> >
<Link2 className="h-3.5 w-3.5" /> <Link2 className="h-3.5 w-3.5" />
</button> </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" /> <MoreHorizontal className="h-3.5 w-3.5" />
</button> </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> </div>
</header> </header>
@ -167,9 +213,11 @@ export default function WorkspaceShell({
<> <>
<input <input
value={doc.title} value={doc.title}
readOnly={titleReadOnly}
onChange={(e) => handleTitleChange(e.target.value)} onChange={(e) => handleTitleChange(e.target.value)}
className={`w-full border-none bg-transparent font-bold text-silos-text outline-none ${ 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...'} placeholder={isPage ? 'Give this page a title' : 'Start with a title...'}
/> />

View file

@ -1,7 +1,32 @@
import { useState, useEffect } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { FileText, Zap, PenTool, LayoutGrid, Plus } from 'lucide-react' import {
import { listDocuments, listDocumentsByStead, type DocumentRecord } from '../../lib/documents' 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' import type { CreateType } from '../../data/createData'
const typeIcons: Record<CreateType, typeof FileText> = { const typeIcons: Record<CreateType, typeof FileText> = {
@ -27,107 +52,281 @@ interface WorkspaceSidebarProps {
export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: WorkspaceSidebarProps) { export default function WorkspaceSidebar({ collapsed, activeDocId, steadId }: WorkspaceSidebarProps) {
const [docs, setDocs] = useState<DocumentRecord[]>([]) const [docs, setDocs] = useState<DocumentRecord[]>([])
const [steads, setSteads] = 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(() => { useEffect(() => {
if (steadId) { const load = steadId
void Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then( ? Promise.all([listDocumentsByStead(steadId), listDocuments('stead')]).then(
([siblings, spaces]) => { ([siblings, spaces]) => {
setDocs(siblings) setDocs(siblings)
setSteads(spaces) setSteads(spaces)
}, },
) )
} else { : listDocuments().then((all) => {
void listDocuments().then((all) => { setDocs(all.filter((doc) => doc.type !== 'stead'))
setDocs(all.filter((d) => d.type !== 'stead')) setSteads(all.filter((doc) => doc.type === 'stead'))
setSteads(all.filter((d) => d.type === 'stead')) })
}) void load
}
}, [activeDocId, steadId]) }, [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)
}, [])
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
}
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)
}
}
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
to={`/workspace/${doc.type}/${doc.id}`}
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'
}`}
>
<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>
{isExpanded && children.map((child) => renderPage(child, depth + 1))}
</div>
)
}
if (collapsed) { if (collapsed) {
return ( return (
<aside className="flex w-12 shrink-0 flex-col items-center border-r border-silos-cream-dark bg-white py-3"> <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) => { {docs.slice(0, 6).map((doc) => {
const Icon = typeIcons[doc.type] const Icon = typeIcons[doc.type]
return ( 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>
<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> </aside>
) )
} }
const movingDoc = moveId ? docs.find((doc) => doc.id === moveId) : undefined
const excludedIds = movingDoc ? descendantsOf(movingDoc.id) : new Set<string>()
return ( return (
<aside className="flex w-56 shrink-0 flex-col border-r border-silos-cream-dark bg-white"> <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"> <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"> <Link to="/dashboard" className="text-xs font-medium text-silos-muted hover:text-silos-brown">← Back to Dashboard</Link>
← Back to Dashboard
</Link>
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
{parent && ( {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>
<Link {docs.length === 0 ? <p className="px-2 py-4 text-xs text-silos-muted">No documents yet</p> : roots.map((doc) => renderPage(doc))}
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) => {
const Icon = typeIcons[doc.type]
return (
<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 ${
doc.id === activeDocId
? 'bg-silos-brown/10 font-medium text-silos-brown'
: 'text-silos-text hover:bg-silos-cream'
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" style={{ color: typeColors[doc.type] }} />
<span className="truncate">{doc.title}</span>
</Link>
)
})
)}
</div> </div>
<div className="border-t border-silos-cream-dark p-2"> <div className="border-t border-silos-cream-dark p-2">
<Link <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>
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> </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> </aside>
) )
} }

View file

@ -23,6 +23,45 @@ export interface ShedTemplate {
available: boolean 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[] = [ export const createOptions: CreateOption[] = [
{ {
id: 'shed', id: 'shed',

View file

@ -1,11 +1,11 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { updateDocument, type DocumentRecord } from '../lib/documents' 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) const isFirstRender = useRef(true)
useEffect(() => { useEffect(() => {
if (!doc) return if (!doc || !enabled) return
if (isFirstRender.current) { if (isFirstRender.current) {
isFirstRender.current = false isFirstRender.current = false
return return
@ -16,5 +16,5 @@ export function useAutoSave(doc: DocumentRecord | null) {
}, 800) }, 800)
return () => clearTimeout(timer) 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; -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 { @keyframes workflow-pulse {
0%, 100% { transform: scale(1); opacity: 1; } 0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.85; } 50% { transform: scale(1.08); opacity: 0.85; }
@ -48,3 +64,97 @@ body {
stroke-dasharray: 8 4; stroke-dasharray: 8 4;
animation: workflow-flow 1.5s linear infinite; 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', method: 'POST',
body: JSON.stringify({ token, new_password }), 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) { export function saveAuth(tokens: AuthTokens) {
@ -180,12 +186,58 @@ export interface DocumentMember {
email: string 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 { export interface ApiDocument {
id: string id: string
type: 'shed' | 'silo' | 'sketch' | 'stead' type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string title: string
template_id?: string | null template_id?: string | null
stead_id?: string | null stead_id?: string | null
parent_page_id?: string | null
content: Record<string, unknown> content: Record<string, unknown>
created_by: string created_by: string
created_at: string created_at: string
@ -197,30 +249,45 @@ export interface ApiDocument {
members?: DocumentMember[] 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 { export interface DocumentCreatePayload {
type: 'shed' | 'silo' | 'sketch' | 'stead' type: 'shed' | 'silo' | 'sketch' | 'stead'
title: string title: string
content?: Record<string, unknown> content?: Record<string, unknown>
template_id?: string template_id?: string
stead_id?: string stead_id?: string
parent_page_id?: string | null
} }
export interface DocumentUpdatePayload { export interface DocumentUpdatePayload {
title?: string title?: string
content?: Record<string, unknown> content?: Record<string, unknown>
starred?: boolean starred?: boolean
stead_id?: string
parent_page_id?: string
} }
export const documentsApi = { export const documentsApi = {
list: (params?: { list: (params?: {
type?: string type?: string
stead_id?: string stead_id?: string
parent_page_id?: string
starred?: boolean starred?: boolean
sort?: 'updated_at' | 'last_visited_at' sort?: 'updated_at' | 'last_visited_at'
}) => { }) => {
const search = new URLSearchParams() const search = new URLSearchParams()
if (params?.type) search.set('type', params.type) if (params?.type) search.set('type', params.type)
if (params?.stead_id) search.set('stead_id', params.stead_id) 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?.starred !== undefined) search.set('starred', String(params.starred))
if (params?.sort) search.set('sort', params.sort) if (params?.sort) search.set('sort', params.sort)
const qs = search.toString() const qs = search.toString()
@ -258,4 +325,43 @@ export const documentsApi = {
delete: (id: string) => delete: (id: string) =>
request<void>(`/documents/${id}`, { method: 'DELETE' }), 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 title: string
templateId?: ShedTemplateId templateId?: ShedTemplateId
steadId?: string steadId?: string
parentPageId?: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
createdBy: string createdBy: string
@ -24,6 +25,7 @@ export interface DocumentRecord extends DocumentMeta {
export interface ListDocumentsOptions { export interface ListDocumentsOptions {
sort?: 'updated_at' | 'last_visited_at' sort?: 'updated_at' | 'last_visited_at'
starred?: boolean starred?: boolean
parentPageId?: string
} }
function fromApi(doc: ApiDocument): DocumentRecord { function fromApi(doc: ApiDocument): DocumentRecord {
@ -33,6 +35,7 @@ function fromApi(doc: ApiDocument): DocumentRecord {
title: doc.title, title: doc.title,
templateId: doc.template_id as ShedTemplateId | undefined, templateId: doc.template_id as ShedTemplateId | undefined,
steadId: doc.stead_id ?? undefined, steadId: doc.stead_id ?? undefined,
parentPageId: doc.parent_page_id ?? undefined,
content: doc.content, content: doc.content,
createdBy: doc.created_by, createdBy: doc.created_by,
createdAt: doc.created_at, createdAt: doc.created_at,
@ -53,13 +56,14 @@ export async function listDocuments(
type, type,
starred: options?.starred, starred: options?.starred,
sort: options?.sort, 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[]> { export async function listDocumentsByStead(steadId: string): Promise<DocumentRecord[]> {
const docs = await documentsApi.list({ stead_id: steadId }) 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[]> { export async function searchDocuments(q: string): Promise<DocumentRecord[]> {
@ -87,6 +91,7 @@ export async function createDocument(
templateId?: ShedTemplateId, templateId?: ShedTemplateId,
_createdBy = 'You', _createdBy = 'You',
steadId?: string, steadId?: string,
parentPageId?: string,
): Promise<DocumentRecord> { ): Promise<DocumentRecord> {
const doc = await documentsApi.create({ const doc = await documentsApi.create({
type, type,
@ -94,18 +99,23 @@ export async function createDocument(
content, content,
template_id: templateId, template_id: templateId,
stead_id: steadId, stead_id: steadId,
parent_page_id: parentPageId,
}) })
return fromApi(doc) return fromApi(doc)
} }
export async function updateDocument( export async function updateDocument(
id: string, id: string,
updates: Partial<Pick<DocumentRecord, 'title' | 'content'>>, updates: Partial<Pick<DocumentRecord, 'title' | 'content' | 'steadId' | 'parentPageId'>> & {
parentPageId?: string | null
},
): Promise<DocumentRecord | null> { ): Promise<DocumentRecord | null> {
try { try {
const doc = await documentsApi.update(id, { const doc = await documentsApi.update(id, {
title: updates.title, title: updates.title,
content: updates.content, content: updates.content,
stead_id: updates.steadId,
parent_page_id: updates.parentPageId,
}) })
return fromApi(doc) return fromApi(doc)
} catch { } 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> { export async function toggleStar(id: string, starred: boolean): Promise<DocumentRecord | null> {
try { try {
const doc = await documentsApi.update(id, { starred }) 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 { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell' import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SiloEditor from '../../components/silo/SiloEditor' import SiloEditor from '../../components/silo/SiloEditor'
import { getDocument, type DocumentRecord } from '../../lib/documents' import { createDocument, deleteDocument, getDocument, updateDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave'
import { useRecordVisit } from '../../hooks/useRecordVisit' 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 { function siloHtml(content: Record<string, unknown>): string {
if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml if (typeof content.bodyHtml === 'string' && content.bodyHtml) return content.bodyHtml
@ -17,6 +19,13 @@ export default function SiloPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null) 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(() => { useEffect(() => {
if (!id) return if (!id) return
@ -34,9 +43,20 @@ export default function SiloPage() {
}) })
}, [id, navigate]) }, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id) 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 if (!doc) return null
const status = (doc.content.status as string) || 'draft' const status = (doc.content.status as string) || 'draft'
@ -49,34 +69,135 @@ export default function SiloPage() {
) )
} }
function publish() { async function update() {
setDoc((prev) => if (!doc) return
prev setSaving(true)
? { setSaveError(null)
...prev, const updated = await updateDocument(doc.id, { title: doc.title, content: doc.content })
content: { setSaving(false)
...prev.content, if (updated) {
status: status === 'published' ? 'draft' : 'published', setDoc(updated)
}, setEditing(false)
} } else {
: prev, setSaveError('Could not save this page. Check your connection and try again.')
) }
} }
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 ( return (
<WorkspaceShell <WorkspaceShell
doc={doc} doc={doc}
onDocChange={setDoc} onDocChange={setDoc}
layout="page" layout="page"
titleReadOnly={!editing}
onTitleChange={(title) =>
setDoc((prev) => (prev ? { ...prev, title } : prev))
}
headerActions={ headerActions={
<> <>
<button {!editing ? (
type="button" <button
onClick={publish} type="button"
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"
{status === 'published' ? 'Unpublish' : 'Publish'} >
</button> 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 <button
type="button" type="button"
onClick={() => navigate('/dashboard')} onClick={() => navigate('/dashboard')}
@ -84,10 +205,60 @@ export default function SiloPage() {
> >
Close Close
</button> </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> </WorkspaceShell>
) )
} }

View file

@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import WorkspaceShell from '../../components/workspace/WorkspaceShell' import WorkspaceShell from '../../components/workspace/WorkspaceShell'
import SketchCanvas from '../../components/sketch/SketchCanvas' import SketchCanvas from '../../components/sketch/SketchCanvas'
import { getDocument, type DocumentRecord } from '../../lib/documents' import { getDocument, type DocumentRecord } from '../../lib/documents'
import { useAutoSave } from '../../hooks/useAutoSave' import { updateDocument } from '../../lib/documents'
import { useRecordVisit } from '../../hooks/useRecordVisit' import { useRecordVisit } from '../../hooks/useRecordVisit'
import { ApiError } from '../../lib/api' import { ApiError } from '../../lib/api'
import { import {
@ -16,6 +16,8 @@ export default function SketchPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const [doc, setDoc] = useState<DocumentRecord | null>(null) const [doc, setDoc] = useState<DocumentRecord | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@ -33,7 +35,6 @@ export default function SketchPage() {
}) })
}, [id, navigate]) }, [id, navigate])
useAutoSave(doc)
useRecordVisit(doc?.id) useRecordVisit(doc?.id)
const sketchContent = useMemo( const sketchContent = useMemo(
@ -48,14 +49,66 @@ export default function SketchPage() {
setDoc((prev) => (prev ? { ...prev, content: next } : prev)) 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 ( 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 h-full flex-col">
<div className="flex items-center border-b border-silos-cream-dark px-6 py-3"> <div className="flex items-center border-b border-silos-cream-dark px-6 py-3">
<input <input
value={doc.title} value={doc.title}
readOnly={!editing}
onChange={(e) => setDoc((prev) => (prev ? { ...prev, title: e.target.value } : prev))} 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" placeholder="Untitled whiteboard"
/> />
</div> </div>
@ -64,6 +117,7 @@ export default function SketchPage() {
elements={sketchContent.elements} elements={sketchContent.elements}
viewport={sketchContent.viewport} viewport={sketchContent.viewport}
onChange={patchSketch} onChange={patchSketch}
readOnly={!editing}
/> />
</div> </div>
</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
```